mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 14:23:32 +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:
@@ -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]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -164,33 +164,6 @@ describe("Risk Insights Type Guards", () => {
|
|||||||
expect(validateOrganizationReportSummary(validData)).toEqual(validData);
|
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", () => {
|
it("should throw error for invalid field types", () => {
|
||||||
const invalidData = {
|
const invalidData = {
|
||||||
totalMemberCount: "10", // should be number
|
totalMemberCount: "10", // should be number
|
||||||
@@ -204,7 +177,7 @@ describe("Risk Insights Type Guards", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
expect(() => validateOrganizationReportSummary(invalidData)).toThrow(
|
expect(() => validateOrganizationReportSummary(invalidData)).toThrow(
|
||||||
/Invalid OrganizationReportSummary/,
|
/Invalid report summary/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -254,7 +227,7 @@ describe("Risk Insights Type Guards", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
expect(() => validateOrganizationReportApplicationArray(invalidData)).toThrow(
|
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);
|
expect(isMemberDetails(invalidData)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return true for undefined userName", () => {
|
it("should return true for null userName", () => {
|
||||||
const validData = {
|
const validData = {
|
||||||
userGuid: "user-1",
|
userGuid: "user-1",
|
||||||
userName: undefined as string | undefined,
|
userName: null as string | null,
|
||||||
email: "john@example.com",
|
email: "john@example.com",
|
||||||
cipherId: "cipher-1",
|
cipherId: "cipher-1",
|
||||||
};
|
};
|
||||||
@@ -369,17 +342,6 @@ describe("Risk Insights Type Guards", () => {
|
|||||||
expect(isMemberDetails(invalidData)).toBe(false);
|
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", () => {
|
it("should return false for prototype pollution attempts", () => {
|
||||||
const invalidData = {
|
const invalidData = {
|
||||||
userGuid: "user-1",
|
userGuid: "user-1",
|
||||||
@@ -482,22 +444,6 @@ describe("Risk Insights Type Guards", () => {
|
|||||||
};
|
};
|
||||||
expect(isApplicationHealthReportDetail(invalidData)).toBe(false);
|
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", () => {
|
describe("isOrganizationReportSummary", () => {
|
||||||
@@ -556,21 +502,6 @@ describe("Risk Insights Type Guards", () => {
|
|||||||
};
|
};
|
||||||
expect(isOrganizationReportSummary(invalidData)).toBe(false);
|
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", () => {
|
describe("isOrganizationReportApplication", () => {
|
||||||
@@ -610,16 +541,6 @@ describe("Risk Insights Type Guards", () => {
|
|||||||
expect(isOrganizationReportApplication(validData)).toBe(true);
|
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__", () => {
|
it("should return false for prototype pollution attempts via __proto__", () => {
|
||||||
const invalidData = {
|
const invalidData = {
|
||||||
applicationName: "Test App",
|
applicationName: "Test App",
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/gu
|
|||||||
|
|
||||||
import { createNewSummaryData } from "../helpers";
|
import { createNewSummaryData } from "../helpers";
|
||||||
|
|
||||||
|
import { RiskInsightsMetricsData } from "./data/risk-insights-metrics.data";
|
||||||
import { OrganizationReportSummary, PasswordHealthReportApplicationId } from "./report-models";
|
import { OrganizationReportSummary, PasswordHealthReportApplicationId } from "./report-models";
|
||||||
|
|
||||||
// -------------------- Password Health Report Models --------------------
|
// -------------------- Password Health Report Models --------------------
|
||||||
@@ -41,6 +42,7 @@ export interface SaveRiskInsightsReportRequest {
|
|||||||
reportData: string;
|
reportData: string;
|
||||||
summaryData: string;
|
summaryData: string;
|
||||||
applicationData: string;
|
applicationData: string;
|
||||||
|
metrics: RiskInsightsMetricsData;
|
||||||
contentEncryptionKey: string;
|
contentEncryptionKey: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -136,6 +138,12 @@ export interface UpdateRiskInsightsApplicationDataRequest {
|
|||||||
applicationData: string;
|
applicationData: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
export interface UpdateRiskInsightsSummaryDataRequest {
|
||||||
|
data: {
|
||||||
|
summaryData: string;
|
||||||
|
metrics: RiskInsightsMetricsData;
|
||||||
|
};
|
||||||
|
}
|
||||||
export class UpdateRiskInsightsApplicationDataResponse extends BaseResponse {
|
export class UpdateRiskInsightsApplicationDataResponse extends BaseResponse {
|
||||||
constructor(response: any) {
|
constructor(response: any) {
|
||||||
super(response);
|
super(response);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,8 +92,8 @@ export const mockApplicationData: OrganizationReportApplication[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const mockEnrichedReportData: ApplicationHealthReportDetailEnriched[] = [
|
export const mockEnrichedReportData: ApplicationHealthReportDetailEnriched[] = [
|
||||||
{ ...mockApplication1, isMarkedAsCritical: true, ciphers: [] },
|
{ ...mockApplication1, isMarkedAsCritical: true },
|
||||||
{ ...mockApplication2, isMarkedAsCritical: false, ciphers: [] },
|
{ ...mockApplication2, isMarkedAsCritical: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const mockCipherViews: CipherView[] = [
|
export const mockCipherViews: CipherView[] = [
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ApplicationHealthReportDetail,
|
ApplicationHealthReportDetail,
|
||||||
OrganizationReportApplication,
|
OrganizationReportApplication,
|
||||||
@@ -8,7 +6,6 @@ import {
|
|||||||
|
|
||||||
export type ApplicationHealthReportDetailEnriched = ApplicationHealthReportDetail & {
|
export type ApplicationHealthReportDetailEnriched = ApplicationHealthReportDetail & {
|
||||||
isMarkedAsCritical: boolean;
|
isMarkedAsCritical: boolean;
|
||||||
ciphers: CipherView[];
|
|
||||||
};
|
};
|
||||||
export interface RiskInsightsEnrichedData {
|
export interface RiskInsightsEnrichedData {
|
||||||
reportData: ApplicationHealthReportDetailEnriched[];
|
reportData: ApplicationHealthReportDetailEnriched[];
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { ExposedPasswordDetail, WeakPasswordDetail } from "./password-health";
|
|||||||
*/
|
*/
|
||||||
export type MemberDetails = {
|
export type MemberDetails = {
|
||||||
userGuid: string;
|
userGuid: string;
|
||||||
userName?: string;
|
userName: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
cipherId: string;
|
cipherId: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
SaveRiskInsightsReportRequest,
|
SaveRiskInsightsReportRequest,
|
||||||
SaveRiskInsightsReportResponse,
|
SaveRiskInsightsReportResponse,
|
||||||
} from "../../models/api-models.types";
|
} from "../../models/api-models.types";
|
||||||
|
import { RiskInsightsMetrics } from "../../models/domain/risk-insights-metrics";
|
||||||
import { mockApplicationData, mockReportData, mockSummaryData } from "../../models/mocks/mock-data";
|
import { mockApplicationData, mockReportData, mockSummaryData } from "../../models/mocks/mock-data";
|
||||||
import { RiskInsightsApiService } from "../api/risk-insights-api.service";
|
import { RiskInsightsApiService } from "../api/risk-insights-api.service";
|
||||||
|
|
||||||
@@ -33,6 +34,20 @@ describe("RiskInsightsApiService", () => {
|
|||||||
const mockSummaryEnc = makeEncString(JSON.stringify(mockSummaryData));
|
const mockSummaryEnc = makeEncString(JSON.stringify(mockSummaryData));
|
||||||
const mockApplicationsEnc = makeEncString(JSON.stringify(mockApplicationData));
|
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 = {
|
const mockSaveRiskInsightsReportRequest: SaveRiskInsightsReportRequest = {
|
||||||
data: {
|
data: {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
@@ -41,6 +56,7 @@ describe("RiskInsightsApiService", () => {
|
|||||||
summaryData: mockReportEnc.decryptedValue ?? "",
|
summaryData: mockReportEnc.decryptedValue ?? "",
|
||||||
applicationData: mockReportEnc.decryptedValue ?? "",
|
applicationData: mockReportEnc.decryptedValue ?? "",
|
||||||
contentEncryptionKey: mockReportKey.decryptedValue ?? "",
|
contentEncryptionKey: mockReportKey.decryptedValue ?? "",
|
||||||
|
metrics: mockMetrics.toRiskInsightsMetricsData(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -191,12 +207,24 @@ describe("RiskInsightsApiService", () => {
|
|||||||
|
|
||||||
mockApiService.send.mockResolvedValueOnce(undefined);
|
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(
|
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||||
"PATCH",
|
"PATCH",
|
||||||
`/reports/organizations/${orgId.toString()}/data/summary/${reportId.toString()}`,
|
`/reports/organizations/${orgId.toString()}/data/summary/${reportId.toString()}`,
|
||||||
data,
|
{
|
||||||
|
summaryData: data.encryptedSummaryData.encryptedString!,
|
||||||
|
metrics: mockMetrics.toRiskInsightsMetricsData(),
|
||||||
|
reportId,
|
||||||
|
organizationId: orgId,
|
||||||
|
},
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { ErrorResponse } from "@bitwarden/common/models/response/error.response"
|
|||||||
import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
|
import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EncryptedDataWithKey,
|
|
||||||
UpdateRiskInsightsApplicationDataRequest,
|
UpdateRiskInsightsApplicationDataRequest,
|
||||||
UpdateRiskInsightsApplicationDataResponse,
|
UpdateRiskInsightsApplicationDataResponse,
|
||||||
|
UpdateRiskInsightsSummaryDataRequest,
|
||||||
} from "../../models";
|
} from "../../models";
|
||||||
import {
|
import {
|
||||||
GetRiskInsightsApplicationDataResponse,
|
GetRiskInsightsApplicationDataResponse,
|
||||||
@@ -73,14 +73,14 @@ export class RiskInsightsApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateRiskInsightsSummary$(
|
updateRiskInsightsSummary$(
|
||||||
summaryData: EncryptedDataWithKey,
|
|
||||||
organizationId: OrganizationId,
|
|
||||||
reportId: OrganizationReportId,
|
reportId: OrganizationReportId,
|
||||||
|
organizationId: OrganizationId,
|
||||||
|
request: UpdateRiskInsightsSummaryDataRequest,
|
||||||
): Observable<void> {
|
): Observable<void> {
|
||||||
const dbResponse = this.apiService.send(
|
const dbResponse = this.apiService.send(
|
||||||
"PATCH",
|
"PATCH",
|
||||||
`/reports/organizations/${organizationId.toString()}/data/summary/${reportId.toString()}`,
|
`/reports/organizations/${organizationId.toString()}/data/summary/${reportId.toString()}`,
|
||||||
summaryData,
|
{ ...request.data, reportId: reportId, organizationId },
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import { KeyService } from "@bitwarden/key-management";
|
|||||||
import { LogService } from "@bitwarden/logging";
|
import { LogService } from "@bitwarden/logging";
|
||||||
|
|
||||||
import { createNewSummaryData } from "../../helpers";
|
import { createNewSummaryData } from "../../helpers";
|
||||||
|
import {
|
||||||
|
validateApplicationHealthReportDetailArray,
|
||||||
|
validateOrganizationReportApplicationArray,
|
||||||
|
validateOrganizationReportSummary,
|
||||||
|
} from "../../helpers/type-guards/risk-insights-type-guards";
|
||||||
import {
|
import {
|
||||||
ApplicationHealthReportDetail,
|
ApplicationHealthReportDetail,
|
||||||
DecryptedReportData,
|
DecryptedReportData,
|
||||||
@@ -18,12 +23,6 @@ import {
|
|||||||
OrganizationReportSummary,
|
OrganizationReportSummary,
|
||||||
} from "../../models";
|
} from "../../models";
|
||||||
|
|
||||||
import {
|
|
||||||
validateApplicationHealthReportDetailArray,
|
|
||||||
validateOrganizationReportApplicationArray,
|
|
||||||
validateOrganizationReportSummary,
|
|
||||||
} from "./risk-insights-type-guards";
|
|
||||||
|
|
||||||
export class RiskInsightsEncryptionService {
|
export class RiskInsightsEncryptionService {
|
||||||
constructor(
|
constructor(
|
||||||
private keyService: KeyService,
|
private keyService: KeyService,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { LogService } from "@bitwarden/logging";
|
|||||||
|
|
||||||
import { createNewSummaryData } from "../../helpers";
|
import { createNewSummaryData } from "../../helpers";
|
||||||
import { RiskInsightsData, SaveRiskInsightsReportResponse } from "../../models";
|
import { RiskInsightsData, SaveRiskInsightsReportResponse } from "../../models";
|
||||||
|
import { RiskInsightsMetrics } from "../../models/domain/risk-insights-metrics";
|
||||||
import { mockMemberCipherDetailsResponse } from "../../models/mocks/member-cipher-details-response.mock";
|
import { mockMemberCipherDetailsResponse } from "../../models/mocks/member-cipher-details-response.mock";
|
||||||
import {
|
import {
|
||||||
mockApplicationData,
|
mockApplicationData,
|
||||||
@@ -182,6 +183,20 @@ describe("RiskInsightsOrchestratorService", () => {
|
|||||||
// Act
|
// Act
|
||||||
service.generateReport();
|
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
|
// Assert
|
||||||
service.rawReportData$.subscribe((state) => {
|
service.rawReportData$.subscribe((state) => {
|
||||||
if (!state.loading && state.data) {
|
if (!state.loading && state.data) {
|
||||||
@@ -193,6 +208,7 @@ describe("RiskInsightsOrchestratorService", () => {
|
|||||||
mockEnrichedReportData,
|
mockEnrichedReportData,
|
||||||
mockSummaryData,
|
mockSummaryData,
|
||||||
mockApplicationData,
|
mockApplicationData,
|
||||||
|
metricsData,
|
||||||
{ organizationId: mockOrgId, userId: mockUserId },
|
{ organizationId: mockOrgId, userId: mockUserId },
|
||||||
);
|
);
|
||||||
expect(state.data.reportData).toEqual(mockEnrichedReportData);
|
expect(state.data.reportData).toEqual(mockEnrichedReportData);
|
||||||
|
|||||||
@@ -47,11 +47,13 @@ import {
|
|||||||
ApplicationHealthReportDetailEnriched,
|
ApplicationHealthReportDetailEnriched,
|
||||||
PasswordHealthReportApplicationsResponse,
|
PasswordHealthReportApplicationsResponse,
|
||||||
} from "../../models";
|
} from "../../models";
|
||||||
|
import { RiskInsightsMetrics } from "../../models/domain/risk-insights-metrics";
|
||||||
import { RiskInsightsEnrichedData } from "../../models/report-data-service.types";
|
import { RiskInsightsEnrichedData } from "../../models/report-data-service.types";
|
||||||
import {
|
import {
|
||||||
CipherHealthReport,
|
CipherHealthReport,
|
||||||
MemberDetails,
|
MemberDetails,
|
||||||
OrganizationReportApplication,
|
OrganizationReportApplication,
|
||||||
|
OrganizationReportSummary,
|
||||||
ReportState,
|
ReportState,
|
||||||
} from "../../models/report-models";
|
} from "../../models/report-models";
|
||||||
import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service";
|
import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service";
|
||||||
@@ -188,6 +190,12 @@ export class RiskInsightsOrchestratorService {
|
|||||||
this.fetchReport();
|
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> {
|
removeCriticalApplication$(criticalApplication: string): Observable<ReportState> {
|
||||||
this.logService.info(
|
this.logService.info(
|
||||||
"[RiskInsightsOrchestratorService] Removing critical applications from report",
|
"[RiskInsightsOrchestratorService] Removing critical applications from report",
|
||||||
@@ -200,24 +208,53 @@ export class RiskInsightsOrchestratorService {
|
|||||||
this._userId$.pipe(filter((userId) => !!userId)),
|
this._userId$.pipe(filter((userId) => !!userId)),
|
||||||
),
|
),
|
||||||
map(([reportState, organizationDetails, 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
|
// Create a set for quick lookup of the new critical apps
|
||||||
const existingApplicationData = reportState?.data?.applicationData || [];
|
const existingApplicationData = report!.applicationData || [];
|
||||||
const updatedApplicationData = this._removeCriticalApplication(
|
const updatedApplicationData = this._removeCriticalApplication(
|
||||||
existingApplicationData,
|
existingApplicationData,
|
||||||
criticalApplication,
|
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 = {
|
const updatedState = {
|
||||||
...reportState,
|
...reportState,
|
||||||
data: {
|
data: {
|
||||||
...reportState.data,
|
...reportState.data,
|
||||||
|
summaryData: updatedSummaryData,
|
||||||
applicationData: updatedApplicationData,
|
applicationData: updatedApplicationData,
|
||||||
},
|
},
|
||||||
} as ReportState;
|
} 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(
|
return from(
|
||||||
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
|
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
|
||||||
{
|
{
|
||||||
@@ -237,39 +274,53 @@ export class RiskInsightsOrchestratorService {
|
|||||||
organizationDetails,
|
organizationDetails,
|
||||||
updatedState,
|
updatedState,
|
||||||
encryptedData,
|
encryptedData,
|
||||||
|
metrics,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
switchMap(({ reportState, organizationDetails, updatedState, encryptedData }) => {
|
switchMap(({ reportState, organizationDetails, updatedState, encryptedData, metrics }) => {
|
||||||
this.logService.debug(
|
this.logService.debug(
|
||||||
`[RiskInsightsOrchestratorService] Saving applicationData with toggled critical flag for report with id: ${reportState?.data?.id} and org id: ${organizationDetails?.organizationId}`,
|
`[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) {
|
if (!reportState?.data?.id || !organizationDetails?.organizationId) {
|
||||||
return of({ ...reportState });
|
return of({ ...reportState });
|
||||||
}
|
}
|
||||||
return this.reportApiService
|
|
||||||
.updateRiskInsightsApplicationData$(
|
// Update applications data with critical marking
|
||||||
reportState.data.id,
|
const updateApplicationsCall = this.reportApiService.updateRiskInsightsApplicationData$(
|
||||||
organizationDetails.organizationId,
|
reportState.data.id,
|
||||||
{
|
organizationDetails.organizationId,
|
||||||
data: {
|
{
|
||||||
applicationData: encryptedData.encryptedApplicationData.toSdk(),
|
data: {
|
||||||
},
|
applicationData: encryptedData.encryptedApplicationData.toSdk(),
|
||||||
},
|
},
|
||||||
)
|
},
|
||||||
.pipe(
|
);
|
||||||
map(() => updatedState),
|
// Update summary after recomputing
|
||||||
tap((finalState) => {
|
const updateSummaryCall = this.reportApiService.updateRiskInsightsSummary$(
|
||||||
this._markUnmarkUpdatesSubject.next({
|
reportState.data.id,
|
||||||
...finalState,
|
organizationDetails.organizationId,
|
||||||
organizationId: reportState.organizationId,
|
{
|
||||||
});
|
data: {
|
||||||
}),
|
summaryData: encryptedData.encryptedSummaryData.toSdk(),
|
||||||
catchError((error: unknown) => {
|
metrics: metrics.toRiskInsightsMetricsData(),
|
||||||
this.logService.error("Failed to save updated applicationData", error);
|
},
|
||||||
return of({ ...reportState, error: "Failed to remove a critical application" });
|
},
|
||||||
}),
|
);
|
||||||
);
|
|
||||||
|
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)),
|
this._userId$.pipe(filter((userId) => !!userId)),
|
||||||
),
|
),
|
||||||
map(([reportState, organizationDetails, 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
|
// Create a set for quick lookup of the new critical apps
|
||||||
const newCriticalAppNamesSet = new Set(criticalApplications);
|
const newCriticalAppNamesSet = new Set(criticalApplications);
|
||||||
const existingApplicationData = reportState?.data?.applicationData || [];
|
const existingApplicationData = report!.applicationData || [];
|
||||||
const updatedApplicationData = this._mergeApplicationData(
|
const updatedApplicationData = this._mergeApplicationData(
|
||||||
existingApplicationData,
|
existingApplicationData,
|
||||||
newCriticalAppNamesSet,
|
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 = {
|
const updatedState = {
|
||||||
...reportState,
|
...reportState,
|
||||||
data: {
|
data: {
|
||||||
...reportState.data,
|
...reportState.data,
|
||||||
|
summaryData: updatedSummaryData,
|
||||||
applicationData: updatedApplicationData,
|
applicationData: updatedApplicationData,
|
||||||
},
|
},
|
||||||
} as ReportState;
|
} 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(
|
return from(
|
||||||
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
|
this.riskInsightsEncryptionService.encryptRiskInsightsReport(
|
||||||
{
|
{
|
||||||
@@ -324,39 +404,52 @@ export class RiskInsightsOrchestratorService {
|
|||||||
organizationDetails,
|
organizationDetails,
|
||||||
updatedState,
|
updatedState,
|
||||||
encryptedData,
|
encryptedData,
|
||||||
|
metrics,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
switchMap(({ reportState, organizationDetails, updatedState, encryptedData }) => {
|
switchMap(({ reportState, organizationDetails, updatedState, encryptedData, metrics }) => {
|
||||||
this.logService.debug(
|
this.logService.debug(
|
||||||
`[RiskInsightsOrchestratorService] Saving critical applications on applicationData with report id: ${reportState?.data?.id} and org id: ${organizationDetails?.organizationId}`,
|
`[RiskInsightsOrchestratorService] Saving critical applications on applicationData with report id: ${reportState?.data?.id} and org id: ${organizationDetails?.organizationId}`,
|
||||||
);
|
);
|
||||||
if (!reportState?.data?.id || !organizationDetails?.organizationId) {
|
if (!reportState?.data?.id || !organizationDetails?.organizationId) {
|
||||||
return of({ ...reportState });
|
return of({ ...reportState });
|
||||||
}
|
}
|
||||||
return this.reportApiService
|
// Update applications data with critical marking
|
||||||
.updateRiskInsightsApplicationData$(
|
const updateApplicationsCall = this.reportApiService.updateRiskInsightsApplicationData$(
|
||||||
reportState.data.id,
|
reportState.data.id,
|
||||||
organizationDetails.organizationId,
|
organizationDetails.organizationId,
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
applicationData: encryptedData.encryptedApplicationData.toSdk(),
|
applicationData: encryptedData.encryptedApplicationData.toSdk(),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
},
|
||||||
.pipe(
|
);
|
||||||
map(() => updatedState),
|
|
||||||
tap((finalState) => {
|
// Update summary after recomputing
|
||||||
this._markUnmarkUpdatesSubject.next({
|
const updateSummaryCall = this.reportApiService.updateRiskInsightsSummary$(
|
||||||
...finalState,
|
reportState.data.id,
|
||||||
organizationId: reportState.organizationId,
|
organizationDetails.organizationId,
|
||||||
});
|
{
|
||||||
}),
|
data: {
|
||||||
catchError((error: unknown) => {
|
summaryData: encryptedData.encryptedSummaryData.toSdk(),
|
||||||
this.logService.error("Failed to save updated applicationData", error);
|
metrics: metrics.toRiskInsightsMetricsData(),
|
||||||
return of({ ...reportState, error: "Failed to save critical applications" });
|
},
|
||||||
}),
|
},
|
||||||
);
|
);
|
||||||
|
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),
|
this.reportService.generateApplicationsReport(cipherHealthReports),
|
||||||
),
|
),
|
||||||
withLatestFrom(this.rawReportData$),
|
withLatestFrom(this.rawReportData$),
|
||||||
map(([report, previousReport]) => ({
|
map(([report, previousReport]) => {
|
||||||
report: report,
|
// Update the application data
|
||||||
summary: this.reportService.getApplicationsSummary(report),
|
const updatedApplicationData = this.reportService.getOrganizationApplications(
|
||||||
applications: this.reportService.getOrganizationApplications(
|
|
||||||
report,
|
report,
|
||||||
previousReport?.data?.applicationData ?? [],
|
previousReport?.data?.applicationData ?? [],
|
||||||
),
|
);
|
||||||
})),
|
|
||||||
switchMap(({ report, summary, applications }) => {
|
const manualEnrichedApplications = report.map(
|
||||||
// Save the report after enrichment
|
(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
|
return this.reportService
|
||||||
.saveRiskInsightsReport$(report, summary, applications, {
|
.saveRiskInsightsReport$(report, summary, applications, metrics, {
|
||||||
organizationId,
|
organizationId,
|
||||||
userId,
|
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.
|
* Associates the members with the ciphers they have access to. Calculates the password health.
|
||||||
* Finds the trimmed uris.
|
* 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(
|
private _mergeApplicationData(
|
||||||
existingApplications: OrganizationReportApplication[],
|
existingApplications: OrganizationReportApplication[],
|
||||||
criticalApplications: Set<string>,
|
criticalApplications: Set<string>,
|
||||||
): OrganizationReportApplication[] {
|
): OrganizationReportApplication[] {
|
||||||
const setToMerge = new Set(criticalApplications);
|
const setToMerge = new Set(criticalApplications);
|
||||||
// First, iterate through the existing apps and update their isCritical flag
|
|
||||||
const updatedApps = existingApplications.map((app) => {
|
const updatedApps = existingApplications.map((app) => {
|
||||||
const foundCritical = setToMerge.has(app.applicationName);
|
const foundCritical = setToMerge.has(app.applicationName);
|
||||||
|
|
||||||
@@ -770,7 +934,10 @@ export class RiskInsightsOrchestratorService {
|
|||||||
(app) => app.isMarkedAsCritical,
|
(app) => app.isMarkedAsCritical,
|
||||||
);
|
);
|
||||||
// Generate a new summary based on just the critical applications
|
// 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 {
|
return {
|
||||||
...enrichedReports,
|
...enrichedReports,
|
||||||
summaryData: summary,
|
summaryData: summary,
|
||||||
@@ -789,24 +956,18 @@ export class RiskInsightsOrchestratorService {
|
|||||||
*/
|
*/
|
||||||
private _setupEnrichedReportData() {
|
private _setupEnrichedReportData() {
|
||||||
// Setup the enriched report data pipeline
|
// Setup the enriched report data pipeline
|
||||||
const enrichmentSubscription = combineLatest([
|
const enrichmentSubscription = combineLatest([this.rawReportData$]).pipe(
|
||||||
this.rawReportData$,
|
switchMap(([rawReportData]) => {
|
||||||
this._ciphers$.pipe(filter((data) => !!data)),
|
|
||||||
]).pipe(
|
|
||||||
switchMap(([rawReportData, ciphers]) => {
|
|
||||||
this.logService.debug(
|
this.logService.debug(
|
||||||
"[RiskInsightsOrchestratorService] Enriching report data with ciphers and critical app status",
|
"[RiskInsightsOrchestratorService] Enriching report data with ciphers and critical app status",
|
||||||
);
|
);
|
||||||
const criticalApps =
|
const criticalAppsData =
|
||||||
rawReportData?.data?.applicationData.filter((app) => app.isCritical) ?? [];
|
rawReportData?.data?.applicationData.filter((app) => app.isCritical) ?? [];
|
||||||
const criticalApplicationNames = new Set(criticalApps.map((ca) => ca.applicationName));
|
|
||||||
const rawReports = rawReportData.data?.reportData || [];
|
const rawReports = rawReportData.data?.reportData || [];
|
||||||
const cipherMap = this.reportService.getApplicationCipherMap(ciphers, rawReports);
|
|
||||||
|
|
||||||
const enrichedReports: ApplicationHealthReportDetailEnriched[] = rawReports.map((app) => ({
|
const enrichedReports: ApplicationHealthReportDetailEnriched[] = rawReports.map((app) => ({
|
||||||
...app,
|
...app,
|
||||||
ciphers: cipherMap.get(app.applicationName) || [],
|
isMarkedAsCritical: this.reportService.isCriticalApplication(app, criticalAppsData),
|
||||||
isMarkedAsCritical: criticalApplicationNames.has(app.applicationName),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const enrichedData = {
|
const enrichedData = {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
GetRiskInsightsReportResponse,
|
GetRiskInsightsReportResponse,
|
||||||
SaveRiskInsightsReportResponse,
|
SaveRiskInsightsReportResponse,
|
||||||
} from "../../models/api-models.types";
|
} from "../../models/api-models.types";
|
||||||
|
import { RiskInsightsMetrics } from "../../models/domain/risk-insights-metrics";
|
||||||
import { mockCiphers } from "../../models/mocks/ciphers.mock";
|
import { mockCiphers } from "../../models/mocks/ciphers.mock";
|
||||||
import { mockMemberCipherDetailsResponse } from "../../models/mocks/member-cipher-details-response.mock";
|
import { mockMemberCipherDetailsResponse } from "../../models/mocks/member-cipher-details-response.mock";
|
||||||
import {
|
import {
|
||||||
@@ -127,10 +128,16 @@ describe("RiskInsightsReportService", () => {
|
|||||||
mockRiskInsightsApiService.saveRiskInsightsReport$.mockReturnValue(of(saveResponse));
|
mockRiskInsightsApiService.saveRiskInsightsReport$.mockReturnValue(of(saveResponse));
|
||||||
|
|
||||||
service
|
service
|
||||||
.saveRiskInsightsReport$(mockReportData, mockSummaryData, mockApplicationData, {
|
.saveRiskInsightsReport$(
|
||||||
organizationId: mockOrganizationId,
|
mockReportData,
|
||||||
userId: mockUserId,
|
mockSummaryData,
|
||||||
})
|
mockApplicationData,
|
||||||
|
new RiskInsightsMetrics(),
|
||||||
|
{
|
||||||
|
organizationId: mockOrganizationId,
|
||||||
|
userId: mockUserId,
|
||||||
|
},
|
||||||
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (response) => {
|
next: (response) => {
|
||||||
done.fail("Expected error due to invalid response");
|
done.fail("Expected error due to invalid response");
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
isSaveRiskInsightsReportResponse,
|
isSaveRiskInsightsReportResponse,
|
||||||
SaveRiskInsightsReportResponse,
|
SaveRiskInsightsReportResponse,
|
||||||
} from "../../models/api-models.types";
|
} from "../../models/api-models.types";
|
||||||
|
import { RiskInsightsMetrics } from "../../models/domain/risk-insights-metrics";
|
||||||
import {
|
import {
|
||||||
ApplicationHealthReportDetail,
|
ApplicationHealthReportDetail,
|
||||||
OrganizationReportSummary,
|
OrganizationReportSummary,
|
||||||
@@ -27,6 +28,13 @@ export class RiskInsightsReportService {
|
|||||||
private riskInsightsEncryptionService: RiskInsightsEncryptionService,
|
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,
|
* Report data for the aggregation of uris to like uris and getting password/member counts,
|
||||||
* members, and at risk statuses.
|
* members, and at risk statuses.
|
||||||
@@ -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
|
* 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
|
* as the total at risk members and at risk applications
|
||||||
* @param reports The previously calculated application health report data
|
* @param reports The previously calculated application health report data
|
||||||
* @returns A summary object containing report totals
|
* @returns A summary object containing report totals
|
||||||
*/
|
*/
|
||||||
getApplicationsSummary(reports: ApplicationHealthReportDetail[]): OrganizationReportSummary {
|
getApplicationsSummary(
|
||||||
const totalMembers = reports.flatMap((x) => x.memberDetails);
|
reports: ApplicationHealthReportDetail[],
|
||||||
const uniqueMembers = getUniqueMembers(totalMembers);
|
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 criticalReports = this.filterApplicationsByCritical(reports, applicationData);
|
||||||
const uniqueAtRiskMembers = getUniqueMembers(atRiskMembers);
|
const criticalUniqueMembers = getUniqueMembers(criticalReports.flatMap((x) => x.memberDetails));
|
||||||
|
const criticalAtRiskUniqueMembers = getUniqueMembers(
|
||||||
|
criticalReports.flatMap((x) => x.atRiskMemberDetails),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalMemberCount: uniqueMembers.length,
|
totalMemberCount: totalUniqueMembers.length,
|
||||||
totalAtRiskMemberCount: uniqueAtRiskMembers.length,
|
totalAtRiskMemberCount: atRiskUniqueMembers.length,
|
||||||
totalApplicationCount: reports.length,
|
totalApplicationCount: reports.length,
|
||||||
totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length,
|
totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length,
|
||||||
totalCriticalMemberCount: 0,
|
totalCriticalMemberCount: criticalUniqueMembers.length,
|
||||||
totalCriticalAtRiskMemberCount: 0,
|
totalCriticalAtRiskMemberCount: criticalAtRiskUniqueMembers.length,
|
||||||
totalCriticalApplicationCount: 0,
|
totalCriticalApplicationCount: criticalReports.length,
|
||||||
totalCriticalAtRiskApplicationCount: 0,
|
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
|
* @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(
|
getOrganizationApplications(
|
||||||
reports: ApplicationHealthReportDetail[],
|
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(
|
return reports.map(
|
||||||
(report): OrganizationReportApplication => ({
|
(report): OrganizationReportApplication => ({
|
||||||
applicationName: report.applicationName,
|
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.
|
* Encrypts the risk insights report data for a specific organization.
|
||||||
* @param organizationId The ID of the organization.
|
* @param organizationId The ID of the organization.
|
||||||
@@ -179,6 +222,7 @@ export class RiskInsightsReportService {
|
|||||||
report: ApplicationHealthReportDetail[],
|
report: ApplicationHealthReportDetail[],
|
||||||
summary: OrganizationReportSummary,
|
summary: OrganizationReportSummary,
|
||||||
applications: OrganizationReportApplication[],
|
applications: OrganizationReportApplication[],
|
||||||
|
metrics: RiskInsightsMetrics,
|
||||||
encryptionParameters: {
|
encryptionParameters: {
|
||||||
organizationId: OrganizationId;
|
organizationId: OrganizationId;
|
||||||
userId: UserId;
|
userId: UserId;
|
||||||
@@ -212,6 +256,7 @@ export class RiskInsightsReportService {
|
|||||||
summaryData: encryptedSummaryData.toSdk(),
|
summaryData: encryptedSummaryData.toSdk(),
|
||||||
applicationData: encryptedApplicationData.toSdk(),
|
applicationData: encryptedApplicationData.toSdk(),
|
||||||
contentEncryptionKey: contentEncryptionKey.toSdk(),
|
contentEncryptionKey: contentEncryptionKey.toSdk(),
|
||||||
|
metrics: metrics.toRiskInsightsMetricsData(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Keep the original EncString alongside the SDK payload so downstream can return the EncString type.
|
// Keep the original EncString alongside the SDK payload so downstream can return the EncString type.
|
||||||
@@ -256,24 +301,6 @@ export class RiskInsightsReportService {
|
|||||||
return applicationMap;
|
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 ---------------------------
|
// --------------------------- 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.
|
* Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item.
|
||||||
|
|||||||
@@ -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[];
|
|
||||||
}
|
|
||||||
@@ -45,7 +45,11 @@
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
[attr.aria-label]="'viewItem' | i18n"
|
[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>
|
||||||
<td
|
<td
|
||||||
class="tw-cursor-pointer"
|
class="tw-cursor-pointer"
|
||||||
|
|||||||
Reference in New Issue
Block a user