diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.spec.ts index 2efd97b3c30..b9b2cd8de97 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.spec.ts @@ -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/, + ); + }); }); }); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.ts index 5206cd1ecff..abeae1fdb29 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.ts @@ -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.", + ); } } } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-type-guards.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-type-guards.spec.ts new file mode 100644 index 00000000000..32505088818 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-type-guards.spec.ts @@ -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); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-type-guards.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-type-guards.ts new file mode 100644 index 00000000000..b1d2550d4fa --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-type-guards.ts @@ -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[]; +}