From 7b8aac229c8aabb31fd77a2ab9a65f9241d6cd2f Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Wed, 23 Oct 2024 10:30:25 -0700 Subject: [PATCH] [PM-13456] - Password health service (#11658) * add password health service * add spec. fix logic in password reuse * move service to bitwarden_license * revert change to tsconfig * fix spec * fix import --- .../password-health-members.component.ts | 177 +++--------------- .../password-health.component.spec.ts | 78 ++------ .../password-health.component.ts | 172 ++--------------- .../password-health.mock.ts | 66 ------- apps/web/tsconfig.json | 1 + bitwarden_license/bit-common/jest.config.js | 6 +- .../reports/access-intelligence/index.ts | 1 + .../services/ciphers.mock.ts | 128 +++++++++++++ .../access-intelligence/services/index.ts | 2 + .../member-cipher-details-response.mock.ts | 68 +++++++ .../services/password-health.service.spec.ts | 136 ++++++++++++++ .../services/password-health.service.ts | 166 ++++++++++++++++ bitwarden_license/bit-common/test.setup.ts | 1 + 13 files changed, 560 insertions(+), 442 deletions(-) delete mode 100644 apps/web/src/app/tools/access-intelligence/password-health.mock.ts create mode 100644 bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/ciphers.mock.ts create mode 100644 bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/index.ts create mode 100644 bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-response.mock.ts create mode 100644 bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.spec.ts create mode 100644 bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.ts create mode 100644 bitwarden_license/bit-common/test.setup.ts diff --git a/apps/web/src/app/tools/access-intelligence/password-health-members.component.ts b/apps/web/src/app/tools/access-intelligence/password-health-members.component.ts index fd04974b2c..30c9ad8dba 100644 --- a/apps/web/src/app/tools/access-intelligence/password-health-members.component.ts +++ b/apps/web/src/app/tools/access-intelligence/password-health-members.component.ts @@ -2,17 +2,15 @@ import { CommonModule } from "@angular/common"; import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; -import { from, map, switchMap, tap } from "rxjs"; +import { map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +// eslint-disable-next-line no-restricted-imports +import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/access-intelligence"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { BadgeModule, @@ -28,10 +26,6 @@ import { HeaderModule } from "../../layouts/header/header.module"; import { OrganizationBadgeModule } from "../../vault/individual-vault/organization-badge/organization-badge.module"; // eslint-disable-next-line no-restricted-imports import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module"; -// eslint-disable-next-line no-restricted-imports -import { cipherData } from "../reports/pages/reports-ciphers.mock"; - -import { userData } from "./password-health.mock"; @Component({ standalone: true, @@ -47,24 +41,18 @@ import { userData } from "./password-health.mock"; HeaderModule, TableModule, ], + providers: [PasswordHealthService], }) export class PasswordHealthMembersComponent implements OnInit { passwordStrengthMap = new Map(); - weakPasswordCiphers: CipherView[] = []; - passwordUseMap = new Map(); exposedPasswordMap = new Map(); - dataSource = new TableDataSource(); - totalMembersMap = new Map(); - reportCiphers: CipherView[] = []; - reportCipherIds: string[] = []; - - organization: Organization; + dataSource = new TableDataSource(); loading = true; @@ -73,7 +61,6 @@ export class PasswordHealthMembersComponent implements OnInit { constructor( protected cipherService: CipherService, protected passwordStrengthService: PasswordStrengthServiceAbstraction, - protected organizationService: OrganizationService, protected auditService: AuditService, protected i18nService: I18nService, protected activatedRoute: ActivatedRoute, @@ -83,151 +70,29 @@ export class PasswordHealthMembersComponent implements OnInit { this.activatedRoute.paramMap .pipe( takeUntilDestroyed(this.destroyRef), - map((params) => params.get("organizationId")), - switchMap((organizationId) => { - return from(this.organizationService.get(organizationId)); + map(async (params) => { + const organizationId = params.get("organizationId"); + await this.setCiphers(organizationId); }), - tap((organization) => { - this.organization = organization; - }), - switchMap(() => from(this.setCiphers())), ) .subscribe(); - - // mock data - will be replaced with actual data - userData.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 setCiphers() { - // const allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organization.id); - const allCiphers = cipherData; - allCiphers.forEach(async (cipher) => { - this.findWeakPassword(cipher); - this.findReusedPassword(cipher); - await this.findExposedPassword(cipher); - }); - this.dataSource.data = this.reportCiphers; - this.loading = false; - } - - protected checkForExistingCipher(ciph: CipherView) { - if (!this.reportCipherIds.includes(ciph.id)) { - this.reportCipherIds.push(ciph.id); - this.reportCiphers.push(ciph); - } - } - - protected async findExposedPassword(cipher: CipherView) { - const { type, login, isDeleted, edit, viewPassword, id } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword - ) { - return; - } - - const exposedCount = await this.auditService.passwordLeaked(login.password); - if (exposedCount > 0) { - this.exposedPasswordMap.set(id, exposedCount); - this.checkForExistingCipher(cipher); - } - } - - protected findReusedPassword(cipher: CipherView) { - const { type, login, isDeleted, edit, viewPassword } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword - ) { - return; - } - - 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); - } - - this.checkForExistingCipher(cipher); - } - - protected findWeakPassword(cipher: CipherView): void { - const { type, login, isDeleted, edit, viewPassword } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !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, + async setCiphers(organizationId: string) { + const passwordHealthService = new PasswordHealthService( + this.passwordStrengthService, + this.auditService, + this.cipherService, + organizationId, ); - if (score != null && score <= 2) { - this.passwordStrengthMap.set(cipher.id, this.scoreKey(score)); - this.checkForExistingCipher(cipher); - } - } + await passwordHealthService.generateReport(); - private 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"]; - } + this.dataSource.data = passwordHealthService.reportCiphers; + this.exposedPasswordMap = passwordHealthService.exposedPasswordMap; + this.passwordStrengthMap = passwordHealthService.passwordStrengthMap; + this.passwordUseMap = passwordHealthService.passwordUseMap; + this.totalMembersMap = passwordHealthService.totalMembersMap; + this.loading = false; } } diff --git a/apps/web/src/app/tools/access-intelligence/password-health.component.spec.ts b/apps/web/src/app/tools/access-intelligence/password-health.component.spec.ts index 4a6d5c50ee..d41807e7d2 100644 --- a/apps/web/src/app/tools/access-intelligence/password-health.component.spec.ts +++ b/apps/web/src/app/tools/access-intelligence/password-health.component.spec.ts @@ -1,11 +1,11 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ActivatedRoute, convertToParamMap } from "@angular/router"; -import { MockProxy, mock } from "jest-mock-extended"; +import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +// eslint-disable-next-line no-restricted-imports +import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/access-intelligence"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -14,39 +14,30 @@ import { TableBodyDirective } from "@bitwarden/components/src/table/table.compon import { LooseComponentsModule } from "../../shared"; import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module"; -// eslint-disable-next-line no-restricted-imports -import { cipherData } from "../reports/pages/reports-ciphers.mock"; import { PasswordHealthComponent } from "./password-health.component"; describe("PasswordHealthComponent", () => { let component: PasswordHealthComponent; let fixture: ComponentFixture; - let passwordStrengthService: MockProxy; - let organizationService: MockProxy; - let cipherServiceMock: MockProxy; - let auditServiceMock: MockProxy; const activeRouteParams = convertToParamMap({ organizationId: "orgId" }); beforeEach(async () => { - passwordStrengthService = mock(); - auditServiceMock = mock(); - organizationService = mock({ - get: jest.fn().mockResolvedValue({ id: "orgId" } as Organization), - }); - cipherServiceMock = mock({ - getAllFromApiForOrganization: jest.fn().mockResolvedValue(cipherData), - }); - await TestBed.configureTestingModule({ imports: [PasswordHealthComponent, PipesModule, TableModule, LooseComponentsModule], declarations: [TableBodyDirective], providers: [ - { provide: CipherService, useValue: cipherServiceMock }, - { provide: PasswordStrengthServiceAbstraction, useValue: passwordStrengthService }, - { provide: OrganizationService, useValue: organizationService }, + { provide: CipherService, useValue: mock() }, { provide: I18nService, useValue: mock() }, - { provide: AuditService, useValue: auditServiceMock }, + { provide: AuditService, useValue: mock() }, + { + provide: PasswordStrengthServiceAbstraction, + useValue: mock(), + }, + { + provide: PasswordHealthService, + useValue: mock(), + }, { provide: ActivatedRoute, useValue: { @@ -69,46 +60,5 @@ describe("PasswordHealthComponent", () => { expect(component).toBeTruthy(); }); - it("should populate reportCiphers with ciphers that have password issues", async () => { - passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 1 } as any); - - auditServiceMock.passwordLeaked.mockResolvedValue(5); - - await component.setCiphers(); - - const cipherIds = component.reportCiphers.map((c) => c.id); - - expect(cipherIds).toEqual([ - "cbea34a8-bde4-46ad-9d19-b05001228ab1", - "cbea34a8-bde4-46ad-9d19-b05001228ab2", - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - ]); - expect(component.reportCiphers.length).toEqual(3); - }); - - it("should correctly populate passwordStrengthMap", async () => { - passwordStrengthService.getPasswordStrength.mockImplementation((password) => { - let score = 0; - if (password === "123") { - score = 1; - } else { - score = 4; - } - return { score } as any; - }); - - auditServiceMock.passwordLeaked.mockResolvedValue(0); - - await component.setCiphers(); - - expect(component.passwordStrengthMap.size).toBeGreaterThan(0); - expect(component.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab2")).toEqual([ - "veryWeak", - "danger", - ]); - expect(component.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228cd3")).toEqual([ - "veryWeak", - "danger", - ]); - }); + it("should call generateReport on init", () => {}); }); diff --git a/apps/web/src/app/tools/access-intelligence/password-health.component.ts b/apps/web/src/app/tools/access-intelligence/password-health.component.ts index 6e8e62c50d..4b7b8e394d 100644 --- a/apps/web/src/app/tools/access-intelligence/password-health.component.ts +++ b/apps/web/src/app/tools/access-intelligence/password-health.component.ts @@ -2,17 +2,15 @@ import { CommonModule } from "@angular/common"; import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; -import { from, map, switchMap, tap } from "rxjs"; +import { map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +// eslint-disable-next-line no-restricted-imports +import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/access-intelligence"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { BadgeModule, @@ -43,23 +41,17 @@ import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module"; HeaderModule, TableModule, ], + providers: [PasswordHealthService], }) export class PasswordHealthComponent implements OnInit { passwordStrengthMap = new Map(); - weakPasswordCiphers: CipherView[] = []; - passwordUseMap = new Map(); exposedPasswordMap = new Map(); dataSource = new TableDataSource(); - reportCiphers: CipherView[] = []; - reportCipherIds: string[] = []; - - organization: Organization; - loading = true; private destroyRef = inject(DestroyRef); @@ -67,7 +59,6 @@ export class PasswordHealthComponent implements OnInit { constructor( protected cipherService: CipherService, protected passwordStrengthService: PasswordStrengthServiceAbstraction, - protected organizationService: OrganizationService, protected auditService: AuditService, protected i18nService: I18nService, protected activatedRoute: ActivatedRoute, @@ -77,153 +68,28 @@ export class PasswordHealthComponent implements OnInit { this.activatedRoute.paramMap .pipe( takeUntilDestroyed(this.destroyRef), - map((params) => params.get("organizationId")), - switchMap((organizationId) => { - return from(this.organizationService.get(organizationId)); + map(async (params) => { + const organizationId = params.get("organizationId"); + await this.setCiphers(organizationId); }), - tap((organization) => { - this.organization = organization; - }), - switchMap(() => from(this.setCiphers())), ) .subscribe(); } - async setCiphers() { - const allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organization.id); - allCiphers.forEach(async (cipher) => { - this.findWeakPassword(cipher); - this.findReusedPassword(cipher); - await this.findExposedPassword(cipher); - }); - this.dataSource.data = this.reportCiphers; - this.loading = false; - - // const reportIssues = allCiphers.map((c) => { - // if (this.passwordStrengthMap.has(c.id)) { - // return c; - // } - - // if (this.passwordUseMap.has(c.id)) { - // return c; - // } - - // if (this.exposedPasswordMap.has(c.id)) { - // return c; - // } - // }); - } - - protected checkForExistingCipher(ciph: CipherView) { - if (!this.reportCipherIds.includes(ciph.id)) { - this.reportCipherIds.push(ciph.id); - this.reportCiphers.push(ciph); - } - } - - protected async findExposedPassword(cipher: CipherView) { - const { type, login, isDeleted, edit, viewPassword, id } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword - ) { - return; - } - - const exposedCount = await this.auditService.passwordLeaked(login.password); - if (exposedCount > 0) { - this.exposedPasswordMap.set(id, exposedCount); - this.checkForExistingCipher(cipher); - } - } - - protected findReusedPassword(cipher: CipherView) { - const { type, login, isDeleted, edit, viewPassword } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword - ) { - return; - } - - 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); - } - - this.checkForExistingCipher(cipher); - } - - protected findWeakPassword(cipher: CipherView): void { - const { type, login, isDeleted, edit, viewPassword } = cipher; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !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, + async setCiphers(organizationId: string) { + const passwordHealthService = new PasswordHealthService( + this.passwordStrengthService, + this.auditService, + this.cipherService, + organizationId, ); - if (score != null && score <= 2) { - this.passwordStrengthMap.set(cipher.id, this.scoreKey(score)); - this.checkForExistingCipher(cipher); - } - } + await passwordHealthService.generateReport(); - private 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"]; - } + this.dataSource.data = passwordHealthService.reportCiphers; + this.exposedPasswordMap = passwordHealthService.exposedPasswordMap; + this.passwordStrengthMap = passwordHealthService.passwordStrengthMap; + this.passwordUseMap = passwordHealthService.passwordUseMap; + this.loading = false; } } diff --git a/apps/web/src/app/tools/access-intelligence/password-health.mock.ts b/apps/web/src/app/tools/access-intelligence/password-health.mock.ts deleted file mode 100644 index d01edc37a5..0000000000 --- a/apps/web/src/app/tools/access-intelligence/password-health.mock.ts +++ /dev/null @@ -1,66 +0,0 @@ -export const userData: any[] = [ - { - userName: "David Brent", - email: "david.brent@wernhamhogg.uk", - usesKeyConnector: true, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab1", - "cbea34a8-bde4-46ad-9d19-b05001228ab2", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - "cbea34a8-bde4-46ad-9d19-b05001227nm5", - ], - }, - { - userName: "Tim Canterbury", - email: "tim.canterbury@wernhamhogg.uk", - usesKeyConnector: false, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab2", - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - "cbea34a8-bde4-46ad-9d19-b05001227nm5", - ], - }, - { - userName: "Gareth Keenan", - email: "gareth.keenan@wernhamhogg.uk", - usesKeyConnector: true, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - "cbea34a8-bde4-46ad-9d19-b05001227nm5", - "cbea34a8-bde4-46ad-9d19-b05001227nm7", - ], - }, - { - userName: "Dawn Tinsley", - email: "dawn.tinsley@wernhamhogg.uk", - usesKeyConnector: true, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab2", - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - ], - }, - { - userName: "Keith Bishop", - email: "keith.bishop@wernhamhogg.uk", - usesKeyConnector: false, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab1", - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - "cbea34a8-bde4-46ad-9d19-b05001227nm5", - ], - }, - { - userName: "Chris Finch", - email: "chris.finch@wernhamhogg.uk", - usesKeyConnector: true, - cipherIds: [ - "cbea34a8-bde4-46ad-9d19-b05001228ab2", - "cbea34a8-bde4-46ad-9d19-b05001228cd3", - "cbea34a8-bde4-46ad-9d19-b05001228xy4", - ], - }, -]; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 3b0c897e91..3799945ea9 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -10,6 +10,7 @@ "@bitwarden/auth/common": ["../../libs/auth/src/common"], "@bitwarden/auth/angular": ["../../libs/auth/src/angular"], "@bitwarden/billing": ["../../libs/billing/src"], + "@bitwarden/bit-common/*": ["../../bitwarden_license/bit-common/src/*"], "@bitwarden/common/*": ["../../libs/common/src/*"], "@bitwarden/components": ["../../libs/components/src"], "@bitwarden/generator-components": ["../../libs/tools/generator/components/src"], diff --git a/bitwarden_license/bit-common/jest.config.js b/bitwarden_license/bit-common/jest.config.js index d79f8ae619..a0441b0188 100644 --- a/bitwarden_license/bit-common/jest.config.js +++ b/bitwarden_license/bit-common/jest.config.js @@ -1,16 +1,16 @@ const { pathsToModuleNameMapper } = require("ts-jest"); - const { compilerOptions } = require("./tsconfig"); - const sharedConfig = require("../../libs/shared/jest.config.angular"); /** @type {import('jest').Config} */ module.exports = { ...sharedConfig, displayName: "bit-common tests", - preset: "ts-jest", testEnvironment: "jsdom", moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { prefix: "/", }), + setupFilesAfterEnv: ["/test.setup.ts"], + transformIgnorePatterns: ["node_modules/(?!(.*\\.mjs$|@angular|rxjs|@bitwarden))"], + moduleFileExtensions: ["ts", "js", "html", "mjs"], }; diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/index.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/index.ts index e69de29bb2..b2221a94a8 100644 --- a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/index.ts +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/index.ts @@ -0,0 +1 @@ +export * from "./services"; diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/ciphers.mock.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/ciphers.mock.ts new file mode 100644 index 0000000000..22b9148c84 --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/ciphers.mock.ts @@ -0,0 +1,128 @@ +export const mockCiphers: any[] = [ + { + initializerKey: 1, + id: "cbea34a8-bde4-46ad-9d19-b05001228ab1", + organizationId: null, + folderId: null, + name: "Cannot Be Edited", + notes: null, + isDeleted: false, + type: 1, + favorite: false, + organizationUseTotp: false, + login: { + password: "123", + }, + edit: false, + 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-b05001228ab2", + organizationId: null, + folderId: null, + name: "Can Be Edited id ending 2", + notes: null, + isDeleted: false, + type: 1, + favorite: false, + organizationUseTotp: false, + login: { + password: "123", + hasUris: true, + uris: [ + { + uri: "http://nothing.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-b05001228cd3", + organizationId: null, + folderId: null, + name: "Can Be Edited id ending 3", + notes: null, + type: 1, + favorite: false, + organizationUseTotp: false, + login: { + password: "123", + hasUris: true, + uris: [ + { + uri: "http://example.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-b05001228xy4", + organizationId: null, + folderId: null, + name: "Can Be Edited id ending 4", + notes: null, + type: 1, + favorite: false, + organizationUseTotp: false, + login: { + hasUris: true, + uris: [{ uri: "101domain.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-b05001227nm5", + organizationId: null, + folderId: null, + name: "Can Be Edited id ending 5", + notes: null, + type: 1, + favorite: false, + organizationUseTotp: false, + login: { + hasUris: true, + uris: [{ uri: "123formbuilder.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, + }, +]; diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/index.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/index.ts new file mode 100644 index 0000000000..c7bace84e5 --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/index.ts @@ -0,0 +1,2 @@ +export * from "./member-cipher-details-api.service"; +export * from "./password-health.service"; diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-response.mock.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-response.mock.ts new file mode 100644 index 0000000000..78cc105e9b --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-response.mock.ts @@ -0,0 +1,68 @@ +export const mockMemberCipherDetailsResponse: { data: any[] } = { + data: [ + { + UserName: "David Brent", + Email: "david.brent@wernhamhogg.uk", + UsesKeyConnector: true, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab1", + "cbea34a8-bde4-46ad-9d19-b05001228ab2", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + "cbea34a8-bde4-46ad-9d19-b05001227nm5", + ], + }, + { + UserName: "Tim Canterbury", + Email: "tim.canterbury@wernhamhogg.uk", + UsesKeyConnector: false, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab2", + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + "cbea34a8-bde4-46ad-9d19-b05001227nm5", + ], + }, + { + UserName: "Gareth Keenan", + Email: "gareth.keenan@wernhamhogg.uk", + UsesKeyConnector: true, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + "cbea34a8-bde4-46ad-9d19-b05001227nm5", + "cbea34a8-bde4-46ad-9d19-b05001227nm7", + ], + }, + { + UserName: "Dawn Tinsley", + Email: "dawn.tinsley@wernhamhogg.uk", + UsesKeyConnector: true, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab2", + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + ], + }, + { + UserName: "Keith Bishop", + Email: "keith.bishop@wernhamhogg.uk", + UsesKeyConnector: false, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab1", + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + "cbea34a8-bde4-46ad-9d19-b05001227nm5", + ], + }, + { + UserName: "Chris Finch", + Email: "chris.finch@wernhamhogg.uk", + UsesKeyConnector: true, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab2", + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + ], + }, + ], +}; diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.spec.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.spec.ts new file mode 100644 index 0000000000..8f391b7d56 --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.spec.ts @@ -0,0 +1,136 @@ +import { TestBed } from "@angular/core/testing"; + +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 { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { mockCiphers } from "./ciphers.mock"; +import { PasswordHealthService } from "./password-health.service"; + +describe("PasswordHealthService", () => { + let service: PasswordHealthService; + let cipherService: CipherService; + + 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(CipherData), + }, + }, + { provide: "organizationId", useValue: "org1" }, + ], + }); + + service = TestBed.inject(PasswordHealthService); + cipherService = TestBed.inject(CipherService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + 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); + }); + + describe("generateReport", () => { + beforeEach(async () => { + await service.generateReport(); + }); + + it("should fetch all ciphers for the organization", () => { + expect(cipherService.getAllFromApiForOrganization).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); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.ts new file mode 100644 index 0000000000..ce78aba426 --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.ts @@ -0,0 +1,166 @@ +import { Inject, Injectable } from "@angular/core"; + +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 { mockCiphers } from "./ciphers.mock"; +import { mockMemberCipherDetailsResponse } from "./member-cipher-details-response.mock"; + +@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, + @Inject("organizationId") private organizationId: string, + ) {} + + async generateReport() { + let allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organizationId); + // TODO remove when actual user member data is available + allCiphers = mockCiphers; + allCiphers.forEach(async (cipher) => { + this.findWeakPassword(cipher); + this.findReusedPassword(cipher); + await this.findExposedPassword(cipher); + }); + + // TODO - fetch actual user member when data is available + mockMemberCipherDetailsResponse.data.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; + } + + const exposedCount = await this.auditService.passwordLeaked(login.password); + if (exposedCount > 0) { + this.exposedPasswordMap.set(id, exposedCount); + this.checkForExistingCipher(cipher); + } + } + + findReusedPassword(cipher: CipherView) { + const { type, login, isDeleted, viewPassword } = cipher; + if ( + type !== CipherType.Login || + login.password == null || + login.password === "" || + isDeleted || + !viewPassword + ) { + return; + } + + 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); + } + + 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, + ); + + if (score != null && score <= 2) { + this.passwordStrengthMap.set(cipher.id, this.scoreKey(score)); + this.checkForExistingCipher(cipher); + } + } + + private 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"]; + } + } + + private checkForExistingCipher(ciph: CipherView) { + if (!this.reportCipherIds.includes(ciph.id)) { + this.reportCipherIds.push(ciph.id); + this.reportCiphers.push(ciph); + } + } +} diff --git a/bitwarden_license/bit-common/test.setup.ts b/bitwarden_license/bit-common/test.setup.ts new file mode 100644 index 0000000000..a702c63396 --- /dev/null +++ b/bitwarden_license/bit-common/test.setup.ts @@ -0,0 +1 @@ +import "jest-preset-angular/setup-jest";