mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[PM-27162] Add runtime type guards for decrypted JSON data (#16996)
* Add runtime type guards for decrypted JSON data - Create risk-insights-type-guards.ts with validation functions - Replace unsafe type assertions with runtime validation in encryption service - Validate ApplicationHealthReportDetail, OrganizationReportSummary, and OrganizationReportApplication - Add detailed error messages for validation failures - Remove TODO comments for type guard implementation Improves security by preventing malformed data from bypassing type safety and ensures data integrity for decrypted report structures. * test file fix * date validation * add runtime type guards and validation failure tests Issue 1: Missing Test Coverage for Type Guard Validation Failures - Create comprehensive test suite with 17 tests covering all validation scenarios - Test invalid structures, missing fields, wrong types, and edge cases - Verify proper error messages and validation logic for all data types Issue 2: Silent Failure on Validation Errors (Security Concern) - Re-throw validation errors instead of silently returning empty/default data - Add descriptive error messages indicating potential data corruption or tampering - Ensure all validation failures are surfaced as security issues, not swallowed Additional Fix: Date Validation Vulnerability - Validate date strings before creating Date objects to prevent Invalid Date (NaN) - Throw explicit errors for unparseable date strings - Update error handling to catch and properly surface date validation failures * add empty string validation and sanitize error messages - Validate array elements are non-empty strings (atRiskCipherIds, cipherIds, newApplications) - Sanitize validation error messages to prevent information disclosure - Log detailed errors for debugging, re-throw generic messages - Add tests for empty string validation and error message sanitization * add comprehensive validation for scalar strings and numeric ranges - Validate all scalar string fields are non-empty (applicationName, userName, email, cipherId, userGuid) - Add numeric range validation (finite, non-negative) for all count fields - Export type guard functions for testability and reusability - Add 19 new tests covering edge cases (empty strings, NaN, Infinity, negative numbers) * prevent prototype pollution and unexpected property injection in type guards - Validate object prototype is Object.prototype (prevents __proto__ attacks) - Check for dangerous own properties (constructor, prototype) - Strict property enumeration - reject objects with unexpected properties - Add comprehensive security tests (prototype pollution, unexpected props) - Protects against data tampering and information leakage * security: always sanitize error messages to prevent information disclosure - Remove fragile pattern matching in error handlers - Always throw generic error messages by default - Log detailed errors for debugging, never expose to callers - Future-proof against validation error message changes - Prevents disclosure of internal data structure details Applies to all decryption/validation methods in encryption service * security: comprehensive hardening of type validation system CRITICAL FIXES: - Add __proto__ to prototype pollution checks (loop-based) - Remove conditional error sanitization (always sanitize) SECURITY ENHANCEMENTS: - Add integer overflow protection (Number.isSafeInteger) - Add DoS prevention (array/string length limits: 50K/1K) - Strengthen all 4 type guards with 10-layer validation LIMITS: - Max string length: 1,000 characters - Max array length: 50,000 elements - Max safe integer: 2^53 - 1 DOCUMENTATION: - Update code-review-methodology.md with patterns - Update .cursorrules with security best practices - Create comprehensive security audit document All 57 tests passing. No linting errors. Defense-in-depth complete - production ready. * fix: consolidate security constants and add upper bound validation CRITICAL FIXES: - Consolidate MAX_STRING_LENGTH and MAX_ARRAY_LENGTH to file level (DRY) - Add MAX_COUNT constant (10M) for upper bound validation - Apply upper bound checks to all 12 count fields BENEFITS: - Single source of truth for security limits - Prevents business logic issues from extreme values - Easier maintenance and updates
This commit is contained in:
@@ -151,14 +151,17 @@ describe("RiskInsightsEncryptionService", () => {
|
||||
|
||||
describe("decryptRiskInsightsReport", () => {
|
||||
it("should decrypt data and return original object", async () => {
|
||||
// Arrange: setup our mocks
|
||||
// Arrange: setup our mocks with valid data structures
|
||||
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
|
||||
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
|
||||
mockEncryptService.decryptString.mockResolvedValue(JSON.stringify(testData));
|
||||
|
||||
// act: call the decrypt method - with any params
|
||||
// actual decryption does not happen here,
|
||||
// we just want to ensure the method calls are correct
|
||||
// Mock decryption to return valid data for each call
|
||||
mockEncryptService.decryptString
|
||||
.mockResolvedValueOnce(JSON.stringify(mockReportData))
|
||||
.mockResolvedValueOnce(JSON.stringify(mockSummaryData))
|
||||
.mockResolvedValueOnce(JSON.stringify(mockApplicationData));
|
||||
|
||||
// act: call the decrypt method
|
||||
const result = await service.decryptRiskInsightsReport(
|
||||
{ organizationId: orgId, userId },
|
||||
mockEncryptedData,
|
||||
@@ -169,33 +172,37 @@ describe("RiskInsightsEncryptionService", () => {
|
||||
expect(mockEncryptService.unwrapSymmetricKey).toHaveBeenCalledWith(mockKey, orgKey);
|
||||
expect(mockEncryptService.decryptString).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Mock decrypt returns JSON.stringify(testData)
|
||||
// Verify decrypted data matches the mocked valid data
|
||||
expect(result).toEqual({
|
||||
reportData: testData,
|
||||
summaryData: testData,
|
||||
applicationData: testData,
|
||||
reportData: mockReportData,
|
||||
summaryData: mockSummaryData,
|
||||
applicationData: mockApplicationData,
|
||||
});
|
||||
});
|
||||
|
||||
it("should invoke data type validation method during decryption", async () => {
|
||||
// Arrange: setup our mocks
|
||||
// Arrange: setup our mocks with valid data structures
|
||||
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
|
||||
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
|
||||
mockEncryptService.decryptString.mockResolvedValue(JSON.stringify(testData));
|
||||
|
||||
// act: call the decrypt method - with any params
|
||||
// actual decryption does not happen here,
|
||||
// we just want to ensure the method calls are correct
|
||||
// Mock decryption to return valid data for each call
|
||||
mockEncryptService.decryptString
|
||||
.mockResolvedValueOnce(JSON.stringify(mockReportData))
|
||||
.mockResolvedValueOnce(JSON.stringify(mockSummaryData))
|
||||
.mockResolvedValueOnce(JSON.stringify(mockApplicationData));
|
||||
|
||||
// act: call the decrypt method
|
||||
const result = await service.decryptRiskInsightsReport(
|
||||
{ organizationId: orgId, userId },
|
||||
mockEncryptedData,
|
||||
mockKey,
|
||||
);
|
||||
|
||||
// Verify that validation passed and returned the correct data
|
||||
expect(result).toEqual({
|
||||
reportData: testData,
|
||||
summaryData: testData,
|
||||
applicationData: testData,
|
||||
reportData: mockReportData,
|
||||
summaryData: mockSummaryData,
|
||||
applicationData: mockApplicationData,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,7 +218,7 @@ describe("RiskInsightsEncryptionService", () => {
|
||||
).rejects.toEqual(Error("Organization key not found"));
|
||||
});
|
||||
|
||||
it("should return null if decrypt throws", async () => {
|
||||
it("should throw if decrypt throws", async () => {
|
||||
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
|
||||
mockEncryptService.unwrapSymmetricKey.mockRejectedValue(new Error("fail"));
|
||||
|
||||
@@ -224,5 +231,106 @@ describe("RiskInsightsEncryptionService", () => {
|
||||
),
|
||||
).rejects.toEqual(Error("fail"));
|
||||
});
|
||||
|
||||
it("should throw error when report data validation fails", async () => {
|
||||
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
|
||||
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
|
||||
|
||||
// Mock decryption to return invalid data
|
||||
mockEncryptService.decryptString
|
||||
.mockResolvedValueOnce(JSON.stringify([{ invalid: "data" }])) // invalid report data
|
||||
.mockResolvedValueOnce(JSON.stringify(mockSummaryData))
|
||||
.mockResolvedValueOnce(JSON.stringify(mockApplicationData));
|
||||
|
||||
await expect(
|
||||
service.decryptRiskInsightsReport(
|
||||
{ organizationId: orgId, userId },
|
||||
mockEncryptedData,
|
||||
mockKey,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
/Report data validation failed.*This may indicate data corruption or tampering/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error when summary data validation fails", async () => {
|
||||
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
|
||||
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
|
||||
|
||||
// Clear and reset the mock
|
||||
mockEncryptService.decryptString.mockReset();
|
||||
|
||||
// Mock decryption - report data should succeed, summary should fail
|
||||
mockEncryptService.decryptString
|
||||
.mockResolvedValueOnce(JSON.stringify(mockReportData)) // valid
|
||||
.mockResolvedValueOnce(JSON.stringify({ invalid: "summary" })) // invalid summary data - fails here
|
||||
.mockResolvedValueOnce(JSON.stringify(mockApplicationData)); // won't be called but prevents fallback
|
||||
|
||||
await expect(
|
||||
service.decryptRiskInsightsReport(
|
||||
{ organizationId: orgId, userId },
|
||||
mockEncryptedData,
|
||||
mockKey,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
/Summary data validation failed.*This may indicate data corruption or tampering/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error when application data validation fails", async () => {
|
||||
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
|
||||
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
|
||||
|
||||
// Clear and reset the mock
|
||||
mockEncryptService.decryptString.mockReset();
|
||||
|
||||
// Mock decryption - report and summary should succeed, application should fail
|
||||
mockEncryptService.decryptString
|
||||
.mockResolvedValueOnce(JSON.stringify(mockReportData)) // valid
|
||||
.mockResolvedValueOnce(JSON.stringify(mockSummaryData)) // valid
|
||||
.mockResolvedValueOnce(JSON.stringify([{ invalid: "application" }])); // invalid app data
|
||||
|
||||
await expect(
|
||||
service.decryptRiskInsightsReport(
|
||||
{ organizationId: orgId, userId },
|
||||
mockEncryptedData,
|
||||
mockKey,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
/Application data validation failed.*This may indicate data corruption or tampering/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for invalid date in application data", async () => {
|
||||
mockKeyService.orgKeys$.mockReturnValue(orgKey$);
|
||||
mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey);
|
||||
|
||||
const invalidApplicationData = [
|
||||
{
|
||||
applicationName: "Test App",
|
||||
isCritical: true,
|
||||
reviewedDate: "invalid-date-string",
|
||||
},
|
||||
];
|
||||
|
||||
// Clear and reset the mock
|
||||
mockEncryptService.decryptString.mockReset();
|
||||
|
||||
// Mock decryption - report and summary succeed, application with invalid date fails
|
||||
mockEncryptService.decryptString
|
||||
.mockResolvedValueOnce(JSON.stringify(mockReportData)) // valid
|
||||
.mockResolvedValueOnce(JSON.stringify(mockSummaryData)) // valid
|
||||
.mockResolvedValueOnce(JSON.stringify(invalidApplicationData)); // invalid date
|
||||
|
||||
await expect(
|
||||
service.decryptRiskInsightsReport(
|
||||
{ organizationId: orgId, userId },
|
||||
mockEncryptedData,
|
||||
mockKey,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
/Application data validation failed.*This may indicate data corruption or tampering/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,14 +10,20 @@ import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { createNewSummaryData } from "../../helpers";
|
||||
import {
|
||||
DecryptedReportData,
|
||||
EncryptedReportData,
|
||||
EncryptedDataWithKey,
|
||||
ApplicationHealthReportDetail,
|
||||
OrganizationReportSummary,
|
||||
DecryptedReportData,
|
||||
EncryptedDataWithKey,
|
||||
EncryptedReportData,
|
||||
OrganizationReportApplication,
|
||||
OrganizationReportSummary,
|
||||
} from "../../models";
|
||||
|
||||
import {
|
||||
validateApplicationHealthReportDetailArray,
|
||||
validateOrganizationReportApplicationArray,
|
||||
validateOrganizationReportSummary,
|
||||
} from "./risk-insights-type-guards";
|
||||
|
||||
export class RiskInsightsEncryptionService {
|
||||
constructor(
|
||||
private keyService: KeyService,
|
||||
@@ -182,11 +188,16 @@ export class RiskInsightsEncryptionService {
|
||||
const decryptedData = await this.encryptService.decryptString(encryptedData, key);
|
||||
const parsedData = JSON.parse(decryptedData);
|
||||
|
||||
// TODO Add type guard to check that parsed data is actual type
|
||||
return parsedData as ApplicationHealthReportDetail[];
|
||||
// Validate parsed data structure with runtime type guards
|
||||
return validateApplicationHealthReportDetailArray(parsedData);
|
||||
} catch (error: unknown) {
|
||||
// Log detailed error for debugging
|
||||
this.logService.error("[RiskInsightsEncryptionService] Failed to decrypt report", error);
|
||||
return [];
|
||||
// Always throw generic message to prevent information disclosure
|
||||
// Original error with detailed validation info is logged, not exposed to caller
|
||||
throw new Error(
|
||||
"Report data validation failed. This may indicate data corruption or tampering.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,14 +213,19 @@ export class RiskInsightsEncryptionService {
|
||||
const decryptedData = await this.encryptService.decryptString(encryptedData, key);
|
||||
const parsedData = JSON.parse(decryptedData);
|
||||
|
||||
// TODO Add type guard to check that parsed data is actual type
|
||||
return parsedData as OrganizationReportSummary;
|
||||
// Validate parsed data structure with runtime type guards
|
||||
return validateOrganizationReportSummary(parsedData);
|
||||
} catch (error: unknown) {
|
||||
// Log detailed error for debugging
|
||||
this.logService.error(
|
||||
"[RiskInsightsEncryptionService] Failed to decrypt report summary",
|
||||
error,
|
||||
);
|
||||
return createNewSummaryData();
|
||||
// Always throw generic message to prevent information disclosure
|
||||
// Original error with detailed validation info is logged, not exposed to caller
|
||||
throw new Error(
|
||||
"Summary data validation failed. This may indicate data corruption or tampering.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,14 +241,19 @@ export class RiskInsightsEncryptionService {
|
||||
const decryptedData = await this.encryptService.decryptString(encryptedData, key);
|
||||
const parsedData = JSON.parse(decryptedData);
|
||||
|
||||
// TODO Add type guard to check that parsed data is actual type
|
||||
return parsedData as OrganizationReportApplication[];
|
||||
// Validate parsed data structure with runtime type guards
|
||||
return validateOrganizationReportApplicationArray(parsedData);
|
||||
} catch (error: unknown) {
|
||||
// Log detailed error for debugging
|
||||
this.logService.error(
|
||||
"[RiskInsightsEncryptionService] Failed to decrypt report applications",
|
||||
error,
|
||||
);
|
||||
return [];
|
||||
// Always throw generic message to prevent information disclosure
|
||||
// Original error with detailed validation info is logged, not exposed to caller
|
||||
throw new Error(
|
||||
"Application data validation failed. This may indicate data corruption or tampering.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
import { MemberDetails } from "../../models";
|
||||
|
||||
import {
|
||||
isApplicationHealthReportDetail,
|
||||
isMemberDetails,
|
||||
isOrganizationReportApplication,
|
||||
isOrganizationReportSummary,
|
||||
validateApplicationHealthReportDetailArray,
|
||||
validateOrganizationReportApplicationArray,
|
||||
validateOrganizationReportSummary,
|
||||
} from "./risk-insights-type-guards";
|
||||
|
||||
describe("Risk Insights Type Guards", () => {
|
||||
describe("validateApplicationHealthReportDetailArray", () => {
|
||||
it("should validate valid ApplicationHealthReportDetail array", () => {
|
||||
const validData = [
|
||||
{
|
||||
applicationName: "Test App",
|
||||
passwordCount: 10,
|
||||
atRiskPasswordCount: 2,
|
||||
atRiskCipherIds: ["cipher-1", "cipher-2"],
|
||||
memberCount: 5,
|
||||
atRiskMemberCount: 1,
|
||||
memberDetails: [
|
||||
{
|
||||
userGuid: "user-1",
|
||||
userName: "John Doe",
|
||||
email: "john@example.com",
|
||||
cipherId: "cipher-1",
|
||||
},
|
||||
],
|
||||
atRiskMemberDetails: [
|
||||
{
|
||||
userGuid: "user-2",
|
||||
userName: "Jane Doe",
|
||||
email: "jane@example.com",
|
||||
cipherId: "cipher-2",
|
||||
},
|
||||
],
|
||||
cipherIds: ["cipher-1", "cipher-2"],
|
||||
},
|
||||
];
|
||||
|
||||
expect(() => validateApplicationHealthReportDetailArray(validData)).not.toThrow();
|
||||
expect(validateApplicationHealthReportDetailArray(validData)).toEqual(validData);
|
||||
});
|
||||
|
||||
it("should throw error for non-array input", () => {
|
||||
expect(() => validateApplicationHealthReportDetailArray("not an array")).toThrow(
|
||||
"Invalid report data: expected array of ApplicationHealthReportDetail, received non-array",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for array with invalid elements", () => {
|
||||
const invalidData = [
|
||||
{
|
||||
applicationName: "Test App",
|
||||
// missing required fields
|
||||
},
|
||||
];
|
||||
|
||||
expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow(
|
||||
/Invalid report data: array contains 1 invalid ApplicationHealthReportDetail element\(s\) at indices: 0/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for array with multiple invalid elements", () => {
|
||||
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
|
||||
];
|
||||
|
||||
expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow(
|
||||
/Invalid report data: array contains 2 invalid ApplicationHealthReportDetail element\(s\) at indices: 0, 2/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for invalid memberDetails", () => {
|
||||
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"],
|
||||
},
|
||||
];
|
||||
|
||||
expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow(
|
||||
/Invalid report data/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for empty string in atRiskCipherIds", () => {
|
||||
const invalidData = [
|
||||
{
|
||||
applicationName: "Test App",
|
||||
passwordCount: 10,
|
||||
atRiskPasswordCount: 2,
|
||||
atRiskCipherIds: ["cipher-1", "", "cipher-3"], // empty string
|
||||
memberCount: 5,
|
||||
atRiskMemberCount: 1,
|
||||
memberDetails: [] as MemberDetails[],
|
||||
atRiskMemberDetails: [] as MemberDetails[],
|
||||
cipherIds: ["cipher-1"],
|
||||
},
|
||||
];
|
||||
|
||||
expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow(
|
||||
/Invalid report data/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for empty string in cipherIds", () => {
|
||||
const invalidData = [
|
||||
{
|
||||
applicationName: "Test App",
|
||||
passwordCount: 10,
|
||||
atRiskPasswordCount: 2,
|
||||
atRiskCipherIds: ["cipher-1"],
|
||||
memberCount: 5,
|
||||
atRiskMemberCount: 1,
|
||||
memberDetails: [] as MemberDetails[],
|
||||
atRiskMemberDetails: [] as MemberDetails[],
|
||||
cipherIds: ["", "cipher-2"], // empty string
|
||||
},
|
||||
];
|
||||
|
||||
expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow(
|
||||
/Invalid report data/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateOrganizationReportSummary", () => {
|
||||
it("should validate valid OrganizationReportSummary", () => {
|
||||
const validData = {
|
||||
totalMemberCount: 10,
|
||||
totalApplicationCount: 5,
|
||||
totalAtRiskMemberCount: 2,
|
||||
totalAtRiskApplicationCount: 1,
|
||||
totalCriticalApplicationCount: 3,
|
||||
totalCriticalMemberCount: 4,
|
||||
totalCriticalAtRiskMemberCount: 1,
|
||||
totalCriticalAtRiskApplicationCount: 1,
|
||||
newApplications: ["app-1", "app-2"],
|
||||
};
|
||||
|
||||
expect(() => validateOrganizationReportSummary(validData)).not.toThrow();
|
||||
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,
|
||||
newApplications: ["app-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
|
||||
newApplications: ["app-1"],
|
||||
};
|
||||
|
||||
expect(() => validateOrganizationReportSummary(invalidData)).toThrow(
|
||||
/Invalid OrganizationReportSummary: missing or invalid fields:.*totalApplicationCount/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for invalid field types", () => {
|
||||
const invalidData = {
|
||||
totalMemberCount: "10", // should be number
|
||||
totalApplicationCount: 5,
|
||||
totalAtRiskMemberCount: 2,
|
||||
totalAtRiskApplicationCount: 1,
|
||||
totalCriticalApplicationCount: 3,
|
||||
totalCriticalMemberCount: 4,
|
||||
totalCriticalAtRiskMemberCount: 1,
|
||||
totalCriticalAtRiskApplicationCount: 1,
|
||||
newApplications: ["app-1"],
|
||||
};
|
||||
|
||||
expect(() => validateOrganizationReportSummary(invalidData)).toThrow(
|
||||
/Invalid OrganizationReportSummary/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for non-array newApplications", () => {
|
||||
const invalidData = {
|
||||
totalMemberCount: 10,
|
||||
totalApplicationCount: 5,
|
||||
totalAtRiskMemberCount: 2,
|
||||
totalAtRiskApplicationCount: 1,
|
||||
totalCriticalApplicationCount: 3,
|
||||
totalCriticalMemberCount: 4,
|
||||
totalCriticalAtRiskMemberCount: 1,
|
||||
totalCriticalAtRiskApplicationCount: 1,
|
||||
newApplications: "not-an-array",
|
||||
};
|
||||
|
||||
expect(() => validateOrganizationReportSummary(invalidData)).toThrow(
|
||||
/Invalid OrganizationReportSummary.*newApplications/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for empty string in newApplications", () => {
|
||||
const invalidData = {
|
||||
totalMemberCount: 10,
|
||||
totalApplicationCount: 5,
|
||||
totalAtRiskMemberCount: 2,
|
||||
totalAtRiskApplicationCount: 1,
|
||||
totalCriticalApplicationCount: 3,
|
||||
totalCriticalMemberCount: 4,
|
||||
totalCriticalAtRiskMemberCount: 1,
|
||||
totalCriticalAtRiskApplicationCount: 1,
|
||||
newApplications: ["app-1", "", "app-3"], // empty string
|
||||
};
|
||||
|
||||
expect(() => validateOrganizationReportSummary(invalidData)).toThrow(
|
||||
/Invalid OrganizationReportSummary/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateOrganizationReportApplicationArray", () => {
|
||||
it("should validate valid OrganizationReportApplication array", () => {
|
||||
const validData = [
|
||||
{
|
||||
applicationName: "Test App",
|
||||
isCritical: true,
|
||||
reviewedDate: null,
|
||||
},
|
||||
{
|
||||
applicationName: "Another App",
|
||||
isCritical: false,
|
||||
reviewedDate: new Date("2024-01-01"),
|
||||
},
|
||||
];
|
||||
|
||||
expect(() => validateOrganizationReportApplicationArray(validData)).not.toThrow();
|
||||
const result = validateOrganizationReportApplicationArray(validData);
|
||||
expect(result[0].applicationName).toBe("Test App");
|
||||
expect(result[1].reviewedDate).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("should convert string dates to Date objects", () => {
|
||||
const validData = [
|
||||
{
|
||||
applicationName: "Test App",
|
||||
isCritical: true,
|
||||
reviewedDate: "2024-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const result = validateOrganizationReportApplicationArray(validData);
|
||||
expect(result[0].reviewedDate).toBeInstanceOf(Date);
|
||||
expect(result[0].reviewedDate?.toISOString()).toBe("2024-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("should throw error for invalid date strings", () => {
|
||||
const invalidData = [
|
||||
{
|
||||
applicationName: "Test App",
|
||||
isCritical: true,
|
||||
reviewedDate: "invalid-date",
|
||||
},
|
||||
];
|
||||
|
||||
expect(() => validateOrganizationReportApplicationArray(invalidData)).toThrow(
|
||||
"Invalid date string: invalid-date",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for non-array input", () => {
|
||||
expect(() => validateOrganizationReportApplicationArray("not an array")).toThrow(
|
||||
"Invalid application data: expected array of OrganizationReportApplication, received non-array",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for array with invalid elements", () => {
|
||||
const invalidData = [
|
||||
{
|
||||
applicationName: "Test App",
|
||||
reviewedDate: null as any,
|
||||
// missing isCritical field
|
||||
} as any,
|
||||
];
|
||||
|
||||
expect(() => validateOrganizationReportApplicationArray(invalidData)).toThrow(
|
||||
/Invalid application data: array contains 1 invalid OrganizationReportApplication element\(s\) at indices: 0/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for invalid field types", () => {
|
||||
const invalidData = [
|
||||
{
|
||||
applicationName: 123 as any, // should be string
|
||||
isCritical: true,
|
||||
reviewedDate: null as any,
|
||||
} as any,
|
||||
];
|
||||
|
||||
expect(() => validateOrganizationReportApplicationArray(invalidData)).toThrow(
|
||||
/Invalid application data/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should accept null reviewedDate", () => {
|
||||
const validData = [
|
||||
{
|
||||
applicationName: "Test App",
|
||||
isCritical: true,
|
||||
reviewedDate: null as any,
|
||||
},
|
||||
];
|
||||
|
||||
expect(() => validateOrganizationReportApplicationArray(validData)).not.toThrow();
|
||||
const result = validateOrganizationReportApplicationArray(validData);
|
||||
expect(result[0].reviewedDate).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Tests for exported type guard functions
|
||||
describe("isMemberDetails", () => {
|
||||
it("should return true for valid MemberDetails", () => {
|
||||
const validData = {
|
||||
userGuid: "user-1",
|
||||
userName: "John Doe",
|
||||
email: "john@example.com",
|
||||
cipherId: "cipher-1",
|
||||
};
|
||||
expect(isMemberDetails(validData)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for empty userGuid", () => {
|
||||
const invalidData = {
|
||||
userGuid: "",
|
||||
userName: "John Doe",
|
||||
email: "john@example.com",
|
||||
cipherId: "cipher-1",
|
||||
};
|
||||
expect(isMemberDetails(invalidData)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for empty userName", () => {
|
||||
const invalidData = {
|
||||
userGuid: "user-1",
|
||||
userName: "",
|
||||
email: "john@example.com",
|
||||
cipherId: "cipher-1",
|
||||
};
|
||||
expect(isMemberDetails(invalidData)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for empty email", () => {
|
||||
const invalidData = {
|
||||
userGuid: "user-1",
|
||||
userName: "John Doe",
|
||||
email: "",
|
||||
cipherId: "cipher-1",
|
||||
};
|
||||
expect(isMemberDetails(invalidData)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for empty cipherId", () => {
|
||||
const invalidData = {
|
||||
userGuid: "user-1",
|
||||
userName: "John Doe",
|
||||
email: "john@example.com",
|
||||
cipherId: "",
|
||||
};
|
||||
expect(isMemberDetails(invalidData)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for objects with unexpected properties", () => {
|
||||
const invalidData = {
|
||||
userGuid: "user-1",
|
||||
userName: "John Doe",
|
||||
email: "john@example.com",
|
||||
cipherId: "cipher-1",
|
||||
unexpectedProperty: "should fail",
|
||||
};
|
||||
expect(isMemberDetails(invalidData)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for prototype pollution attempts", () => {
|
||||
const invalidData = {
|
||||
userGuid: "user-1",
|
||||
userName: "John Doe",
|
||||
email: "john@example.com",
|
||||
cipherId: "cipher-1",
|
||||
__proto__: { malicious: "payload" },
|
||||
};
|
||||
expect(isMemberDetails(invalidData)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isApplicationHealthReportDetail", () => {
|
||||
it("should return true for valid ApplicationHealthReportDetail", () => {
|
||||
const validData = {
|
||||
applicationName: "Test App",
|
||||
passwordCount: 10,
|
||||
atRiskPasswordCount: 2,
|
||||
atRiskCipherIds: ["cipher-1"],
|
||||
memberCount: 5,
|
||||
atRiskMemberCount: 1,
|
||||
memberDetails: [] as MemberDetails[],
|
||||
atRiskMemberDetails: [] as MemberDetails[],
|
||||
cipherIds: ["cipher-1"],
|
||||
};
|
||||
expect(isApplicationHealthReportDetail(validData)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for empty applicationName", () => {
|
||||
const invalidData = {
|
||||
applicationName: "",
|
||||
passwordCount: 10,
|
||||
atRiskPasswordCount: 2,
|
||||
atRiskCipherIds: ["cipher-1"],
|
||||
memberCount: 5,
|
||||
atRiskMemberCount: 1,
|
||||
memberDetails: [] as MemberDetails[],
|
||||
atRiskMemberDetails: [] as MemberDetails[],
|
||||
cipherIds: ["cipher-1"],
|
||||
};
|
||||
expect(isApplicationHealthReportDetail(invalidData)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for NaN passwordCount", () => {
|
||||
const invalidData = {
|
||||
applicationName: "Test App",
|
||||
passwordCount: NaN,
|
||||
atRiskPasswordCount: 2,
|
||||
atRiskCipherIds: ["cipher-1"],
|
||||
memberCount: 5,
|
||||
atRiskMemberCount: 1,
|
||||
memberDetails: [] as MemberDetails[],
|
||||
atRiskMemberDetails: [] as MemberDetails[],
|
||||
cipherIds: ["cipher-1"],
|
||||
};
|
||||
expect(isApplicationHealthReportDetail(invalidData)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for Infinity passwordCount", () => {
|
||||
const invalidData = {
|
||||
applicationName: "Test App",
|
||||
passwordCount: Infinity,
|
||||
atRiskPasswordCount: 2,
|
||||
atRiskCipherIds: ["cipher-1"],
|
||||
memberCount: 5,
|
||||
atRiskMemberCount: 1,
|
||||
memberDetails: [] as MemberDetails[],
|
||||
atRiskMemberDetails: [] as MemberDetails[],
|
||||
cipherIds: ["cipher-1"],
|
||||
};
|
||||
expect(isApplicationHealthReportDetail(invalidData)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for negative passwordCount", () => {
|
||||
const invalidData = {
|
||||
applicationName: "Test App",
|
||||
passwordCount: -5,
|
||||
atRiskPasswordCount: 2,
|
||||
atRiskCipherIds: ["cipher-1"],
|
||||
memberCount: 5,
|
||||
atRiskMemberCount: 1,
|
||||
memberDetails: [] as MemberDetails[],
|
||||
atRiskMemberDetails: [] as MemberDetails[],
|
||||
cipherIds: ["cipher-1"],
|
||||
};
|
||||
expect(isApplicationHealthReportDetail(invalidData)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for negative memberCount", () => {
|
||||
const invalidData = {
|
||||
applicationName: "Test App",
|
||||
passwordCount: 10,
|
||||
atRiskPasswordCount: 2,
|
||||
atRiskCipherIds: ["cipher-1"],
|
||||
memberCount: -1,
|
||||
atRiskMemberCount: 1,
|
||||
memberDetails: [] as MemberDetails[],
|
||||
atRiskMemberDetails: [] as MemberDetails[],
|
||||
cipherIds: ["cipher-1"],
|
||||
};
|
||||
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", () => {
|
||||
it("should return true for valid OrganizationReportSummary", () => {
|
||||
const validData = {
|
||||
totalMemberCount: 10,
|
||||
totalApplicationCount: 5,
|
||||
totalAtRiskMemberCount: 2,
|
||||
totalAtRiskApplicationCount: 1,
|
||||
totalCriticalApplicationCount: 3,
|
||||
totalCriticalMemberCount: 4,
|
||||
totalCriticalAtRiskMemberCount: 1,
|
||||
totalCriticalAtRiskApplicationCount: 1,
|
||||
newApplications: ["app-1"],
|
||||
};
|
||||
expect(isOrganizationReportSummary(validData)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for NaN totalMemberCount", () => {
|
||||
const invalidData = {
|
||||
totalMemberCount: NaN,
|
||||
totalApplicationCount: 5,
|
||||
totalAtRiskMemberCount: 2,
|
||||
totalAtRiskApplicationCount: 1,
|
||||
totalCriticalApplicationCount: 3,
|
||||
totalCriticalMemberCount: 4,
|
||||
totalCriticalAtRiskMemberCount: 1,
|
||||
totalCriticalAtRiskApplicationCount: 1,
|
||||
newApplications: ["app-1"],
|
||||
};
|
||||
expect(isOrganizationReportSummary(invalidData)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for Infinity totalApplicationCount", () => {
|
||||
const invalidData = {
|
||||
totalMemberCount: 10,
|
||||
totalApplicationCount: Infinity,
|
||||
totalAtRiskMemberCount: 2,
|
||||
totalAtRiskApplicationCount: 1,
|
||||
totalCriticalApplicationCount: 3,
|
||||
totalCriticalMemberCount: 4,
|
||||
totalCriticalAtRiskMemberCount: 1,
|
||||
totalCriticalAtRiskApplicationCount: 1,
|
||||
newApplications: ["app-1"],
|
||||
};
|
||||
expect(isOrganizationReportSummary(invalidData)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for negative totalAtRiskMemberCount", () => {
|
||||
const invalidData = {
|
||||
totalMemberCount: 10,
|
||||
totalApplicationCount: 5,
|
||||
totalAtRiskMemberCount: -1,
|
||||
totalAtRiskApplicationCount: 1,
|
||||
totalCriticalApplicationCount: 3,
|
||||
totalCriticalMemberCount: 4,
|
||||
totalCriticalAtRiskMemberCount: 1,
|
||||
totalCriticalAtRiskApplicationCount: 1,
|
||||
newApplications: ["app-1"],
|
||||
};
|
||||
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,
|
||||
newApplications: ["app-1"],
|
||||
extraField: "should be rejected",
|
||||
};
|
||||
expect(isOrganizationReportSummary(invalidData)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOrganizationReportApplication", () => {
|
||||
it("should return true for valid OrganizationReportApplication", () => {
|
||||
const validData = {
|
||||
applicationName: "Test App",
|
||||
isCritical: true,
|
||||
reviewedDate: null as Date | null,
|
||||
};
|
||||
expect(isOrganizationReportApplication(validData)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for empty applicationName", () => {
|
||||
const invalidData = {
|
||||
applicationName: "",
|
||||
isCritical: true,
|
||||
reviewedDate: null as Date | null,
|
||||
};
|
||||
expect(isOrganizationReportApplication(invalidData)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for Date reviewedDate", () => {
|
||||
const validData = {
|
||||
applicationName: "Test App",
|
||||
isCritical: true,
|
||||
reviewedDate: new Date(),
|
||||
};
|
||||
expect(isOrganizationReportApplication(validData)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for string reviewedDate", () => {
|
||||
const validData = {
|
||||
applicationName: "Test App",
|
||||
isCritical: false,
|
||||
reviewedDate: "2024-01-01",
|
||||
};
|
||||
expect(isOrganizationReportApplication(validData)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for objects with unexpected properties", () => {
|
||||
const invalidData = {
|
||||
applicationName: "Test App",
|
||||
isCritical: true,
|
||||
reviewedDate: null as Date | null,
|
||||
injectedProperty: "malicious",
|
||||
};
|
||||
expect(isOrganizationReportApplication(invalidData)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for prototype pollution attempts via __proto__", () => {
|
||||
const invalidData = {
|
||||
applicationName: "Test App",
|
||||
isCritical: true,
|
||||
reviewedDate: null as Date | null,
|
||||
__proto__: { polluted: true },
|
||||
};
|
||||
expect(isOrganizationReportApplication(invalidData)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,404 @@
|
||||
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 &&
|
||||
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",
|
||||
"newApplications",
|
||||
];
|
||||
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 &&
|
||||
Array.isArray(obj.newApplications) &&
|
||||
obj.newApplications.length <= MAX_ARRAY_LENGTH &&
|
||||
obj.newApplications.every(
|
||||
(app: any) => typeof app === "string" && app.length > 0 && app.length <= MAX_STRING_LENGTH,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)");
|
||||
}
|
||||
if (!Array.isArray(data?.newApplications)) {
|
||||
missingFields.push("newApplications (string[])");
|
||||
}
|
||||
|
||||
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[];
|
||||
}
|
||||
Reference in New Issue
Block a user