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