diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/index.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/index.ts new file mode 100644 index 00000000000..bfbae18b55b --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/index.ts @@ -0,0 +1,2 @@ +// Abstractions +export * from "./abstractions/access-report-encryption.service"; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/index.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/index.ts index 5508eba65e5..4b34a0b6bf4 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/index.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/index.ts @@ -1 +1,3 @@ export * from "./risk-insights-data-mappers"; +export * from "./type-guards/basic-type-guards"; +export * from "./type-guards/risk-insights-type-guards"; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/README.md b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/README.md new file mode 100644 index 00000000000..621e9a80d23 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/README.md @@ -0,0 +1,199 @@ +# Type Guards + +**Purpose:** Reference guide for the type guard infrastructure used to validate decrypted Access Intelligence report data. + +--- + +## Overview + +All data arriving from decrypted blobs must pass through these guards before use. +Two categories of building blocks work together: + +| File | What it contains | +| ------------------------------ | ------------------------------------------------------- | +| `basic-type-guards.ts` | Primitive guards and factory functions | +| `risk-insights-type-guards.ts` | Access Intelligence validators composed from primitives | + +--- + +## Factory Functions + +### `createValidator(validators)` + +Creates a type guard for a **fixed-schema plain object** from a map of per-field guards. +Use this for every object validator — never write manual object validators. + +```typescript +// ✅ DO — compose via createValidator +const isMyModel = createValidator({ + id: isBoundedString, // required — must be present and non-empty + label: isBoundedStringOrNull, // required — must be present, may be null + reportedAt: isDateStringOrUndefined, // optional — key may be absent from JSON +}); + +// ❌ DON'T — write manual field-by-field checks +function isMyModel(value: unknown): value is MyModel { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + return isBoundedString(obj["id"]) && ...; +} +``` + +**Built-in protections:** + +- Rejects non-plain-objects (arrays, class instances, `null`) +- Blocks prototype pollution (`__proto__`, `constructor`, `prototype`) +- Absent keys pass `undefined` to the guard — required guards reject it naturally; + optional guards (e.g. `isBoundedStringOrUndefined`) accept it + +#### `.explain()` — Diagnostic Mode + +Every validator returned by `createValidator` exposes an `.explain(value)` method. +It returns `string[]` — empty if validation passes, otherwise one message per failing field. + +Use `.explain()` only in error messages and logging. Never use it in hot data paths — +it iterates all fields even after the boolean guard has already returned `false`. + +```typescript +const errors = isMyModel.explain(suspectValue); +if (errors.length > 0) { + throw new Error(`Validation failed:\n ${errors.join("\n ")}`); +} +``` + +**Error message format:** + +| Situation | Example message | +| ---------------------------- | ---------------------------------------------------------------------- | +| Not a plain object | `"expected plain object, got Array(3)"` | +| Class instance or null-proto | `"rejected: non-plain object (class instance or Object.create(null))"` | +| Prototype pollution attempt | `"rejected: prototype pollution attempt via key \"__proto__\""` | +| Field fails its guard | `"field 'email': guard isBoundedString failed — got null"` | +| Required field absent | `"field 'id': guard isBoundedString failed — got undefined"` | + +**Security note:** Messages describe types only (`"null"`, `"string"`, `"Array(3)"`) — +never actual values. Safe for production logs (no PII or vault data exposed). + +**Constraint:** `.explain()` reports top-level field failures only. It does not recurse into +why a nested guard (e.g. `isMemberDetailsArray`) failed element-by-element. + +--- + +### `createBoundedArrayGuard(isType)` + +Creates a type guard for an array of `T`, bounded to `BOUNDED_ARRAY_MAX_LENGTH` (50 000 items). + +```typescript +const isMyModelArray = createBoundedArrayGuard(isMyModel); +``` + +--- + +## Primitive Guards + +Primitives are `(value: unknown) => value is T` functions that test a single value. +They are the building blocks passed to `createValidator`. + +### Choosing the Right Guard for a Field + +| TypeScript field type | Guard to use | +| ------------------------------------------ | ---------------------------- | +| `string` (required) | `isBoundedString` | +| `string \| null` | `isBoundedStringOrNull` | +| `string \| undefined` (optional field) | `isBoundedStringOrUndefined` | +| `number` (count, non-negative) | `isBoundedPositiveNumber` | +| `boolean` | `isBoolean` | +| `Record` | `isBooleanRecord` | +| `Date` | `isDate` | +| `Date \| null` | `isDateOrNull` | +| `string` (ISO date, required) | `isDateString` | +| `string \| null` (ISO date, nullable) | `isDateStringOrNull` | +| `string \| undefined` (ISO date, optional) | `isDateStringOrUndefined` | + +> ⚠️ **Optional vs. nullable:** Use `*OrUndefined` guards for fields that may be absent from +> JSON-parsed objects (`JSON.stringify` drops `undefined` values, so the key won't exist after +> `JSON.parse`). Use `*OrNull` guards for fields that are always present but may be `null`. + +--- + +## Adding a New Validator + +### Step 1 — Do you need a new primitive? + +If the field type is not covered by the table above, add it to `basic-type-guards.ts`. +Keep each primitive focused on one type. + +```typescript +// basic-type-guards.ts +export function isMyNewType(value: unknown): value is MyNewType { + // single-type check only +} +``` + +### Step 2 — Compose the object validator + +```typescript +// risk-insights-type-guards.ts +const isMyModel = createValidator({ + id: isBoundedString, + count: isBoundedPositiveNumber, + reportedAt: isDateStringOrUndefined, +}); +const isMyModelArray = createBoundedArrayGuard(isMyModel); +``` + +### Step 3 — Add a validate function for blob entry points + +Validate functions are called at the decryption boundary and throw on invalid data. + +```typescript +export function validateMyModelArray(data: unknown): MyModel[] { + if (!Array.isArray(data)) { + throw new Error("Invalid data: expected array, received non-array"); + } + const invalidItems = data + .map((item, index) => ({ item, index })) + .filter(({ item }) => !isMyModel(item)); + if (invalidItems.length > 0) { + const elementMessages = invalidItems.map(({ item, index }) => { + const fieldErrors = isMyModel.explain(item).join("; "); + return ` element[${index}]: ${fieldErrors}`; + }); + throw new Error( + `Invalid data: ${invalidItems.length} invalid element(s)\n` + elementMessages.join("\n"), + ); + } + if (!isMyModelArray(data)) { + throw new Error("Invalid data"); + } + return data; +} +``` + +--- + +## When Manual Validation Is Acceptable + +`createValidator` handles fixed-schema objects. Two cases require hand-written logic: + +1. **Dynamic-key dictionaries** (e.g. `Record`) — use a manual loop: + + ```typescript + // validateAccessReportPayload does this for memberRegistry + for (const [key, entry] of Object.entries(registry)) { + if (!isBoundedString(key) || !isMemberRegistryEntryData(entry)) { ... } + } + ``` + +2. **Custom primitive guards** for field types not in `basic-type-guards.ts` (e.g. `isCipherId`, + `isValidDateOrNull`) — these are single-value primitives, not object validators. + +> **Rule:** Never write a manual multi-field object validator. If `createValidator` can't +> express the validation, add the missing primitive to `basic-type-guards.ts` and compose. +> If the shape is truly dynamic, use a manual loop with existing primitives. + +--- + +**Document Version:** 1.0 +**Last Updated:** 2026-03-06 +**Maintainer:** DIRT Team diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/basic-type-guards.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/basic-type-guards.spec.ts new file mode 100644 index 00000000000..4fc69357f9f --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/basic-type-guards.spec.ts @@ -0,0 +1,337 @@ +import { + createBoundedArrayGuard, + createValidator, + isBoolean, + isBooleanRecord, + isBoundedArray, + isBoundedPositiveNumber, + isBoundedString, + isBoundedStringOrNull, + isBoundedStringOrUndefined, + isDate, + isDateOrNull, + isDateString, + isDateStringOrNull, + isDateStringOrUndefined, + BOUNDED_ARRAY_MAX_LENGTH, + BOUNDED_STRING_MAX_LENGTH, +} from "./basic-type-guards"; + +describe("basic-type-guards", () => { + describe("isBoolean", () => { + it("should return true for true", () => expect(isBoolean(true)).toBe(true)); + it("should return true for false", () => expect(isBoolean(false)).toBe(true)); + it("should return false for 1", () => expect(isBoolean(1)).toBe(false)); + it("should return false for string", () => expect(isBoolean("true")).toBe(false)); + it("should return false for null", () => expect(isBoolean(null)).toBe(false)); + }); + + describe("isBoundedPositiveNumber", () => { + it("should return true for 0", () => expect(isBoundedPositiveNumber(0)).toBe(true)); + it("should return true for positive integer", () => + expect(isBoundedPositiveNumber(42)).toBe(true)); + it("should return false for negative number", () => + expect(isBoundedPositiveNumber(-1)).toBe(false)); + it("should return false for NaN", () => expect(isBoundedPositiveNumber(NaN)).toBe(false)); + it("should return false for Infinity", () => + expect(isBoundedPositiveNumber(Infinity)).toBe(false)); + it("should return false for float", () => expect(isBoundedPositiveNumber(1.5)).toBe(false)); + it("should return false for string", () => expect(isBoundedPositiveNumber("5")).toBe(false)); + it("should return false for number exceeding max", () => { + expect(isBoundedPositiveNumber(10_000_001)).toBe(false); + }); + }); + + describe("isBoundedString", () => { + it("should return true for a normal string", () => expect(isBoundedString("hello")).toBe(true)); + it("should return false for empty string", () => expect(isBoundedString("")).toBe(false)); + it("should return false for null", () => expect(isBoundedString(null)).toBe(false)); + it("should return false for undefined", () => expect(isBoundedString(undefined)).toBe(false)); + it("should return false for number", () => expect(isBoundedString(42)).toBe(false)); + it("should return false for string exceeding max length", () => { + expect(isBoundedString("a".repeat(BOUNDED_STRING_MAX_LENGTH + 1))).toBe(false); + }); + it("should return true for string at max length", () => { + expect(isBoundedString("a".repeat(BOUNDED_STRING_MAX_LENGTH))).toBe(true); + }); + }); + + describe("isBoundedStringOrNull", () => { + it("should return true for valid string", () => + expect(isBoundedStringOrNull("hello")).toBe(true)); + it("should return true for null", () => expect(isBoundedStringOrNull(null)).toBe(true)); + it("should return true for undefined (null-ish)", () => + expect(isBoundedStringOrNull(undefined)).toBe(true)); + it("should return false for empty string", () => expect(isBoundedStringOrNull("")).toBe(false)); + it("should return false for number", () => expect(isBoundedStringOrNull(42)).toBe(false)); + }); + + describe("isBoundedStringOrUndefined", () => { + it("should return true for valid string", () => + expect(isBoundedStringOrUndefined("hello")).toBe(true)); + it("should return true for undefined", () => + expect(isBoundedStringOrUndefined(undefined)).toBe(true)); + it("should return false for null", () => expect(isBoundedStringOrUndefined(null)).toBe(false)); + it("should return false for empty string", () => + expect(isBoundedStringOrUndefined("")).toBe(false)); + it("should return false for number", () => expect(isBoundedStringOrUndefined(42)).toBe(false)); + }); + + describe("isBooleanRecord", () => { + it("should return true for valid Record", () => { + expect(isBooleanRecord({ a: true, b: false })).toBe(true); + }); + it("should return true for empty object", () => expect(isBooleanRecord({})).toBe(true)); + it("should return false for object with non-boolean values", () => { + expect(isBooleanRecord({ a: "true" })).toBe(false); + }); + it("should return false for array", () => expect(isBooleanRecord([true])).toBe(false)); + it("should return false for null", () => expect(isBooleanRecord(null)).toBe(false)); + }); + + describe("isDate", () => { + it("should return true for a valid Date", () => expect(isDate(new Date())).toBe(true)); + it("should return false for an invalid Date", () => + expect(isDate(new Date("invalid"))).toBe(false)); + it("should return false for a date string", () => expect(isDate("2024-01-01")).toBe(false)); + it("should return false for null", () => expect(isDate(null)).toBe(false)); + }); + + describe("isDateOrNull", () => { + it("should return true for a valid Date", () => expect(isDateOrNull(new Date())).toBe(true)); + it("should return true for null", () => expect(isDateOrNull(null)).toBe(true)); + it("should return false for undefined", () => expect(isDateOrNull(undefined)).toBe(false)); + it("should return false for a date string", () => + expect(isDateOrNull("2024-01-01")).toBe(false)); + }); + + describe("isDateString", () => { + it("should return true for ISO date string", () => { + expect(isDateString("2024-01-15T10:30:00.000Z")).toBe(true); + }); + it("should return true for simple date string", () => { + expect(isDateString("2024-01-01")).toBe(true); + }); + it("should return false for invalid date string", () => { + expect(isDateString("not-a-date")).toBe(false); + }); + it("should return false for null", () => expect(isDateString(null)).toBe(false)); + it("should return false for Date object", () => expect(isDateString(new Date())).toBe(false)); + }); + + describe("isDateStringOrNull", () => { + it("should return true for valid date string", () => { + expect(isDateStringOrNull("2024-01-01")).toBe(true); + }); + it("should return true for null", () => expect(isDateStringOrNull(null)).toBe(true)); + it("should return false for undefined", () => + expect(isDateStringOrNull(undefined)).toBe(false)); + it("should return false for invalid string", () => { + expect(isDateStringOrNull("not-a-date")).toBe(false); + }); + }); + + describe("isDateStringOrUndefined", () => { + it("should return true for valid date string", () => { + expect(isDateStringOrUndefined("2024-01-15T10:30:00.000Z")).toBe(true); + }); + it("should return true for undefined", () => { + expect(isDateStringOrUndefined(undefined)).toBe(true); + }); + it("should return false for null", () => expect(isDateStringOrUndefined(null)).toBe(false)); + it("should return false for invalid string", () => { + expect(isDateStringOrUndefined("not-a-date")).toBe(false); + }); + it("should return false for Date object", () => { + expect(isDateStringOrUndefined(new Date())).toBe(false); + }); + }); + + describe("isBoundedArray", () => { + it("should return true for an empty array", () => expect(isBoundedArray([])).toBe(true)); + it("should return true for a small array", () => expect(isBoundedArray([1, 2, 3])).toBe(true)); + it("should return false for a non-array", () => expect(isBoundedArray("array")).toBe(false)); + it("should return false for array exceeding max length", () => { + const oversized = new Array(BOUNDED_ARRAY_MAX_LENGTH + 1); + expect(isBoundedArray(oversized)).toBe(false); + }); + }); + + describe("createBoundedArrayGuard", () => { + const isNumberArray = createBoundedArrayGuard( + (v: unknown): v is number => typeof v === "number", + ); + + it("should return true for an array of valid items", () => { + expect(isNumberArray([1, 2, 3])).toBe(true); + }); + it("should return true for an empty array", () => expect(isNumberArray([])).toBe(true)); + it("should return false if any item fails the guard", () => { + expect(isNumberArray([1, "two", 3])).toBe(false); + }); + it("should return false for a non-array", () => expect(isNumberArray("not-array")).toBe(false)); + }); + + describe("createValidator", () => { + interface TestModel { + id: string; + label: string | null; + tag?: string; + } + + const isTestModel = createValidator({ + id: isBoundedString, + label: isBoundedStringOrNull, + tag: isBoundedStringOrUndefined, + }); + + it("should return true for a valid object with all fields present", () => { + expect(isTestModel({ id: "abc", label: "name", tag: "optional" })).toBe(true); + }); + + it("should return true when optional field key is absent from object", () => { + expect(isTestModel({ id: "abc", label: null })).toBe(true); + }); + + it("should return false when required field is missing", () => { + expect(isTestModel({ label: "name" })).toBe(false); + }); + + it("should return false when required field fails its guard", () => { + expect(isTestModel({ id: "", label: "name" })).toBe(false); + }); + + it("should return false for null", () => expect(isTestModel(null)).toBe(false)); + it("should return false for array", () => expect(isTestModel([])).toBe(false)); + it("should return false for string", () => expect(isTestModel("string")).toBe(false)); + + it("should return false for class instances (non-plain-object)", () => { + class Foo { + id = "abc"; + label = "name"; + } + expect(isTestModel(new Foo())).toBe(false); + }); + + it("should return false for prototype pollution via __proto__", () => { + const obj = { id: "abc", label: "name", __proto__: { polluted: true } }; + expect(isTestModel(obj)).toBe(false); + }); + + it("should return false for prototype pollution via constructor property", () => { + const obj = Object.create(null) as Record; + obj["id"] = "abc"; + obj["label"] = "name"; + // Object.create(null) has no prototype — getPrototypeOf returns null ≠ Object.prototype + expect(isTestModel(obj)).toBe(false); + }); + }); + + describe("createValidator — EnhancedGuard (.explain())", () => { + interface TestModel { + id: string; + label: string | null; + tag?: string; + } + + const isTestModel = createValidator({ + id: isBoundedString, + label: isBoundedStringOrNull, + tag: isBoundedStringOrUndefined, + }); + + describe("structural failures", () => { + it("should return a structural error for null", () => { + const result = isTestModel.explain(null); + expect(result).toHaveLength(1); + expect(result[0]).toContain("expected plain object"); + expect(result[0]).toContain("null"); + }); + + it("should return a structural error for an array", () => { + const result = isTestModel.explain([]); + expect(result).toHaveLength(1); + expect(result[0]).toContain("expected plain object"); + expect(result[0]).toContain("Array(0)"); + }); + + it("should return a structural error for a string", () => { + const result = isTestModel.explain("hello"); + expect(result).toHaveLength(1); + expect(result[0]).toContain("expected plain object"); + expect(result[0]).toContain("string"); + }); + + it("should return a prototype pollution error for a class instance", () => { + class Foo { + id = "abc"; + label = "name"; + } + const result = isTestModel.explain(new Foo()); + expect(result).toHaveLength(1); + expect(result[0]).toContain("non-plain object"); + }); + + it("should return a prototype pollution error for Object.create(null)", () => { + const obj = Object.create(null) as Record; + obj["id"] = "abc"; + obj["label"] = "name"; + const result = isTestModel.explain(obj); + expect(result).toHaveLength(1); + expect(result[0]).toContain("non-plain object"); + }); + + it("should return a prototype pollution error for __proto__ key", () => { + const obj = JSON.parse( + '{"id":"abc","label":"name","__proto__":{"polluted":true}}', + ) as unknown; + const result = isTestModel.explain(obj); + expect(result).toHaveLength(1); + expect(result[0]).toContain("prototype pollution"); + }); + }); + + describe("field-level failures", () => { + it("should return empty array for a valid object", () => { + expect(isTestModel.explain({ id: "abc", label: "name", tag: "opt" })).toHaveLength(0); + }); + + it("should return empty array when optional field is absent", () => { + expect(isTestModel.explain({ id: "abc", label: null })).toHaveLength(0); + }); + + it("should identify a single failing required field", () => { + const result = isTestModel.explain({ id: "", label: "name" }); + expect(result).toHaveLength(1); + expect(result[0]).toContain("field 'id'"); + expect(result[0]).toContain("isBoundedString"); + }); + + it("should identify multiple failing fields", () => { + const result = isTestModel.explain({ id: "", label: 42 }); + expect(result).toHaveLength(2); + expect(result.some((m) => m.includes("field 'id'"))).toBe(true); + expect(result.some((m) => m.includes("field 'label'"))).toBe(true); + }); + + it("should report undefined for a missing required field", () => { + const result = isTestModel.explain({ label: "name" }); + expect(result).toHaveLength(1); + expect(result[0]).toContain("field 'id'"); + expect(result[0]).toContain("undefined"); + }); + + it("should include the guard function name in the message", () => { + const result = isTestModel.explain({ id: null, label: "name" }); + expect(result[0]).toContain("isBoundedString"); + }); + + it("should describe the received type, not the actual value", () => { + const result = isTestModel.explain({ id: 42, label: "name" }); + expect(result[0]).toContain("number"); + // Should NOT include the actual numeric value as a data point + expect(result[0]).not.toContain("42"); + }); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/basic-type-guards.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/basic-type-guards.ts index 47d638b41e5..eaa1161a78f 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/basic-type-guards.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/basic-type-guards.ts @@ -7,11 +7,13 @@ // Basic types export type BoundedString = string; export type BoundedStringOrNull = BoundedString | null; +export type BoundedStringOrUndefined = BoundedString | undefined; export type PositiveSafeNumber = number; export type BoundedArray = T[]; export type DateOrNull = Date | null; export type DateString = string; export type DateStringOrNull = DateString | null; +export type DateStringOrUndefined = DateString | undefined; // Constants /** @@ -44,12 +46,32 @@ export function isBoundedStringOrNull(value: unknown): value is BoundedStringOrN return value == null || isBoundedString(value); } +export function isBoundedStringOrUndefined(value: unknown): value is BoundedStringOrUndefined { + return value === undefined || isBoundedString(value); +} + export const isBoundedStringArray = createBoundedArrayGuard(isBoundedString); export function isBoundedArray(arr: unknown): arr is BoundedArray { return Array.isArray(arr) && arr.length < BOUNDED_ARRAY_MAX_LENGTH; } +/** + * A type guard to check if a value is a plain object with string keys and boolean values + * @param value The value to check + * @returns True if the value is a Record, false otherwise + */ +export function isBooleanRecord(value: unknown): value is Record { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const entries = Object.entries(value as object); + if (entries.length > BOUNDED_ARRAY_MAX_LENGTH) { + return false; + } + return entries.every(([k, v]) => isBoundedString(k) && isBoolean(v)); +} + /** * A type guard to check if a value is a valid Date object * @param value The value to check @@ -97,6 +119,16 @@ export function isDateStringOrNull(value: unknown): value is DateStringOrNull { return value === null || isDateString(value); } +/** + * A type guard to check if a value is a valid date string or undefined (absent field). + * Use this for optional date fields where the key may be absent from JSON-parsed objects. + * @param value The value to check + * @returns True if the value is undefined or a valid date string, false otherwise + */ +export function isDateStringOrUndefined(value: unknown): value is DateStringOrUndefined { + return value === undefined || isDateString(value); +} + /** * A higher-order function that takes a type guard for T and returns a * new type guard for an array of T. @@ -110,33 +142,102 @@ export function createBoundedArrayGuard(isType: (item: unknown) => item is T) type TempObject = Record; /** + * Describes the type/shape of a value without exposing actual content. + * Safe to include in logs — never reveals PII or vault data. + */ +function describeType(value: unknown): string { + if (value === null) { + return "null"; + } + if (value === undefined) { + return "undefined"; + } + if (Array.isArray(value)) { + return `Array(${value.length})`; + } + if (typeof value === "object") { + return "object"; + } + return typeof value; // "string", "number", "boolean" +} + +/** + * Checks that obj is a plain JSON-parsed object (not null, not a class instance, + * not a prototype-polluted object). + * Returns null if the structure is valid, or a diagnostic string if not. + */ +function checkStructure(obj: unknown): string | null { + if (typeof obj !== "object" || obj === null || Array.isArray(obj)) { + return `expected plain object, got ${describeType(obj)}`; + } + + if (Object.getPrototypeOf(obj) !== Object.prototype) { + return "rejected: non-plain object (class instance or Object.create(null))"; + } + + // Prevent dangerous properties that could be used for prototype pollution + const dangerousKeys = ["__proto__", "constructor", "prototype"]; + for (const key of dangerousKeys) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + return `rejected: prototype pollution attempt via key "${key}"`; + } + } + + return null; +} + +/** + * A type guard function with an attached `.explain()` method. + * The guard itself behaves identically to a standard type predicate. + * `.explain()` returns an array of diagnostic messages describing which fields + * failed validation — safe to include in logs (never exposes actual values, only types). + */ +export type EnhancedGuard = ((obj: unknown) => obj is T) & { + explain: (obj: unknown) => string[]; +}; + +/** + * Factory that creates a type-safe object validator from a map of per-field guards. * - * @param validators - * @returns + * Pass a `{ fieldName: typeGuardFn }` map and receive a single function that validates + * a plain JSON-parsed object against all listed fields. The returned validator: + * - Rejects non-plain-objects (arrays, class instances, null) + * - Blocks prototype pollution via `__proto__` / `constructor` / `prototype` + * - For each field in the map: if the field is **absent** from the object, passes + * `undefined` to the guard — required fields fail naturally (e.g. `isBoundedString(undefined) + * → false`), optional fields pass if their guard accepts `undefined` + * (e.g. `isDateStringOrUndefined(undefined) → true`) + * + * The returned `EnhancedGuard` also exposes an `.explain(value)` method that returns + * field-level diagnostic messages (which fields failed and what type was received). + * + * @example + * ```typescript + * // All required fields — key must be present and pass its guard + * const isMemberEntry = createValidator({ + * id: isBoundedString, + * userName: isBoundedStringOrNull, // present, but may be null + * email: isBoundedString, + * }); + * + * // Mixed required and optional fields — optional fields use an *OrUndefined guard + * const isReportEntry = createValidator({ + * applicationName: isBoundedString, // required + * memberRefs: isBooleanRecord, // required + * iconUri: isBoundedStringOrUndefined, // optional — key may be absent + * }); + * ``` */ export function createValidator(validators: { [K in keyof T]: (value: unknown) => value is T[K]; -}): (obj: unknown) => obj is T { +}): EnhancedGuard { const keys = Object.keys(validators) as (keyof T)[]; - return function (obj: unknown): obj is T { - if (typeof obj !== "object" || obj === null) { + const guard = function (obj: unknown): obj is T { + if (checkStructure(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; @@ -149,14 +250,109 @@ export function createValidator(validators: { // return false; // } - // Check for each property's existence and type + // For each field in the validator map, pass the value (or `undefined` if absent) to + // its guard. Required guards (e.g. isBoundedString) reject undefined naturally; + // optional guards (e.g. isBoundedStringOrUndefined) accept it. 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]); + const value = key in tempObj ? tempObj[key] : undefined; + return validators[key](value); }); }; + + guard.explain = function (obj: unknown): string[] { + const structuralError = checkStructure(obj); + if (structuralError !== null) { + return [structuralError]; + } + + const tempObj = obj as TempObject; + const failures: string[] = []; + + for (const key of keys) { + const value = key in tempObj ? tempObj[key] : undefined; + if (!validators[key](value)) { + const nestedExplain = (validators[key] as EnhancedGuard).explain; + if (typeof nestedExplain === "function") { + const nested = nestedExplain(value); + failures.push(...nested.map((e) => `field '${String(key)}': ${e}`)); + } else { + failures.push( + `field '${String(key)}': guard ${validators[key].name || "(anonymous)"} failed — got ${describeType(value)}`, + ); + } + } + } + + return failures; + }; + + return guard; +} + +/** + * A higher-order function that takes an EnhancedGuard for T and returns an + * EnhancedGuard for T[], with element-level diagnostics in `.explain()`. + * Use this instead of createBoundedArrayGuard when you need recursive diagnostics. + */ +export function createEnhancedBoundedArrayGuard(isType: EnhancedGuard): EnhancedGuard { + const guard = function (value: unknown): value is T[] { + return Array.isArray(value) && value.length <= BOUNDED_ARRAY_MAX_LENGTH && value.every(isType); + }; + guard.explain = function (value: unknown): string[] { + if (!Array.isArray(value)) { + return [`expected array, got ${describeType(value)}`]; + } + if (value.length > BOUNDED_ARRAY_MAX_LENGTH) { + return [`array length ${value.length} exceeds max ${BOUNDED_ARRAY_MAX_LENGTH}`]; + } + const errors: string[] = []; + value.forEach((item, index) => { + if (!isType(item)) { + const fieldErrors = isType.explain(item); + errors.push(...fieldErrors.map((e) => `[${index}]: ${e}`)); + } + }); + return errors; + }; + return guard; +} + +/** + * A higher-order function that takes an EnhancedGuard for T and returns an + * EnhancedGuard for Record, with entry-level diagnostics in `.explain()`. + * Use this for dynamic-key dictionaries (e.g. member registries keyed by user GUID). + */ +export function createBoundedRecordGuard( + isValue: EnhancedGuard, +): EnhancedGuard> { + const guard = function (value: unknown): value is Record { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const entries = Object.entries(value as object); + if (entries.length > BOUNDED_ARRAY_MAX_LENGTH) { + return false; + } + return entries.every(([k, v]) => isBoundedString(k) && isValue(v)); + }; + guard.explain = function (value: unknown): string[] { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + return [`expected plain object, got ${describeType(value)}`]; + } + const entries = Object.entries(value as object); + if (entries.length > BOUNDED_ARRAY_MAX_LENGTH) { + return [`record length ${entries.length} exceeds max ${BOUNDED_ARRAY_MAX_LENGTH}`]; + } + const errors: string[] = []; + for (const [k, v] of entries) { + if (!isBoundedString(k)) { + errors.push(`key: invalid — got ${describeType(k)}`); + } else if (!isValue(v)) { + const fieldErrors = isValue.explain(v); + errors.push(...fieldErrors.map((e) => `["${k}"]: ${e}`)); + } + } + return errors; + }; + return guard; } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/risk-insights-type-guards.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/risk-insights-type-guards.spec.ts index f48437ab006..be71700695f 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/risk-insights-type-guards.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/risk-insights-type-guards.spec.ts @@ -3,11 +3,15 @@ import { MemberDetails } from "../../models"; import { isApplicationHealthReportDetail, isMemberDetails, + isMemberRegistryEntryData, isOrganizationReportApplication, isOrganizationReportSummary, validateApplicationHealthReportDetailArray, validateOrganizationReportApplicationArray, validateOrganizationReportSummary, + validateAccessReportSettingsDataArray, + validateAccessReportSummaryData, + validateAccessReportPayload, } from "./risk-insights-type-guards"; describe("Risk Insights Type Guards", () => { @@ -60,7 +64,7 @@ describe("Risk Insights Type Guards", () => { ]; expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow( - /Invalid report data: array contains 1 invalid ApplicationHealthReportDetail element\(s\) at indices: 0/, + /Invalid report data: array contains 1 invalid ApplicationHealthReportDetail element\(s\)/, ); }); @@ -82,7 +86,7 @@ describe("Risk Insights Type Guards", () => { ]; expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow( - /Invalid report data: array contains 2 invalid ApplicationHealthReportDetail element\(s\) at indices: 0, 2/, + /Invalid report data: array contains 2 invalid ApplicationHealthReportDetail element\(s\)/, ); }); @@ -227,7 +231,7 @@ describe("Risk Insights Type Guards", () => { ]; expect(() => validateOrganizationReportApplicationArray(invalidData)).toThrow( - "Invalid application data: array contains 1 invalid OrganizationReportApplication element(s) at indices: 0", + /Invalid application data: array contains 1 invalid OrganizationReportApplication element\(s\)/, ); }); @@ -247,7 +251,7 @@ describe("Risk Insights Type Guards", () => { ]; expect(() => validateOrganizationReportApplicationArray(invalidData)).toThrow( - /Invalid application data: array contains 1 invalid OrganizationReportApplication element\(s\) at indices: 0/, + /Invalid application data: array contains 1 invalid OrganizationReportApplication element\(s\)/, ); }); @@ -551,4 +555,324 @@ describe("Risk Insights Type Guards", () => { expect(isOrganizationReportApplication(invalidData)).toBe(false); }); }); + + describe("isMemberRegistryEntryData", () => { + it("should return true for valid MemberRegistryEntryData", () => { + const validData = { id: "u1", userName: "Alice", email: "alice@example.com" }; + expect(isMemberRegistryEntryData(validData)).toBe(true); + }); + + it("should return false for empty id", () => { + const invalidData = { id: "", userName: "Alice", email: "alice@example.com" }; + expect(isMemberRegistryEntryData(invalidData)).toBe(false); + }); + + it("should return false for empty userName (empty string is not a valid non-empty string)", () => { + const invalidData = { id: "u1", userName: "", email: "alice@example.com" }; + expect(isMemberRegistryEntryData(invalidData)).toBe(false); + }); + + it("should return true when userName is absent (userName is optional)", () => { + const dataWithoutUserName = { id: "u1", email: "alice@example.com" }; + expect(isMemberRegistryEntryData(dataWithoutUserName)).toBe(true); + }); + + it("should return true when userName is undefined (userName is optional)", () => { + const dataWithUndefinedUserName: unknown = { + id: "u1", + userName: undefined, + email: "alice@example.com", + }; + expect(isMemberRegistryEntryData(dataWithUndefinedUserName)).toBe(true); + }); + + it("should return false for empty email", () => { + const invalidData = { id: "u1", userName: "Alice", email: "" }; + expect(isMemberRegistryEntryData(invalidData)).toBe(false); + }); + + it("should return false for missing field", () => { + const invalidData = { id: "u1", userName: "Alice" }; + expect(isMemberRegistryEntryData(invalidData)).toBe(false); + }); + + it("should return false for non-object", () => { + expect(isMemberRegistryEntryData("not an object")).toBe(false); + expect(isMemberRegistryEntryData(null)).toBe(false); + }); + + it("should return false for prototype pollution attempts", () => { + const invalidData = { + id: "u1", + userName: "Alice", + email: "alice@example.com", + __proto__: { malicious: true }, + }; + expect(isMemberRegistryEntryData(invalidData)).toBe(false); + }); + }); + + describe("validateAccessReportPayload", () => { + const validV2Data = { + version: 2, + reports: [ + { + applicationName: "github.com", + passwordCount: 2, + atRiskPasswordCount: 1, + memberRefs: { u1: true, u2: false }, + cipherRefs: { c1: true, c2: false }, + memberCount: 2, + atRiskMemberCount: 1, + }, + ], + memberRegistry: { + u1: { id: "u1", userName: "Alice", email: "alice@example.com" }, + u2: { id: "u2", userName: "Bob", email: "bob@example.com" }, + }, + }; + + it("should validate valid V2 report data", () => { + expect(() => validateAccessReportPayload(validV2Data)).not.toThrow(); + const result = validateAccessReportPayload(validV2Data); + expect(result.reports).toHaveLength(1); + expect(Object.keys(result.memberRegistry)).toHaveLength(2); + }); + + it("should validate V2 data with empty reports and registry", () => { + const emptyV2Data: unknown = { version: 2, reports: [], memberRegistry: {} }; + expect(() => validateAccessReportPayload(emptyV2Data)).not.toThrow(); + }); + + it("should throw for non-object input", () => { + expect(() => validateAccessReportPayload("not an object")).toThrow( + /expected object, received non-object/, + ); + expect(() => validateAccessReportPayload(null)).toThrow( + /expected object, received non-object/, + ); + expect(() => validateAccessReportPayload([1, 2, 3])).toThrow( + /expected object, received non-object/, + ); + }); + + it("should throw for invalid reports array", () => { + const invalidReports = { ...validV2Data, reports: "not an array" }; + expect(() => validateAccessReportPayload(invalidReports)).toThrow( + /reports array failed validation/, + ); + }); + + it("should throw for missing memberRegistry", () => { + const noRegistry: unknown = { version: 2, reports: [] }; + expect(() => validateAccessReportPayload(noRegistry)).toThrow( + /memberRegistry failed validation/, + ); + }); + + it("should throw for invalid memberRegistry entry", () => { + const invalidEntry = { + ...validV2Data, + memberRegistry: { + u1: { id: "u1", userName: "Alice" }, // missing email + }, + }; + expect(() => validateAccessReportPayload(invalidEntry)).toThrow( + /memberRegistry failed validation/, + ); + }); + + it("should normalize empty userName to undefined for backwards compatibility", () => { + const dataWithEmptyUserName: unknown = { + version: 2, + reports: [], + memberRegistry: { + u1: { id: "u1", userName: "", email: "alice@example.com" }, + }, + }; + const result = validateAccessReportPayload(dataWithEmptyUserName); + expect(result.memberRegistry["u1"].userName).toBeUndefined(); + }); + }); + + describe("validateAccessReportSummaryData", () => { + const validSummary = { + totalMemberCount: 10, + totalApplicationCount: 5, + totalAtRiskMemberCount: 2, + totalAtRiskApplicationCount: 1, + totalCriticalApplicationCount: 3, + totalCriticalMemberCount: 4, + totalCriticalAtRiskMemberCount: 1, + totalCriticalAtRiskApplicationCount: 1, + }; + + it("should validate valid summary data", () => { + expect(() => validateAccessReportSummaryData(validSummary)).not.toThrow(); + const result = validateAccessReportSummaryData(validSummary); + expect(result.totalMemberCount).toBe(10); + expect(result.totalApplicationCount).toBe(5); + }); + + it("should throw for invalid field types", () => { + const invalid = { ...validSummary, totalMemberCount: "10" }; + expect(() => validateAccessReportSummaryData(invalid)).toThrow(/Invalid report summary/); + }); + + it("should throw for non-object input", () => { + expect(() => validateAccessReportSummaryData(null)).toThrow(/Invalid report summary/); + expect(() => validateAccessReportSummaryData("string")).toThrow(/Invalid report summary/); + }); + }); + + describe("validateAccessReportSettingsDataArray", () => { + it("should validate valid V2 application data array", () => { + const validData = [ + { applicationName: "app.com", isCritical: true, reviewedDate: "2024-01-15T10:30:00.000Z" }, + { applicationName: "other.com", isCritical: false }, + ]; + + expect(() => validateAccessReportSettingsDataArray(validData)).not.toThrow(); + const result = validateAccessReportSettingsDataArray(validData); + expect(result).toHaveLength(2); + expect(result[0].reviewedDate).toBe("2024-01-15T10:30:00.000Z"); + expect(result[1].reviewedDate).toBeUndefined(); + }); + + it("should accept missing reviewedDate (undefined)", () => { + const validData = [{ applicationName: "app.com", isCritical: false }]; + const result = validateAccessReportSettingsDataArray(validData); + expect(result[0].reviewedDate).toBeUndefined(); + }); + + it("should throw for non-array input", () => { + expect(() => validateAccessReportSettingsDataArray("not an array")).toThrow( + "Invalid application data: expected array of AccessReportSettingsData, received non-array", + ); + }); + + it("should throw for array with invalid elements", () => { + const invalidData = [{ applicationName: "app.com" }]; // missing isCritical + expect(() => validateAccessReportSettingsDataArray(invalidData)).toThrow( + /Invalid application data: array contains 1 invalid AccessReportSettingsData element\(s\)/, + ); + }); + + it("should throw for null reviewedDate", () => { + const invalidData: unknown = [ + { applicationName: "app.com", isCritical: true, reviewedDate: null }, + ]; + expect(() => validateAccessReportSettingsDataArray(invalidData)).toThrow( + /Invalid application data/, + ); + }); + + it("should throw for invalid date string in reviewedDate", () => { + const invalidData = [ + { applicationName: "app.com", isCritical: true, reviewedDate: "not-a-date" }, + ]; + expect(() => validateAccessReportSettingsDataArray(invalidData)).toThrow( + /Invalid application data/, + ); + }); + }); + + describe("field-level diagnostics", () => { + describe("validateApplicationHealthReportDetailArray", () => { + it("should include element index and field name in error message", () => { + const invalidData = [{ applicationName: "Test App" }]; // missing many required fields + + let caught: Error | null = null; + try { + validateApplicationHealthReportDetailArray(invalidData); + } catch (e) { + caught = e as Error; + } + + expect(caught).not.toBeNull(); + expect(caught!.message).toMatch(/element\[0\]/); + expect(caught!.message).toMatch(/field '/); + }); + + it("should identify memberDetails field failure when memberDetails has wrong shape", () => { + const invalidData = [ + { + applicationName: "Test App", + passwordCount: 10, + atRiskPasswordCount: 2, + atRiskCipherIds: ["cipher-1"], + memberCount: 5, + atRiskMemberCount: 1, + memberDetails: [{ userGuid: "user-1" }] as any, // missing required fields + atRiskMemberDetails: [] as MemberDetails[], + cipherIds: ["cipher-1"], + }, + ]; + + let caught: Error | null = null; + try { + validateApplicationHealthReportDetailArray(invalidData); + } catch (e) { + caught = e as Error; + } + + expect(caught).not.toBeNull(); + expect(caught!.message).toContain("element[0]"); + expect(caught!.message).toContain("memberDetails"); + }); + + it("should list each failing element by index, skipping valid ones", () => { + const invalidData = [ + { applicationName: "App 1" }, // invalid + { + applicationName: "App 2", + passwordCount: 10, + atRiskPasswordCount: 2, + atRiskCipherIds: ["cipher-1"], + memberCount: 5, + atRiskMemberCount: 1, + memberDetails: [] as MemberDetails[], + atRiskMemberDetails: [] as MemberDetails[], + cipherIds: ["cipher-1"], + }, // valid + { applicationName: "App 3" }, // invalid + ]; + + let caught: Error | null = null; + try { + validateApplicationHealthReportDetailArray(invalidData); + } catch (e) { + caught = e as Error; + } + + expect(caught).not.toBeNull(); + expect(caught!.message).toContain("element[0]"); + expect(caught!.message).toContain("element[2]"); + expect(caught!.message).not.toContain("element[1]"); + }); + }); + + describe("validateAccessReportPayload — memberRegistry diagnostics", () => { + it("should include the registry key and failing field name when a registry entry is invalid", () => { + const invalidPayload = { + version: 2, + reports: [] as unknown[], + memberRegistry: { + u1: { id: "u1", userName: "Alice" }, // missing email + }, + }; + + let caught: Error | null = null; + try { + validateAccessReportPayload(invalidPayload); + } catch (e) { + caught = e as Error; + } + + expect(caught).not.toBeNull(); + expect(caught!.message).toContain('"u1"'); + expect(caught!.message).toContain("email"); + }); + }); + }); }); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/risk-insights-type-guards.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/risk-insights-type-guards.ts index c1aa028da1f..ae1f6c0f3a6 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/risk-insights-type-guards.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/type-guards/risk-insights-type-guards.ts @@ -1,5 +1,12 @@ import { CipherId } from "@bitwarden/common/types/guid"; +import { + MemberRegistryEntryData, + AccessReportSettingsData, + ApplicationHealthData, + AccessReportSummaryData, +} from "../../../../access-intelligence/models"; +import { AccessReportPayload } from "../../../../access-intelligence/services"; import { ApplicationHealthReportDetail, MemberDetails, @@ -9,17 +16,22 @@ import { import { createBoundedArrayGuard, + createBoundedRecordGuard, + createEnhancedBoundedArrayGuard, createValidator, isBoolean, + isBooleanRecord, isBoundedString, isBoundedStringOrNull, + isBoundedStringOrUndefined, isBoundedPositiveNumber, BOUNDED_ARRAY_MAX_LENGTH, isDate, isDateString, + isDateStringOrUndefined, } from "./basic-type-guards"; -// Risk Insights specific type guards +// === Type Guards for Access Intelligence === /** * Type guard to validate MemberDetails structure @@ -30,14 +42,27 @@ export const isMemberDetails = createValidator({ userGuid: isBoundedString, userName: isBoundedStringOrNull, email: isBoundedString, - cipherId: isBoundedString, + cipherId: isBoundedString, // TODO is isBoundedStringOrNull for backwards compatibility }); -export const isMemberDetailsArray = createBoundedArrayGuard(isMemberDetails); +export const isMemberDetailsArray = createEnhancedBoundedArrayGuard(isMemberDetails); + +/** + * Type guard to validate MemberRegistryEntryData structure + * Exported for testability + * Strict validation: rejects objects with unexpected properties and prototype pollution + */ +export const isMemberRegistryEntryData = createValidator({ + id: isBoundedString, + userName: isBoundedStringOrUndefined, + email: isBoundedString, +}); +const isMemberRegistry = createBoundedRecordGuard(isMemberRegistryEntryData); export function isCipherId(value: unknown): value is CipherId { return value == null || isBoundedString(value); } export const isCipherIdArray = createBoundedArrayGuard(isCipherId); + /** * Type guard to validate ApplicationHealthReportDetail structure * Exported for testability @@ -58,6 +83,19 @@ export const isApplicationHealthReportDetailArray = createBoundedArrayGuard( isApplicationHealthReportDetail, ); +const isApplicationHealthData = createValidator({ + applicationName: isBoundedString, + passwordCount: isBoundedPositiveNumber, + atRiskPasswordCount: isBoundedPositiveNumber, + memberRefs: isBooleanRecord, + cipherRefs: isBooleanRecord, + memberCount: isBoundedPositiveNumber, + atRiskMemberCount: isBoundedPositiveNumber, + iconUri: isBoundedStringOrUndefined, + iconCipherId: isBoundedStringOrUndefined, +}); +const isApplicationHealthDataArray = createBoundedArrayGuard(isApplicationHealthData); + /** * Type guard to validate OrganizationReportSummary structure * Exported for testability @@ -98,6 +136,8 @@ export const isOrganizationReportApplicationArray = createBoundedArrayGuard( isOrganizationReportApplication, ); +// === Validate Functions === + /** * Validates and returns an array of ApplicationHealthReportDetail * @throws Error if validation fails @@ -122,9 +162,13 @@ export function validateApplicationHealthReportDetailArray( .filter(({ item }) => !isApplicationHealthReportDetail(item)); if (invalidItems.length > 0) { - const invalidIndices = invalidItems.map(({ index }) => index).join(", "); + const elementMessages = invalidItems.map(({ item, index }) => { + const fieldErrors = isApplicationHealthReportDetail.explain(item).join("; "); + return ` element[${index}]: ${fieldErrors}`; + }); throw new Error( - `Invalid report data: array contains ${invalidItems.length} invalid ApplicationHealthReportDetail element(s) at indices: ${invalidIndices}`, + `Invalid report data: array contains ${invalidItems.length} invalid ApplicationHealthReportDetail element(s)\n` + + elementMessages.join("\n"), ); } @@ -149,6 +193,28 @@ export function validateOrganizationReportSummary(data: unknown): OrganizationRe return data; } +export const isAccessReportSummaryData = createValidator({ + totalMemberCount: isBoundedPositiveNumber, + totalApplicationCount: isBoundedPositiveNumber, + totalAtRiskMemberCount: isBoundedPositiveNumber, + totalAtRiskApplicationCount: isBoundedPositiveNumber, + totalCriticalApplicationCount: isBoundedPositiveNumber, + totalCriticalMemberCount: isBoundedPositiveNumber, + totalCriticalAtRiskMemberCount: isBoundedPositiveNumber, + totalCriticalAtRiskApplicationCount: isBoundedPositiveNumber, +}); + +/** + * Validates and returns AccessReportSummaryData + * @throws Error if validation fails + */ +export function validateAccessReportSummaryData(data: unknown): AccessReportSummaryData { + if (!isAccessReportSummaryData(data)) { + throw new Error("Invalid report summary"); + } + return data; +} + /** * Validates and returns an array of OrganizationReportApplication * @throws Error if validation fails @@ -173,9 +239,13 @@ export function validateOrganizationReportApplicationArray( .filter(({ item }) => !isOrganizationReportApplication(item)); if (invalidItems.length > 0) { - const invalidIndices = invalidItems.map(({ index }) => index).join(", "); + const elementMessages = invalidItems.map(({ item, index }) => { + const fieldErrors = isOrganizationReportApplication.explain(item).join("; "); + return ` element[${index}]: ${fieldErrors}`; + }); throw new Error( - `Invalid application data: array contains ${invalidItems.length} invalid OrganizationReportApplication element(s) at indices: ${invalidIndices}`, + `Invalid application data: array contains ${invalidItems.length} invalid OrganizationReportApplication element(s)\n` + + elementMessages.join("\n"), ); } @@ -203,3 +273,89 @@ export function validateOrganizationReportApplicationArray( // Convert string dates to Date objects for reviewedDate return mappedData; } + +const isAccessReportSettingsData = createValidator({ + applicationName: isBoundedString, + isCritical: isBoolean, + reviewedDate: isDateStringOrUndefined, +}); +export const isAccessReportSettingsDataArray = createBoundedArrayGuard(isAccessReportSettingsData); + +/** + * Validates and returns an array of AccessReportSettingsData + * @throws Error if validation fails + */ +export function validateAccessReportSettingsDataArray(data: unknown): AccessReportSettingsData[] { + if (!Array.isArray(data)) { + throw new Error( + "Invalid application data: expected array of AccessReportSettingsData, 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 }) => !isAccessReportSettingsData(item)); + + if (invalidItems.length > 0) { + const elementMessages = invalidItems.map(({ item, index }) => { + const fieldErrors = isAccessReportSettingsData.explain(item).join("; "); + return ` element[${index}]: ${fieldErrors}`; + }); + throw new Error( + `Invalid application data: array contains ${invalidItems.length} invalid AccessReportSettingsData element(s)\n` + + elementMessages.join("\n"), + ); + } + + if (!isAccessReportSettingsDataArray(data)) { + // Throw for type casting return + // Should never get here + throw new Error("Invalid application data"); + } + + return data; +} + +/** + * Validates and returns AccessReportPayload + * @throws Error if validation fails + */ +export function validateAccessReportPayload(data: unknown): AccessReportPayload { + if (data == null || typeof data !== "object" || Array.isArray(data)) { + throw new Error("Invalid report payload: expected object, received non-object"); + } + + const obj = data as Record; + + if (!isApplicationHealthDataArray(obj["reports"])) { + throw new Error("Invalid report payload: reports array failed validation"); + } + + // Pre-normalize "" → undefined before validation for backwards compatibility with + // blobs that stored empty string. The guard uses isBoundedStringOrUndefined which + // rejects "", so normalization must happen before the guard runs. + if (typeof obj["memberRegistry"] === "object" && obj["memberRegistry"] !== null) { + for (const entry of Object.values(obj["memberRegistry"] as Record)) { + if ( + typeof entry === "object" && + entry !== null && + (entry as Record)["userName"] === "" + ) { + (entry as Record)["userName"] = undefined; + } + } + } + + if (!isMemberRegistry(obj["memberRegistry"])) { + const errors = isMemberRegistry.explain(obj["memberRegistry"]).join("; "); + throw new Error(`Invalid report payload: memberRegistry failed validation: ${errors}`); + } + + return data as AccessReportPayload; +}