1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-12 14:34:02 +00:00

Risk insights aggregation in a new service. Initial PR.

This commit is contained in:
Tom
2024-11-20 14:00:49 -05:00
parent ca839b3d80
commit bb70127638
2 changed files with 365 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,364 @@
import { Injectable } from "@angular/core";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { BadgeVariant } from "@bitwarden/components";
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
export type ApplicationHealthReportDetail = {
applicationName: string;
passwordCount: number;
atRiskPasswordCount: number;
memberCount: number;
atRiskMemberCount: number;
memberDetails: MemberDetailsFlat[];
atRiskMemberDetails: MemberDetailsFlat[];
};
export type CipherHealthReportUriDetail = {
cipherId: string;
reusedPasswordCount: number;
weakPasswordDetail: WeakPasswordDetail;
exposedPasswordDetail: ExposedPasswordDetail;
cipherMembers: MemberDetailsFlat[];
trimmedUri: string;
};
export type CipherHealthReportDetail = CipherView & {
reusedPasswordCount: number;
weakPasswordDetail: WeakPasswordDetail;
exposedPasswordDetail: ExposedPasswordDetail;
cipherMembers: MemberDetailsFlat[];
trimmedUris: string[];
};
export type WeakPasswordDetail = {
score: number;
detailValue: WeakPasswordScore;
};
export type WeakPasswordScore = {
label: string;
badgeVariant: BadgeVariant;
};
export type ExposedPasswordDetail = {
exposedXTimes: number;
};
export type MemberDetailsFlat = {
userName: string;
email: string;
cipherId: string;
};
@Injectable()
export class RiskInsightsReportService {
passwordUseMap = new Map<string, number>();
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
async generateRawDataReport(organizationId: string): Promise<CipherHealthReportDetail[]> {
const allCiphers = await this.cipherService.getAllFromApiForOrganization(organizationId);
const memberCipherDetails =
await this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId);
const flattenedDetails: MemberDetailsFlat[] = memberCipherDetails.flatMap((dtl) =>
dtl.cipherIds.map((c) => this.getMemberDetailsFlat(dtl.userName, dtl.email, c)),
);
return this.getCipherDetails(allCiphers, flattenedDetails);
}
// Report data for raw cipher health broken out into the uris
// Can be used in the raw data + members + uri diagnostic report
async generateRawDataUriReport(organizationId: string): Promise<CipherHealthReportUriDetail[]> {
const cipherHealthDetails = await this.generateRawDataReport(organizationId);
return this.getCipherUriDetails(cipherHealthDetails);
}
// Report data for the aggregation of uris to like uris and getting password/member counts,
// members, and at risk statuses.
async generateApplicationsReport(
organizationId: string,
): Promise<ApplicationHealthReportDetail[]> {
const cipherHealthUriReport = await this.generateRawDataUriReport(organizationId);
return this.getApplicationHealthReport(cipherHealthUriReport);
}
async getCipherDetails(
ciphers: CipherView[],
memberDetails: MemberDetailsFlat[],
): Promise<CipherHealthReportDetail[]> {
const cipherHealthReports: CipherHealthReportDetail[] = [];
for (const cipher of ciphers) {
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
this.findReusedPassword(cipher);
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 = this.passwordUseMap.has(detail.id)
? this.passwordUseMap.get(detail.id)
: 0;
});
return cipherHealthReports;
}
// Flattens the cipher to trimmed uris. Used for the raw data + uri
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
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 > 0) {
atRisk = true;
}
if (index === -1) {
appReports.push(this.getApplicationReportDetail(uri, atRisk));
} else {
appReports[index] = this.getApplicationReportDetail(uri, atRisk, appReports[index]);
}
});
return appReports;
}
findReusedPassword(cipher: CipherView) {
const { type, login, isDeleted, viewPassword } = cipher;
if (
type !== CipherType.Login ||
login.password == null ||
login.password === "" ||
isDeleted ||
!viewPassword
) {
return;
}
if (this.passwordUseMap.has(login.password)) {
this.passwordUseMap.set(login.password, (this.passwordUseMap.get(login.password) || 0) + 1);
} else {
this.passwordUseMap.set(login.password, 1);
}
}
async findExposedPassword(cipher: CipherView): Promise<ExposedPasswordDetail> {
const { type, login, isDeleted, viewPassword } = cipher;
if (
type !== CipherType.Login ||
login.password == null ||
login.password === "" ||
isDeleted ||
!viewPassword
) {
return null;
}
const exposedCount = await this.auditService.passwordLeaked(login.password);
if (exposedCount > 0) {
const exposedDetail = { exposedXTimes: exposedCount } as ExposedPasswordDetail;
return exposedDetail;
}
return null;
}
findWeakPassword(cipher: CipherView): WeakPasswordDetail {
const { type, login, isDeleted, viewPassword } = cipher;
if (
type !== CipherType.Login ||
login.password == null ||
login.password === "" ||
isDeleted ||
!viewPassword
) {
return null;
}
const hasUserName = this.isUserNameNotEmpty(cipher);
let userInput: string[] = [];
if (hasUserName) {
const atPosition = login.username.indexOf("@");
if (atPosition > -1) {
userInput = userInput
.concat(
login.username
.substring(0, atPosition)
.trim()
.toLowerCase()
.split(/[^A-Za-z0-9]/),
)
.filter((i) => i.length >= 3);
} else {
userInput = login.username
.trim()
.toLowerCase()
.split(/[^A-Za-z0-9]/)
.filter((i) => i.length >= 3);
}
}
const { score } = this.passwordStrengthService.getPasswordStrength(
login.password,
null,
userInput.length > 0 ? userInput : null,
);
if (score != null && score <= 2) {
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.
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;
}
// Gets a distinct list of members given a list of members.
// Uses a set here to avoid using indexOf.
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
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);
}
}