diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts index 624b695e6be..f7d635bb5fe 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts @@ -73,7 +73,6 @@ export function createNewSummaryData(): OrganizationReportSummary { totalCriticalAtRiskMemberCount: 0, totalCriticalApplicationCount: 0, totalCriticalAtRiskApplicationCount: 0, - newApplications: [], }; } export function getAtRiskApplicationList( diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/mock-data.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/mock-data.ts index 1a30ad754c3..022b0aa42fb 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/mock-data.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mocks/mock-data.ts @@ -77,7 +77,6 @@ export const mockSummaryData: OrganizationReportSummary = { totalCriticalAtRiskMemberCount: 1, totalCriticalApplicationCount: 1, totalCriticalAtRiskApplicationCount: 1, - newApplications: [], }; export const mockApplicationData: OrganizationReportApplication[] = [ { diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts index 1e1de537543..497c10dbcad 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts @@ -55,8 +55,6 @@ export type OrganizationReportSummary = { totalCriticalMemberCount: number; totalCriticalAtRiskMemberCount: number; totalCriticalAtRiskApplicationCount: number; - /** Optional for backward compatibility - legacy encrypted data predates this field */ - newApplications?: string[]; }; /** diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts index 61b7c659977..393dbf903c0 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts @@ -98,6 +98,26 @@ export class RiskInsightsOrchestratorService { // --------------------------- Critical Application data --------------------- criticalReportResults$: Observable = of(null); + // --------------------------- New Applications Observable --------------------- + /** + * Observable that emits the list of new application names (applications not yet reviewed). + * Derived from applicationData where reviewedDate === null. + */ + newApplications$: Observable = this.rawReportData$.pipe( + map((reportState) => { + if (!reportState.data?.applicationData) { + return []; + } + return reportState.data.applicationData + .filter((app) => app.reviewedDate === null) + .map((app) => app.applicationName); + }), + distinctUntilChanged( + (prev, curr) => prev.length === curr.length && prev.every((app, i) => app === curr[i]), + ), + shareReplay({ bufferSize: 1, refCount: true }), + ); + // --------------------------- Trigger subjects --------------------- private _initializeOrganizationTriggerSubject = new Subject(); private _fetchReportTriggerSubject = new Subject(); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.spec.ts index 3211b44322a..3061ea8cded 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.spec.ts @@ -175,7 +175,6 @@ describe("RiskInsightsReportService", () => { totalCriticalAtRiskMemberCount: 1, totalCriticalApplicationCount: 1, totalCriticalAtRiskApplicationCount: 1, - newApplications: [], }, applicationData: [], }; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.ts index 470442a811b..4f1649c3c89 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.ts @@ -57,22 +57,6 @@ export class RiskInsightsReportService { const uniqueAtRiskMembers = getUniqueMembers(atRiskMembers); // TODO: Replace with actual new applications detection logic (PM-26185) - const dummyNewApplications = [ - "github.com", - "google.com", - "stackoverflow.com", - "gitlab.com", - "bitbucket.org", - "npmjs.com", - "docker.com", - "aws.amazon.com", - "azure.microsoft.com", - "jenkins.io", - "terraform.io", - "kubernetes.io", - "atlassian.net", - ]; - return { totalMemberCount: uniqueMembers.length, totalAtRiskMemberCount: uniqueAtRiskMembers.length, @@ -82,7 +66,6 @@ export class RiskInsightsReportService { totalCriticalAtRiskMemberCount: 0, totalCriticalApplicationCount: 0, totalCriticalAtRiskApplicationCount: 0, - newApplications: dummyNewApplications, }; } 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 index 0ee038a06aa..05aa9ab4f70 100644 --- 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 @@ -158,7 +158,6 @@ describe("Risk Insights Type Guards", () => { totalCriticalMemberCount: 4, totalCriticalAtRiskMemberCount: 1, totalCriticalAtRiskApplicationCount: 1, - newApplications: ["app-1", "app-2"], }; expect(() => validateOrganizationReportSummary(validData)).not.toThrow(); @@ -174,7 +173,6 @@ describe("Risk Insights Type Guards", () => { totalCriticalMemberCount: 4, totalCriticalAtRiskMemberCount: 1, totalCriticalAtRiskApplicationCount: 1, - newApplications: ["app-1"], }; expect(() => validateOrganizationReportSummary(invalidData)).toThrow( @@ -186,7 +184,6 @@ describe("Risk Insights Type Guards", () => { const invalidData = { totalMemberCount: 10, // missing multiple fields - newApplications: ["app-1"], }; expect(() => validateOrganizationReportSummary(invalidData)).toThrow( @@ -204,7 +201,6 @@ describe("Risk Insights Type Guards", () => { totalCriticalMemberCount: 4, totalCriticalAtRiskMemberCount: 1, totalCriticalAtRiskApplicationCount: 1, - newApplications: ["app-1"], }; expect(() => validateOrganizationReportSummary(invalidData)).toThrow( @@ -212,111 +208,6 @@ describe("Risk Insights Type Guards", () => { ); }); - 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/, - ); - }); - - // Backward compatibility tests - legacy encrypted data predates newApplications field - it("should accept OrganizationReportSummary without newApplications field (backward compatibility)", () => { - const validLegacyData = { - totalMemberCount: 10, - totalApplicationCount: 5, - totalAtRiskMemberCount: 2, - totalAtRiskApplicationCount: 1, - totalCriticalApplicationCount: 3, - totalCriticalMemberCount: 4, - totalCriticalAtRiskMemberCount: 1, - totalCriticalAtRiskApplicationCount: 1, - // newApplications field intentionally omitted - legacy data - }; - - expect(() => validateOrganizationReportSummary(validLegacyData)).not.toThrow(); - expect(validateOrganizationReportSummary(validLegacyData)).toEqual(validLegacyData); - }); - - it("should accept OrganizationReportSummary with undefined newApplications (backward compatibility)", () => { - const validData = { - totalMemberCount: 10, - totalApplicationCount: 5, - totalAtRiskMemberCount: 2, - totalAtRiskApplicationCount: 1, - totalCriticalApplicationCount: 3, - totalCriticalMemberCount: 4, - totalCriticalAtRiskMemberCount: 1, - totalCriticalAtRiskApplicationCount: 1, - newApplications: undefined as string[] | undefined, - }; - - expect(() => validateOrganizationReportSummary(validData)).not.toThrow(); - }); - - it("should enforce array length limits on newApplications when present", () => { - const invalidData = { - totalMemberCount: 10, - totalApplicationCount: 5, - totalAtRiskMemberCount: 2, - totalAtRiskApplicationCount: 1, - totalCriticalApplicationCount: 3, - totalCriticalMemberCount: 4, - totalCriticalAtRiskMemberCount: 1, - totalCriticalAtRiskApplicationCount: 1, - newApplications: new Array(50001).fill("app"), // exceeds MAX_ARRAY_LENGTH - }; - - expect(() => validateOrganizationReportSummary(invalidData)).toThrow( - /Invalid OrganizationReportSummary.*newApplications/, - ); - }); - - it("should enforce string length limits on newApplications when present", () => { - const invalidData = { - totalMemberCount: 10, - totalApplicationCount: 5, - totalAtRiskMemberCount: 2, - totalAtRiskApplicationCount: 1, - totalCriticalApplicationCount: 3, - totalCriticalMemberCount: 4, - totalCriticalAtRiskMemberCount: 1, - totalCriticalAtRiskApplicationCount: 1, - newApplications: ["app-1", "a".repeat(1001)], // exceeds MAX_STRING_LENGTH - }; - - expect(() => validateOrganizationReportSummary(invalidData)).toThrow( - /Invalid OrganizationReportSummary.*newApplications/, - ); - }); }); describe("validateOrganizationReportApplicationArray", () => { @@ -621,7 +512,6 @@ describe("Risk Insights Type Guards", () => { totalCriticalMemberCount: 4, totalCriticalAtRiskMemberCount: 1, totalCriticalAtRiskApplicationCount: 1, - newApplications: ["app-1"], }; expect(isOrganizationReportSummary(validData)).toBe(true); }); @@ -636,7 +526,6 @@ describe("Risk Insights Type Guards", () => { totalCriticalMemberCount: 4, totalCriticalAtRiskMemberCount: 1, totalCriticalAtRiskApplicationCount: 1, - newApplications: ["app-1"], }; expect(isOrganizationReportSummary(invalidData)).toBe(false); }); @@ -651,7 +540,6 @@ describe("Risk Insights Type Guards", () => { totalCriticalMemberCount: 4, totalCriticalAtRiskMemberCount: 1, totalCriticalAtRiskApplicationCount: 1, - newApplications: ["app-1"], }; expect(isOrganizationReportSummary(invalidData)).toBe(false); }); @@ -666,7 +554,6 @@ describe("Risk Insights Type Guards", () => { totalCriticalMemberCount: 4, totalCriticalAtRiskMemberCount: 1, totalCriticalAtRiskApplicationCount: 1, - newApplications: ["app-1"], }; expect(isOrganizationReportSummary(invalidData)).toBe(false); }); @@ -681,87 +568,11 @@ describe("Risk Insights Type Guards", () => { totalCriticalMemberCount: 4, totalCriticalAtRiskMemberCount: 1, totalCriticalAtRiskApplicationCount: 1, - newApplications: ["app-1"], extraField: "should be rejected", }; expect(isOrganizationReportSummary(invalidData)).toBe(false); }); - // Backward compatibility tests - legacy encrypted data predates newApplications field - it("should return true for OrganizationReportSummary without newApplications field (backward compatibility)", () => { - const validLegacyData = { - totalMemberCount: 10, - totalApplicationCount: 5, - totalAtRiskMemberCount: 2, - totalAtRiskApplicationCount: 1, - totalCriticalApplicationCount: 3, - totalCriticalMemberCount: 4, - totalCriticalAtRiskMemberCount: 1, - totalCriticalAtRiskApplicationCount: 1, - // newApplications field intentionally omitted - legacy data - }; - expect(isOrganizationReportSummary(validLegacyData)).toBe(true); - }); - - it("should return true for OrganizationReportSummary with undefined newApplications (backward compatibility)", () => { - const validData = { - totalMemberCount: 10, - totalApplicationCount: 5, - totalAtRiskMemberCount: 2, - totalAtRiskApplicationCount: 1, - totalCriticalApplicationCount: 3, - totalCriticalMemberCount: 4, - totalCriticalAtRiskMemberCount: 1, - totalCriticalAtRiskApplicationCount: 1, - newApplications: undefined as string[] | undefined, - }; - expect(isOrganizationReportSummary(validData)).toBe(true); - }); - - it("should return false for empty string in newApplications when present", () => { - 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 should be rejected - }; - expect(isOrganizationReportSummary(invalidData)).toBe(false); - }); - - it("should return false when newApplications exceeds array length limit", () => { - const invalidData = { - totalMemberCount: 10, - totalApplicationCount: 5, - totalAtRiskMemberCount: 2, - totalAtRiskApplicationCount: 1, - totalCriticalApplicationCount: 3, - totalCriticalMemberCount: 4, - totalCriticalAtRiskMemberCount: 1, - totalCriticalAtRiskApplicationCount: 1, - newApplications: new Array(50001).fill("app"), // exceeds MAX_ARRAY_LENGTH - }; - expect(isOrganizationReportSummary(invalidData)).toBe(false); - }); - - it("should return false when newApplications contains strings exceeding length limit", () => { - const invalidData = { - totalMemberCount: 10, - totalApplicationCount: 5, - totalAtRiskMemberCount: 2, - totalAtRiskApplicationCount: 1, - totalCriticalApplicationCount: 3, - totalCriticalMemberCount: 4, - totalCriticalAtRiskMemberCount: 1, - totalCriticalAtRiskApplicationCount: 1, - newApplications: ["app-1", "a".repeat(1001)], // exceeds MAX_STRING_LENGTH - }; - expect(isOrganizationReportSummary(invalidData)).toBe(false); - }); }); describe("isOrganizationReportApplication", () => { 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 index b44f64d4021..c225586d6f7 100644 --- 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 @@ -181,7 +181,6 @@ export function isOrganizationReportSummary(obj: any): obj is OrganizationReport "totalCriticalMemberCount", "totalCriticalAtRiskMemberCount", "totalCriticalAtRiskApplicationCount", - "newApplications", ]; const actualKeys = Object.keys(obj); const hasUnexpectedProps = actualKeys.some((key) => !allowedKeys.includes(key)); @@ -229,15 +228,7 @@ export function isOrganizationReportSummary(obj: any): obj is OrganizationReport Number.isFinite(obj.totalCriticalAtRiskApplicationCount) && Number.isSafeInteger(obj.totalCriticalAtRiskApplicationCount) && obj.totalCriticalAtRiskApplicationCount >= 0 && - obj.totalCriticalAtRiskApplicationCount <= MAX_COUNT && - // newApplications is optional (backward compatibility - legacy encrypted data predates this field) - (obj.newApplications === undefined || - (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, - ))) + obj.totalCriticalAtRiskApplicationCount <= MAX_COUNT ); } @@ -349,19 +340,6 @@ export function validateOrganizationReportSummary(data: any): OrganizationReport if (typeof data?.totalCriticalAtRiskApplicationCount !== "number") { missingFields.push("totalCriticalAtRiskApplicationCount (number)"); } - // newApplications is optional (backward compatibility - legacy encrypted data predates this field) - // Only validate if present, but enforce all constraints to prevent DoS attacks - if ( - data?.newApplications !== undefined && - (!Array.isArray(data?.newApplications) || - data.newApplications.length > MAX_ARRAY_LENGTH || - !data.newApplications.every( - (app: any) => - typeof app === "string" && app.length > 0 && app.length <= MAX_STRING_LENGTH, - )) - ) { - missingFields.push("newApplications (optional string[])"); - } throw new Error( `Invalid OrganizationReportSummary: ${missingFields.length > 0 ? `missing or invalid fields: ${missingFields.join(", ")}` : "structure validation failed"}`, diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts index a25f2ba7fe8..f205aaba342 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts @@ -20,7 +20,6 @@ export class AllActivitiesService { totalCriticalApplicationCount: 0, totalAtRiskApplicationCount: 0, totalCriticalAtRiskApplicationCount: 0, - newApplications: [], }); reportSummary$ = this.reportSummarySubject$.asObservable(); @@ -78,7 +77,6 @@ export class AllActivitiesService { totalAtRiskMemberCount: summary.totalAtRiskMemberCount, totalApplicationCount: summary.totalApplicationCount, totalAtRiskApplicationCount: summary.totalAtRiskApplicationCount, - newApplications: summary.newApplications, }); } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts index 6855274498a..066598df63d 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts @@ -28,6 +28,7 @@ export class RiskInsightsDataService { readonly enrichedReportData$: Observable = of(null); readonly isGeneratingReport$: Observable = of(false); readonly criticalReportResults$: Observable = of(null); + readonly newApplications$: Observable = of([]); // ------------------------- Drawer Variables --------------------- // Drawer variables unified into a single BehaviorSubject @@ -48,6 +49,7 @@ export class RiskInsightsDataService { this.organizationDetails$ = this.orchestrator.organizationDetails$; this.enrichedReportData$ = this.orchestrator.enrichedReportData$; this.criticalReportResults$ = this.orchestrator.criticalReportResults$; + this.newApplications$ = this.orchestrator.newApplications$; // Expose the loading state this.isLoading$ = this.reportState$.pipe( diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts index fa97d0f67e6..cca4b1c7a06 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts @@ -69,8 +69,13 @@ export class AllActivityComponent implements OnInit { this.totalCriticalAppsAtRiskMemberCount = summary.totalCriticalAtRiskMemberCount; this.totalCriticalAppsCount = summary.totalCriticalApplicationCount; this.totalCriticalAppsAtRiskCount = summary.totalCriticalAtRiskApplicationCount; - this.newApplications = summary.newApplications ?? []; - this.newApplicationsCount = summary.newApplications?.length ?? 0; + }); + + this.dataService.newApplications$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((newApps) => { + this.newApplications = newApps; + this.newApplicationsCount = newApps.length; }); this.allActivitiesService.passwordChangeProgressMetricHasProgressBar$