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:
parent
1b6b5d3110
commit
30c151f44a
@ -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;
|
||||
};
|
@ -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[] = [
|
||||
{
|
||||
initializerKey: 1,
|
||||
id: "cbea34a8-bde4-46ad-9d19-b05001228ab1",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
name: "Cannot Be Edited",
|
||||
name: "Weak Password Cipher",
|
||||
notes: null,
|
||||
isDeleted: false,
|
||||
type: 1,
|
||||
@ -14,10 +22,11 @@ export const mockCiphers: any[] = [
|
||||
password: "123",
|
||||
hasUris: true,
|
||||
uris: [
|
||||
{ uri: "www.google.com" },
|
||||
{ uri: "accounts.google.com" },
|
||||
{ uri: "https://www.google.com" },
|
||||
{ uri: "https://www.google.com/login" },
|
||||
createLoginUriView("101domain.com"),
|
||||
createLoginUriView("www.google.com"),
|
||||
createLoginUriView("accounts.google.com"),
|
||||
createLoginUriView("https://www.google.com"),
|
||||
createLoginUriView("https://www.google.com/login"),
|
||||
],
|
||||
},
|
||||
edit: false,
|
||||
@ -31,23 +40,18 @@ export const mockCiphers: any[] = [
|
||||
},
|
||||
{
|
||||
initializerKey: 1,
|
||||
id: "cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||
id: "cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
name: "Can Be Edited id ending 2",
|
||||
name: "Strong Password Cipher",
|
||||
notes: null,
|
||||
isDeleted: false,
|
||||
type: 1,
|
||||
favorite: false,
|
||||
organizationUseTotp: false,
|
||||
login: {
|
||||
password: "123",
|
||||
password: "Password!123",
|
||||
hasUris: true,
|
||||
uris: [
|
||||
{
|
||||
uri: "http://nothing.com",
|
||||
},
|
||||
],
|
||||
uris: [createLoginUriView("http://example.com")],
|
||||
},
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
@ -60,22 +64,18 @@ export const mockCiphers: any[] = [
|
||||
},
|
||||
{
|
||||
initializerKey: 1,
|
||||
id: "cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||
id: "cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
name: "Can Be Edited id ending 3",
|
||||
name: "Strong password Cipher",
|
||||
notes: null,
|
||||
type: 1,
|
||||
favorite: false,
|
||||
organizationUseTotp: false,
|
||||
login: {
|
||||
password: "123",
|
||||
hasUris: true,
|
||||
uris: [
|
||||
{
|
||||
uri: "http://example.com",
|
||||
},
|
||||
],
|
||||
password: "Password!1234",
|
||||
uris: [createLoginUriView("101domain.com")],
|
||||
},
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
@ -91,14 +91,15 @@ export const mockCiphers: any[] = [
|
||||
id: "cbea34a8-bde4-46ad-9d19-b05001228xy4",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
name: "Can Be Edited id ending 4",
|
||||
name: "Strong password Cipher",
|
||||
notes: null,
|
||||
type: 1,
|
||||
favorite: false,
|
||||
organizationUseTotp: false,
|
||||
login: {
|
||||
hasUris: true,
|
||||
uris: [{ uri: "101domain.com" }],
|
||||
password: "Password!123",
|
||||
uris: [createLoginUriView("example.com")],
|
||||
},
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
@ -114,14 +115,39 @@ export const mockCiphers: any[] = [
|
||||
id: "cbea34a8-bde4-46ad-9d19-b05001227nm5",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
name: "Can Be Edited id ending 5",
|
||||
name: "Exposed password Cipher",
|
||||
notes: null,
|
||||
type: 1,
|
||||
favorite: false,
|
||||
organizationUseTotp: false,
|
||||
login: {
|
||||
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,
|
||||
viewPassword: true,
|
||||
|
@ -1,2 +1,3 @@
|
||||
export * from "./member-cipher-details-api.service";
|
||||
export * from "./password-health.service";
|
||||
export * from "./risk-insights-report.service";
|
||||
|
@ -69,6 +69,12 @@ export const mockMemberCipherDetails: any = [
|
||||
"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", () => {
|
||||
@ -91,7 +97,7 @@ describe("Member Cipher Details API Service", () => {
|
||||
const orgId = "1234";
|
||||
const result = await memberCipherDetailsApiService.getMemberCipherDetails(orgId);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toHaveLength(6);
|
||||
expect(result).toHaveLength(7);
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
"/reports/member-cipher-details/" + orgId,
|
||||
|
@ -3,18 +3,15 @@ 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 { 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;
|
||||
let cipherService: CipherService;
|
||||
let memberCipherDetailsApiService: MemberCipherDetailsApiService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@ -51,8 +48,6 @@ describe("PasswordHealthService", () => {
|
||||
});
|
||||
|
||||
service = TestBed.inject(PasswordHealthService);
|
||||
cipherService = TestBed.inject(CipherService);
|
||||
memberCipherDetailsApiService = TestBed.inject(MemberCipherDetailsApiService);
|
||||
});
|
||||
|
||||
it("should be created", () => {
|
||||
@ -67,83 +62,4 @@ describe("PasswordHealthService", () => {
|
||||
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 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user