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:
@@ -49,7 +49,7 @@ export class RiskInsightsDataService {
|
||||
this.isLoadingSubject.next(true);
|
||||
}
|
||||
this.reportService
|
||||
.generateApplicationsReport$(organizationId)
|
||||
.LEGACY_generateApplicationsReport$(organizationId)
|
||||
.pipe(
|
||||
finalize(() => {
|
||||
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 { 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);
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user