1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-26968] Save risk insights summary and metrics (#17132)

* Update type guards

* Add metric data types. Update places saving a risk insights report summary to save metrics

* Fix types and test error

* Fix critical report members

* Update test case for null username in type-guard

* Fix report application mapped data check
This commit is contained in:
Leslie Tilton
2025-10-31 08:46:37 -05:00
committed by GitHub
parent 98849a5a65
commit 6024e1d05f
20 changed files with 924 additions and 608 deletions

View File

@@ -0,0 +1,162 @@
// I'm leaving this here as an example of further improvements we can make to check types
// We can define a nominal type for PositiveSafeNumber to enhance type safety
// const POSITIVE_SAFE_NUMBER_SYMBOL: unique symbol = Symbol("POSITIVE_SAFE_NUMBER");
// This file sets up basic types and guards for values we expect from decrypted data
// Basic types
export type BoundedString = string;
export type BoundedStringOrNull = BoundedString | null;
export type PositiveSafeNumber = number;
export type BoundedArray<T> = T[];
export type DateOrNull = Date | null;
export type DateString = string;
export type DateStringOrNull = DateString | null;
// Constants
/**
* Security limits for validation (prevent DoS attacks and ensure reasonable data sizes)
*/
export const BOUNDED_STRING_MAX_LENGTH = 1000; // Reasonable limit for names, emails, GUIDs
export const BOUNDED_ARRAY_MAX_LENGTH = 50000; // Reasonable limit for report arrays
export const BOUNDED_NUMBER_MAX_COUNT = 10000000; // 10 million - reasonable upper bound for count fields
// Type guard methods
export function isBoolean(value: unknown): value is boolean {
return typeof value === "boolean";
}
export function isBoundedPositiveNumber(value: unknown): value is PositiveSafeNumber {
return (
typeof value === "number" &&
Number.isFinite(value) &&
Number.isSafeInteger(value) &&
value >= 0 &&
value <= BOUNDED_NUMBER_MAX_COUNT
);
}
export function isBoundedString(value: unknown): value is BoundedString {
return typeof value === "string" && value.length > 0 && value.length <= BOUNDED_STRING_MAX_LENGTH;
}
export function isBoundedStringOrNull(value: unknown): value is BoundedStringOrNull {
return value == null || isBoundedString(value);
}
export const isBoundedStringArray = createBoundedArrayGuard(isBoundedString);
export function isBoundedArray<T>(arr: unknown): arr is BoundedArray<T> {
return Array.isArray(arr) && arr.length < BOUNDED_ARRAY_MAX_LENGTH;
}
/**
* A type guard to check if a value is a valid Date object
* @param value The value to check
* @returns True if the value is a valid Date object, false otherwise
*/
export function isDate(value: unknown): value is Date {
return value instanceof Date && !isNaN(value.getTime());
}
/**
* A type guard to check if a value is a valid Date object or null
* @param value The value to check
* @returns True if the value is a valid Date object, false otherwise
*/
export function isDateOrNull(value: unknown): value is DateOrNull {
return value === null || isDate(value);
}
/**
* A type guard to check if a value is a valid date string
* This also checks that the string value can be correctly parsed into a valid Date object
* @param value The value to check
* @returns True if the value is a valid date string, false otherwise
*/
export function isDateString(value: unknown): value is DateString {
if (typeof value !== "string") {
return false;
}
// Attempt to create a Date object from the string.
const date = new Date(value);
// Return true only if the string produced a valid date.
// We use `getTime()` to check for validity, as `new Date('invalid')` returns `NaN` for its time value.
return !isNaN(date.getTime());
}
/**
* A type guard to check if a value is a valid date string or null
* This also checks that the string value can be correctly parsed into a valid Date object
* @param value The value to check
* @returns True if the value is a valid date string or null, false otherwise
*/
export function isDateStringOrNull(value: unknown): value is DateStringOrNull {
return value === null || isDateString(value);
}
/**
* A higher-order function that takes a type guard for T and returns a
* new type guard for an array of T.
*/
export function createBoundedArrayGuard<T>(isType: (item: unknown) => item is T) {
return function (arr: unknown): arr is T[] {
return isBoundedArray(arr) && arr.every(isType);
};
}
type TempObject = Record<PropertyKey, unknown>;
/**
*
* @param validators
* @returns
*/
export function createValidator<T>(validators: {
[K in keyof T]: (value: unknown) => value is T[K];
}): (obj: unknown) => obj is T {
const keys = Object.keys(validators) as (keyof T)[];
return function (obj: unknown): obj is T {
if (typeof obj !== "object" || obj === null) {
return false;
}
if (Object.getPrototypeOf(obj) !== Object.prototype) {
return false;
}
// Prevent dangerous properties that could be used for prototype pollution
// Check for __proto__, constructor, and prototype as own properties
const dangerousKeys = ["__proto__", "constructor", "prototype"];
for (const key of dangerousKeys) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
return false;
}
}
// Type cast to TempObject for key checks
const tempObj = obj as TempObject;
// Commenting out for compatibility of removed keys from data
// Leaving the code commented for now for further discussion
// Check for unexpected properties
// const actualKeys = Object.keys(tempObj);
// const expectedKeys = new Set(keys as string[]);
// if (actualKeys.some((key) => !expectedKeys.has(key))) {
// return false;
// }
// Check for each property's existence and type
return keys.every((key) => {
// Use 'in' to check for property existence before accessing it
if (!(key in tempObj)) {
return false;
}
// Pass the value to its specific validator
return validators[key](tempObj[key]);
});
};
}

View File

@@ -164,33 +164,6 @@ describe("Risk Insights Type Guards", () => {
expect(validateOrganizationReportSummary(validData)).toEqual(validData);
});
it("should throw error for missing totalMemberCount", () => {
const invalidData = {
totalApplicationCount: 5,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
};
expect(() => validateOrganizationReportSummary(invalidData)).toThrow(
/Invalid OrganizationReportSummary: missing or invalid fields: totalMemberCount \(number\)/,
);
});
it("should throw error for multiple missing fields", () => {
const invalidData = {
totalMemberCount: 10,
// missing multiple fields
};
expect(() => validateOrganizationReportSummary(invalidData)).toThrow(
/Invalid OrganizationReportSummary: missing or invalid fields:.*totalApplicationCount/,
);
});
it("should throw error for invalid field types", () => {
const invalidData = {
totalMemberCount: "10", // should be number
@@ -204,7 +177,7 @@ describe("Risk Insights Type Guards", () => {
};
expect(() => validateOrganizationReportSummary(invalidData)).toThrow(
/Invalid OrganizationReportSummary/,
/Invalid report summary/,
);
});
});
@@ -254,7 +227,7 @@ describe("Risk Insights Type Guards", () => {
];
expect(() => validateOrganizationReportApplicationArray(invalidData)).toThrow(
"Invalid date string: invalid-date",
"Invalid application data: array contains 1 invalid OrganizationReportApplication element(s) at indices: 0",
);
});
@@ -339,10 +312,10 @@ describe("Risk Insights Type Guards", () => {
expect(isMemberDetails(invalidData)).toBe(false);
});
it("should return true for undefined userName", () => {
it("should return true for null userName", () => {
const validData = {
userGuid: "user-1",
userName: undefined as string | undefined,
userName: null as string | null,
email: "john@example.com",
cipherId: "cipher-1",
};
@@ -369,17 +342,6 @@ describe("Risk Insights Type Guards", () => {
expect(isMemberDetails(invalidData)).toBe(false);
});
it("should return false for objects with unexpected properties", () => {
const invalidData = {
userGuid: "user-1",
userName: "John Doe",
email: "john@example.com",
cipherId: "cipher-1",
unexpectedProperty: "should fail",
};
expect(isMemberDetails(invalidData)).toBe(false);
});
it("should return false for prototype pollution attempts", () => {
const invalidData = {
userGuid: "user-1",
@@ -482,22 +444,6 @@ describe("Risk Insights Type Guards", () => {
};
expect(isApplicationHealthReportDetail(invalidData)).toBe(false);
});
it("should return false for objects with unexpected properties", () => {
const invalidData = {
applicationName: "Test App",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
injectedProperty: "malicious",
};
expect(isApplicationHealthReportDetail(invalidData)).toBe(false);
});
});
describe("isOrganizationReportSummary", () => {
@@ -556,21 +502,6 @@ describe("Risk Insights Type Guards", () => {
};
expect(isOrganizationReportSummary(invalidData)).toBe(false);
});
it("should return false for objects with unexpected properties", () => {
const invalidData = {
totalMemberCount: 10,
totalApplicationCount: 5,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
extraField: "should be rejected",
};
expect(isOrganizationReportSummary(invalidData)).toBe(false);
});
});
describe("isOrganizationReportApplication", () => {
@@ -610,16 +541,6 @@ describe("Risk Insights Type Guards", () => {
expect(isOrganizationReportApplication(validData)).toBe(true);
});
it("should return false for objects with unexpected properties", () => {
const invalidData = {
applicationName: "Test App",
isCritical: true,
reviewedDate: null as Date | null,
injectedProperty: "malicious",
};
expect(isOrganizationReportApplication(invalidData)).toBe(false);
});
it("should return false for prototype pollution attempts via __proto__", () => {
const invalidData = {
applicationName: "Test App",

View File

@@ -0,0 +1,200 @@
import {
ApplicationHealthReportDetail,
MemberDetails,
OrganizationReportApplication,
OrganizationReportSummary,
} from "../../models";
import {
createBoundedArrayGuard,
createValidator,
isBoolean,
isBoundedString,
isBoundedStringArray,
isBoundedStringOrNull,
isBoundedPositiveNumber,
BOUNDED_ARRAY_MAX_LENGTH,
isDate,
isDateString,
} from "./basic-type-guards";
// Risk Insights specific type guards
/**
* Type guard to validate MemberDetails structure
* Exported for testability
* Strict validation: rejects objects with unexpected properties and prototype pollution
*/
export const isMemberDetails = createValidator<MemberDetails>({
userGuid: isBoundedString,
userName: isBoundedStringOrNull,
email: isBoundedString,
cipherId: isBoundedString,
});
export const isMemberDetailsArray = createBoundedArrayGuard(isMemberDetails);
/**
* Type guard to validate ApplicationHealthReportDetail structure
* Exported for testability
* Strict validation: rejects objects with unexpected properties and prototype pollution
*/
export const isApplicationHealthReportDetail = createValidator<ApplicationHealthReportDetail>({
applicationName: isBoundedString,
atRiskCipherIds: isBoundedStringArray,
atRiskMemberCount: isBoundedPositiveNumber,
atRiskMemberDetails: isMemberDetailsArray,
atRiskPasswordCount: isBoundedPositiveNumber,
cipherIds: isBoundedStringArray,
memberCount: isBoundedPositiveNumber,
memberDetails: isMemberDetailsArray,
passwordCount: isBoundedPositiveNumber,
});
export const isApplicationHealthReportDetailArray = createBoundedArrayGuard(
isApplicationHealthReportDetail,
);
/**
* Type guard to validate OrganizationReportSummary structure
* Exported for testability
* Strict validation: rejects objects with unexpected properties and prototype pollution
*/
export const isOrganizationReportSummary = createValidator<OrganizationReportSummary>({
totalMemberCount: isBoundedPositiveNumber,
totalApplicationCount: isBoundedPositiveNumber,
totalAtRiskMemberCount: isBoundedPositiveNumber,
totalAtRiskApplicationCount: isBoundedPositiveNumber,
totalCriticalApplicationCount: isBoundedPositiveNumber,
totalCriticalMemberCount: isBoundedPositiveNumber,
totalCriticalAtRiskMemberCount: isBoundedPositiveNumber,
totalCriticalAtRiskApplicationCount: isBoundedPositiveNumber,
});
// Adding to support reviewedDate casting for mapping until the date is saved as a string
function isValidDateOrNull(value: unknown): value is Date | null {
return value == null || isDate(value) || isDateString(value);
}
/**
* Type guard to validate OrganizationReportApplication structure
* Exported for testability
* Strict validation: rejects objects with unexpected properties and prototype pollution
*/
export const isOrganizationReportApplication = createValidator<OrganizationReportApplication>({
applicationName: isBoundedString,
isCritical: isBoolean,
// ReviewedDate is currently being saved to the database as a Date type
// We can improve this when OrganizationReportApplication is updated
// to use the Domain, Api, and View model pattern to convert the type to a string
// for storage instead of Date
// Should eventually be changed to isDateStringOrNull
reviewedDate: isValidDateOrNull,
});
export const isOrganizationReportApplicationArray = createBoundedArrayGuard(
isOrganizationReportApplication,
);
/**
* Validates and returns an array of ApplicationHealthReportDetail
* @throws Error if validation fails
*/
export function validateApplicationHealthReportDetailArray(
data: unknown,
): ApplicationHealthReportDetail[] {
if (!Array.isArray(data)) {
throw new Error(
"Invalid report data: expected array of ApplicationHealthReportDetail, received non-array",
);
}
if (data.length > BOUNDED_ARRAY_MAX_LENGTH) {
throw new Error(
`Invalid report data: array length ${data.length} exceeds maximum allowed length ${BOUNDED_ARRAY_MAX_LENGTH}`,
);
}
const invalidItems = data
.map((item, index) => ({ item, index }))
.filter(({ item }) => !isApplicationHealthReportDetail(item));
if (invalidItems.length > 0) {
const invalidIndices = invalidItems.map(({ index }) => index).join(", ");
throw new Error(
`Invalid report data: array contains ${invalidItems.length} invalid ApplicationHealthReportDetail element(s) at indices: ${invalidIndices}`,
);
}
if (!isApplicationHealthReportDetailArray(data)) {
// Throw for type casting return
// Should never get here
throw new Error("Invalid report data");
}
return data;
}
/**
* Validates and returns OrganizationReportSummary
* @throws Error if validation fails
*/
export function validateOrganizationReportSummary(data: unknown): OrganizationReportSummary {
if (!isOrganizationReportSummary(data)) {
throw new Error("Invalid report summary");
}
return data;
}
/**
* Validates and returns an array of OrganizationReportApplication
* @throws Error if validation fails
*/
export function validateOrganizationReportApplicationArray(
data: unknown,
): OrganizationReportApplication[] {
if (!Array.isArray(data)) {
throw new Error(
"Invalid application data: expected array of OrganizationReportApplication, received non-array",
);
}
if (data.length > BOUNDED_ARRAY_MAX_LENGTH) {
throw new Error(
`Invalid application data: array length ${data.length} exceeds maximum allowed length ${BOUNDED_ARRAY_MAX_LENGTH}`,
);
}
const invalidItems = data
.map((item, index) => ({ item, index }))
.filter(({ item }) => !isOrganizationReportApplication(item));
if (invalidItems.length > 0) {
const invalidIndices = invalidItems.map(({ index }) => index).join(", ");
throw new Error(
`Invalid application data: array contains ${invalidItems.length} invalid OrganizationReportApplication element(s) at indices: ${invalidIndices}`,
);
}
const mappedData = data.map((item) => ({
...item,
reviewedDate: item.reviewedDate
? item.reviewedDate instanceof Date
? item.reviewedDate
: (() => {
const date = new Date(item.reviewedDate);
if (!isDate(date)) {
throw new Error(`Invalid date string: ${item.reviewedDate}`);
}
return date;
})()
: null,
}));
if (!isOrganizationReportApplicationArray(mappedData)) {
// Throw for type casting return
// Should never get here
throw new Error("Invalid application data");
}
// Convert string dates to Date objects for reviewedDate
return mappedData;
}

View File

@@ -4,6 +4,7 @@ import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/gu
import { createNewSummaryData } from "../helpers";
import { RiskInsightsMetricsData } from "./data/risk-insights-metrics.data";
import { OrganizationReportSummary, PasswordHealthReportApplicationId } from "./report-models";
// -------------------- Password Health Report Models --------------------
@@ -41,6 +42,7 @@ export interface SaveRiskInsightsReportRequest {
reportData: string;
summaryData: string;
applicationData: string;
metrics: RiskInsightsMetricsData;
contentEncryptionKey: string;
};
}
@@ -136,6 +138,12 @@ export interface UpdateRiskInsightsApplicationDataRequest {
applicationData: string;
};
}
export interface UpdateRiskInsightsSummaryDataRequest {
data: {
summaryData: string;
metrics: RiskInsightsMetricsData;
};
}
export class UpdateRiskInsightsApplicationDataResponse extends BaseResponse {
constructor(response: any) {
super(response);

View File

@@ -0,0 +1,42 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
export class RiskInsightsMetricsApi extends BaseResponse {
totalApplicationCount: number = 0;
totalAtRiskApplicationCount: number = 0;
totalCriticalApplicationCount: number = 0;
totalCriticalAtRiskApplicationCount: number = 0;
totalMemberCount: number = 0;
totalAtRiskMemberCount: number = 0;
totalCriticalMemberCount: number = 0;
totalCriticalAtRiskMemberCount: number = 0;
totalPasswordCount: number = 0;
totalAtRiskPasswordCount: number = 0;
totalCriticalPasswordCount: number = 0;
totalCriticalAtRiskPasswordCount: number = 0;
constructor(data: any) {
super(data);
if (data == null) {
return;
}
this.totalApplicationCount = this.getResponseProperty("totalApplicationCount");
this.totalAtRiskApplicationCount = this.getResponseProperty("totalAtRiskApplicationCount");
this.totalCriticalApplicationCount = this.getResponseProperty("totalCriticalApplicationCount");
this.totalCriticalAtRiskApplicationCount = this.getResponseProperty(
"totalCriticalAtRiskApplicationCount",
);
this.totalMemberCount = this.getResponseProperty("totalMemberCount");
this.totalAtRiskMemberCount = this.getResponseProperty("totalAtRiskMemberCount");
this.totalCriticalMemberCount = this.getResponseProperty("totalCriticalMemberCount");
this.totalCriticalAtRiskMemberCount = this.getResponseProperty(
"totalCriticalAtRiskMemberCount",
);
this.totalPasswordCount = this.getResponseProperty("totalPasswordCount");
this.totalAtRiskPasswordCount = this.getResponseProperty("totalAtRiskPasswordCount");
this.totalCriticalPasswordCount = this.getResponseProperty("totalCriticalPasswordCount");
this.totalCriticalAtRiskPasswordCount = this.getResponseProperty(
"totalCriticalAtRiskPasswordCount",
);
}
}

View File

@@ -0,0 +1,34 @@
import { RiskInsightsMetricsApi } from "../api/risk-insights-metrics.api";
export class RiskInsightsMetricsData {
totalApplicationCount: number = 0;
totalAtRiskApplicationCount: number = 0;
totalCriticalApplicationCount: number = 0;
totalCriticalAtRiskApplicationCount: number = 0;
totalMemberCount: number = 0;
totalAtRiskMemberCount: number = 0;
totalCriticalMemberCount: number = 0;
totalCriticalAtRiskMemberCount: number = 0;
totalPasswordCount: number = 0;
totalAtRiskPasswordCount: number = 0;
totalCriticalPasswordCount: number = 0;
totalCriticalAtRiskPasswordCount: number = 0;
constructor(data?: RiskInsightsMetricsApi) {
if (data == null) {
return;
}
this.totalApplicationCount = data.totalApplicationCount;
this.totalAtRiskApplicationCount = data.totalAtRiskApplicationCount;
this.totalCriticalApplicationCount = data.totalCriticalApplicationCount;
this.totalCriticalAtRiskApplicationCount = data.totalCriticalAtRiskApplicationCount;
this.totalMemberCount = data.totalMemberCount;
this.totalAtRiskMemberCount = data.totalAtRiskMemberCount;
this.totalCriticalMemberCount = data.totalCriticalMemberCount;
this.totalCriticalAtRiskMemberCount = data.totalCriticalAtRiskMemberCount;
this.totalPasswordCount = data.totalPasswordCount;
this.totalAtRiskPasswordCount = data.totalAtRiskPasswordCount;
this.totalCriticalPasswordCount = data.totalCriticalPasswordCount;
this.totalCriticalAtRiskPasswordCount = data.totalCriticalAtRiskPasswordCount;
}
}

View File

@@ -0,0 +1,55 @@
import Domain from "@bitwarden/common/platform/models/domain/domain-base";
import { RiskInsightsMetricsData } from "../data/risk-insights-metrics.data";
export class RiskInsightsMetrics extends Domain {
totalApplicationCount: number = 0;
totalAtRiskApplicationCount: number = 0;
totalCriticalApplicationCount: number = 0;
totalCriticalAtRiskApplicationCount: number = 0;
totalMemberCount: number = 0;
totalAtRiskMemberCount: number = 0;
totalCriticalMemberCount: number = 0;
totalCriticalAtRiskMemberCount: number = 0;
totalPasswordCount: number = 0;
totalAtRiskPasswordCount: number = 0;
totalCriticalPasswordCount: number = 0;
totalCriticalAtRiskPasswordCount: number = 0;
constructor(data?: RiskInsightsMetricsData) {
super();
if (data == null) {
return;
}
this.totalApplicationCount = data.totalApplicationCount;
this.totalAtRiskApplicationCount = data.totalAtRiskApplicationCount;
this.totalCriticalApplicationCount = data.totalCriticalApplicationCount;
this.totalCriticalAtRiskApplicationCount = data.totalCriticalAtRiskApplicationCount;
this.totalMemberCount = data.totalMemberCount;
this.totalAtRiskMemberCount = data.totalAtRiskMemberCount;
this.totalCriticalMemberCount = data.totalCriticalMemberCount;
this.totalCriticalAtRiskMemberCount = data.totalCriticalAtRiskMemberCount;
this.totalPasswordCount = data.totalPasswordCount;
this.totalAtRiskPasswordCount = data.totalAtRiskPasswordCount;
this.totalCriticalPasswordCount = data.totalCriticalPasswordCount;
this.totalCriticalAtRiskPasswordCount = data.totalCriticalAtRiskPasswordCount;
}
toRiskInsightsMetricsData(): RiskInsightsMetricsData {
const m = new RiskInsightsMetricsData();
m.totalApplicationCount = this.totalApplicationCount;
m.totalAtRiskApplicationCount = this.totalAtRiskApplicationCount;
m.totalCriticalApplicationCount = this.totalCriticalApplicationCount;
m.totalCriticalAtRiskApplicationCount = this.totalCriticalAtRiskApplicationCount;
m.totalMemberCount = this.totalMemberCount;
m.totalAtRiskMemberCount = this.totalAtRiskMemberCount;
m.totalCriticalMemberCount = this.totalCriticalMemberCount;
m.totalCriticalAtRiskMemberCount = this.totalCriticalAtRiskMemberCount;
m.totalPasswordCount = this.totalPasswordCount;
m.totalAtRiskPasswordCount = this.totalAtRiskPasswordCount;
m.totalCriticalPasswordCount = this.totalCriticalPasswordCount;
m.totalCriticalAtRiskPasswordCount = this.totalCriticalAtRiskPasswordCount;
return m;
}
}

View File

@@ -92,8 +92,8 @@ export const mockApplicationData: OrganizationReportApplication[] = [
];
export const mockEnrichedReportData: ApplicationHealthReportDetailEnriched[] = [
{ ...mockApplication1, isMarkedAsCritical: true, ciphers: [] },
{ ...mockApplication2, isMarkedAsCritical: false, ciphers: [] },
{ ...mockApplication1, isMarkedAsCritical: true },
{ ...mockApplication2, isMarkedAsCritical: false },
];
export const mockCipherViews: CipherView[] = [

View File

@@ -1,5 +1,3 @@
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
ApplicationHealthReportDetail,
OrganizationReportApplication,
@@ -8,7 +6,6 @@ import {
export type ApplicationHealthReportDetailEnriched = ApplicationHealthReportDetail & {
isMarkedAsCritical: boolean;
ciphers: CipherView[];
};
export interface RiskInsightsEnrichedData {
reportData: ApplicationHealthReportDetailEnriched[];

View File

@@ -15,7 +15,7 @@ import { ExposedPasswordDetail, WeakPasswordDetail } from "./password-health";
*/
export type MemberDetails = {
userGuid: string;
userName?: string;
userName: string | null;
email: string;
cipherId: string;
};

View File

@@ -0,0 +1,52 @@
import { Jsonify } from "type-fest";
import { View } from "@bitwarden/common/models/view/view";
import { RiskInsightsMetrics } from "../domain/risk-insights-metrics";
export class RiskInsightsMetricsView implements View {
totalApplicationCount: number = 0;
totalAtRiskApplicationCount: number = 0;
totalCriticalApplicationCount: number = 0;
totalCriticalAtRiskApplicationCount: number = 0;
totalMemberCount: number = 0;
totalAtRiskMemberCount: number = 0;
totalCriticalMemberCount: number = 0;
totalCriticalAtRiskMemberCount: number = 0;
totalPasswordCount: number = 0;
totalAtRiskPasswordCount: number = 0;
totalCriticalPasswordCount: number = 0;
totalCriticalAtRiskPasswordCount: number = 0;
constructor(data?: RiskInsightsMetrics) {
if (data == null) {
return;
}
this.totalApplicationCount = data.totalApplicationCount;
this.totalAtRiskApplicationCount = data.totalAtRiskApplicationCount;
this.totalCriticalApplicationCount = data.totalCriticalApplicationCount;
this.totalCriticalAtRiskApplicationCount = data.totalCriticalAtRiskApplicationCount;
this.totalMemberCount = data.totalMemberCount;
this.totalAtRiskMemberCount = data.totalAtRiskMemberCount;
this.totalCriticalMemberCount = data.totalCriticalMemberCount;
this.totalCriticalAtRiskMemberCount = data.totalCriticalAtRiskMemberCount;
this.totalPasswordCount = data.totalPasswordCount;
this.totalAtRiskPasswordCount = data.totalAtRiskPasswordCount;
this.totalCriticalPasswordCount = data.totalCriticalPasswordCount;
this.totalCriticalAtRiskPasswordCount = data.totalCriticalAtRiskPasswordCount;
}
toJSON() {
return this;
}
static fromJSON(
obj: Partial<Jsonify<RiskInsightsMetricsView>>,
): RiskInsightsMetricsView | undefined {
return Object.assign(new RiskInsightsMetricsView(), obj);
}
// toSdkRiskInsightsMetricsView(): SdkRiskInsightsMetricsView {}
// static fromRiskInsightsMetricsView(obj?: SdkRiskInsightsMetricsView): RiskInsightsMetricsView | undefined {}
}

View File

@@ -15,6 +15,7 @@ import {
SaveRiskInsightsReportRequest,
SaveRiskInsightsReportResponse,
} from "../../models/api-models.types";
import { RiskInsightsMetrics } from "../../models/domain/risk-insights-metrics";
import { mockApplicationData, mockReportData, mockSummaryData } from "../../models/mocks/mock-data";
import { RiskInsightsApiService } from "../api/risk-insights-api.service";
@@ -33,6 +34,20 @@ describe("RiskInsightsApiService", () => {
const mockSummaryEnc = makeEncString(JSON.stringify(mockSummaryData));
const mockApplicationsEnc = makeEncString(JSON.stringify(mockApplicationData));
const mockMetrics: RiskInsightsMetrics = new RiskInsightsMetrics();
mockMetrics.totalApplicationCount = 3;
mockMetrics.totalAtRiskApplicationCount = 1;
mockMetrics.totalAtRiskMemberCount = 2;
mockMetrics.totalAtRiskPasswordCount = 1;
mockMetrics.totalCriticalApplicationCount = 1;
mockMetrics.totalCriticalAtRiskApplicationCount = 1;
mockMetrics.totalCriticalMemberCount = 1;
mockMetrics.totalCriticalAtRiskMemberCount = 1;
mockMetrics.totalCriticalPasswordCount = 0;
mockMetrics.totalCriticalAtRiskPasswordCount = 0;
mockMetrics.totalMemberCount = 5;
mockMetrics.totalPasswordCount = 2;
const mockSaveRiskInsightsReportRequest: SaveRiskInsightsReportRequest = {
data: {
organizationId: orgId,
@@ -41,6 +56,7 @@ describe("RiskInsightsApiService", () => {
summaryData: mockReportEnc.decryptedValue ?? "",
applicationData: mockReportEnc.decryptedValue ?? "",
contentEncryptionKey: mockReportKey.decryptedValue ?? "",
metrics: mockMetrics.toRiskInsightsMetricsData(),
},
};
@@ -191,12 +207,24 @@ describe("RiskInsightsApiService", () => {
mockApiService.send.mockResolvedValueOnce(undefined);
const result = await firstValueFrom(service.updateRiskInsightsSummary$(data, orgId, reportId));
const result = await firstValueFrom(
service.updateRiskInsightsSummary$(reportId, orgId, {
data: {
summaryData: data.encryptedSummaryData.encryptedString!,
metrics: mockMetrics.toRiskInsightsMetricsData(),
},
}),
);
expect(mockApiService.send).toHaveBeenCalledWith(
"PATCH",
`/reports/organizations/${orgId.toString()}/data/summary/${reportId.toString()}`,
data,
{
summaryData: data.encryptedSummaryData.encryptedString!,
metrics: mockMetrics.toRiskInsightsMetricsData(),
reportId,
organizationId: orgId,
},
true,
true,
);

View File

@@ -5,9 +5,9 @@ import { ErrorResponse } from "@bitwarden/common/models/response/error.response"
import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
import {
EncryptedDataWithKey,
UpdateRiskInsightsApplicationDataRequest,
UpdateRiskInsightsApplicationDataResponse,
UpdateRiskInsightsSummaryDataRequest,
} from "../../models";
import {
GetRiskInsightsApplicationDataResponse,
@@ -73,14 +73,14 @@ export class RiskInsightsApiService {
}
updateRiskInsightsSummary$(
summaryData: EncryptedDataWithKey,
organizationId: OrganizationId,
reportId: OrganizationReportId,
organizationId: OrganizationId,
request: UpdateRiskInsightsSummaryDataRequest,
): Observable<void> {
const dbResponse = this.apiService.send(
"PATCH",
`/reports/organizations/${organizationId.toString()}/data/summary/${reportId.toString()}`,
summaryData,
{ ...request.data, reportId: reportId, organizationId },
true,
true,
);

View File

@@ -9,6 +9,11 @@ import { KeyService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { createNewSummaryData } from "../../helpers";
import {
validateApplicationHealthReportDetailArray,
validateOrganizationReportApplicationArray,
validateOrganizationReportSummary,
} from "../../helpers/type-guards/risk-insights-type-guards";
import {
ApplicationHealthReportDetail,
DecryptedReportData,
@@ -18,12 +23,6 @@ import {
OrganizationReportSummary,
} from "../../models";
import {
validateApplicationHealthReportDetailArray,
validateOrganizationReportApplicationArray,
validateOrganizationReportSummary,
} from "./risk-insights-type-guards";
export class RiskInsightsEncryptionService {
constructor(
private keyService: KeyService,

View File

@@ -11,6 +11,7 @@ import { LogService } from "@bitwarden/logging";
import { createNewSummaryData } from "../../helpers";
import { RiskInsightsData, SaveRiskInsightsReportResponse } from "../../models";
import { RiskInsightsMetrics } from "../../models/domain/risk-insights-metrics";
import { mockMemberCipherDetailsResponse } from "../../models/mocks/member-cipher-details-response.mock";
import {
mockApplicationData,
@@ -182,6 +183,20 @@ describe("RiskInsightsOrchestratorService", () => {
// Act
service.generateReport();
const metricsData = new RiskInsightsMetrics();
metricsData.totalApplicationCount = 3;
metricsData.totalAtRiskApplicationCount = 1;
metricsData.totalAtRiskMemberCount = 2;
metricsData.totalAtRiskPasswordCount = 1;
metricsData.totalCriticalApplicationCount = 1;
metricsData.totalCriticalAtRiskApplicationCount = 1;
metricsData.totalCriticalMemberCount = 1;
metricsData.totalCriticalAtRiskMemberCount = 1;
metricsData.totalCriticalPasswordCount = 0;
metricsData.totalCriticalAtRiskPasswordCount = 0;
metricsData.totalMemberCount = 5;
metricsData.totalPasswordCount = 2;
// Assert
service.rawReportData$.subscribe((state) => {
if (!state.loading && state.data) {
@@ -193,6 +208,7 @@ describe("RiskInsightsOrchestratorService", () => {
mockEnrichedReportData,
mockSummaryData,
mockApplicationData,
metricsData,
{ organizationId: mockOrgId, userId: mockUserId },
);
expect(state.data.reportData).toEqual(mockEnrichedReportData);

View File

@@ -47,11 +47,13 @@ import {
ApplicationHealthReportDetailEnriched,
PasswordHealthReportApplicationsResponse,
} from "../../models";
import { RiskInsightsMetrics } from "../../models/domain/risk-insights-metrics";
import { RiskInsightsEnrichedData } from "../../models/report-data-service.types";
import {
CipherHealthReport,
MemberDetails,
OrganizationReportApplication,
OrganizationReportSummary,
ReportState,
} from "../../models/report-models";
import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service";
@@ -188,6 +190,12 @@ export class RiskInsightsOrchestratorService {
this.fetchReport();
}
/**
* Removes a critical application from a report.
*
* @param criticalApplication Application name of the critical application to remove
* @returns
*/
removeCriticalApplication$(criticalApplication: string): Observable<ReportState> {
this.logService.info(
"[RiskInsightsOrchestratorService] Removing critical applications from report",
@@ -200,24 +208,53 @@ export class RiskInsightsOrchestratorService {
this._userId$.pipe(filter((userId) => !!userId)),
),
map(([reportState, organizationDetails, userId]) => {
const report = reportState?.data;
if (!report) {
throwError(() => Error("Tried to update critical applications without a report"));
}
// Create a set for quick lookup of the new critical apps
const existingApplicationData = reportState?.data?.applicationData || [];
const existingApplicationData = report!.applicationData || [];
const updatedApplicationData = this._removeCriticalApplication(
existingApplicationData,
criticalApplication,
);
// Updated summary data after changing critical apps
const updatedSummaryData = this.reportService.getApplicationsSummary(
report!.reportData,
updatedApplicationData,
);
// Used for creating metrics with updated application data
const manualEnrichedApplications = report!.reportData.map(
(application): ApplicationHealthReportDetailEnriched => ({
...application,
isMarkedAsCritical: this.reportService.isCriticalApplication(
application,
updatedApplicationData,
),
}),
);
// For now, merge the report with the critical marking flag to make the enriched type
// We don't care about the individual ciphers in this instance
// After the report and enriched report types are consolidated, this mapping can be removed
// and the class will expose getCriticalApplications
const metrics = this._getReportMetrics(manualEnrichedApplications, updatedSummaryData);
const updatedState = {
...reportState,
data: {
...reportState.data,
summaryData: updatedSummaryData,
applicationData: updatedApplicationData,
},
} as ReportState;
return { reportState, organizationDetails, updatedState, userId };
return { reportState, organizationDetails, updatedState, userId, metrics };
}),
switchMap(({ reportState, organizationDetails, updatedState, userId }) => {
switchMap(({ reportState, organizationDetails, updatedState, userId, metrics }) => {
return from(
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
{
@@ -237,39 +274,53 @@ export class RiskInsightsOrchestratorService {
organizationDetails,
updatedState,
encryptedData,
metrics,
})),
);
}),
switchMap(({ reportState, organizationDetails, updatedState, encryptedData }) => {
switchMap(({ reportState, organizationDetails, updatedState, encryptedData, metrics }) => {
this.logService.debug(
`[RiskInsightsOrchestratorService] Saving applicationData with toggled critical flag for report with id: ${reportState?.data?.id} and org id: ${organizationDetails?.organizationId}`,
);
if (!reportState?.data?.id || !organizationDetails?.organizationId) {
return of({ ...reportState });
}
return this.reportApiService
.updateRiskInsightsApplicationData$(
reportState.data.id,
organizationDetails.organizationId,
{
data: {
applicationData: encryptedData.encryptedApplicationData.toSdk(),
},
// Update applications data with critical marking
const updateApplicationsCall = this.reportApiService.updateRiskInsightsApplicationData$(
reportState.data.id,
organizationDetails.organizationId,
{
data: {
applicationData: encryptedData.encryptedApplicationData.toSdk(),
},
)
.pipe(
map(() => updatedState),
tap((finalState) => {
this._markUnmarkUpdatesSubject.next({
...finalState,
organizationId: reportState.organizationId,
});
}),
catchError((error: unknown) => {
this.logService.error("Failed to save updated applicationData", error);
return of({ ...reportState, error: "Failed to remove a critical application" });
}),
);
},
);
// Update summary after recomputing
const updateSummaryCall = this.reportApiService.updateRiskInsightsSummary$(
reportState.data.id,
organizationDetails.organizationId,
{
data: {
summaryData: encryptedData.encryptedSummaryData.toSdk(),
metrics: metrics.toRiskInsightsMetricsData(),
},
},
);
return forkJoin([updateApplicationsCall, updateSummaryCall]).pipe(
map(() => updatedState),
tap((finalState) => {
this._markUnmarkUpdatesSubject.next({
...finalState,
organizationId: reportState.organizationId,
});
}),
catchError((error: unknown) => {
this.logService.error("Failed to save remove critical application", error);
return of({ ...reportState, error: "Failed to remove a critical application" });
}),
);
}),
);
}
@@ -286,25 +337,54 @@ export class RiskInsightsOrchestratorService {
this._userId$.pipe(filter((userId) => !!userId)),
),
map(([reportState, organizationDetails, userId]) => {
const report = reportState?.data;
if (!report) {
throwError(() => Error("Tried to update critical applications without a report"));
}
// Create a set for quick lookup of the new critical apps
const newCriticalAppNamesSet = new Set(criticalApplications);
const existingApplicationData = reportState?.data?.applicationData || [];
const existingApplicationData = report!.applicationData || [];
const updatedApplicationData = this._mergeApplicationData(
existingApplicationData,
newCriticalAppNamesSet,
);
// Updated summary data after changing critical apps
const updatedSummaryData = this.reportService.getApplicationsSummary(
report!.reportData,
updatedApplicationData,
);
// Used for creating metrics with updated application data
const manualEnrichedApplications = report!.reportData.map(
(application): ApplicationHealthReportDetailEnriched => ({
...application,
isMarkedAsCritical: this.reportService.isCriticalApplication(
application,
updatedApplicationData,
),
}),
);
// For now, merge the report with the critical marking flag to make the enriched type
// We don't care about the individual ciphers in this instance
// After the report and enriched report types are consolidated, this mapping can be removed
// and the class will expose getCriticalApplications
const metrics = this._getReportMetrics(manualEnrichedApplications, updatedSummaryData);
const updatedState = {
...reportState,
data: {
...reportState.data,
summaryData: updatedSummaryData,
applicationData: updatedApplicationData,
},
} as ReportState;
return { reportState, organizationDetails, updatedState, userId };
return { reportState, organizationDetails, updatedState, userId, metrics };
}),
switchMap(({ reportState, organizationDetails, updatedState, userId }) => {
switchMap(({ reportState, organizationDetails, updatedState, userId, metrics }) => {
return from(
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
{
@@ -324,39 +404,52 @@ export class RiskInsightsOrchestratorService {
organizationDetails,
updatedState,
encryptedData,
metrics,
})),
);
}),
switchMap(({ reportState, organizationDetails, updatedState, encryptedData }) => {
switchMap(({ reportState, organizationDetails, updatedState, encryptedData, metrics }) => {
this.logService.debug(
`[RiskInsightsOrchestratorService] Saving critical applications on applicationData with report id: ${reportState?.data?.id} and org id: ${organizationDetails?.organizationId}`,
);
if (!reportState?.data?.id || !organizationDetails?.organizationId) {
return of({ ...reportState });
}
return this.reportApiService
.updateRiskInsightsApplicationData$(
reportState.data.id,
organizationDetails.organizationId,
{
data: {
applicationData: encryptedData.encryptedApplicationData.toSdk(),
},
// Update applications data with critical marking
const updateApplicationsCall = this.reportApiService.updateRiskInsightsApplicationData$(
reportState.data.id,
organizationDetails.organizationId,
{
data: {
applicationData: encryptedData.encryptedApplicationData.toSdk(),
},
)
.pipe(
map(() => updatedState),
tap((finalState) => {
this._markUnmarkUpdatesSubject.next({
...finalState,
organizationId: reportState.organizationId,
});
}),
catchError((error: unknown) => {
this.logService.error("Failed to save updated applicationData", error);
return of({ ...reportState, error: "Failed to save critical applications" });
}),
);
},
);
// Update summary after recomputing
const updateSummaryCall = this.reportApiService.updateRiskInsightsSummary$(
reportState.data.id,
organizationDetails.organizationId,
{
data: {
summaryData: encryptedData.encryptedSummaryData.toSdk(),
metrics: metrics.toRiskInsightsMetricsData(),
},
},
);
return forkJoin([updateApplicationsCall, updateSummaryCall]).pipe(
map(() => updatedState),
tap((finalState) => {
this._markUnmarkUpdatesSubject.next({
...finalState,
organizationId: reportState.organizationId,
});
}),
catchError((error: unknown) => {
this.logService.error("Failed to save critical applications", error);
return of({ ...reportState, error: "Failed to save critical applications" });
}),
);
}),
);
}
@@ -503,18 +596,43 @@ export class RiskInsightsOrchestratorService {
this.reportService.generateApplicationsReport(cipherHealthReports),
),
withLatestFrom(this.rawReportData$),
map(([report, previousReport]) => ({
report: report,
summary: this.reportService.getApplicationsSummary(report),
applications: this.reportService.getOrganizationApplications(
map(([report, previousReport]) => {
// Update the application data
const updatedApplicationData = this.reportService.getOrganizationApplications(
report,
previousReport?.data?.applicationData ?? [],
),
})),
switchMap(({ report, summary, applications }) => {
// Save the report after enrichment
);
const manualEnrichedApplications = report.map(
(application): ApplicationHealthReportDetailEnriched => ({
...application,
isMarkedAsCritical: this.reportService.isCriticalApplication(
application,
updatedApplicationData,
),
}),
);
const updatedSummary = this.reportService.getApplicationsSummary(
report,
updatedApplicationData,
);
// For now, merge the report with the critical marking flag to make the enriched type
// We don't care about the individual ciphers in this instance
// After the report and enriched report types are consolidated, this mapping can be removed
// and the class will expose getCriticalApplications
const metrics = this._getReportMetrics(manualEnrichedApplications, updatedSummary);
return {
report,
summary: updatedSummary,
applications: updatedApplicationData,
metrics,
};
}),
switchMap(({ report, summary, applications, metrics }) => {
return this.reportService
.saveRiskInsightsReport$(report, summary, applications, {
.saveRiskInsightsReport$(report, summary, applications, metrics, {
organizationId,
userId,
})
@@ -557,6 +675,50 @@ export class RiskInsightsOrchestratorService {
);
}
// Calculates the metrics for a report
// This function will be moved to the RiskInsightsReportService after the
// ApplicationHealthReportDetail and ApplicationHealthReportDetailEnriched types
// are consolidated into one
_getReportMetrics(
reports: ApplicationHealthReportDetailEnriched[],
summary: OrganizationReportSummary,
): RiskInsightsMetrics {
const metrics = new RiskInsightsMetrics();
const s = summary;
// Copy summary information
metrics.totalApplicationCount = s.totalApplicationCount;
metrics.totalAtRiskApplicationCount = s.totalAtRiskApplicationCount;
metrics.totalCriticalApplicationCount = s.totalCriticalApplicationCount;
metrics.totalCriticalAtRiskApplicationCount = s.totalCriticalAtRiskApplicationCount;
metrics.totalMemberCount = s.totalMemberCount;
metrics.totalAtRiskMemberCount = s.totalAtRiskMemberCount;
metrics.totalCriticalMemberCount = s.totalCriticalMemberCount;
metrics.totalCriticalAtRiskMemberCount = s.totalCriticalAtRiskMemberCount;
// Calculate additional metrics
let totalPasswordCount = 0;
let totalAtRiskPasswordCount = 0;
let totalCriticalPasswordCount = 0;
let totalCriticalAtRiskPasswordCount = 0;
reports.forEach((report) => {
totalPasswordCount += report.cipherIds.length;
totalAtRiskPasswordCount += report.atRiskCipherIds.length;
if (report.isMarkedAsCritical) {
totalCriticalPasswordCount += report.cipherIds.length;
totalCriticalAtRiskPasswordCount += report.atRiskCipherIds.length;
}
});
metrics.totalPasswordCount = totalPasswordCount;
metrics.totalAtRiskPasswordCount = totalAtRiskPasswordCount;
metrics.totalCriticalPasswordCount = totalCriticalPasswordCount;
metrics.totalCriticalAtRiskPasswordCount = totalCriticalAtRiskPasswordCount;
return metrics;
}
/**
* Associates the members with the ciphers they have access to. Calculates the password health.
* Finds the trimmed uris.
@@ -597,12 +759,14 @@ export class RiskInsightsOrchestratorService {
);
}
// Updates the existing application data to include critical applications
// Does not remove critical applications not in the set
private _mergeApplicationData(
existingApplications: OrganizationReportApplication[],
criticalApplications: Set<string>,
): OrganizationReportApplication[] {
const setToMerge = new Set(criticalApplications);
// First, iterate through the existing apps and update their isCritical flag
const updatedApps = existingApplications.map((app) => {
const foundCritical = setToMerge.has(app.applicationName);
@@ -770,7 +934,10 @@ export class RiskInsightsOrchestratorService {
(app) => app.isMarkedAsCritical,
);
// Generate a new summary based on just the critical applications
const summary = this.reportService.getApplicationsSummary(criticalApplications);
const summary = this.reportService.getApplicationsSummary(
criticalApplications,
enrichedReports.applicationData,
);
return {
...enrichedReports,
summaryData: summary,
@@ -789,24 +956,18 @@ export class RiskInsightsOrchestratorService {
*/
private _setupEnrichedReportData() {
// Setup the enriched report data pipeline
const enrichmentSubscription = combineLatest([
this.rawReportData$,
this._ciphers$.pipe(filter((data) => !!data)),
]).pipe(
switchMap(([rawReportData, ciphers]) => {
const enrichmentSubscription = combineLatest([this.rawReportData$]).pipe(
switchMap(([rawReportData]) => {
this.logService.debug(
"[RiskInsightsOrchestratorService] Enriching report data with ciphers and critical app status",
);
const criticalApps =
const criticalAppsData =
rawReportData?.data?.applicationData.filter((app) => app.isCritical) ?? [];
const criticalApplicationNames = new Set(criticalApps.map((ca) => ca.applicationName));
const rawReports = rawReportData.data?.reportData || [];
const cipherMap = this.reportService.getApplicationCipherMap(ciphers, rawReports);
const enrichedReports: ApplicationHealthReportDetailEnriched[] = rawReports.map((app) => ({
...app,
ciphers: cipherMap.get(app.applicationName) || [],
isMarkedAsCritical: criticalApplicationNames.has(app.applicationName),
isMarkedAsCritical: this.reportService.isCriticalApplication(app, criticalAppsData),
}));
const enrichedData = {

View File

@@ -11,6 +11,7 @@ import {
GetRiskInsightsReportResponse,
SaveRiskInsightsReportResponse,
} from "../../models/api-models.types";
import { RiskInsightsMetrics } from "../../models/domain/risk-insights-metrics";
import { mockCiphers } from "../../models/mocks/ciphers.mock";
import { mockMemberCipherDetailsResponse } from "../../models/mocks/member-cipher-details-response.mock";
import {
@@ -127,10 +128,16 @@ describe("RiskInsightsReportService", () => {
mockRiskInsightsApiService.saveRiskInsightsReport$.mockReturnValue(of(saveResponse));
service
.saveRiskInsightsReport$(mockReportData, mockSummaryData, mockApplicationData, {
organizationId: mockOrganizationId,
userId: mockUserId,
})
.saveRiskInsightsReport$(
mockReportData,
mockSummaryData,
mockApplicationData,
new RiskInsightsMetrics(),
{
organizationId: mockOrganizationId,
userId: mockUserId,
},
)
.subscribe({
next: (response) => {
done.fail("Expected error due to invalid response");

View File

@@ -9,6 +9,7 @@ import {
isSaveRiskInsightsReportResponse,
SaveRiskInsightsReportResponse,
} from "../../models/api-models.types";
import { RiskInsightsMetrics } from "../../models/domain/risk-insights-metrics";
import {
ApplicationHealthReportDetail,
OrganizationReportSummary,
@@ -27,6 +28,13 @@ export class RiskInsightsReportService {
private riskInsightsEncryptionService: RiskInsightsEncryptionService,
) {}
filterApplicationsByCritical(
report: ApplicationHealthReportDetail[],
applicationData: OrganizationReportApplication[],
): ApplicationHealthReportDetail[] {
return report.filter((application) => this.isCriticalApplication(application, applicationData));
}
/**
* Report data for the aggregation of uris to like uris and getting password/member counts,
* members, and at risk statuses.
@@ -43,36 +51,62 @@ export class RiskInsightsReportService {
);
}
/**
*
* @param applications The list of application health report details to map ciphers to
* @param organizationId
* @returns
*/
getApplicationCipherMap(
ciphers: CipherView[],
applications: ApplicationHealthReportDetail[],
): Map<string, CipherView[]> {
const cipherMap = new Map<string, CipherView[]>();
applications.forEach((app) => {
const filteredCiphers = ciphers.filter((c) => app.cipherIds.includes(c.id));
cipherMap.set(app.applicationName, filteredCiphers);
});
return cipherMap;
}
/**
* 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
*/
getApplicationsSummary(reports: ApplicationHealthReportDetail[]): OrganizationReportSummary {
const totalMembers = reports.flatMap((x) => x.memberDetails);
const uniqueMembers = getUniqueMembers(totalMembers);
getApplicationsSummary(
reports: ApplicationHealthReportDetail[],
applicationData: OrganizationReportApplication[],
): OrganizationReportSummary {
const totalUniqueMembers = getUniqueMembers(reports.flatMap((x) => x.memberDetails));
const atRiskUniqueMembers = getUniqueMembers(reports.flatMap((x) => x.atRiskMemberDetails));
const atRiskMembers = reports.flatMap((x) => x.atRiskMemberDetails);
const uniqueAtRiskMembers = getUniqueMembers(atRiskMembers);
const criticalReports = this.filterApplicationsByCritical(reports, applicationData);
const criticalUniqueMembers = getUniqueMembers(criticalReports.flatMap((x) => x.memberDetails));
const criticalAtRiskUniqueMembers = getUniqueMembers(
criticalReports.flatMap((x) => x.atRiskMemberDetails),
);
return {
totalMemberCount: uniqueMembers.length,
totalAtRiskMemberCount: uniqueAtRiskMembers.length,
totalMemberCount: totalUniqueMembers.length,
totalAtRiskMemberCount: atRiskUniqueMembers.length,
totalApplicationCount: reports.length,
totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length,
totalCriticalMemberCount: 0,
totalCriticalAtRiskMemberCount: 0,
totalCriticalApplicationCount: 0,
totalCriticalAtRiskApplicationCount: 0,
totalCriticalMemberCount: criticalUniqueMembers.length,
totalCriticalAtRiskMemberCount: criticalAtRiskUniqueMembers.length,
totalCriticalApplicationCount: criticalReports.length,
totalCriticalAtRiskApplicationCount: criticalReports.filter(
(app) => app.atRiskPasswordCount > 0,
).length,
};
}
/**
* Generate a snapshot of applications and related data associated to this report
* Get information associated to the report applications that can be modified
*
* @param reports
* @returns A list of applications with a critical marking flag
* @returns A list of applications with a critical marking flag and review date
*/
getOrganizationApplications(
reports: ApplicationHealthReportDetail[],
@@ -92,7 +126,7 @@ export class RiskInsightsReportService {
});
}
// No previous applications, return all as non-critical with current date
// No previous applications, return all as non-critical with no review date
return reports.map(
(report): OrganizationReportApplication => ({
applicationName: report.applicationName,
@@ -168,6 +202,15 @@ export class RiskInsightsReportService {
);
}
isCriticalApplication(
application: ApplicationHealthReportDetail,
applicationData: OrganizationReportApplication[],
): boolean {
return applicationData.some(
(a) => a.applicationName == application.applicationName && a.isCritical,
);
}
/**
* Encrypts the risk insights report data for a specific organization.
* @param organizationId The ID of the organization.
@@ -179,6 +222,7 @@ export class RiskInsightsReportService {
report: ApplicationHealthReportDetail[],
summary: OrganizationReportSummary,
applications: OrganizationReportApplication[],
metrics: RiskInsightsMetrics,
encryptionParameters: {
organizationId: OrganizationId;
userId: UserId;
@@ -212,6 +256,7 @@ export class RiskInsightsReportService {
summaryData: encryptedSummaryData.toSdk(),
applicationData: encryptedApplicationData.toSdk(),
contentEncryptionKey: contentEncryptionKey.toSdk(),
metrics: metrics.toRiskInsightsMetricsData(),
},
},
// Keep the original EncString alongside the SDK payload so downstream can return the EncString type.
@@ -256,24 +301,6 @@ export class RiskInsightsReportService {
return applicationMap;
}
/**
*
* @param applications The list of application health report details to map ciphers to
* @param organizationId
* @returns
*/
getApplicationCipherMap(
ciphers: CipherView[],
applications: ApplicationHealthReportDetail[],
): Map<string, CipherView[]> {
const cipherMap = new Map<string, CipherView[]>();
applications.forEach((app) => {
const filteredCiphers = ciphers.filter((c) => app.cipherIds.includes(c.id));
cipherMap.set(app.applicationName, filteredCiphers);
});
return cipherMap;
}
// --------------------------- Aggregation methods ---------------------------
/**
* Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item.

View File

@@ -1,397 +0,0 @@
import {
ApplicationHealthReportDetail,
MemberDetails,
OrganizationReportApplication,
OrganizationReportSummary,
} from "../../models";
/**
* Security limits for validation (prevent DoS attacks and ensure reasonable data sizes)
*/
const MAX_STRING_LENGTH = 1000; // Reasonable limit for names, emails, GUIDs
const MAX_ARRAY_LENGTH = 50000; // Reasonable limit for report arrays
const MAX_COUNT = 10000000; // 10 million - reasonable upper bound for count fields
/**
* Type guard to validate MemberDetails structure
* Exported for testability
* Strict validation: rejects objects with unexpected properties and prototype pollution
*/
export function isMemberDetails(obj: any): obj is MemberDetails {
if (typeof obj !== "object" || obj === null) {
return false;
}
// Prevent prototype pollution - check prototype is Object.prototype
if (Object.getPrototypeOf(obj) !== Object.prototype) {
return false;
}
// Prevent dangerous properties that could be used for prototype pollution
// Check for __proto__, constructor, and prototype as own properties
const dangerousKeys = ["__proto__", "constructor", "prototype"];
for (const key of dangerousKeys) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
return false;
}
}
// Strict property validation - reject unexpected properties
const allowedKeys = ["userGuid", "userName", "email", "cipherId"];
const actualKeys = Object.keys(obj);
const hasUnexpectedProps = actualKeys.some((key) => !allowedKeys.includes(key));
if (hasUnexpectedProps) {
return false;
}
return (
typeof obj.userGuid === "string" &&
obj.userGuid.length > 0 &&
obj.userGuid.length <= MAX_STRING_LENGTH &&
(obj.userName === null ||
obj.userName === undefined ||
(typeof obj.userName === "string" &&
obj.userName.length > 0 &&
obj.userName.length <= MAX_STRING_LENGTH)) &&
typeof obj.email === "string" &&
obj.email.length > 0 &&
obj.email.length <= MAX_STRING_LENGTH &&
typeof obj.cipherId === "string" &&
obj.cipherId.length > 0 &&
obj.cipherId.length <= MAX_STRING_LENGTH
);
}
/**
* Type guard to validate ApplicationHealthReportDetail structure
* Exported for testability
* Strict validation: rejects objects with unexpected properties and prototype pollution
*/
export function isApplicationHealthReportDetail(obj: any): obj is ApplicationHealthReportDetail {
if (typeof obj !== "object" || obj === null) {
return false;
}
// Prevent prototype pollution - check prototype is Object.prototype
if (Object.getPrototypeOf(obj) !== Object.prototype) {
return false;
}
// Prevent dangerous properties that could be used for prototype pollution
// Check for __proto__, constructor, and prototype as own properties
const dangerousKeys = ["__proto__", "constructor", "prototype"];
for (const key of dangerousKeys) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
return false;
}
}
// Strict property validation - reject unexpected properties
const allowedKeys = [
"applicationName",
"passwordCount",
"atRiskPasswordCount",
"atRiskCipherIds",
"memberCount",
"atRiskMemberCount",
"memberDetails",
"atRiskMemberDetails",
"cipherIds",
];
const actualKeys = Object.keys(obj);
const hasUnexpectedProps = actualKeys.some((key) => !allowedKeys.includes(key));
if (hasUnexpectedProps) {
return false;
}
return (
typeof obj.applicationName === "string" &&
obj.applicationName.length > 0 &&
obj.applicationName.length <= MAX_STRING_LENGTH &&
typeof obj.passwordCount === "number" &&
Number.isFinite(obj.passwordCount) &&
Number.isSafeInteger(obj.passwordCount) &&
obj.passwordCount >= 0 &&
obj.passwordCount <= MAX_COUNT &&
typeof obj.atRiskPasswordCount === "number" &&
Number.isFinite(obj.atRiskPasswordCount) &&
Number.isSafeInteger(obj.atRiskPasswordCount) &&
obj.atRiskPasswordCount >= 0 &&
obj.atRiskPasswordCount <= MAX_COUNT &&
Array.isArray(obj.atRiskCipherIds) &&
obj.atRiskCipherIds.length <= MAX_ARRAY_LENGTH &&
obj.atRiskCipherIds.every(
(id: any) => typeof id === "string" && id.length > 0 && id.length <= MAX_STRING_LENGTH,
) &&
typeof obj.memberCount === "number" &&
Number.isFinite(obj.memberCount) &&
Number.isSafeInteger(obj.memberCount) &&
obj.memberCount >= 0 &&
obj.memberCount <= MAX_COUNT &&
typeof obj.atRiskMemberCount === "number" &&
Number.isFinite(obj.atRiskMemberCount) &&
Number.isSafeInteger(obj.atRiskMemberCount) &&
obj.atRiskMemberCount >= 0 &&
obj.atRiskMemberCount <= MAX_COUNT &&
Array.isArray(obj.memberDetails) &&
obj.memberDetails.length <= MAX_ARRAY_LENGTH &&
obj.memberDetails.every(isMemberDetails) &&
Array.isArray(obj.atRiskMemberDetails) &&
obj.atRiskMemberDetails.length <= MAX_ARRAY_LENGTH &&
obj.atRiskMemberDetails.every(isMemberDetails) &&
Array.isArray(obj.cipherIds) &&
obj.cipherIds.length <= MAX_ARRAY_LENGTH &&
obj.cipherIds.every(
(id: any) => typeof id === "string" && id.length > 0 && id.length <= MAX_STRING_LENGTH,
)
);
}
/**
* Type guard to validate OrganizationReportSummary structure
* Exported for testability
* Strict validation: rejects objects with unexpected properties and prototype pollution
*/
export function isOrganizationReportSummary(obj: any): obj is OrganizationReportSummary {
if (typeof obj !== "object" || obj === null) {
return false;
}
// Prevent prototype pollution - check prototype is Object.prototype
if (Object.getPrototypeOf(obj) !== Object.prototype) {
return false;
}
// Prevent dangerous properties that could be used for prototype pollution
// Check for __proto__, constructor, and prototype as own properties
const dangerousKeys = ["__proto__", "constructor", "prototype"];
for (const key of dangerousKeys) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
return false;
}
}
// Strict property validation - reject unexpected properties
const allowedKeys = [
"totalMemberCount",
"totalApplicationCount",
"totalAtRiskMemberCount",
"totalAtRiskApplicationCount",
"totalCriticalApplicationCount",
"totalCriticalMemberCount",
"totalCriticalAtRiskMemberCount",
"totalCriticalAtRiskApplicationCount",
];
const actualKeys = Object.keys(obj);
const hasUnexpectedProps = actualKeys.some((key) => !allowedKeys.includes(key));
if (hasUnexpectedProps) {
return false;
}
return (
typeof obj.totalMemberCount === "number" &&
Number.isFinite(obj.totalMemberCount) &&
Number.isSafeInteger(obj.totalMemberCount) &&
obj.totalMemberCount >= 0 &&
obj.totalMemberCount <= MAX_COUNT &&
typeof obj.totalApplicationCount === "number" &&
Number.isFinite(obj.totalApplicationCount) &&
Number.isSafeInteger(obj.totalApplicationCount) &&
obj.totalApplicationCount >= 0 &&
obj.totalApplicationCount <= MAX_COUNT &&
typeof obj.totalAtRiskMemberCount === "number" &&
Number.isFinite(obj.totalAtRiskMemberCount) &&
Number.isSafeInteger(obj.totalAtRiskMemberCount) &&
obj.totalAtRiskMemberCount >= 0 &&
obj.totalAtRiskMemberCount <= MAX_COUNT &&
typeof obj.totalAtRiskApplicationCount === "number" &&
Number.isFinite(obj.totalAtRiskApplicationCount) &&
Number.isSafeInteger(obj.totalAtRiskApplicationCount) &&
obj.totalAtRiskApplicationCount >= 0 &&
obj.totalAtRiskApplicationCount <= MAX_COUNT &&
typeof obj.totalCriticalApplicationCount === "number" &&
Number.isFinite(obj.totalCriticalApplicationCount) &&
Number.isSafeInteger(obj.totalCriticalApplicationCount) &&
obj.totalCriticalApplicationCount >= 0 &&
obj.totalCriticalApplicationCount <= MAX_COUNT &&
typeof obj.totalCriticalMemberCount === "number" &&
Number.isFinite(obj.totalCriticalMemberCount) &&
Number.isSafeInteger(obj.totalCriticalMemberCount) &&
obj.totalCriticalMemberCount >= 0 &&
obj.totalCriticalMemberCount <= MAX_COUNT &&
typeof obj.totalCriticalAtRiskMemberCount === "number" &&
Number.isFinite(obj.totalCriticalAtRiskMemberCount) &&
Number.isSafeInteger(obj.totalCriticalAtRiskMemberCount) &&
obj.totalCriticalAtRiskMemberCount >= 0 &&
obj.totalCriticalAtRiskMemberCount <= MAX_COUNT &&
typeof obj.totalCriticalAtRiskApplicationCount === "number" &&
Number.isFinite(obj.totalCriticalAtRiskApplicationCount) &&
Number.isSafeInteger(obj.totalCriticalAtRiskApplicationCount) &&
obj.totalCriticalAtRiskApplicationCount >= 0 &&
obj.totalCriticalAtRiskApplicationCount <= MAX_COUNT
);
}
/**
* Type guard to validate OrganizationReportApplication structure
* Exported for testability
* Strict validation: rejects objects with unexpected properties and prototype pollution
*/
export function isOrganizationReportApplication(obj: any): obj is OrganizationReportApplication {
if (typeof obj !== "object" || obj === null) {
return false;
}
// Prevent prototype pollution - check prototype is Object.prototype
if (Object.getPrototypeOf(obj) !== Object.prototype) {
return false;
}
// Prevent dangerous properties that could be used for prototype pollution
// Check for __proto__, constructor, and prototype as own properties
const dangerousKeys = ["__proto__", "constructor", "prototype"];
for (const key of dangerousKeys) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
return false;
}
}
// Strict property validation - reject unexpected properties
const allowedKeys = ["applicationName", "isCritical", "reviewedDate"];
const actualKeys = Object.keys(obj);
const hasUnexpectedProps = actualKeys.some((key) => !allowedKeys.includes(key));
if (hasUnexpectedProps) {
return false;
}
return (
typeof obj.applicationName === "string" &&
obj.applicationName.length > 0 &&
obj.applicationName.length <= MAX_STRING_LENGTH &&
typeof obj.isCritical === "boolean" &&
(obj.reviewedDate === null ||
obj.reviewedDate instanceof Date ||
typeof obj.reviewedDate === "string")
);
}
/**
* Validates and returns an array of ApplicationHealthReportDetail
* @throws Error if validation fails
*/
export function validateApplicationHealthReportDetailArray(
data: any,
): ApplicationHealthReportDetail[] {
if (!Array.isArray(data)) {
throw new Error(
"Invalid report data: expected array of ApplicationHealthReportDetail, received non-array",
);
}
if (data.length > MAX_ARRAY_LENGTH) {
throw new Error(
`Invalid report data: array length ${data.length} exceeds maximum allowed length ${MAX_ARRAY_LENGTH}`,
);
}
const invalidItems = data
.map((item, index) => ({ item, index }))
.filter(({ item }) => !isApplicationHealthReportDetail(item));
if (invalidItems.length > 0) {
const invalidIndices = invalidItems.map(({ index }) => index).join(", ");
throw new Error(
`Invalid report data: array contains ${invalidItems.length} invalid ApplicationHealthReportDetail element(s) at indices: ${invalidIndices}`,
);
}
return data as ApplicationHealthReportDetail[];
}
/**
* Validates and returns OrganizationReportSummary
* @throws Error if validation fails
*/
export function validateOrganizationReportSummary(data: any): OrganizationReportSummary {
if (!isOrganizationReportSummary(data)) {
const missingFields: string[] = [];
if (typeof data?.totalMemberCount !== "number") {
missingFields.push("totalMemberCount (number)");
}
if (typeof data?.totalApplicationCount !== "number") {
missingFields.push("totalApplicationCount (number)");
}
if (typeof data?.totalAtRiskMemberCount !== "number") {
missingFields.push("totalAtRiskMemberCount (number)");
}
if (typeof data?.totalAtRiskApplicationCount !== "number") {
missingFields.push("totalAtRiskApplicationCount (number)");
}
if (typeof data?.totalCriticalApplicationCount !== "number") {
missingFields.push("totalCriticalApplicationCount (number)");
}
if (typeof data?.totalCriticalMemberCount !== "number") {
missingFields.push("totalCriticalMemberCount (number)");
}
if (typeof data?.totalCriticalAtRiskMemberCount !== "number") {
missingFields.push("totalCriticalAtRiskMemberCount (number)");
}
if (typeof data?.totalCriticalAtRiskApplicationCount !== "number") {
missingFields.push("totalCriticalAtRiskApplicationCount (number)");
}
throw new Error(
`Invalid OrganizationReportSummary: ${missingFields.length > 0 ? `missing or invalid fields: ${missingFields.join(", ")}` : "structure validation failed"}`,
);
}
return data as OrganizationReportSummary;
}
/**
* Validates and returns an array of OrganizationReportApplication
* @throws Error if validation fails
*/
export function validateOrganizationReportApplicationArray(
data: any,
): OrganizationReportApplication[] {
if (!Array.isArray(data)) {
throw new Error(
"Invalid application data: expected array of OrganizationReportApplication, received non-array",
);
}
if (data.length > MAX_ARRAY_LENGTH) {
throw new Error(
`Invalid application data: array length ${data.length} exceeds maximum allowed length ${MAX_ARRAY_LENGTH}`,
);
}
const invalidItems = data
.map((item, index) => ({ item, index }))
.filter(({ item }) => !isOrganizationReportApplication(item));
if (invalidItems.length > 0) {
const invalidIndices = invalidItems.map(({ index }) => index).join(", ");
throw new Error(
`Invalid application data: array contains ${invalidItems.length} invalid OrganizationReportApplication element(s) at indices: ${invalidIndices}`,
);
}
// Convert string dates to Date objects for reviewedDate
return data.map((item) => ({
...item,
reviewedDate: item.reviewedDate
? item.reviewedDate instanceof Date
? item.reviewedDate
: (() => {
const date = new Date(item.reviewedDate);
if (isNaN(date.getTime())) {
throw new Error(`Invalid date string: ${item.reviewedDate}`);
}
return date;
})()
: null,
})) as OrganizationReportApplication[];
}

View File

@@ -45,7 +45,11 @@
tabindex="0"
[attr.aria-label]="'viewItem' | i18n"
>
<app-vault-icon *ngIf="row.ciphers.length > 0" [cipher]="row.ciphers[0]"></app-vault-icon>
<!-- Passing the first cipher of the application for app-vault-icon cipher input requirement -->
<app-vault-icon
*ngIf="row.cipherIds.length > 0"
[cipher]="row.cipherIds[0]"
></app-vault-icon>
</td>
<td
class="tw-cursor-pointer"