mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat(compliance): add Cyber Essentials 3.3 for Azure (#11588)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
This commit is contained in:
co-authored by
pedrooot
parent
c89d900aae
commit
301edea7ce
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: [],
|
||||
})),
|
||||
),
|
||||
})),
|
||||
);
|
||||
};
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user