fix(ui): add FedRAMP 20x cross-provider mappers (#12810)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Pedro Martín
2026-09-15 11:07:31 +02:00
committed by GitHub
co-authored by alejandrobailo
parent 61ef44a03b
commit c0fdd5bdf3
18 changed files with 526 additions and 115 deletions
@@ -0,0 +1 @@
Per-provider breakdown and OCSF download for FedRAMP 20x KSI and Class C FRR in the cross-provider compliance view
@@ -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 ? (
<ComplianceDetailSection title="Description">
<ComplianceDetailText>{requirement.description}</ComplianceDetailText>
</ComplianceDetailSection>
) : null;
export const FedRAMP20xKSICustomDetails = ({
requirement,
}: FedRAMP20xDetailsProps) => {
return (
<ComplianceDetailContainer>
<DescriptionSection requirement={requirement} />
<ComplianceBadgeContainer>
{requirement.theme && (
<ComplianceBadge
label="Theme"
value={requirement.theme as string}
variant="tag"
/>
)}
{requirement.class_applicability && (
<ComplianceBadge
label="Class Applicability"
value={requirement.class_applicability as string}
variant="tag"
/>
)}
</ComplianceBadgeContainer>
{requirement.nist_controls && (
<ComplianceDetailSection title="NIST SP 800-53 Controls">
<ComplianceDetailText>
{requirement.nist_controls as string}
</ComplianceDetailText>
</ComplianceDetailSection>
)}
</ComplianceDetailContainer>
);
};
export const FedRAMP20xFRRCustomDetails = ({
requirement,
}: FedRAMP20xDetailsProps) => {
return (
<ComplianceDetailContainer>
<DescriptionSection requirement={requirement} />
<ComplianceBadgeContainer>
{requirement.ruleset && (
<ComplianceBadge
label="Ruleset"
value={requirement.ruleset as string}
variant="tag"
/>
)}
{requirement.subset && (
<ComplianceBadge
label="Subset"
value={requirement.subset as string}
variant="tag"
/>
)}
{requirement.force && (
<ComplianceBadge
label="Force"
value={requirement.force as string}
variant="tag"
/>
)}
</ComplianceBadgeContainer>
</ComplianceDetailContainer>
);
};
+2 -8
View File
@@ -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,
+2 -8
View File
@@ -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.
@@ -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: (
<ComplianceAccordionTitle
label={category.name}
pass={category.pass}
fail={category.fail}
manual={category.manual}
isParentLevel={true}
/>
),
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: (
<ComplianceAccordionRequirementTitle
type=""
name={requirement.name}
status={requirement.status as FindingStatus}
invalidConfig={requirement.invalid_config}
/>
),
content: (
<ClientAccordionContent
key={`content-${framework.name}-${category.name}-${requirement.name}`}
requirement={requirement}
scanId={safeId}
framework={framework.name}
disableFindings={
requirement.check_ids.length === 0 && requirement.manual === 0
}
/>
),
items: [],
})),
),
})),
);
};
+10 -1
View File
@@ -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 => {
@@ -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);
+30
View File
@@ -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<string, ComplianceMapper> => ({
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 }),
},
});
/**
@@ -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", () => {
+4 -1
View File
@@ -167,7 +167,8 @@ export const pickLatestCisPerProvider = (
* Only universal compliance frameworks that declare an ``outputs`` block in
* their schema (see ``prowler/compliance/<name>.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<string> = 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 =>
+2 -8
View File
@@ -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,
+2 -8
View File
@@ -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,
+2 -8
View File
@@ -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,
+183
View File
@@ -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<Pick<OverviewRequirement, "id" | "name" | "attributes">>,
): 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",
);
});
});
+110
View File
@@ -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<string, string | undefined>;
const mapByGroup = <TMetadata>(
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<FedRAMP20xKSIAttributesMetadata>(
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<FedRAMP20xFRRAttributesMetadata>(
attributesData,
requirementsData,
(attrs) => attrs.Ruleset,
(attrs) => ({
ruleset: attrs.Ruleset,
subset: attrs.Subset,
force: attrs.Force,
}),
);
+55
View File
@@ -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: (
<ComplianceAccordionTitle
label={category.name}
pass={category.pass}
fail={category.fail}
manual={category.manual}
isParentLevel={true}
/>
),
content: "",
items: category.controls.flatMap((control) =>
control.requirements.map((requirement) => ({
key: `${framework.name}-${category.name}-${requirement.name}`,
title: (
<ComplianceAccordionRequirementTitle
type=""
name={requirement.name}
status={requirement.status as FindingStatus}
invalidConfig={requirement.invalid_config}
/>
),
content: (
<ClientAccordionContent
key={`content-${framework.name}-${category.name}-${requirement.name}`}
requirement={requirement}
scanId={safeId}
framework={framework.name}
disableFindings={
requirement.check_ids.length === 0 && requirement.manual === 0
}
/>
),
items: [],
})),
),
})),
);
};
+1 -7
View File
@@ -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,
+16
View File
@@ -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