feat(compliance): add Cyber Essentials 3.3 for Azure (#11588)

Co-authored-by: pedrooot <pedromarting3@gmail.com>
This commit is contained in:
Muhammad Ibrahim
2026-08-27 13:31:18 +02:00
committed by GitHub
co-authored by pedrooot
parent c89d900aae
commit 301edea7ce
13 changed files with 1495 additions and 0 deletions
@@ -0,0 +1 @@
NCSC Cyber Essentials 3.3 compliance support with its dedicated mapper, details panel, and icon
@@ -0,0 +1,67 @@
import { Requirement } from "@/types/compliance";
import {
ComplianceBadge,
ComplianceBadgeContainer,
ComplianceDetailContainer,
ComplianceDetailSection,
ComplianceDetailText,
} from "./shared-components";
interface CyberEssentialsDetailsProps {
requirement: Requirement;
}
export const CyberEssentialsCustomDetails = ({
requirement,
}: CyberEssentialsDetailsProps) => {
return (
<ComplianceDetailContainer>
{requirement.description && (
<ComplianceDetailSection title="Description">
<ComplianceDetailText>{requirement.description}</ComplianceDetailText>
</ComplianceDetailSection>
)}
<ComplianceBadgeContainer>
{requirement.theme && (
<ComplianceBadge
label="Theme"
value={requirement.theme as string}
variant="tag"
/>
)}
{requirement.assessment_status && (
<ComplianceBadge
label="Assessment Status"
value={requirement.assessment_status as string}
variant="info"
/>
)}
{requirement.cloud_applicability && (
<ComplianceBadge
label="Cloud Applicability"
value={requirement.cloud_applicability as string}
variant="secondary"
/>
)}
</ComplianceBadgeContainer>
{requirement.remediation_procedure && (
<ComplianceDetailSection title="Remediation Procedure">
<ComplianceDetailText>
{requirement.remediation_procedure as string}
</ComplianceDetailText>
</ComplianceDetailSection>
)}
{requirement.references && (
<ComplianceDetailSection title="References">
<ComplianceDetailText>
{requirement.references as string}
</ComplianceDetailText>
</ComplianceDetailSection>
)}
</ComplianceDetailContainer>
);
};
@@ -70,6 +70,20 @@ describe("getComplianceIcon", () => {
expect(getComplianceIcon("asd_essential_eight_aws")).toBe(essentialLogo);
});
it("resolves Cyber Essentials distinctly from ASD Essential Eight", () => {
// `essentials` (plural) must win over the `essential` keyword, otherwise
// NCSC Cyber Essentials would collapse to the ASD Essential Eight logo.
const cyberEssentialsLogo = getComplianceIcon("cyber_essentials_3.3");
const asdEssentialEightLogo = getComplianceIcon(
"asd_essential_eight_aws",
);
expect(cyberEssentialsLogo).toBeDefined();
expect(cyberEssentialsLogo).not.toBe(asdEssentialEightLogo);
expect(getComplianceIcon("NCSC Cyber Essentials")).toBe(
cyberEssentialsLogo,
);
});
it("resolves NIS2 distinctly from NIST", () => {
const nis2Logo = getComplianceIcon("NIS2");
const nistLogo = getComplianceIcon("NIST-800-53");
@@ -7,6 +7,7 @@ import CISLogo from "./cis.svg";
import CISALogo from "./cisa.svg";
import CMMCLogo from "./cmmc.svg";
import CSALogo from "./csa.svg";
import CyberEssentialsLogo from "./cyber-essentials.svg";
import DORALogo from "./dora.svg";
import ENSLogo from "./ens.png";
import FedRAMPLogo from "./fedramp.svg";
@@ -41,6 +42,14 @@ import SOC2Logo from "./soc2.svg";
// Best Practices, Account Security Onboarding, Foundational Technical Review)
// fall through to it because they expose no other matching keyword.
const COMPLIANCE_LOGOS = [
// `essentials` (plural) MUST come before `essential` (singular). NCSC Cyber
// Essentials ids/names contain `essentials` (e.g. `cyber_essentials_3.3`,
// "NCSC Cyber Essentials"), whereas ASD Essential Eight is `essential_eight`
// (no trailing `s`). Without this ordering the `essential` keyword below
// would shadow Cyber Essentials and resolve it to the ASD Essential Eight
// logo. `cyber` alone is avoided because it also matches
// `rbi_cyber_security_framework`.
["essentials", CyberEssentialsLogo],
["essential", ASDEssentialEightLogo],
["cisa", CISALogo],
["cis", CISLogo],
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 130" fill="none">
<!-- Mark: navy disc with a green leaf and a light-blue check swoosh -->
<circle cx="62" cy="65" r="52" fill="#20204E"/>
<path d="M44,46 Q62,60 80,46 Q74,79 62,98 Q50,79 44,46 Z" fill="#8CC63F"/>
<path d="M55,96 C64,78 80,61 99,45 C111,34 123,25 132,18 L147,32 C135,45 117,62 99,77 C85,88 71,93 55,96 Z" fill="#7CB9E2"/>
<!-- Wordmark -->
<text x="132" y="58" font-family="Helvetica, Arial, sans-serif" font-size="38" font-weight="700" fill="#7CB9E2" letter-spacing="3">CYBER</text>
<text x="132" y="104" font-family="Helvetica, Arial, sans-serif" font-size="38" font-weight="700" fill="#8CC63F" letter-spacing="1">ESSENTIALS</text>
</svg>

After

Width:  |  Height:  |  Size: 729 B

@@ -46,6 +46,10 @@ vi.mock(
"@/components/compliance/compliance-custom-details/csa-details",
() => ({ CSACustomDetails: stubFactory("CSAStub") }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/cyber-essentials-details",
() => ({ CyberEssentialsCustomDetails: stubFactory("CyberEssentialsStub") }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/ens-details",
() => ({ ENSCustomDetails: stubFactory("ENSStub") }),
@@ -154,6 +158,7 @@ describe("getComplianceMapper", () => {
{ framework: "CSA-CCM", expected: "CSAStub" },
{ framework: "CMMC", expected: "CMMCStub" },
{ framework: "Okta-IDaaS-STIG", expected: "OktaIDaaSStigStub" },
{ framework: "Cyber-Essentials", expected: "CyberEssentialsStub" },
];
for (const { framework, expected } of wiring) {
@@ -200,6 +205,7 @@ describe("getComplianceMapper", () => {
"CSA-CCM",
"CMMC",
"Okta-IDaaS-STIG",
"Cyber-Essentials",
]) {
const mapper = getComplianceMapper(framework);
expect(Object.keys(mapper).sort(), framework).toEqual(expectedKeys);
+19
View File
@@ -8,6 +8,7 @@ import { CISControlsCustomDetails } from "@/components/compliance/compliance-cus
import { CISCustomDetails } from "@/components/compliance/compliance-custom-details/cis-details";
import { CMMCCustomDetails } from "@/components/compliance/compliance-custom-details/cmmc-details";
import { CSACustomDetails } from "@/components/compliance/compliance-custom-details/csa-details";
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 { GenericCustomDetails } from "@/components/compliance/compliance-custom-details/generic-details";
@@ -59,6 +60,10 @@ import {
mapComplianceData as mapCSAComplianceData,
toAccordionItems as toCSAAccordionItems,
} from "./csa";
import {
mapComplianceData as mapCyberEssentialsComplianceData,
toAccordionItems as toCyberEssentialsAccordionItems,
} from "./cyber-essentials";
import {
mapComplianceData as mapDORAComplianceData,
toAccordionItems as toDORAAccordionItems,
@@ -264,6 +269,20 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
getDetailsComponent: (requirement: Requirement) =>
createElement(DORACustomDetails, { requirement }),
},
// Cyber Essentials v3.3 — universal framework keyed by the `framework` field
// of `prowler/compliance/cyber_essentials_3.3.json` ("Cyber-Essentials").
// Groups by Theme (the 5 NCSC control themes) and surfaces Theme /
// AssessmentStatus / CloudApplicability / RemediationProcedure / References in
// the requirement detail drawer.
"Cyber-Essentials": {
mapComplianceData: mapCyberEssentialsComplianceData,
toAccordionItems: toCyberEssentialsAccordionItems,
getTopFailedSections,
calculateCategoryHeatmapData: (data: Framework[]) =>
calculateCategoryHeatmapData(data),
getDetailsComponent: (requirement: Requirement) =>
createElement(CyberEssentialsCustomDetails, { requirement }),
},
// CMMC 2.0 — universal framework keyed by the `framework` field of
// `prowler/compliance/cmmc_2.0.json` ("CMMC"). Groups by Domain (14 NIST
// 800-171 families) and surfaces Domain / Level / Source Requirement in the
+521
View File
@@ -0,0 +1,521 @@
import { describe, expect, it, vi } from "vitest";
// `cyber-essentials.tsx` re-exports `toAccordionItems`, which builds JSX
// referencing the client-side accordion components. Those components
// transitively import server-only code (next-auth → next/server) and would
// crash vitest at load time. Mocking the JSX deps lets us load the module and
// exercise the real `mapComplianceData` and `toAccordionItems` functions,
// which are what we actually want to test.
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 {
AttributesData,
AttributesItemData,
CyberEssentialsAttributesMetadata,
CyberEssentialsTheme,
REQUIREMENT_STATUS,
RequirementItemData,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
import {
CYBER_ESSENTIALS_THEME_ORDER,
mapComplianceData,
toAccordionItems,
} from "./cyber-essentials";
const FRAMEWORK = "Cyber-Essentials";
const baseMetadata = (
overrides: Partial<CyberEssentialsAttributesMetadata> = {},
): CyberEssentialsAttributesMetadata => ({
Theme: "Firewalls",
AssessmentStatus: "Automated",
CloudApplicability: "full",
RemediationProcedure: "Steps to remediate.",
References: "https://example.com/a",
...overrides,
});
const buildAttribute = (
id: string,
metadata: CyberEssentialsAttributesMetadata,
{
name,
description = "Canonical Cyber Essentials clause text.",
checks = ["check_one"],
}: { name?: string; description?: string; checks?: string[] } = {},
): AttributesItemData => ({
type: "compliance-requirements-attributes",
id,
attributes: {
framework_description: "NCSC Cyber Essentials",
framework: FRAMEWORK,
...(name !== undefined ? { name } : {}),
version: "3.3",
description,
attributes: {
metadata: [metadata],
check_ids: checks,
},
},
});
const buildRequirement = (
id: string,
status: RequirementStatus = REQUIREMENT_STATUS.PASS,
): RequirementItemData => ({
type: "compliance-requirements-details",
id,
attributes: {
framework: FRAMEWORK,
version: "3.3",
description: "Canonical clause text.",
status,
},
});
const buildInputs = (
pairs: Array<{
attribute: AttributesItemData;
requirement: RequirementItemData;
}>,
): { attributesData: AttributesData; requirementsData: RequirementsData } => ({
attributesData: { data: pairs.map((p) => p.attribute) },
requirementsData: { data: pairs.map((p) => p.requirement) },
});
// One requirement per theme, intentionally supplied OUT of canonical order so
// the sort under test has something to reorder.
const oneRequirementPerThemeUnordered = (): Array<{
attribute: AttributesItemData;
requirement: RequirementItemData;
}> => {
const themesOutOfOrder: CyberEssentialsTheme[] = [
"Malware Protection",
"User Access Control",
"Firewalls",
"Security Update Management",
"Secure Configuration",
];
return themesOutOfOrder.map((theme, index) => {
const id = `CE-${index}`;
return {
attribute: buildAttribute(id, baseMetadata({ Theme: theme })),
requirement: buildRequirement(id),
};
});
};
describe("mapComplianceData (Cyber Essentials)", () => {
it("returns an empty list when there are no attributes", () => {
const { attributesData, requirementsData } = buildInputs([]);
expect(mapComplianceData(attributesData, requirementsData)).toEqual([]);
});
describe("five-theme grouping and order", () => {
it("orders the five themes into the canonical reading order regardless of API order", () => {
const { attributesData, requirementsData } = buildInputs(
oneRequirementPerThemeUnordered(),
);
const [framework] = mapComplianceData(attributesData, requirementsData);
expect(framework.categories.map((c) => c.name)).toEqual([
...CYBER_ESSENTIALS_THEME_ORDER,
]);
});
it("groups every requirement sharing a Theme under a single category", () => {
const pairs = [
{
attribute: buildAttribute(
"CE-FW-1",
baseMetadata({ Theme: "Firewalls" }),
),
requirement: buildRequirement("CE-FW-1"),
},
{
attribute: buildAttribute(
"CE-FW-2",
baseMetadata({ Theme: "Firewalls" }),
),
requirement: buildRequirement("CE-FW-2"),
},
{
attribute: buildAttribute(
"CE-MP-1",
baseMetadata({ Theme: "Malware Protection" }),
),
requirement: buildRequirement("CE-MP-1"),
},
];
const { attributesData, requirementsData } = buildInputs(pairs);
const [framework] = mapComplianceData(attributesData, requirementsData);
const firewalls = framework.categories.find(
(c) => c.name === "Firewalls",
);
const malware = framework.categories.find(
(c) => c.name === "Malware Protection",
);
expect(framework.categories).toHaveLength(2);
// Flat 2-level structure: theme → single control → requirements.
expect(firewalls?.controls).toHaveLength(1);
expect(firewalls?.controls[0].requirements).toHaveLength(2);
expect(malware?.controls[0].requirements).toHaveLength(1);
});
it("sinks an unknown theme below the five canonical themes", () => {
const pairs = [
{
attribute: buildAttribute("CE-X-1", {
...baseMetadata(),
Theme: "Some Future Theme" as CyberEssentialsTheme,
}),
requirement: buildRequirement("CE-X-1"),
},
{
attribute: buildAttribute(
"CE-MP-1",
baseMetadata({ Theme: "Malware Protection" }),
),
requirement: buildRequirement("CE-MP-1"),
},
];
const { attributesData, requirementsData } = buildInputs(pairs);
const [framework] = mapComplianceData(attributesData, requirementsData);
expect(framework.categories.map((c) => c.name)).toEqual([
"Malware Protection",
"Some Future Theme",
]);
});
});
describe("status counters", () => {
it("derives per-requirement counters from RequirementStatus", () => {
const cases: Array<{
status: RequirementStatus;
expected: "pass" | "fail" | "manual";
}> = [
{ status: REQUIREMENT_STATUS.PASS, expected: "pass" },
{ status: REQUIREMENT_STATUS.FAIL, expected: "fail" },
{ status: REQUIREMENT_STATUS.MANUAL, expected: "manual" },
];
for (const { status, expected } of cases) {
const { attributesData, requirementsData } = buildInputs([
{
attribute: buildAttribute(`CE-${status}`, baseMetadata()),
requirement: buildRequirement(`CE-${status}`, status),
},
]);
const [framework] = mapComplianceData(attributesData, requirementsData);
const requirementOut =
framework.categories[0].controls[0].requirements[0];
expect(requirementOut.pass).toBe(expected === "pass" ? 1 : 0);
expect(requirementOut.fail).toBe(expected === "fail" ? 1 : 0);
expect(requirementOut.manual).toBe(expected === "manual" ? 1 : 0);
}
});
it("aggregates counters up through category and framework levels", () => {
const pairs = [
{
attribute: buildAttribute(
"CE-FW-1",
baseMetadata({ Theme: "Firewalls" }),
),
requirement: buildRequirement("CE-FW-1", REQUIREMENT_STATUS.PASS),
},
{
attribute: buildAttribute(
"CE-FW-2",
baseMetadata({ Theme: "Firewalls" }),
),
requirement: buildRequirement("CE-FW-2", REQUIREMENT_STATUS.FAIL),
},
{
attribute: buildAttribute(
"CE-MP-1",
baseMetadata({ Theme: "Malware Protection" }),
),
requirement: buildRequirement("CE-MP-1", REQUIREMENT_STATUS.MANUAL),
},
];
const { attributesData, requirementsData } = buildInputs(pairs);
const [framework] = mapComplianceData(attributesData, requirementsData);
const firewalls = framework.categories.find(
(c) => c.name === "Firewalls",
)!;
expect(firewalls.pass).toBe(1);
expect(firewalls.fail).toBe(1);
expect(firewalls.manual).toBe(0);
expect(framework.pass).toBe(1);
expect(framework.fail).toBe(1);
expect(framework.manual).toBe(1);
});
});
describe("manual requirements with empty check lists", () => {
it("carries through a MANUAL requirement that has no checks", () => {
const { attributesData, requirementsData } = buildInputs([
{
attribute: buildAttribute(
"CE-SUM-03",
baseMetadata({
Theme: "Security Update Management",
AssessmentStatus: "Manual",
CloudApplicability: "partial",
}),
{ name: "Automatic updates enabled where possible", checks: [] },
),
requirement: buildRequirement("CE-SUM-03", REQUIREMENT_STATUS.MANUAL),
},
]);
const [framework] = mapComplianceData(attributesData, requirementsData);
const requirementOut =
framework.categories[0].controls[0].requirements[0];
expect(requirementOut.check_ids).toEqual([]);
expect(requirementOut.manual).toBe(1);
expect(requirementOut.assessment_status).toBe("Manual");
});
it("defaults check_ids to an empty array when the attribute omits them", () => {
const attribute = buildAttribute("CE-SUM-01", baseMetadata());
// Drop check_ids entirely to mimic an API payload without the field.
delete (attribute.attributes.attributes as { check_ids?: string[] })
.check_ids;
const { attributesData, requirementsData } = buildInputs([
{ attribute, requirement: buildRequirement("CE-SUM-01") },
]);
const [framework] = mapComplianceData(attributesData, requirementsData);
expect(
framework.categories[0].controls[0].requirements[0].check_ids,
).toEqual([]);
});
it("keeps findings enabled for a manual requirement with no checks", () => {
// A MANUAL requirement carries a manual count of 1, so the accordion
// still surfaces its (manual) finding even though it has no checks —
// findings are only disabled when there is nothing to show at all
// (empty checks AND no manual count).
const { attributesData, requirementsData } = buildInputs([
{
attribute: buildAttribute("CE-SUM-03", baseMetadata(), {
checks: [],
}),
requirement: buildRequirement("CE-SUM-03", REQUIREMENT_STATUS.MANUAL),
},
]);
const frameworks = mapComplianceData(attributesData, requirementsData);
const items = toAccordionItems(frameworks, "scan-1");
const content = items[0].items![0].content as {
props: { disableFindings: boolean };
};
expect(content.props.disableFindings).toBe(false);
});
it("disables findings for a non-manual requirement that has no checks", () => {
const { attributesData, requirementsData } = buildInputs([
{
attribute: buildAttribute("CE-SUM-01", baseMetadata(), {
checks: [],
}),
requirement: buildRequirement("CE-SUM-01", REQUIREMENT_STATUS.PASS),
},
]);
const frameworks = mapComplianceData(attributesData, requirementsData);
const items = toAccordionItems(frameworks, "scan-1");
const content = items[0].items![0].content as {
props: { disableFindings: boolean };
};
expect(content.props.disableFindings).toBe(true);
});
});
describe("preservation of Cyber Essentials metadata fields", () => {
it("propagates Theme, AssessmentStatus, CloudApplicability, RemediationProcedure and References", () => {
const metadata = baseMetadata({
Theme: "User Access Control",
AssessmentStatus: "Manual",
CloudApplicability: "partial",
RemediationProcedure: "Remediate the access control gap.",
References:
"NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section D",
});
const { attributesData, requirementsData } = buildInputs([
{
attribute: buildAttribute("CE-UAC-1", metadata),
requirement: buildRequirement("CE-UAC-1"),
},
]);
const [framework] = mapComplianceData(attributesData, requirementsData);
const requirementOut =
framework.categories[0].controls[0].requirements[0];
expect(requirementOut.theme).toBe("User Access Control");
expect(requirementOut.assessment_status).toBe("Manual");
expect(requirementOut.cloud_applicability).toBe("partial");
expect(requirementOut.remediation_procedure).toBe(
"Remediate the access control gap.",
);
expect(requirementOut.references).toBe(
"NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section D",
);
});
it("prefixes the requirement name with its id when a name is present", () => {
const { attributesData, requirementsData } = buildInputs([
{
attribute: buildAttribute("CE-FW-1", baseMetadata(), {
name: "Boundary firewalls in place",
}),
requirement: buildRequirement("CE-FW-1"),
},
]);
const [framework] = mapComplianceData(attributesData, requirementsData);
expect(framework.categories[0].controls[0].requirements[0].name).toBe(
"CE-FW-1 - Boundary firewalls in place",
);
});
it("falls back to the bare id when no name is supplied", () => {
const { attributesData, requirementsData } = buildInputs([
{
attribute: buildAttribute("CE-FW-1", baseMetadata()),
requirement: buildRequirement("CE-FW-1"),
},
]);
const [framework] = mapComplianceData(attributesData, requirementsData);
expect(framework.categories[0].controls[0].requirements[0].name).toBe(
"CE-FW-1",
);
});
it("uses the literal API description for the requirement description", () => {
const { attributesData, requirementsData } = buildInputs([
{
attribute: buildAttribute("CE-FW-1", baseMetadata(), {
description: "Boundary firewalls must be configured.",
}),
requirement: buildRequirement("CE-FW-1"),
},
]);
const [framework] = mapComplianceData(attributesData, requirementsData);
expect(
framework.categories[0].controls[0].requirements[0].description,
).toBe("Boundary firewalls must be configured.");
});
});
describe("skipping malformed entries", () => {
it("skips attribute items whose metadata is missing", () => {
const valid = buildAttribute("CE-FW-1", baseMetadata());
const broken = buildAttribute("CE-FW-2", baseMetadata());
broken.attributes.attributes.metadata = [];
const { attributesData, requirementsData } = buildInputs([
{ attribute: valid, requirement: buildRequirement("CE-FW-1") },
{ attribute: broken, requirement: buildRequirement("CE-FW-2") },
]);
const [framework] = mapComplianceData(attributesData, requirementsData);
expect(framework.categories[0].controls[0].requirements).toHaveLength(1);
expect(framework.categories[0].controls[0].requirements[0].name).toBe(
"CE-FW-1",
);
});
it("skips attribute items without a matching requirement entry", () => {
const result = mapComplianceData(
{
data: [
buildAttribute("CE-FW-1", baseMetadata()),
buildAttribute("CE-FW-2", baseMetadata()),
],
},
{ data: [buildRequirement("CE-FW-1")] },
);
expect(result[0].categories[0].controls[0].requirements).toHaveLength(1);
});
});
});
describe("toAccordionItems (Cyber Essentials)", () => {
it("produces one accordion item per theme, in canonical order", () => {
const { attributesData, requirementsData } = buildInputs(
oneRequirementPerThemeUnordered(),
);
const frameworks = mapComplianceData(attributesData, requirementsData);
const items = toAccordionItems(frameworks, "scan-1");
expect(items.map((item) => item.key)).toEqual(
CYBER_ESSENTIALS_THEME_ORDER.map((theme) => `${FRAMEWORK}-${theme}`),
);
});
it("returns an empty list when given no frameworks", () => {
expect(toAccordionItems([], "scan-1")).toEqual([]);
});
it("keeps findings enabled for an automated requirement that has checks", () => {
const { attributesData, requirementsData } = buildInputs([
{
attribute: buildAttribute("CE-FW-1", baseMetadata(), {
checks: ["check_one"],
}),
requirement: buildRequirement("CE-FW-1"),
},
]);
const frameworks = mapComplianceData(attributesData, requirementsData);
const items = toAccordionItems(frameworks, "scan-1");
const content = items[0].items![0].content as {
props: { disableFindings: boolean };
};
expect(content.props.disableFindings).toBe(false);
});
});
+158
View File
@@ -0,0 +1,158 @@
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 {
AttributesData,
CyberEssentialsAttributesMetadata,
Framework,
Requirement,
REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
import {
calculateFrameworkCounters,
createRequirementsMap,
findOrCreateCategory,
findOrCreateControl,
findOrCreateFramework,
} from "./commons";
// Display order for the five Cyber Essentials control themes in the accordion
// and any grouped chart. Mirrors the order declared in
// `prowler/compliance/cyber_essentials_3.3.json` so the UI always renders
// themes in the canonical reading order regardless of API response order.
export const CYBER_ESSENTIALS_THEME_ORDER: readonly string[] = [
"Firewalls",
"Secure Configuration",
"Security Update Management",
"User Access Control",
"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,
): 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 CyberEssentialsAttributesMetadata[];
const attrs = metadataArray?.[0];
if (!attrs) continue;
const requirementData = requirementsMap.get(id);
if (!requirementData) continue;
const frameworkName = attributeItem.attributes.framework;
// Group by Theme (top-level accordion section). The remaining attributes
// live inside the requirement so they show up on the detail drawer.
const categoryName = attrs.Theme;
const requirementName = attributeItem.attributes.name || "";
const description = attributeItem.attributes.description;
const status = requirementData.attributes.status || "";
const checks = attributeItem.attributes.attributes.check_ids || [];
const framework = findOrCreateFramework(frameworks, frameworkName);
const category = findOrCreateCategory(framework.categories, categoryName);
// Flat 2-level structure: theme → requirements (no intermediate control).
const control = findOrCreateControl(category.controls, categoryName);
const finalStatus: RequirementStatus = status as RequirementStatus;
const requirement: Requirement = {
name: requirementName ? `${id} - ${requirementName}` : id,
description,
status: finalStatus,
check_ids: checks,
invalid_config: requirementData.attributes.invalid_config || false,
...getStatusCounters(finalStatus),
theme: attrs.Theme,
assessment_status: attrs.AssessmentStatus,
cloud_applicability: attrs.CloudApplicability,
remediation_procedure: attrs.RemediationProcedure,
references: attrs.References,
};
control.requirements.push(requirement);
}
// Sort categories by canonical theme order so the framework always reads from
// "Firewalls" down to "Malware Protection", regardless of map insertion order
// driven by the API response.
for (const framework of frameworks) {
framework.categories.sort((a, b) => {
const ia = CYBER_ESSENTIALS_THEME_ORDER.indexOf(a.name);
const ib = CYBER_ESSENTIALS_THEME_ORDER.indexOf(b.name);
// Unknown themes (defensive — shouldn't happen) sink to the bottom.
const orderA = ia === -1 ? CYBER_ESSENTIALS_THEME_ORDER.length : ia;
const orderB = ib === -1 ? CYBER_ESSENTIALS_THEME_ORDER.length : ib;
return orderA - orderB;
});
}
calculateFrameworkCounters(frameworks);
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: "",
// Theme → requirements (flat, no intermediate "control" level).
items: category.controls.flatMap((control) =>
control.requirements.map((requirement, reqIndex) => ({
key: `${framework.name}-${category.name}-req-${reqIndex}`,
title: (
<ComplianceAccordionRequirementTitle
type=""
name={requirement.name}
status={requirement.status as FindingStatus}
invalidConfig={requirement.invalid_config}
/>
),
content: (
<ClientAccordionContent
key={`content-${framework.name}-${category.name}-req-${reqIndex}`}
requirement={requirement}
scanId={safeId}
framework={framework.name}
disableFindings={
requirement.check_ids.length === 0 && requirement.manual === 0
}
/>
),
items: [],
})),
),
})),
);
};
+31
View File
@@ -427,6 +427,36 @@ export interface CISControlsRequirement extends Requirement {
implementation_groups?: string[];
}
// Universal framework — flat attributes dict with Theme/AssessmentStatus/
// CloudApplicability/RemediationProcedure/References. `Theme` is the canonical
// grouping key for tables and PDF; the enum mirrors the five Cyber Essentials
// control themes declared in `prowler/compliance/cyber_essentials_3.3.json`.
export const CYBER_ESSENTIALS_THEME = {
FIREWALLS: "Firewalls",
SECURE_CONFIGURATION: "Secure Configuration",
SECURITY_UPDATE_MANAGEMENT: "Security Update Management",
USER_ACCESS_CONTROL: "User Access Control",
MALWARE_PROTECTION: "Malware Protection",
} as const;
export type CyberEssentialsTheme =
(typeof CYBER_ESSENTIALS_THEME)[keyof typeof CYBER_ESSENTIALS_THEME];
export interface CyberEssentialsAttributesMetadata {
Theme: CyberEssentialsTheme;
AssessmentStatus: string; // "Automated" or "Manual"
CloudApplicability: string; // "full", "partial" or "non-applicable"
RemediationProcedure: string;
References: string;
}
export interface CyberEssentialsRequirement extends Requirement {
theme: CyberEssentialsAttributesMetadata["Theme"];
assessment_status: CyberEssentialsAttributesMetadata["AssessmentStatus"];
cloud_applicability: CyberEssentialsAttributesMetadata["CloudApplicability"];
remediation_procedure: CyberEssentialsAttributesMetadata["RemediationProcedure"];
references: CyberEssentialsAttributesMetadata["References"];
}
// CMMC 2.0 (Cybersecurity Maturity Model Certification, 32 CFR Part 170).
// Universal framework — flat attributes dict with Domain/Level/SourceRequirement.
// `Domain` is the grouping key; `Level` (1/2/3) and `SourceRequirement` are
@@ -469,6 +499,7 @@ export interface AttributesItemData {
| OktaIDaaSStigAttributesMetadata[]
| DORAAttributesMetadata[]
| CISControlsAttributesMetadata[]
| CyberEssentialsAttributesMetadata[]
| CMMCAttributesMetadata[]
| GenericAttributesMetadata[];
check_ids: string[];