diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts index 81648f08934..f3736c517e7 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts @@ -49,7 +49,7 @@ export class RiskInsightsDataService { this.isLoadingSubject.next(true); } this.reportService - .generateApplicationsReport$(organizationId) + .LEGACY_generateApplicationsReport$(organizationId) .pipe( finalize(() => { this.isLoadingSubject.next(false); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts index f78018194a5..45f9aeed1da 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts @@ -7,8 +7,11 @@ import { EncryptedString } from "@bitwarden/common/key-management/crypto/models/ import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; 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 { GetRiskInsightsReportResponse } from "../models/api-models.types"; +import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response"; import { mockCiphers } from "./ciphers.mock"; import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; @@ -19,6 +22,8 @@ import { RiskInsightsReportService } from "./risk-insights-report.service"; describe("RiskInsightsReportService", () => { let service: RiskInsightsReportService; + + // Mock services const pwdStrengthService = mock(); const auditService = mock(); const cipherService = mock(); @@ -28,7 +33,11 @@ describe("RiskInsightsReportService", () => { encryptRiskInsightsReport: jest.fn().mockResolvedValue("encryptedReportData"), decryptRiskInsightsReport: jest.fn().mockResolvedValue("decryptedReportData"), }); - const orgId = "orgId" as OrganizationId; + + // Mock data + const mockOrgId = "orgId" as OrganizationId; + let mockCipherViews: CipherView[]; + let mockMemberDetails: MemberCipherDetailsResponse[]; beforeEach(() => { pwdStrengthService.getPasswordStrength.mockImplementation((password: string) => { @@ -52,10 +61,74 @@ describe("RiskInsightsReportService", () => { mockRiskInsightsApiService, mockRiskInsightsEncryptionService, ); + + // Reset mock ciphers before each test + mockCipherViews = [ + mock({ + id: "cipher-1", + type: CipherType.Login, + login: { password: "pass1", username: "user1", uris: [{ uri: "https://app.com/login" }] }, + isDeleted: false, + viewPassword: true, + }), + mock({ + id: "cipher-2", + type: CipherType.Login, + login: { password: "pass2", username: "user2", uris: [{ uri: "app.com/home" }] }, + isDeleted: false, + viewPassword: true, + }), + mock({ + id: "cipher-3", + type: CipherType.Login, + login: { password: "pass3", username: "user3", uris: [{ uri: "https://other.com" }] }, + isDeleted: false, + viewPassword: true, + }), + ]; + mockMemberDetails = [ + mock({ + cipherIds: ["cipher-1"], + userGuid: "user1", + userName: "User 1", + email: "user1@app.com", + }), + mock({ + cipherIds: ["cipher-2"], + userGuid: "user2", + userName: "User 2", + email: "user2@app.com", + }), + mock({ + cipherIds: ["cipher-3"], + userGuid: "user3", + userName: "User 3", + email: "user3@other.com", + }), + ]; + }); + + it("should group and aggregate application health reports correctly", (done) => { + // Mock the service methods + cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCipherViews); + memberCipherDetailsService.getMemberCipherDetails.mockResolvedValue(mockMemberDetails); + + service.generateApplicationsReport$("orgId" as any).subscribe((result) => { + expect(Array.isArray(result)).toBe(true); + + // Should group by application name (trimmedUris) + const appCom = result.find((r) => r.applicationName === "app.com"); + const otherCom = result.find((r) => r.applicationName === "other.com"); + expect(appCom).toBeTruthy(); + expect(appCom?.passwordCount).toBe(2); + expect(otherCom).toBeTruthy(); + expect(otherCom?.passwordCount).toBe(1); + done(); + }); }); it("should generate the raw data report correctly", async () => { - const result = await firstValueFrom(service.generateRawDataReport$(orgId)); + const result = await firstValueFrom(service.LEGACY_generateRawDataReport$(mockOrgId)); expect(result).toHaveLength(6); @@ -81,7 +154,7 @@ describe("RiskInsightsReportService", () => { }); it("should generate the raw data + uri report correctly", async () => { - const result = await firstValueFrom(service.generateRawDataUriReport$(orgId)); + const result = await firstValueFrom(service.generateRawDataUriReport$(mockOrgId)); expect(result).toHaveLength(11); @@ -104,7 +177,7 @@ describe("RiskInsightsReportService", () => { }); it("should generate applications health report data correctly", async () => { - const result = await firstValueFrom(service.generateApplicationsReport$(orgId)); + const result = await firstValueFrom(service.LEGACY_generateApplicationsReport$(mockOrgId)); expect(result).toHaveLength(8); @@ -145,7 +218,9 @@ describe("RiskInsightsReportService", () => { }); it("should generate applications summary data correctly", async () => { - const reportResult = await firstValueFrom(service.generateApplicationsReport$(orgId)); + const reportResult = await firstValueFrom( + service.LEGACY_generateApplicationsReport$(mockOrgId), + ); const reportSummary = service.generateApplicationsSummary(reportResult); expect(reportSummary.totalMemberCount).toEqual(7); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts index ca9381573f2..e6843385833 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts @@ -3,13 +3,17 @@ import { BehaviorSubject, concatMap, + filter, first, firstValueFrom, + forkJoin, from, map, + mergeMap, Observable, of, switchMap, + toArray, zip, } from "rxjs"; @@ -23,6 +27,7 @@ import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { + flattenMemberDetails, getApplicationReportDetail, getFlattenedCipherDetails, getMemberDetailsFlat, @@ -41,8 +46,11 @@ import { import { ApplicationHealthReportDetail, OrganizationReportSummary, - AtRiskMemberDetail, AtRiskApplicationDetail, + AtRiskMemberDetail, + CipherHealthReport, + MemberDetails, + PasswordHealthData, RiskInsightsReportData, } from "../models/report-models"; @@ -83,7 +91,7 @@ export class RiskInsightsReportService { * @param organizationId * @returns Cipher health report data with members and trimmed uris */ - generateRawDataReport$( + LEGACY_generateRawDataReport$( organizationId: OrganizationId, ): Observable { const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId)); @@ -98,7 +106,9 @@ export class RiskInsightsReportService { ); return [allCiphers, details] as const; }), - concatMap(([ciphers, flattenedDetails]) => this.getCipherDetails(ciphers, flattenedDetails)), + concatMap(([ciphers, flattenedDetails]) => + this.LEGACY_getCipherDetails(ciphers, flattenedDetails), + ), first(), ); @@ -114,7 +124,7 @@ export class RiskInsightsReportService { generateRawDataUriReport$( organizationId: OrganizationId, ): Observable { - const cipherHealthDetails$ = this.generateRawDataReport$(organizationId); + const cipherHealthDetails$ = this.LEGACY_generateRawDataReport$(organizationId); const results$ = cipherHealthDetails$.pipe( map((healthDetails) => this.getCipherUriDetails(healthDetails)), first(), @@ -123,6 +133,24 @@ export class RiskInsightsReportService { 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 + */ + LEGACY_generateApplicationsReport$( + organizationId: OrganizationId, + ): Observable { + const cipherHealthUriReport$ = this.generateRawDataUriReport$(organizationId); + const results$ = cipherHealthUriReport$.pipe( + map((uriDetails) => this.LEGACY_getApplicationHealthReport(uriDetails)), + first(), + ); + + return results$; + } + /** * Report data for the aggregation of uris to like uris and getting password/member counts, * members, and at risk statuses. @@ -132,13 +160,21 @@ export class RiskInsightsReportService { generateApplicationsReport$( organizationId: OrganizationId, ): Observable { - const cipherHealthUriReport$ = this.generateRawDataUriReport$(organizationId); - const results$ = cipherHealthUriReport$.pipe( - map((uriDetails) => this.getApplicationHealthReport(uriDetails)), - first(), - ); + const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId)); + const memberCiphers$ = from( + this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId), + ).pipe(map((memberCiphers) => flattenMemberDetails(memberCiphers))); - return results$; + return forkJoin([allCiphers$, memberCiphers$]).pipe( + switchMap(([ciphers, memberCiphers]) => this._getCipherDetails(ciphers, memberCiphers)), + map((cipherApplications) => { + const groupedByApplication = this._groupCiphersByApplication(cipherApplications); + + return Array.from(groupedByApplication.entries()).map(([application, ciphers]) => + this._getApplicationHealthReport(application, ciphers), + ); + }), + ); } /** @@ -313,7 +349,7 @@ export class RiskInsightsReportService { * @param memberDetails Org members * @returns Cipher password health data with trimmed uris and associated members */ - private async getCipherDetails( + private async LEGACY_getCipherDetails( ciphers: CipherView[], memberDetails: LEGACY_MemberDetailsFlat[], ): Promise { @@ -379,7 +415,7 @@ export class RiskInsightsReportService { * @param cipherHealthUriReport Cipher and password health info broken out into their uris * @returns Application health reports */ - private getApplicationHealthReport( + private LEGACY_getApplicationHealthReport( cipherHealthUriReport: LEGACY_CipherHealthReportUriDetail[], ): ApplicationHealthReportDetail[] { const appReports: ApplicationHealthReportDetail[] = []; @@ -497,4 +533,270 @@ export class RiskInsightsReportService { } return true; } + + private _buildPasswordUseMap(ciphers: CipherView[]): Map { + const passwordUseMap = new Map(); + ciphers.forEach((cipher) => { + const password = cipher.login.password; + passwordUseMap.set(password, (passwordUseMap.get(password) || 0) + 1); + }); + return passwordUseMap; + } + + private _groupCiphersByApplication( + cipherHealthData: CipherHealthReport[], + ): Map { + const applicationMap = new Map(); + + cipherHealthData.forEach((cipher: CipherHealthReport) => { + cipher.applications.forEach((application) => { + const existingApplication = applicationMap.get(application) || []; + existingApplication.push(cipher); + applicationMap.set(application, existingApplication); + }); + }); + + return applicationMap; + } + + /** + * 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 cipherHealthReport Cipher and password health info broken out into their uris + * @returns Application health reports + */ + private _getApplicationHealthReport( + application: string, + ciphers: CipherHealthReport[], + ): ApplicationHealthReportDetail { + let aggregatedReport: ApplicationHealthReportDetail | undefined; + + ciphers.forEach((cipher) => { + const isAtRisk = this._isPasswordAtRisk(cipher.healthData); + aggregatedReport = this._aggregateReport(application, cipher, isAtRisk, aggregatedReport); + }); + + return aggregatedReport!; + } + + private _aggregateReport( + application: string, + newCipherReport: CipherHealthReport, + isAtRisk: boolean, + existingReport?: ApplicationHealthReportDetail, + ): ApplicationHealthReportDetail { + let baseReport = existingReport + ? this._updateExistingReport(existingReport, newCipherReport) + : this._createNewReport(application, newCipherReport); + if (isAtRisk) { + baseReport = { ...baseReport, ...this._getAtRiskData(baseReport, newCipherReport) }; + } + + baseReport.memberCount = baseReport.memberDetails.length; + baseReport.atRiskMemberCount = baseReport.atRiskMemberDetails.length; + + return baseReport; + } + private _createNewReport( + application: string, + cipherReport: CipherHealthReport, + ): ApplicationHealthReportDetail { + return { + applicationName: application, + cipherIds: [cipherReport.cipher.id], + passwordCount: 1, + memberDetails: [...cipherReport.cipherMembers], + memberCount: cipherReport.cipherMembers.length, + atRiskCipherIds: [], + atRiskMemberCount: 0, + atRiskMemberDetails: [], + atRiskPasswordCount: 0, + }; + } + + private _updateExistingReport( + existingReport: ApplicationHealthReportDetail, + newCipherReport: CipherHealthReport, + ): ApplicationHealthReportDetail { + return { + ...existingReport, + passwordCount: existingReport.passwordCount + 1, + memberDetails: getUniqueMembers( + existingReport.memberDetails.concat(newCipherReport.cipherMembers), + ), + cipherIds: existingReport.cipherIds.concat(newCipherReport.cipher.id), + }; + } + + private _getAtRiskData(report: ApplicationHealthReportDetail, cipherReport: CipherHealthReport) { + const atRiskMemberDetails = getUniqueMembers( + report.atRiskMemberDetails.concat(cipherReport.cipherMembers), + ); + return { + atRiskPasswordCount: report.atRiskPasswordCount + 1, + atRiskCipherIds: report.atRiskCipherIds.concat(cipherReport.cipher.id), + atRiskMemberDetails, + atRiskMemberCount: atRiskMemberDetails.length, + }; + } + + // TODO Move to health service + private _isPasswordAtRisk(healthData: PasswordHealthData): boolean { + return !!( + healthData.exposedPasswordDetail || + healthData.weakPasswordDetail || + healthData.reusedPasswordCount > 1 + ); + } + /** + * 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 _getCipherDetails( + ciphers: CipherView[], + memberDetails: MemberDetails[], + ): Observable { + const validCiphers = ciphers.filter((cipher) => this.isValidCipher(cipher)); + // Build password use map + const passwordUseMap = this._buildPasswordUseMap(validCiphers); + + return this.auditPasswordLeaks$(validCiphers).pipe( + map((exposedDetails) => { + return validCiphers.map((cipher) => { + const exposedPassword = exposedDetails.find((x) => x.cipherId === cipher.id); + const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id); + + const result = { + cipher: cipher, + cipherMembers, + healthData: { + weakPasswordDetail: this.findWeakPasswordDetails(cipher), + exposedPasswordDetail: exposedPassword, + reusedPasswordCount: passwordUseMap.get(cipher.login.password) ?? 0, + }, + applications: getTrimmedCipherUris(cipher), + } as CipherHealthReport; + return result; + }); + }), + ); + } + + // TODO This is a temp implementation until the function is available in the password health service + /** + * 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 + */ + isValidCipher(c: CipherView): boolean { + const { type, login, isDeleted, viewPassword } = c; + if ( + type !== CipherType.Login || + login.password == null || + login.password === "" || + isDeleted || + !viewPassword + ) { + return false; + } + return true; + } + + // TODO This is a temp implementation until the function is available in the password health service + /** + * Extracts username parts from the cipher's username. + * This is used to help determine password strength. + * + * @param cipherUsername The username from the cipher. + * @returns An array of username parts. + */ + extractUsernameParts(cipherUsername: string) { + const atPosition = cipherUsername.indexOf("@"); + const userNameToProcess = + atPosition > -1 ? cipherUsername.substring(0, atPosition) : cipherUsername; + + return userNameToProcess + .trim() + .toLowerCase() + .split(/[^A-Za-z0-9]/) + .filter((i) => i.length >= 3); + } + + // TODO This is a temp implementation until the function is available in the password health service + /** + * Checks if the cipher has a weak password based on the password strength score. + * + * @param cipher + * @returns + */ + findWeakPasswordDetails(cipher: CipherView): WeakPasswordDetail | null { + // Validate the cipher + if (!this.isValidCipher(cipher)) { + return null; + } + + // Check the username + const userInput = this.isUserNameNotEmpty(cipher) + ? this.extractUsernameParts(cipher.login.username) + : null; + + const { score } = this.passwordStrengthService.getPasswordStrength( + cipher.login.password, + null, + userInput, + ); + + // If a score is not found or a score is less than 3, it's weak + if (score != null && score <= 2) { + return { score: score, detailValue: this.getPasswordScoreInfo(score) }; + } + return null; + } + + // TODO This is a temp implementation until the function is available in the password health service + /** + * Gets the password score information based on the score. + * + * @param score + * @returns An object containing the label and badge variant for the password score. + */ + getPasswordScoreInfo(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" }; + } + } + + // TODO This is a temp implementation until the function is available in the password health service + /** + * Finds exposed passwords in a list of ciphers. + * + * @param ciphers The list of ciphers to check. + * @returns An observable that emits an array of ExposedPasswordDetail. + */ + auditPasswordLeaks$(ciphers: CipherView[]): Observable { + return from(ciphers).pipe( + filter((cipher) => this.isValidCipher(cipher)), + mergeMap((cipher) => + this.auditService + .passwordLeaked(cipher.login.password) + .then((exposedCount) => ({ cipher, exposedCount })), + ), + filter(({ exposedCount }) => exposedCount > 0), + map(({ cipher, exposedCount }) => ({ + exposedXTimes: exposedCount, + cipherId: cipher.id, + })), + toArray(), + ); + } }