1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-18 15:47:57 +01:00

[PM-13455] Risk insights aggregation in a new service. (#12071)

* Risk insights aggregation in a new service. Initial PR.

* Ignoring all non-login items and refactoring into a method

* Cleaning up the documentation a little

* logic for generating the report summary

* application summary to list at risk applications not passwords

* Adding more documentation and moving types to it's own file

* Awaiting the raw data report and adding the start of the test file

* Adding more test cases

* Removing unnecessary file

* Test cases update

* Fixing memeber details test to have new member

* Fixing password health tests

* Moving to observables

* removing commented code

* commented code

* Switching from ternary to if/else

* nullable types

* one more nullable type

* Adding the fixme for strict types

* moving the fixme

---------

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
This commit is contained in:
Tom 2024-12-12 09:55:43 -05:00 committed by GitHub
parent 1b6b5d3110
commit 30c151f44a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 696 additions and 112 deletions

View File

@ -0,0 +1,92 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { BadgeVariant } from "@bitwarden/components";
/**
* All applications report summary. The total members,
* total at risk members, application, and at risk application
* counts. Aggregated from all calculated applications
*/
export type ApplicationHealthReportSummary = {
totalMemberCount: number;
totalAtRiskMemberCount: number;
totalApplicationCount: number;
totalAtRiskApplicationCount: number;
};
/**
* All applications report detail. Application is the cipher
* uri. Has the at risk, password, and member information
*/
export type ApplicationHealthReportDetail = {
applicationName: string;
passwordCount: number;
atRiskPasswordCount: number;
memberCount: number;
memberDetails: MemberDetailsFlat[];
atRiskMemberDetails: MemberDetailsFlat[];
};
/**
* Breaks the cipher health info out by uri and passes
* along the password health and member info
*/
export type CipherHealthReportUriDetail = {
cipherId: string;
reusedPasswordCount: number;
weakPasswordDetail: WeakPasswordDetail;
exposedPasswordDetail: ExposedPasswordDetail;
cipherMembers: MemberDetailsFlat[];
trimmedUri: string;
};
/**
* Associates a cipher with it's essential information.
* Gets the password health details, cipher members, and
* the trimmed uris for the cipher
*/
export type CipherHealthReportDetail = CipherView & {
reusedPasswordCount: number;
weakPasswordDetail: WeakPasswordDetail;
exposedPasswordDetail: ExposedPasswordDetail;
cipherMembers: MemberDetailsFlat[];
trimmedUris: string[];
};
/**
* Weak password details containing the score
* and the score type for the label and badge
*/
export type WeakPasswordDetail = {
score: number;
detailValue: WeakPasswordScore;
} | null;
/**
* Weak password details containing the badge and
* the label for the password score
*/
export type WeakPasswordScore = {
label: string;
badgeVariant: BadgeVariant;
} | null;
/**
* How many times a password has been exposed
*/
export type ExposedPasswordDetail = {
exposedXTimes: number;
} | null;
/**
* Flattened member details that associates an
* organization member to a cipher
*/
export type MemberDetailsFlat = {
userName: string;
email: string;
cipherId: string;
};

View File

@ -1,10 +1,18 @@
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
const createLoginUriView = (uri: string): LoginUriView => {
const view = new LoginUriView();
view.uri = uri;
return view;
};
export const mockCiphers: any[] = [ export const mockCiphers: any[] = [
{ {
initializerKey: 1, initializerKey: 1,
id: "cbea34a8-bde4-46ad-9d19-b05001228ab1", id: "cbea34a8-bde4-46ad-9d19-b05001228ab1",
organizationId: null, organizationId: null,
folderId: null, folderId: null,
name: "Cannot Be Edited", name: "Weak Password Cipher",
notes: null, notes: null,
isDeleted: false, isDeleted: false,
type: 1, type: 1,
@ -14,10 +22,11 @@ export const mockCiphers: any[] = [
password: "123", password: "123",
hasUris: true, hasUris: true,
uris: [ uris: [
{ uri: "www.google.com" }, createLoginUriView("101domain.com"),
{ uri: "accounts.google.com" }, createLoginUriView("www.google.com"),
{ uri: "https://www.google.com" }, createLoginUriView("accounts.google.com"),
{ uri: "https://www.google.com/login" }, createLoginUriView("https://www.google.com"),
createLoginUriView("https://www.google.com/login"),
], ],
}, },
edit: false, edit: false,
@ -31,23 +40,18 @@ export const mockCiphers: any[] = [
}, },
{ {
initializerKey: 1, initializerKey: 1,
id: "cbea34a8-bde4-46ad-9d19-b05001228ab2", id: "cbea34a8-bde4-46ad-9d19-b05001228cd3",
organizationId: null, organizationId: null,
folderId: null, folderId: null,
name: "Can Be Edited id ending 2", name: "Strong Password Cipher",
notes: null, notes: null,
isDeleted: false,
type: 1, type: 1,
favorite: false, favorite: false,
organizationUseTotp: false, organizationUseTotp: false,
login: { login: {
password: "123", password: "Password!123",
hasUris: true, hasUris: true,
uris: [ uris: [createLoginUriView("http://example.com")],
{
uri: "http://nothing.com",
},
],
}, },
edit: true, edit: true,
viewPassword: true, viewPassword: true,
@ -60,22 +64,18 @@ export const mockCiphers: any[] = [
}, },
{ {
initializerKey: 1, initializerKey: 1,
id: "cbea34a8-bde4-46ad-9d19-b05001228cd3", id: "cbea34a8-bde4-46ad-9d19-b05001228ab2",
organizationId: null, organizationId: null,
folderId: null, folderId: null,
name: "Can Be Edited id ending 3", name: "Strong password Cipher",
notes: null, notes: null,
type: 1, type: 1,
favorite: false, favorite: false,
organizationUseTotp: false, organizationUseTotp: false,
login: { login: {
password: "123",
hasUris: true, hasUris: true,
uris: [ password: "Password!1234",
{ uris: [createLoginUriView("101domain.com")],
uri: "http://example.com",
},
],
}, },
edit: true, edit: true,
viewPassword: true, viewPassword: true,
@ -91,14 +91,15 @@ export const mockCiphers: any[] = [
id: "cbea34a8-bde4-46ad-9d19-b05001228xy4", id: "cbea34a8-bde4-46ad-9d19-b05001228xy4",
organizationId: null, organizationId: null,
folderId: null, folderId: null,
name: "Can Be Edited id ending 4", name: "Strong password Cipher",
notes: null, notes: null,
type: 1, type: 1,
favorite: false, favorite: false,
organizationUseTotp: false, organizationUseTotp: false,
login: { login: {
hasUris: true, hasUris: true,
uris: [{ uri: "101domain.com" }], password: "Password!123",
uris: [createLoginUriView("example.com")],
}, },
edit: true, edit: true,
viewPassword: true, viewPassword: true,
@ -114,14 +115,39 @@ export const mockCiphers: any[] = [
id: "cbea34a8-bde4-46ad-9d19-b05001227nm5", id: "cbea34a8-bde4-46ad-9d19-b05001227nm5",
organizationId: null, organizationId: null,
folderId: null, folderId: null,
name: "Can Be Edited id ending 5", name: "Exposed password Cipher",
notes: null, notes: null,
type: 1, type: 1,
favorite: false, favorite: false,
organizationUseTotp: false, organizationUseTotp: false,
login: { login: {
hasUris: true, hasUris: true,
uris: [{ uri: "123formbuilder.com" }], password: "123",
uris: [createLoginUriView("123formbuilder.com"), createLoginUriView("www.google.com")],
},
edit: true,
viewPassword: true,
collectionIds: [],
revisionDate: "2023-08-03T17:40:59.793Z",
creationDate: "2023-08-03T17:40:59.793Z",
deletedDate: null,
reprompt: 0,
localData: null,
},
{
initializerKey: 1,
id: "cbea34a8-bde4-46ad-9d19-b05001227tt1",
organizationId: null,
folderId: null,
name: "Secure Co Login",
notes: null,
type: 1,
favorite: false,
organizationUseTotp: false,
login: {
hasUris: true,
password: "4gRyhhOX2Og2p0",
uris: [createLoginUriView("SecureCo.com")],
}, },
edit: true, edit: true,
viewPassword: true, viewPassword: true,

View File

@ -1,2 +1,3 @@
export * from "./member-cipher-details-api.service"; export * from "./member-cipher-details-api.service";
export * from "./password-health.service"; export * from "./password-health.service";
export * from "./risk-insights-report.service";

View File

@ -69,6 +69,12 @@ export const mockMemberCipherDetails: any = [
"cbea34a8-bde4-46ad-9d19-b05001228xy4", "cbea34a8-bde4-46ad-9d19-b05001228xy4",
], ],
}, },
{
userName: "Mister Secure",
email: "mister.secure@secureco.com",
usesKeyConnector: true,
cipherIds: ["cbea34a8-bde4-46ad-9d19-b05001227tt1"],
},
]; ];
describe("Member Cipher Details API Service", () => { describe("Member Cipher Details API Service", () => {
@ -91,7 +97,7 @@ describe("Member Cipher Details API Service", () => {
const orgId = "1234"; const orgId = "1234";
const result = await memberCipherDetailsApiService.getMemberCipherDetails(orgId); const result = await memberCipherDetailsApiService.getMemberCipherDetails(orgId);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result).toHaveLength(6); expect(result).toHaveLength(7);
expect(apiService.send).toHaveBeenCalledWith( expect(apiService.send).toHaveBeenCalledWith(
"GET", "GET",
"/reports/member-cipher-details/" + orgId, "/reports/member-cipher-details/" + orgId,

View File

@ -3,18 +3,15 @@ import { TestBed } from "@angular/core/testing";
import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { mockCiphers } from "./ciphers.mock"; import { mockCiphers } from "./ciphers.mock";
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
import { mockMemberCipherDetails } from "./member-cipher-details-api.service.spec"; import { mockMemberCipherDetails } from "./member-cipher-details-api.service.spec";
import { PasswordHealthService } from "./password-health.service"; import { PasswordHealthService } from "./password-health.service";
// FIXME: Remove password-health report service after PR-15498 completion
describe("PasswordHealthService", () => { describe("PasswordHealthService", () => {
let service: PasswordHealthService; let service: PasswordHealthService;
let cipherService: CipherService;
let memberCipherDetailsApiService: MemberCipherDetailsApiService;
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
@ -51,8 +48,6 @@ describe("PasswordHealthService", () => {
}); });
service = TestBed.inject(PasswordHealthService); service = TestBed.inject(PasswordHealthService);
cipherService = TestBed.inject(CipherService);
memberCipherDetailsApiService = TestBed.inject(MemberCipherDetailsApiService);
}); });
it("should be created", () => { it("should be created", () => {
@ -67,83 +62,4 @@ describe("PasswordHealthService", () => {
expect(service.exposedPasswordMap.size).toBe(0); expect(service.exposedPasswordMap.size).toBe(0);
expect(service.totalMembersMap.size).toBe(0); expect(service.totalMembersMap.size).toBe(0);
}); });
describe("generateReport", () => {
beforeEach(async () => {
await service.generateReport();
});
it("should fetch all ciphers for the organization", () => {
expect(cipherService.getAllFromApiForOrganization).toHaveBeenCalledWith("org1");
});
it("should fetch member cipher details", () => {
expect(memberCipherDetailsApiService.getMemberCipherDetails).toHaveBeenCalledWith("org1");
});
it("should populate reportCiphers with ciphers that have issues", () => {
expect(service.reportCiphers.length).toBeGreaterThan(0);
});
it("should detect weak passwords", () => {
expect(service.passwordStrengthMap.size).toBeGreaterThan(0);
expect(service.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab1")).toEqual([
"veryWeak",
"danger",
]);
expect(service.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab2")).toEqual([
"veryWeak",
"danger",
]);
expect(service.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228cd3")).toEqual([
"veryWeak",
"danger",
]);
});
it("should detect reused passwords", () => {
expect(service.passwordUseMap.get("123")).toBe(3);
});
it("should detect exposed passwords", () => {
expect(service.exposedPasswordMap.size).toBeGreaterThan(0);
expect(service.exposedPasswordMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab1")).toBe(100);
});
it("should calculate total members per cipher", () => {
expect(service.totalMembersMap.size).toBeGreaterThan(0);
expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab1")).toBe(2);
expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab2")).toBe(4);
expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228cd3")).toBe(5);
expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001227nm5")).toBe(4);
expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001227nm7")).toBe(1);
expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228xy4")).toBe(6);
});
});
describe("findWeakPassword", () => {
it("should add weak passwords to passwordStrengthMap", () => {
const weakCipher = mockCiphers.find((c) => c.login?.password === "123") as CipherView;
service.findWeakPassword(weakCipher);
expect(service.passwordStrengthMap.get(weakCipher.id)).toEqual(["veryWeak", "danger"]);
});
});
describe("findReusedPassword", () => {
it("should detect password reuse", () => {
mockCiphers.forEach((cipher) => {
service.findReusedPassword(cipher as CipherView);
});
const reuseCounts = Array.from(service.passwordUseMap.values()).filter((count) => count > 1);
expect(reuseCounts.length).toBeGreaterThan(0);
});
});
describe("findExposedPassword", () => {
it("should add exposed passwords to exposedPasswordMap", async () => {
const exposedCipher = mockCiphers.find((c) => c.login?.password === "123") as CipherView;
await service.findExposedPassword(exposedCipher);
expect(service.exposedPasswordMap.get(exposedCipher.id)).toBe(100);
});
});
}); });

View File

@ -0,0 +1,148 @@
import { TestBed } from "@angular/core/testing";
import { firstValueFrom } from "rxjs";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { mockCiphers } from "./ciphers.mock";
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
import { mockMemberCipherDetails } from "./member-cipher-details-api.service.spec";
import { RiskInsightsReportService } from "./risk-insights-report.service";
describe("RiskInsightsReportService", () => {
let service: RiskInsightsReportService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
RiskInsightsReportService,
{
provide: PasswordStrengthServiceAbstraction,
useValue: {
getPasswordStrength: (password: string) => {
const score = password.length < 4 ? 1 : 4;
return { score };
},
},
},
{
provide: AuditService,
useValue: {
passwordLeaked: (password: string) => Promise.resolve(password === "123" ? 100 : 0),
},
},
{
provide: CipherService,
useValue: {
getAllFromApiForOrganization: jest.fn().mockResolvedValue(mockCiphers),
},
},
{
provide: MemberCipherDetailsApiService,
useValue: {
getMemberCipherDetails: jest.fn().mockResolvedValue(mockMemberCipherDetails),
},
},
],
});
service = TestBed.inject(RiskInsightsReportService);
});
it("should generate the raw data report correctly", async () => {
const result = await firstValueFrom(service.generateRawDataReport$("orgId"));
expect(result).toHaveLength(6);
let testCaseResults = result.filter((x) => x.id === "cbea34a8-bde4-46ad-9d19-b05001228ab1");
expect(testCaseResults).toHaveLength(1);
let testCase = testCaseResults[0];
expect(testCase).toBeTruthy();
expect(testCase.cipherMembers).toHaveLength(2);
expect(testCase.trimmedUris).toHaveLength(3);
expect(testCase.weakPasswordDetail).toBeTruthy();
expect(testCase.exposedPasswordDetail).toBeTruthy();
expect(testCase.reusedPasswordCount).toEqual(2);
testCaseResults = result.filter((x) => x.id === "cbea34a8-bde4-46ad-9d19-b05001227tt1");
expect(testCaseResults).toHaveLength(1);
testCase = testCaseResults[0];
expect(testCase).toBeTruthy();
expect(testCase.cipherMembers).toHaveLength(1);
expect(testCase.trimmedUris).toHaveLength(1);
expect(testCase.weakPasswordDetail).toBeFalsy();
expect(testCase.exposedPasswordDetail).toBeFalsy();
expect(testCase.reusedPasswordCount).toEqual(1);
});
it("should generate the raw data + uri report correctly", async () => {
const result = await firstValueFrom(service.generateRawDataUriReport$("orgId"));
expect(result).toHaveLength(9);
// Two ciphers that have google.com as their uri. There should be 2 results
const googleResults = result.filter((x) => x.trimmedUri === "google.com");
expect(googleResults).toHaveLength(2);
// Verify the details for one of the googles matches the password health info
// expected
const firstGoogle = googleResults.filter(
(x) => x.cipherId === "cbea34a8-bde4-46ad-9d19-b05001228ab1" && x.trimmedUri === "google.com",
)[0];
expect(firstGoogle.weakPasswordDetail).toBeTruthy();
expect(firstGoogle.exposedPasswordDetail).toBeTruthy();
expect(firstGoogle.reusedPasswordCount).toEqual(2);
});
it("should generate applications health report data correctly", async () => {
const result = await firstValueFrom(service.generateApplicationsReport$("orgId"));
expect(result).toHaveLength(6);
// Two ciphers have google.com associated with them. The first cipher
// has 2 members and the second has 4. However, the 2 members in the first
// cipher are also associated with the second. The total amount of members
// should be 4 not 6
const googleTestResults = result.filter((x) => x.applicationName === "google.com");
expect(googleTestResults).toHaveLength(1);
const googleTest = googleTestResults[0];
expect(googleTest.memberCount).toEqual(4);
// Both ciphers have at risk passwords
expect(googleTest.passwordCount).toEqual(2);
// All members are at risk since both ciphers are at risk
expect(googleTest.atRiskMemberDetails).toHaveLength(4);
expect(googleTest.atRiskPasswordCount).toEqual(2);
// There are 2 ciphers associated with 101domain.com
const domain101TestResults = result.filter((x) => x.applicationName === "101domain.com");
expect(domain101TestResults).toHaveLength(1);
const domain101Test = domain101TestResults[0];
expect(domain101Test.passwordCount).toEqual(2);
// The first cipher is at risk. The second cipher is not at risk
expect(domain101Test.atRiskPasswordCount).toEqual(1);
// The first cipher has 2 members. The second cipher the second
// cipher has 4. One of the members in the first cipher is associated
// with the second. So there should be 5 members total.
expect(domain101Test.memberCount).toEqual(5);
// The first cipher is at risk. The total at risk members is 2 and
// at risk password count is 1.
expect(domain101Test.atRiskMemberDetails).toHaveLength(2);
expect(domain101Test.atRiskPasswordCount).toEqual(1);
});
it("should generate applications summary data correctly", async () => {
const reportResult = await firstValueFrom(service.generateApplicationsReport$("orgId"));
const reportSummary = service.generateApplicationsSummary(reportResult);
expect(reportSummary.totalMemberCount).toEqual(7);
expect(reportSummary.totalAtRiskMemberCount).toEqual(6);
expect(reportSummary.totalApplicationCount).toEqual(6);
expect(reportSummary.totalAtRiskApplicationCount).toEqual(5);
});
});

View File

@ -0,0 +1,395 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import { concatMap, first, from, map, Observable, zip } from "rxjs";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
ApplicationHealthReportDetail,
ApplicationHealthReportSummary,
CipherHealthReportDetail,
CipherHealthReportUriDetail,
ExposedPasswordDetail,
MemberDetailsFlat,
WeakPasswordDetail,
WeakPasswordScore,
} from "../models/password-health";
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
@Injectable()
export class RiskInsightsReportService {
constructor(
private passwordStrengthService: PasswordStrengthServiceAbstraction,
private auditService: AuditService,
private cipherService: CipherService,
private memberCipherDetailsApiService: MemberCipherDetailsApiService,
) {}
/**
* Report data from raw cipher health data.
* Can be used in the Raw Data diagnostic tab (just exclude the members in the view)
* and can be used in the raw data + members tab when including the members in the view
* @param organizationId
* @returns Cipher health report data with members and trimmed uris
*/
generateRawDataReport$(organizationId: string): Observable<CipherHealthReportDetail[]> {
const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId));
const memberCiphers$ = from(
this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId),
);
const results$ = zip(allCiphers$, memberCiphers$).pipe(
map(([allCiphers, memberCiphers]) => {
const details: MemberDetailsFlat[] = memberCiphers.flatMap((dtl) =>
dtl.cipherIds.map((c) => this.getMemberDetailsFlat(dtl.userName, dtl.email, c)),
);
return [allCiphers, details] as const;
}),
concatMap(([ciphers, flattenedDetails]) => this.getCipherDetails(ciphers, flattenedDetails)),
first(),
);
return results$;
}
/**
* Report data for raw cipher health broken out into the uris
* Can be used in the raw data + members + uri diagnostic report
* @param organizationId Id of the organization
* @returns Cipher health report data flattened to the uris
*/
generateRawDataUriReport$(organizationId: string): Observable<CipherHealthReportUriDetail[]> {
const cipherHealthDetails$ = this.generateRawDataReport$(organizationId);
const results$ = cipherHealthDetails$.pipe(
map((healthDetails) => this.getCipherUriDetails(healthDetails)),
first(),
);
return results$;
}
/**
* Report data for the aggregation of uris to like uris and getting password/member counts,
* members, and at risk statuses.
* @param organizationId Id of the organization
* @returns The all applications health report data
*/
generateApplicationsReport$(organizationId: string): Observable<ApplicationHealthReportDetail[]> {
const cipherHealthUriReport$ = this.generateRawDataUriReport$(organizationId);
const results$ = cipherHealthUriReport$.pipe(
map((uriDetails) => this.getApplicationHealthReport(uriDetails)),
first(),
);
return results$;
}
/**
* Gets the summary from the application health report. Returns total members and applications as well
* as the total at risk members and at risk applications
* @param reports The previously calculated application health report data
* @returns A summary object containing report totals
*/
generateApplicationsSummary(
reports: ApplicationHealthReportDetail[],
): ApplicationHealthReportSummary {
const totalMembers = reports.flatMap((x) => x.memberDetails);
const uniqueMembers = this.getUniqueMembers(totalMembers);
const atRiskMembers = reports.flatMap((x) => x.atRiskMemberDetails);
const uniqueAtRiskMembers = this.getUniqueMembers(atRiskMembers);
return {
totalMemberCount: uniqueMembers.length,
totalAtRiskMemberCount: uniqueAtRiskMembers.length,
totalApplicationCount: reports.length,
totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length,
};
}
/**
* Associates the members with the ciphers they have access to. Calculates the password health.
* Finds the trimmed uris.
* @param ciphers Org ciphers
* @param memberDetails Org members
* @returns Cipher password health data with trimmed uris and associated members
*/
private async getCipherDetails(
ciphers: CipherView[],
memberDetails: MemberDetailsFlat[],
): Promise<CipherHealthReportDetail[]> {
const cipherHealthReports: CipherHealthReportDetail[] = [];
const passwordUseMap = new Map<string, number>();
for (const cipher of ciphers) {
if (this.validateCipher(cipher)) {
const weakPassword = this.findWeakPassword(cipher);
// Looping over all ciphers needs to happen first to determine reused passwords over all ciphers.
// Store in the set and evaluate later
if (passwordUseMap.has(cipher.login.password)) {
passwordUseMap.set(
cipher.login.password,
(passwordUseMap.get(cipher.login.password) || 0) + 1,
);
} else {
passwordUseMap.set(cipher.login.password, 1);
}
const exposedPassword = await this.findExposedPassword(cipher);
// Get the cipher members
const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id);
// Trim uris to host name and create the cipher health report
const cipherTrimmedUris = this.getTrimmedCipherUris(cipher);
const cipherHealth = {
...cipher,
weakPasswordDetail: weakPassword,
exposedPasswordDetail: exposedPassword,
cipherMembers: cipherMembers,
trimmedUris: cipherTrimmedUris,
} as CipherHealthReportDetail;
cipherHealthReports.push(cipherHealth);
}
}
// loop for reused passwords
cipherHealthReports.forEach((detail) => {
detail.reusedPasswordCount = passwordUseMap.get(detail.login.password) ?? 0;
});
return cipherHealthReports;
}
/**
* Flattens the cipher to trimmed uris. Used for the raw data + uri
* @param cipherHealthReport Cipher health report with uris and members
* @returns Flattened cipher health details to uri
*/
private getCipherUriDetails(
cipherHealthReport: CipherHealthReportDetail[],
): CipherHealthReportUriDetail[] {
return cipherHealthReport.flatMap((rpt) =>
rpt.trimmedUris.map((u) => this.getFlattenedCipherDetails(rpt, u)),
);
}
/**
* Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item.
* If the item is new, create and add the object with the flattened details
* @param cipherHealthUriReport Cipher and password health info broken out into their uris
* @returns Application health reports
*/
private getApplicationHealthReport(
cipherHealthUriReport: CipherHealthReportUriDetail[],
): ApplicationHealthReportDetail[] {
const appReports: ApplicationHealthReportDetail[] = [];
cipherHealthUriReport.forEach((uri) => {
const index = appReports.findIndex((item) => item.applicationName === uri.trimmedUri);
let atRisk: boolean = false;
if (uri.exposedPasswordDetail || uri.weakPasswordDetail || uri.reusedPasswordCount > 1) {
atRisk = true;
}
if (index === -1) {
appReports.push(this.getApplicationReportDetail(uri, atRisk));
} else {
appReports[index] = this.getApplicationReportDetail(uri, atRisk, appReports[index]);
}
});
return appReports;
}
private async findExposedPassword(cipher: CipherView): Promise<ExposedPasswordDetail> {
const exposedCount = await this.auditService.passwordLeaked(cipher.login.password);
if (exposedCount > 0) {
const exposedDetail = { exposedXTimes: exposedCount } as ExposedPasswordDetail;
return exposedDetail;
}
return null;
}
private findWeakPassword(cipher: CipherView): WeakPasswordDetail {
const hasUserName = this.isUserNameNotEmpty(cipher);
let userInput: string[] = [];
if (hasUserName) {
const atPosition = cipher.login.username.indexOf("@");
if (atPosition > -1) {
userInput = userInput
.concat(
cipher.login.username
.substring(0, atPosition)
.trim()
.toLowerCase()
.split(/[^A-Za-z0-9]/),
)
.filter((i) => i.length >= 3);
} else {
userInput = cipher.login.username
.trim()
.toLowerCase()
.split(/[^A-Za-z0-9]/)
.filter((i) => i.length >= 3);
}
}
const { score } = this.passwordStrengthService.getPasswordStrength(
cipher.login.password,
null,
userInput.length > 0 ? userInput : null,
);
if (score != null && score <= 2) {
const scoreValue = this.weakPasswordScore(score);
const weakPasswordDetail = { score: score, detailValue: scoreValue } as WeakPasswordDetail;
return weakPasswordDetail;
}
return null;
}
private weakPasswordScore(score: number): WeakPasswordScore {
switch (score) {
case 4:
return { label: "strong", badgeVariant: "success" };
case 3:
return { label: "good", badgeVariant: "primary" };
case 2:
return { label: "weak", badgeVariant: "warning" };
default:
return { label: "veryWeak", badgeVariant: "danger" };
}
}
/**
* Create the new application health report detail object with the details from the cipher health report uri detail object
* update or create the at risk values if the item is at risk.
* @param newUriDetail New cipher uri detail
* @param isAtRisk If the cipher has a weak, exposed, or reused password it is at risk
* @param existingUriDetail The previously processed Uri item
* @returns The new or updated application health report detail
*/
private getApplicationReportDetail(
newUriDetail: CipherHealthReportUriDetail,
isAtRisk: boolean,
existingUriDetail?: ApplicationHealthReportDetail,
): ApplicationHealthReportDetail {
const reportDetail = {
applicationName: existingUriDetail
? existingUriDetail.applicationName
: newUriDetail.trimmedUri,
passwordCount: existingUriDetail ? existingUriDetail.passwordCount + 1 : 1,
memberDetails: existingUriDetail
? this.getUniqueMembers(existingUriDetail.memberDetails.concat(newUriDetail.cipherMembers))
: newUriDetail.cipherMembers,
atRiskMemberDetails: existingUriDetail ? existingUriDetail.atRiskMemberDetails : [],
atRiskPasswordCount: existingUriDetail ? existingUriDetail.atRiskPasswordCount : 0,
} as ApplicationHealthReportDetail;
if (isAtRisk) {
(reportDetail.atRiskPasswordCount = reportDetail.atRiskPasswordCount + 1),
(reportDetail.atRiskMemberDetails = this.getUniqueMembers(
reportDetail.atRiskMemberDetails.concat(newUriDetail.cipherMembers),
));
}
reportDetail.memberCount = reportDetail.memberDetails.length;
return reportDetail;
}
/**
* Get a distinct array of members from a combined list. Input list may contain
* duplicate members.
* @param orgMembers Input list of members
* @returns Distinct array of members
*/
private getUniqueMembers(orgMembers: MemberDetailsFlat[]): MemberDetailsFlat[] {
const existingEmails = new Set<string>();
const distinctUsers = orgMembers.filter((member) => {
if (existingEmails.has(member.email)) {
return false;
}
existingEmails.add(member.email);
return true;
});
return distinctUsers;
}
private getFlattenedCipherDetails(
detail: CipherHealthReportDetail,
uri: string,
): CipherHealthReportUriDetail {
return {
cipherId: detail.id,
reusedPasswordCount: detail.reusedPasswordCount,
weakPasswordDetail: detail.weakPasswordDetail,
exposedPasswordDetail: detail.exposedPasswordDetail,
cipherMembers: detail.cipherMembers,
trimmedUri: uri,
};
}
private getMemberDetailsFlat(
userName: string,
email: string,
cipherId: string,
): MemberDetailsFlat {
return {
userName: userName,
email: email,
cipherId: cipherId,
};
}
/**
* Trim the cipher uris down to get the password health application.
* The uri should only exist once after being trimmed. No duplication.
* Example:
* - Untrimmed Uris: https://gmail.com, gmail.com/login
* - Both would trim to gmail.com
* - The cipher trimmed uri list should only return on instance in the list
* @param cipher
* @returns distinct list of trimmed cipher uris
*/
private getTrimmedCipherUris(cipher: CipherView): string[] {
const cipherUris: string[] = [];
const uris = cipher.login?.uris ?? [];
uris.map((u: { uri: string }) => {
const uri = Utils.getHostname(u.uri).replace("www.", "");
if (!cipherUris.includes(uri)) {
cipherUris.push(uri);
}
});
return cipherUris;
}
private isUserNameNotEmpty(c: CipherView): boolean {
return !Utils.isNullOrWhitespace(c.login.username);
}
/**
* Validates that the cipher is a login item, has a password
* is not deleted, and the user can view the password
* @param c the input cipher
*/
private validateCipher(c: CipherView): boolean {
const { type, login, isDeleted, viewPassword } = c;
if (
type !== CipherType.Login ||
login.password == null ||
login.password === "" ||
isDeleted ||
!viewPassword
) {
return false;
}
return true;
}
}