1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 05:13:29 +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:
Leslie Tilton
2025-09-19 13:40:32 -05:00
committed by GitHub
parent ea5eb9aaf7
commit fb7b29d6b3
3 changed files with 395 additions and 18 deletions

View File

@@ -49,7 +49,7 @@ export class RiskInsightsDataService {
this.isLoadingSubject.next(true);
}
this.reportService
.generateApplicationsReport$(organizationId)
.LEGACY_generateApplicationsReport$(organizationId)
.pipe(
finalize(() => {
this.isLoadingSubject.next(false);

View File

@@ -7,8 +7,11 @@ import { EncryptedString } from "@bitwarden/common/key-management/crypto/models/
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
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 { MemberCipherDetailsResponse } from "../response/member-cipher-details.response";
import { mockCiphers } from "./ciphers.mock";
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
@@ -19,6 +22,8 @@ import { RiskInsightsReportService } from "./risk-insights-report.service";
describe("RiskInsightsReportService", () => {
let service: RiskInsightsReportService;
// Mock services
const pwdStrengthService = mock<PasswordStrengthServiceAbstraction>();
const auditService = mock<AuditService>();
const cipherService = mock<CipherService>();
@@ -28,7 +33,11 @@ describe("RiskInsightsReportService", () => {
encryptRiskInsightsReport: jest.fn().mockResolvedValue("encryptedReportData"),
decryptRiskInsightsReport: jest.fn().mockResolvedValue("decryptedReportData"),
});
const orgId = "orgId" as OrganizationId;
// Mock data
const mockOrgId = "orgId" as OrganizationId;
let mockCipherViews: CipherView[];
let mockMemberDetails: MemberCipherDetailsResponse[];
beforeEach(() => {
pwdStrengthService.getPasswordStrength.mockImplementation((password: string) => {
@@ -52,10 +61,74 @@ describe("RiskInsightsReportService", () => {
mockRiskInsightsApiService,
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 () => {
const result = await firstValueFrom(service.generateRawDataReport$(orgId));
const result = await firstValueFrom(service.LEGACY_generateRawDataReport$(mockOrgId));
expect(result).toHaveLength(6);
@@ -81,7 +154,7 @@ describe("RiskInsightsReportService", () => {
});
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);
@@ -104,7 +177,7 @@ describe("RiskInsightsReportService", () => {
});
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);
@@ -145,7 +218,9 @@ describe("RiskInsightsReportService", () => {
});
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);
expect(reportSummary.totalMemberCount).toEqual(7);

View File

@@ -3,13 +3,17 @@
import {
BehaviorSubject,
concatMap,
filter,
first,
firstValueFrom,
forkJoin,
from,
map,
mergeMap,
Observable,
of,
switchMap,
toArray,
zip,
} from "rxjs";
@@ -23,6 +27,7 @@ import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
flattenMemberDetails,
getApplicationReportDetail,
getFlattenedCipherDetails,
getMemberDetailsFlat,
@@ -41,8 +46,11 @@ import {
import {
ApplicationHealthReportDetail,
OrganizationReportSummary,
AtRiskMemberDetail,
AtRiskApplicationDetail,
AtRiskMemberDetail,
CipherHealthReport,
MemberDetails,
PasswordHealthData,
RiskInsightsReportData,
} from "../models/report-models";
@@ -83,7 +91,7 @@ export class RiskInsightsReportService {
* @param organizationId
* @returns Cipher health report data with members and trimmed uris
*/
generateRawDataReport$(
LEGACY_generateRawDataReport$(
organizationId: OrganizationId,
): Observable<LEGACY_CipherHealthReportDetail[]> {
const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId));
@@ -98,7 +106,9 @@ export class RiskInsightsReportService {
);
return [allCiphers, details] as const;
}),
concatMap(([ciphers, flattenedDetails]) => this.getCipherDetails(ciphers, flattenedDetails)),
concatMap(([ciphers, flattenedDetails]) =>
this.LEGACY_getCipherDetails(ciphers, flattenedDetails),
),
first(),
);
@@ -114,7 +124,7 @@ export class RiskInsightsReportService {
generateRawDataUriReport$(
organizationId: OrganizationId,
): Observable<LEGACY_CipherHealthReportUriDetail[]> {
const cipherHealthDetails$ = this.generateRawDataReport$(organizationId);
const cipherHealthDetails$ = this.LEGACY_generateRawDataReport$(organizationId);
const results$ = cipherHealthDetails$.pipe(
map((healthDetails) => this.getCipherUriDetails(healthDetails)),
first(),
@@ -123,6 +133,24 @@ export class RiskInsightsReportService {
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,
* members, and at risk statuses.
@@ -132,13 +160,21 @@ export class RiskInsightsReportService {
generateApplicationsReport$(
organizationId: OrganizationId,
): Observable<ApplicationHealthReportDetail[]> {
const cipherHealthUriReport$ = this.generateRawDataUriReport$(organizationId);
const results$ = cipherHealthUriReport$.pipe(
map((uriDetails) => this.getApplicationHealthReport(uriDetails)),
first(),
);
const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId));
const memberCiphers$ = from(
this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId),
).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
* @returns Cipher password health data with trimmed uris and associated members
*/
private async getCipherDetails(
private async LEGACY_getCipherDetails(
ciphers: CipherView[],
memberDetails: LEGACY_MemberDetailsFlat[],
): Promise<LEGACY_CipherHealthReportDetail[]> {
@@ -379,7 +415,7 @@ export class RiskInsightsReportService {
* @param cipherHealthUriReport Cipher and password health info broken out into their uris
* @returns Application health reports
*/
private getApplicationHealthReport(
private LEGACY_getApplicationHealthReport(
cipherHealthUriReport: LEGACY_CipherHealthReportUriDetail[],
): ApplicationHealthReportDetail[] {
const appReports: ApplicationHealthReportDetail[] = [];
@@ -497,4 +533,270 @@ export class RiskInsightsReportService {
}
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(),
);
}
}