1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +00:00

[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
This commit is contained in:
Jordan Aasen
2024-10-23 10:30:25 -07:00
committed by GitHub
parent 74dabb97bf
commit 7b8aac229c
13 changed files with 560 additions and 442 deletions

View File

@@ -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: "<rootDir>/",
}),
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
transformIgnorePatterns: ["node_modules/(?!(.*\\.mjs$|@angular|rxjs|@bitwarden))"],
moduleFileExtensions: ["ts", "js", "html", "mjs"],
};

View File

@@ -0,0 +1 @@
export * from "./services";

View File

@@ -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,
},
];

View File

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

View File

@@ -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",
],
},
],
};

View File

@@ -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);
});
});
});

View File

@@ -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<string, [string, BadgeVariant]>();
passwordUseMap = new Map<string, number>();
exposedPasswordMap = new Map<string, number>();
totalMembersMap = new Map<string, number>();
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);
}
}
}

View File

@@ -0,0 +1 @@
import "jest-preset-angular/setup-jest";