From c0fdd5bdf37d6cd82f23b434af62a035ee3e64b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Tue, 15 Sep 2026 11:07:31 +0200 Subject: [PATCH] fix(ui): add FedRAMP 20x cross-provider mappers (#12810) Co-authored-by: alejandrobailo --- ...ramp-20x-cross-provider-breakdown.fixed.md | 1 + .../fedramp-20x-details.tsx | 89 +++++++++ ui/lib/compliance/c5.tsx | 10 +- ui/lib/compliance/cis-controls.tsx | 10 +- ui/lib/compliance/{cmmc.tsx => cmmc.ts} | 70 +------ ui/lib/compliance/commons.tsx | 11 +- ui/lib/compliance/compliance-mapper.test.ts | 11 ++ ui/lib/compliance/compliance-mapper.ts | 30 +++ .../compliance-report-types.test.ts | 2 + ui/lib/compliance/compliance-report-types.ts | 5 +- ui/lib/compliance/csa.tsx | 10 +- ui/lib/compliance/cyber-essentials.tsx | 10 +- ui/lib/compliance/dora.tsx | 10 +- ui/lib/compliance/fedramp-20x.test.ts | 183 ++++++++++++++++++ ui/lib/compliance/fedramp-20x.ts | 110 +++++++++++ ui/lib/compliance/grouped-accordion.tsx | 55 ++++++ ui/lib/compliance/okta-idaas-stig.tsx | 8 +- ui/types/compliance.ts | 16 ++ 18 files changed, 526 insertions(+), 115 deletions(-) create mode 100644 ui/changelog.d/fedramp-20x-cross-provider-breakdown.fixed.md create mode 100644 ui/components/compliance/compliance-custom-details/fedramp-20x-details.tsx rename ui/lib/compliance/{cmmc.tsx => cmmc.ts} (56%) create mode 100644 ui/lib/compliance/fedramp-20x.test.ts create mode 100644 ui/lib/compliance/fedramp-20x.ts create mode 100644 ui/lib/compliance/grouped-accordion.tsx diff --git a/ui/changelog.d/fedramp-20x-cross-provider-breakdown.fixed.md b/ui/changelog.d/fedramp-20x-cross-provider-breakdown.fixed.md new file mode 100644 index 0000000000..dc7c71ce1b --- /dev/null +++ b/ui/changelog.d/fedramp-20x-cross-provider-breakdown.fixed.md @@ -0,0 +1 @@ +Per-provider breakdown and OCSF download for FedRAMP 20x KSI and Class C FRR in the cross-provider compliance view diff --git a/ui/components/compliance/compliance-custom-details/fedramp-20x-details.tsx b/ui/components/compliance/compliance-custom-details/fedramp-20x-details.tsx new file mode 100644 index 0000000000..31f684a33b --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/fedramp-20x-details.tsx @@ -0,0 +1,89 @@ +import { Requirement } from "@/types/compliance"; + +import { + ComplianceBadge, + ComplianceBadgeContainer, + ComplianceDetailContainer, + ComplianceDetailSection, + ComplianceDetailText, +} from "./shared-components"; + +interface FedRAMP20xDetailsProps { + requirement: Requirement; +} + +const DescriptionSection = ({ requirement }: FedRAMP20xDetailsProps) => + requirement.description ? ( + + {requirement.description} + + ) : null; + +export const FedRAMP20xKSICustomDetails = ({ + requirement, +}: FedRAMP20xDetailsProps) => { + return ( + + + + + {requirement.theme && ( + + )} + {requirement.class_applicability && ( + + )} + + + {requirement.nist_controls && ( + + + {requirement.nist_controls as string} + + + )} + + ); +}; + +export const FedRAMP20xFRRCustomDetails = ({ + requirement, +}: FedRAMP20xDetailsProps) => { + return ( + + + + + {requirement.ruleset && ( + + )} + {requirement.subset && ( + + )} + {requirement.force && ( + + )} + + + ); +}; diff --git a/ui/lib/compliance/c5.tsx b/ui/lib/compliance/c5.tsx index 4839df3d66..c39a2dd538 100644 --- a/ui/lib/compliance/c5.tsx +++ b/ui/lib/compliance/c5.tsx @@ -3,13 +3,12 @@ import { ComplianceAccordionRequirementTitle } from "@/components/compliance/com import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; import { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; import { FindingStatus } from "@/components/shadcn/table/status-finding-badge"; -import { +import type { AttributesData, C5AttributesMetadata, Control, Framework, Requirement, - REQUIREMENT_STATUS, RequirementsData, RequirementStatus, } from "@/types/compliance"; @@ -20,14 +19,9 @@ import { findOrCreateCategory, findOrCreateControl, findOrCreateFramework, + getStatusCounters, } from "./commons"; -const getStatusCounters = (status: RequirementStatus) => ({ - pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0, - fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0, - manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0, -}); - export const mapComplianceData = ( attributesData: AttributesData, requirementsData: RequirementsData, diff --git a/ui/lib/compliance/cis-controls.tsx b/ui/lib/compliance/cis-controls.tsx index 7a7f283fc6..a1fab412e1 100644 --- a/ui/lib/compliance/cis-controls.tsx +++ b/ui/lib/compliance/cis-controls.tsx @@ -3,12 +3,11 @@ import { ComplianceAccordionRequirementTitle } from "@/components/compliance/com import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; import { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; import { FindingStatus } from "@/components/shadcn/table/status-finding-badge"; -import { +import type { AttributesData, CISControlsAttributesMetadata, Framework, Requirement, - REQUIREMENT_STATUS, RequirementsData, RequirementStatus, } from "@/types/compliance"; @@ -19,14 +18,9 @@ import { findOrCreateCategory, findOrCreateControl, findOrCreateFramework, + getStatusCounters, } from "./commons"; -const getStatusCounters = (status: RequirementStatus) => ({ - pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0, - fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0, - manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0, -}); - // Sort the 18 CIS Controls by their leading number ("1. ...", "2. ...", ..., // "18. ...") so the accordion always reads in canonical control order // regardless of how the API returns the sections. diff --git a/ui/lib/compliance/cmmc.tsx b/ui/lib/compliance/cmmc.ts similarity index 56% rename from ui/lib/compliance/cmmc.tsx rename to ui/lib/compliance/cmmc.ts index 557c28b95c..748d0ed39c 100644 --- a/ui/lib/compliance/cmmc.tsx +++ b/ui/lib/compliance/cmmc.ts @@ -1,14 +1,8 @@ -import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; -import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; -import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; -import { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; -import { FindingStatus } from "@/components/shadcn/table/status-finding-badge"; -import { +import type { AttributesData, CMMCAttributesMetadata, Framework, Requirement, - REQUIREMENT_STATUS, RequirementsData, RequirementStatus, } from "@/types/compliance"; @@ -19,8 +13,11 @@ import { findOrCreateCategory, findOrCreateControl, findOrCreateFramework, + getStatusCounters, } from "./commons"; +export { toGroupedAccordionItems as toAccordionItems } from "./grouped-accordion"; + // Canonical NIST SP 800-171 family order for the 14 CMMC domains, so the // accordion always reads in the same order regardless of the API response. export const CMMC_DOMAIN_ORDER: readonly string[] = [ @@ -40,12 +37,6 @@ export const CMMC_DOMAIN_ORDER: readonly string[] = [ "System and Information Integrity", ]; -const getStatusCounters = (status: RequirementStatus) => ({ - pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0, - fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0, - manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0, -}); - export const mapComplianceData = ( attributesData: AttributesData, requirementsData: RequirementsData, @@ -109,56 +100,3 @@ export const mapComplianceData = ( return frameworks; }; - -export const toAccordionItems = ( - data: Framework[], - scanId: string | undefined, -): AccordionItemProps[] => { - const safeId = scanId || ""; - - return data.flatMap((framework) => - framework.categories.map((category) => ({ - key: `${framework.name}-${category.name}`, - title: ( - - ), - content: "", - // Domain → requirements (flat, no intermediate "control" level). - // Keys are derived from the requirement name (which starts with the - // unique CMMC id, e.g. "AC.L1-b.1.i") instead of the array index, so - // expanded state stays attached to the right requirement even if the - // list is reordered or filtered. - items: category.controls.flatMap((control) => - control.requirements.map((requirement) => ({ - key: `${framework.name}-${category.name}-${requirement.name}`, - title: ( - - ), - content: ( - - ), - items: [], - })), - ), - })), - ); -}; diff --git a/ui/lib/compliance/commons.tsx b/ui/lib/compliance/commons.tsx index 24a7b8407c..15b315be4d 100644 --- a/ui/lib/compliance/commons.tsx +++ b/ui/lib/compliance/commons.tsx @@ -7,6 +7,7 @@ import { REQUIREMENT_STATUS, RequirementItemData, RequirementsData, + RequirementsTotals, RequirementStatus, TOP_FAILED_DATA_TYPE, TopFailedDataType, @@ -66,7 +67,7 @@ const incrementFailedCount = ( }; export const updateCounters = ( - target: { pass: number; fail: number; manual: number }, + target: RequirementsTotals, status: RequirementStatus, ) => { if (status === REQUIREMENT_STATUS.MANUAL) { @@ -78,6 +79,14 @@ export const updateCounters = ( } }; +export const getStatusCounters = ( + status: RequirementStatus, +): RequirementsTotals => { + const counters: RequirementsTotals = { pass: 0, fail: 0, manual: 0 }; + updateCounters(counters, status); + return counters; +}; + export const getTopFailedSections = ( mappedData: Framework[], ): TopFailedResult => { diff --git a/ui/lib/compliance/compliance-mapper.test.ts b/ui/lib/compliance/compliance-mapper.test.ts index c7ce69040e..be7ba8074c 100644 --- a/ui/lib/compliance/compliance-mapper.test.ts +++ b/ui/lib/compliance/compliance-mapper.test.ts @@ -54,6 +54,13 @@ vi.mock( "@/components/compliance/compliance-custom-details/ens-details", () => ({ ENSCustomDetails: stubFactory("ENSStub") }), ); +vi.mock( + "@/components/compliance/compliance-custom-details/fedramp-20x-details", + () => ({ + FedRAMP20xFRRCustomDetails: stubFactory("FedRAMP20xFRRStub"), + FedRAMP20xKSICustomDetails: stubFactory("FedRAMP20xKSIStub"), + }), +); vi.mock( "@/components/compliance/compliance-custom-details/generic-details", () => ({ GenericCustomDetails: stubFactory("GenericStub") }), @@ -159,6 +166,8 @@ describe("getComplianceMapper", () => { { framework: "CMMC", expected: "CMMCStub" }, { framework: "Okta-IDaaS-STIG", expected: "OktaIDaaSStigStub" }, { framework: "Cyber-Essentials", expected: "CyberEssentialsStub" }, + { framework: "FedRAMP-20x-KSI", expected: "FedRAMP20xKSIStub" }, + { framework: "FedRAMP-20x-FRR-Class-C", expected: "FedRAMP20xFRRStub" }, ]; for (const { framework, expected } of wiring) { @@ -206,6 +215,8 @@ describe("getComplianceMapper", () => { "CMMC", "Okta-IDaaS-STIG", "Cyber-Essentials", + "FedRAMP-20x-KSI", + "FedRAMP-20x-FRR-Class-C", ]) { const mapper = getComplianceMapper(framework); expect(Object.keys(mapper).sort(), framework).toEqual(expectedKeys); diff --git a/ui/lib/compliance/compliance-mapper.ts b/ui/lib/compliance/compliance-mapper.ts index 80b3558aeb..d80a980485 100644 --- a/ui/lib/compliance/compliance-mapper.ts +++ b/ui/lib/compliance/compliance-mapper.ts @@ -11,6 +11,10 @@ import { CSACustomDetails } from "@/components/compliance/compliance-custom-deta import { CyberEssentialsCustomDetails } from "@/components/compliance/compliance-custom-details/cyber-essentials-details"; import { DORACustomDetails } from "@/components/compliance/compliance-custom-details/dora-details"; import { ENSCustomDetails } from "@/components/compliance/compliance-custom-details/ens-details"; +import { + FedRAMP20xFRRCustomDetails, + FedRAMP20xKSICustomDetails, +} from "@/components/compliance/compliance-custom-details/fedramp-20x-details"; import { GenericCustomDetails } from "@/components/compliance/compliance-custom-details/generic-details"; import { ISOCustomDetails } from "@/components/compliance/compliance-custom-details/iso-details"; import { KISACustomDetails } from "@/components/compliance/compliance-custom-details/kisa-details"; @@ -72,6 +76,11 @@ import { mapComplianceData as mapENSComplianceData, toAccordionItems as toENSAccordionItems, } from "./ens"; +import { + mapFRRComplianceData as mapFedRAMP20xFRRComplianceData, + mapKSIComplianceData as mapFedRAMP20xKSIComplianceData, + toAccordionItems as toFedRAMP20xAccordionItems, +} from "./fedramp-20x"; import { mapComplianceData as mapGenericComplianceData, toAccordionItems as toGenericAccordionItems, @@ -296,6 +305,27 @@ const getComplianceMappers = (): Record => ({ getDetailsComponent: (requirement: Requirement) => createElement(CMMCCustomDetails, { requirement }), }, + // Universal frameworks must compose requirement names as `${id} - ${name}` + // (see `composeRequirementName`); the generic mapper does not, so they need + // a dedicated entry for the cross-provider breakdown to render. + "FedRAMP-20x-KSI": { + mapComplianceData: mapFedRAMP20xKSIComplianceData, + toAccordionItems: toFedRAMP20xAccordionItems, + getTopFailedSections, + calculateCategoryHeatmapData: (data: Framework[]) => + calculateCategoryHeatmapData(data), + getDetailsComponent: (requirement: Requirement) => + createElement(FedRAMP20xKSICustomDetails, { requirement }), + }, + "FedRAMP-20x-FRR-Class-C": { + mapComplianceData: mapFedRAMP20xFRRComplianceData, + toAccordionItems: toFedRAMP20xAccordionItems, + getTopFailedSections, + calculateCategoryHeatmapData: (data: Framework[]) => + calculateCategoryHeatmapData(data), + getDetailsComponent: (requirement: Requirement) => + createElement(FedRAMP20xFRRCustomDetails, { requirement }), + }, }); /** diff --git a/ui/lib/compliance/compliance-report-types.test.ts b/ui/lib/compliance/compliance-report-types.test.ts index 8a431d9748..59b26beed3 100644 --- a/ui/lib/compliance/compliance-report-types.test.ts +++ b/ui/lib/compliance/compliance-report-types.test.ts @@ -41,6 +41,8 @@ describe("isOcsfSupported", () => { expect(isOcsfSupported("csa_ccm_4.0")).toBe(true); expect(isOcsfSupported("cis_controls_8.1")).toBe(true); expect(isOcsfSupported("cmmc_2.0")).toBe(true); + expect(isOcsfSupported("fedramp_20x_ksi_2026")).toBe(true); + expect(isOcsfSupported("fedramp_20x_frr_class_c_2026")).toBe(true); }); it("returns false for legacy/per-provider frameworks without OCSF output", () => { diff --git a/ui/lib/compliance/compliance-report-types.ts b/ui/lib/compliance/compliance-report-types.ts index 68ef3072de..2553df013d 100644 --- a/ui/lib/compliance/compliance-report-types.ts +++ b/ui/lib/compliance/compliance-report-types.ts @@ -167,7 +167,8 @@ export const pickLatestCisPerProvider = ( * Only universal compliance frameworks that declare an ``outputs`` block in * their schema (see ``prowler/compliance/.json``) produce a dedicated * OCSF artifact during scan output generation. Today that is DORA, - * CSA CCM 4.0, CIS Controls 8.1 and CMMC 2.0. Any other framework only + * CSA CCM 4.0, CIS Controls 8.1, CMMC 2.0 and FedRAMP 20x (KSI and Class C + * FRR). Any other framework only * offers CSV (and, for the curated list above, PDF). * * Keep this Set in lock-step with the backend: ``get_prowler_provider_compliance`` @@ -182,6 +183,8 @@ const OCSF_SUPPORTED_COMPLIANCE_IDS: ReadonlySet = new Set([ "csa_ccm_4.0", "cis_controls_8.1", "cmmc_2.0", + "fedramp_20x_ksi_2026", + "fedramp_20x_frr_class_c_2026", ]); export const isOcsfSupported = (complianceId: string | undefined): boolean => diff --git a/ui/lib/compliance/csa.tsx b/ui/lib/compliance/csa.tsx index 3bb601de0c..c38136b19f 100644 --- a/ui/lib/compliance/csa.tsx +++ b/ui/lib/compliance/csa.tsx @@ -4,12 +4,11 @@ import { ComplianceAccordionTitle } from "@/components/compliance/compliance-acc import { ComplianceBadgeVariant } from "@/components/compliance/compliance-custom-details/shared-components"; import { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; import { FindingStatus } from "@/components/shadcn/table/status-finding-badge"; -import { +import type { AttributesData, CSAAttributesMetadata, Framework, Requirement, - REQUIREMENT_STATUS, RequirementsData, RequirementStatus, } from "@/types/compliance"; @@ -20,6 +19,7 @@ import { findOrCreateCategory, findOrCreateControl, findOrCreateFramework, + getStatusCounters, } from "./commons"; export interface CSAMappingSection { @@ -36,12 +36,6 @@ export const CSA_MAPPING_SECTIONS: CSAMappingSection[] = [ }, ]; -const getStatusCounters = (status: RequirementStatus) => ({ - pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0, - fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0, - manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0, -}); - export const mapComplianceData = ( attributesData: AttributesData, requirementsData: RequirementsData, diff --git a/ui/lib/compliance/cyber-essentials.tsx b/ui/lib/compliance/cyber-essentials.tsx index aee4d8c5db..ebdd73209d 100644 --- a/ui/lib/compliance/cyber-essentials.tsx +++ b/ui/lib/compliance/cyber-essentials.tsx @@ -3,12 +3,11 @@ import { ComplianceAccordionRequirementTitle } from "@/components/compliance/com import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; import { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; import { FindingStatus } from "@/components/shadcn/table/status-finding-badge"; -import { +import type { AttributesData, CyberEssentialsAttributesMetadata, Framework, Requirement, - REQUIREMENT_STATUS, RequirementsData, RequirementStatus, } from "@/types/compliance"; @@ -19,6 +18,7 @@ import { findOrCreateCategory, findOrCreateControl, findOrCreateFramework, + getStatusCounters, } from "./commons"; // Display order for the five Cyber Essentials control themes in the accordion @@ -33,12 +33,6 @@ export const CYBER_ESSENTIALS_THEME_ORDER: readonly string[] = [ "Malware Protection", ]; -const getStatusCounters = (status: RequirementStatus) => ({ - pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0, - fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0, - manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0, -}); - export const mapComplianceData = ( attributesData: AttributesData, requirementsData: RequirementsData, diff --git a/ui/lib/compliance/dora.tsx b/ui/lib/compliance/dora.tsx index 8ac364f29f..7fc6bba26c 100644 --- a/ui/lib/compliance/dora.tsx +++ b/ui/lib/compliance/dora.tsx @@ -3,12 +3,11 @@ import { ComplianceAccordionRequirementTitle } from "@/components/compliance/com import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; import { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; import { FindingStatus } from "@/components/shadcn/table/status-finding-badge"; -import { +import type { AttributesData, DORAAttributesMetadata, Framework, Requirement, - REQUIREMENT_STATUS, RequirementsData, RequirementStatus, } from "@/types/compliance"; @@ -19,6 +18,7 @@ import { findOrCreateCategory, findOrCreateControl, findOrCreateFramework, + getStatusCounters, } from "./commons"; // Display order for DORA pillars in the accordion and any grouped chart. The @@ -33,12 +33,6 @@ export const DORA_PILLAR_ORDER: readonly string[] = [ "Information Sharing", ]; -const getStatusCounters = (status: RequirementStatus) => ({ - pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0, - fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0, - manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0, -}); - export const mapComplianceData = ( attributesData: AttributesData, requirementsData: RequirementsData, diff --git a/ui/lib/compliance/fedramp-20x.test.ts b/ui/lib/compliance/fedramp-20x.test.ts new file mode 100644 index 0000000000..21d40c69bc --- /dev/null +++ b/ui/lib/compliance/fedramp-20x.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock( + "@/components/compliance/compliance-accordion/client-accordion-content", + () => ({ ClientAccordionContent: () => null }), +); +vi.mock( + "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title", + () => ({ ComplianceAccordionRequirementTitle: () => null }), +); +vi.mock( + "@/components/compliance/compliance-accordion/compliance-accordion-title", + () => ({ ComplianceAccordionTitle: () => null }), +); + +import { + buildRequirementExtrasMap, + crossProviderToMapperInput, +} from "@/app/(prowler)/compliance/_lib/cross-provider-adapter"; +import type { CrossProviderOverviewAttributes } from "@/app/(prowler)/compliance/_types"; +import { Framework, Requirement } from "@/types/compliance"; + +import { + mapFRRComplianceData, + mapKSIComplianceData, + toAccordionItems, +} from "./fedramp-20x"; + +type OverviewRequirement = + CrossProviderOverviewAttributes["requirements"][number]; + +const buildOverview = ( + framework: string, + requirements: Array>, +): CrossProviderOverviewAttributes => + ({ + framework, + version: "2026", + description: "", + providers: ["aws"], + compatible_providers: ["aws"], + scan_ids_by_provider: {}, + requirements: requirements.map((requirement) => ({ + ...requirement, + description: "Requirement text.", + status: "PASS", + providers: { aws: "PASS" }, + check_ids_by_provider: { aws: ["check_one"] }, + })), + }) as unknown as CrossProviderOverviewAttributes; + +const mapOverview = ( + overview: CrossProviderOverviewAttributes, + mapper: typeof mapKSIComplianceData, +): Framework[] => { + const { attributesData, requirementsData } = + crossProviderToMapperInput(overview); + return mapper(attributesData, requirementsData); +}; + +const allRequirements = (frameworks: Framework[]): Requirement[] => + frameworks.flatMap((framework) => + framework.categories.flatMap((category) => + category.controls.flatMap((control) => control.requirements), + ), + ); + +const KSI_OVERVIEW = buildOverview("FedRAMP-20x-KSI", [ + { + id: "KSI-SVC-VRI", + name: "Validating Resource Integrity", + attributes: { + Theme: "KSI-SVC: Service Configuration", + NISTControls: "SC-13", + ClassApplicability: "Required for Classes B and C", + }, + }, + { + id: "KSI-CED-RAT", + name: "Reviewing All Training", + attributes: { + Theme: "KSI-CED: Cybersecurity Education", + NISTControls: null, + ClassApplicability: "Required for Classes B and C", + }, + }, +]); + +const FRR_OVERVIEW = buildOverview("FedRAMP-20x-FRR-Class-C", [ + { + id: "CMU-CSO-UVM", + name: "Using Validated Cryptographic Modules", + attributes: { + Ruleset: "CMU: Cryptographic Module Use", + Subset: "CSO: Cloud Service Provider Responsibilities", + Force: "MUST", + }, + }, + { + id: "AFC-CSO-INB", + name: "Maintain a FedRAMP Security Inbox", + attributes: { + Ruleset: "AFC: Addressing FedRAMP Communication", + Subset: "CSO: General Provider Responsibilities", + Force: "MUST", + }, + }, +]); + +describe.each([ + { label: "KSI", overview: KSI_OVERVIEW, mapper: mapKSIComplianceData }, + { + label: "FRR Class C", + overview: FRR_OVERVIEW, + mapper: mapFRRComplianceData, + }, +])("FedRAMP 20x $label cross-provider join", ({ overview, mapper }) => { + it("names every requirement with a key of the per-provider breakdown", () => { + const extras = buildRequirementExtrasMap(overview); + const names = allRequirements(mapOverview(overview, mapper)).map( + (requirement) => requirement.name, + ); + + expect(names).toHaveLength(overview.requirements.length); + for (const name of names) { + expect(extras.has(name), name).toBe(true); + } + }); +}); + +describe("mapKSIComplianceData", () => { + it("groups by Theme in catalog order and exposes KSI attributes", () => { + const [framework] = mapOverview(KSI_OVERVIEW, mapKSIComplianceData); + + expect(framework.categories.map((category) => category.name)).toEqual([ + "KSI-CED: Cybersecurity Education", + "KSI-SVC: Service Configuration", + ]); + + const [svc] = framework.categories[1].controls[0].requirements; + expect(svc.name).toBe("KSI-SVC-VRI - Validating Resource Integrity"); + expect(svc.theme).toBe("KSI-SVC: Service Configuration"); + expect(svc.nist_controls).toBe("SC-13"); + expect(svc.class_applicability).toBe("Required for Classes B and C"); + + const [ced] = framework.categories[0].controls[0].requirements; + expect(ced.nist_controls).toBeUndefined(); + }); +}); + +describe("mapFRRComplianceData", () => { + it("groups by Ruleset in catalog order and exposes FRR attributes", () => { + const [framework] = mapOverview(FRR_OVERVIEW, mapFRRComplianceData); + + expect(framework.categories.map((category) => category.name)).toEqual([ + "AFC: Addressing FedRAMP Communication", + "CMU: Cryptographic Module Use", + ]); + + const [cmu] = framework.categories[1].controls[0].requirements; + expect(cmu.ruleset).toBe("CMU: Cryptographic Module Use"); + expect(cmu.subset).toBe("CSO: Cloud Service Provider Responsibilities"); + expect(cmu.force).toBe("MUST"); + expect(framework.pass).toBe(2); + }); +}); + +describe("toAccordionItems (FedRAMP 20x)", () => { + it("keys requirement leaves by name instead of position", () => { + const items = toAccordionItems( + mapOverview(FRR_OVERVIEW, mapFRRComplianceData), + "scan-1", + ); + + expect(items.map((item) => item.key)).toEqual([ + "FedRAMP-20x-FRR-Class-C-AFC: Addressing FedRAMP Communication", + "FedRAMP-20x-FRR-Class-C-CMU: Cryptographic Module Use", + ]); + expect(items[1].items?.[0]?.key).toBe( + "FedRAMP-20x-FRR-Class-C-CMU: Cryptographic Module Use-CMU-CSO-UVM - Using Validated Cryptographic Modules", + ); + }); +}); diff --git a/ui/lib/compliance/fedramp-20x.ts b/ui/lib/compliance/fedramp-20x.ts new file mode 100644 index 0000000000..fd7d9fb1a3 --- /dev/null +++ b/ui/lib/compliance/fedramp-20x.ts @@ -0,0 +1,110 @@ +import type { + AttributesData, + FedRAMP20xFRRAttributesMetadata, + FedRAMP20xKSIAttributesMetadata, + Framework, + Requirement, + RequirementsData, + RequirementStatus, +} from "@/types/compliance"; + +import { + calculateFrameworkCounters, + createRequirementsMap, + findOrCreateCategory, + findOrCreateControl, + findOrCreateFramework, + getStatusCounters, +} from "./commons"; + +export { toGroupedAccordionItems as toAccordionItems } from "./grouped-accordion"; + +type RequirementFields = Record; + +const mapByGroup = ( + attributesData: AttributesData, + requirementsData: RequirementsData, + getGroup: (attrs: TMetadata) => string, + getFields: (attrs: TMetadata) => RequirementFields, +): Framework[] => { + const attributes = attributesData?.data || []; + const requirementsMap = createRequirementsMap(requirementsData); + const frameworks: Framework[] = []; + + for (const attributeItem of attributes) { + const id = attributeItem.id; + const metadataArray = attributeItem.attributes?.attributes + ?.metadata as unknown as TMetadata[]; + const attrs = metadataArray?.[0]; + if (!attrs) continue; + + const requirementData = requirementsMap.get(id); + if (!requirementData) continue; + + const categoryName = getGroup(attrs); + const requirementName = attributeItem.attributes.name || ""; + const status = (requirementData.attributes.status || + "") as RequirementStatus; + + const framework = findOrCreateFramework( + frameworks, + attributeItem.attributes.framework, + ); + const category = findOrCreateCategory(framework.categories, categoryName); + const control = findOrCreateControl(category.controls, categoryName); + + // The name must match `composeRequirementName` in the cross-provider + // adapter, or the per-provider breakdown cannot be joined. + const requirement: Requirement = { + ...getFields(attrs), + name: requirementName ? `${id} - ${requirementName}` : id, + description: attributeItem.attributes.description, + status, + check_ids: attributeItem.attributes.attributes.check_ids || [], + invalid_config: requirementData.attributes.invalid_config || false, + ...getStatusCounters(status), + }; + + control.requirements.push(requirement); + } + + // Theme and Ruleset names start with their catalog code, so alphabetical + // order is the catalog order. + for (const framework of frameworks) { + framework.categories.sort((a, b) => a.name.localeCompare(b.name)); + } + + calculateFrameworkCounters(frameworks); + + return frameworks; +}; + +export const mapKSIComplianceData = ( + attributesData: AttributesData, + requirementsData: RequirementsData, +): Framework[] => + mapByGroup( + attributesData, + requirementsData, + (attrs) => attrs.Theme, + (attrs) => ({ + theme: attrs.Theme, + nist_controls: attrs.NISTControls || undefined, + class_applicability: attrs.ClassApplicability, + }), + ); + +export const mapFRRComplianceData = ( + attributesData: AttributesData, + requirementsData: RequirementsData, +): Framework[] => + mapByGroup( + attributesData, + requirementsData, + (attrs) => attrs.Ruleset, + (attrs) => ({ + ruleset: attrs.Ruleset, + subset: attrs.Subset, + force: attrs.Force, + }), + ); diff --git a/ui/lib/compliance/grouped-accordion.tsx b/ui/lib/compliance/grouped-accordion.tsx new file mode 100644 index 0000000000..cea6651079 --- /dev/null +++ b/ui/lib/compliance/grouped-accordion.tsx @@ -0,0 +1,55 @@ +import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; +import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; +import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; +import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; +import type { FindingStatus } from "@/components/shadcn/table/status-finding-badge"; +import type { Framework } from "@/types/compliance"; + +/** Category → requirement accordion, with stable keys across reordering. */ +export const toGroupedAccordionItems = ( + data: Framework[], + scanId: string | undefined, +): AccordionItemProps[] => { + const safeId = scanId || ""; + + return data.flatMap((framework) => + framework.categories.map((category) => ({ + key: `${framework.name}-${category.name}`, + title: ( + + ), + content: "", + items: category.controls.flatMap((control) => + control.requirements.map((requirement) => ({ + key: `${framework.name}-${category.name}-${requirement.name}`, + title: ( + + ), + content: ( + + ), + items: [], + })), + ), + })), + ); +}; diff --git a/ui/lib/compliance/okta-idaas-stig.tsx b/ui/lib/compliance/okta-idaas-stig.tsx index 8603c33711..3d8b797b8a 100644 --- a/ui/lib/compliance/okta-idaas-stig.tsx +++ b/ui/lib/compliance/okta-idaas-stig.tsx @@ -10,7 +10,6 @@ import { isOktaIDaaSStigAttributesMetadata, OktaIDaaSStigRequirement, Requirement, - REQUIREMENT_STATUS, RequirementsData, RequirementStatus, } from "@/types/compliance"; @@ -21,14 +20,9 @@ import { findOrCreateCategory, findOrCreateControl, findOrCreateFramework, + getStatusCounters, } from "./commons"; -const getStatusCounters = (status: RequirementStatus) => ({ - pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0, - fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0, - manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0, -}); - export const mapComplianceData = ( attributesData: AttributesData, requirementsData: RequirementsData, diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts index be7462f549..e7f27a8e09 100644 --- a/ui/types/compliance.ts +++ b/ui/types/compliance.ts @@ -474,6 +474,20 @@ export interface CMMCAttributesMetadata { SourceRequirement: string; } +// FedRAMP 20x KSI (`prowler/compliance/fedramp_20x_ksi_2026.json`), grouped by Theme. +export interface FedRAMP20xKSIAttributesMetadata { + Theme: string; + NISTControls?: string | null; + ClassApplicability: string; +} + +// FedRAMP 20x Class C FRR (`prowler/compliance/fedramp_20x_frr_class_c_2026.json`), grouped by Ruleset. +export interface FedRAMP20xFRRAttributesMetadata { + Ruleset: string; + Subset: string; + Force: string; +} + export interface AttributesItemData { type: "compliance-requirements-attributes"; id: string; @@ -501,6 +515,8 @@ export interface AttributesItemData { | CISControlsAttributesMetadata[] | CyberEssentialsAttributesMetadata[] | CMMCAttributesMetadata[] + | FedRAMP20xKSIAttributesMetadata[] + | FedRAMP20xFRRAttributesMetadata[] | GenericAttributesMetadata[]; check_ids: string[]; // MITRE structure