diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.spec.ts index b81acb09bed..65ee2c8bb74 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.spec.ts @@ -1,65 +1,106 @@ -import { TestBed } from "@angular/core/testing"; +import { mock } from "jest-mock-extended"; +import { ZXCVBNResult } from "zxcvbn"; 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 { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { mockCiphers } from "./ciphers.mock"; -import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; -import { mockMemberCipherDetails } from "./member-cipher-details-api.service.spec"; import { PasswordHealthService } from "./password-health.service"; -// FIXME: Remove password-health report service after PR-15498 completion describe("PasswordHealthService", () => { let service: PasswordHealthService; + + // Mock services + const passwordStrengthService = mock(); + const auditService = mock(); + + // Mock data + let mockValidCipher: CipherView; + let mockInvalidCipher: CipherView; + let mockExposedCiphers: CipherView[]; + beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - PasswordHealthService, - { - 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), - }, - }, - { provide: "organizationId", useValue: "org1" }, - ], + // Setup mock service implementations + passwordStrengthService.getPasswordStrength.mockImplementation((password: string) => { + return { score: password === "weak" ? 1 : 4 } as ZXCVBNResult; }); + auditService.passwordLeaked.mockImplementation((password: string) => + Promise.resolve(password === "leaked" ? 2 : 0), + ); + service = new PasswordHealthService(passwordStrengthService, auditService); - service = TestBed.inject(PasswordHealthService); + // Setup mock data + mockValidCipher = mock({ + type: CipherType.Login, + login: { password: "weak", username: "user" }, + isDeleted: false, + viewPassword: true, + }); + mockInvalidCipher = mock({ + type: CipherType.Card, + login: { password: "" }, + isDeleted: true, + viewPassword: false, + }); + mockExposedCiphers = [ + mock({ + id: "cipher-id-1", + type: CipherType.Login, + login: { password: "leaked", username: "user" }, + isDeleted: false, + viewPassword: true, + }), + mock({ + id: "cipher-id-2", + type: CipherType.Login, + login: { password: "safe", username: "user" }, + isDeleted: false, + viewPassword: true, + }), + ]; }); - it("should be created", () => { - expect(service).toBeTruthy(); + it("should extract username parts", () => { + expect(service.extractUsernameParts("john.doe@example.com")).toEqual(["john", "doe"]); + expect(service.extractUsernameParts("a@b.com")).toEqual(["a"]); + expect(service.extractUsernameParts("user_name")).toEqual(["user", "name"]); }); - it("should initialize properties", () => { - expect(service.reportCiphers).toEqual([]); - expect(service.reportCipherIds).toEqual([]); - expect(service.passwordStrengthMap.size).toBe(0); - expect(service.passwordUseMap.size).toBe(0); - expect(service.exposedPasswordMap.size).toBe(0); - expect(service.totalMembersMap.size).toBe(0); + it("should identify weak passwords", () => { + const result = service.findWeakPasswordDetails(mockValidCipher); + expect(result).toEqual({ + score: 1, + detailValue: { label: "veryWeak", badgeVariant: "danger" }, + }); + }); + + it("should return null for invalid cipher in findWeakPasswordDetails", () => { + const cipher = { type: CipherType.Card } as any; + expect(service.findWeakPasswordDetails(cipher)).toBeNull(); + }); + + it("should get password score info", () => { + expect(service.getPasswordScoreInfo(4)).toEqual({ label: "strong", badgeVariant: "success" }); + expect(service.getPasswordScoreInfo(2)).toEqual({ label: "weak", badgeVariant: "warning" }); + expect(service.getPasswordScoreInfo(0)).toEqual({ label: "veryWeak", badgeVariant: "danger" }); + }); + + it("should check if username is not empty", () => { + expect(service.isUserNameNotEmpty({ login: { username: "user" } } as any)).toBe(true); + expect(service.isUserNameNotEmpty({ login: { username: "" } } as any)).toBe(false); + }); + + it("should validate cipher correctly", () => { + expect(service.isValidCipher(mockValidCipher)).toBe(true); + + expect(service.isValidCipher(mockInvalidCipher)).toBe(false); + }); + + it("should audit password leaks", (done) => { + service.auditPasswordLeaks$(mockExposedCiphers).subscribe((result) => { + expect(result).toEqual([{ exposedXTimes: 2, cipherId: "cipher-id-1" }]); + done(); + }); }); }); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts index f2f9a9868f7..865f5cd712f 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts @@ -1,186 +1,136 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Inject, Injectable } from "@angular/core"; +import { filter, from, map, mergeMap, Observable, toArray } 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 { BadgeVariant } from "@bitwarden/components"; -import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; +import { + ExposedPasswordDetail, + WeakPasswordDetail, + WeakPasswordScore, +} from "../models/password-health"; -@Injectable() export class PasswordHealthService { - reportCiphers: CipherView[] = []; - - reportCipherIds: string[] = []; - - passwordStrengthMap = new Map(); - - passwordUseMap = new Map(); - - exposedPasswordMap = new Map(); - - totalMembersMap = new Map(); - constructor( private passwordStrengthService: PasswordStrengthServiceAbstraction, private auditService: AuditService, - private cipherService: CipherService, - private memberCipherDetailsApiService: MemberCipherDetailsApiService, - @Inject("organizationId") private organizationId: string, ) {} - async generateReport() { - const allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organizationId); - allCiphers.forEach(async (cipher) => { - this.findWeakPassword(cipher); - this.findReusedPassword(cipher); - await this.findExposedPassword(cipher); - }); - - const memberCipherDetails = await this.memberCipherDetailsApiService.getMemberCipherDetails( - this.organizationId, + /** + * 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(), ); - - memberCipherDetails.forEach((user) => { - user.cipherIds.forEach((cipherId: string) => { - if (this.totalMembersMap.has(cipherId)) { - this.totalMembersMap.set(cipherId, (this.totalMembersMap.get(cipherId) || 0) + 1); - } else { - this.totalMembersMap.set(cipherId, 1); - } - }); - }); } - async findExposedPassword(cipher: CipherView) { - const { type, login, isDeleted, viewPassword, id } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - !viewPassword - ) { - return; - } + /** + * 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; - const exposedCount = await this.auditService.passwordLeaked(login.password); - if (exposedCount > 0) { - this.exposedPasswordMap.set(id, exposedCount); - this.checkForExistingCipher(cipher); - } + return userNameToProcess + .trim() + .toLowerCase() + .split(/[^A-Za-z0-9]/); } - findReusedPassword(cipher: CipherView) { - const { type, login, isDeleted, viewPassword } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - !viewPassword - ) { - return; + /** + * 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; } - if (this.passwordUseMap.has(login.password)) { - this.passwordUseMap.set(login.password, (this.passwordUseMap.get(login.password) || 0) + 1); - } else { - this.passwordUseMap.set(login.password, 1); - } + // Check the username + const userInput = this.isUserNameNotEmpty(cipher) + ? this.extractUsernameParts(cipher.login.username) + : undefined; - this.checkForExistingCipher(cipher); - } - - findWeakPassword(cipher: CipherView): void { - const { type, login, isDeleted, viewPassword } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - !viewPassword - ) { - return; - } - - const hasUserName = this.isUserNameNotEmpty(cipher); - let userInput: string[] = []; - if (hasUserName) { - const atPosition = login.username.indexOf("@"); - if (atPosition > -1) { - userInput = userInput - .concat( - login.username - .substring(0, atPosition) - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/), - ) - .filter((i) => i.length >= 3); - } else { - userInput = login.username - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/) - .filter((i) => i.length >= 3); - } - } const { score } = this.passwordStrengthService.getPasswordStrength( - login.password, - null, - userInput.length > 0 ? userInput : null, + cipher.login.password, + undefined, // No email available in this context + userInput, ); + // If a score is not found or a score is less than 3, it's weak if (score != null && score <= 2) { - this.passwordStrengthMap.set(cipher.id, this.scoreKey(score)); - this.checkForExistingCipher(cipher); + return { score: score, detailValue: this.getPasswordScoreInfo(score) }; + } + return null; + } + + /** + * 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" }; } } - private isUserNameNotEmpty(c: CipherView): boolean { + /** + * Checks if the username on the cipher is not empty. + */ + isUserNameNotEmpty(c: CipherView): boolean { return !Utils.isNullOrWhitespace(c.login.username); } - private scoreKey(score: number): [string, BadgeVariant] { - switch (score) { - case 4: - return ["strong", "success"]; - case 3: - return ["good", "primary"]; - case 2: - return ["weak", "warning"]; - default: - return ["veryWeak", "danger"]; + /** + * 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; } - } - - checkForExistingCipher(ciph: CipherView) { - if (!this.reportCipherIds.includes(ciph.id)) { - this.reportCipherIds.push(ciph.id); - this.reportCiphers.push(ciph); - } - } - - groupCiphersByLoginUri(): CipherView[] { - const cipherViews: CipherView[] = []; - const cipherUris: string[] = []; - const ciphers = this.reportCiphers; - - ciphers.forEach((ciph) => { - const uris = ciph.login?.uris ?? []; - uris.map((u: { uri: string }) => { - const uri = Utils.getHostname(u.uri).replace("www.", ""); - cipherUris.push(uri); - cipherViews.push({ ...ciph, hostURI: uri } as CipherView & { hostURI: string }); - }); - }); - - return cipherViews; + return true; } }