feat(ui): ASD Essential Eight compliance framework support (#11071)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Pedro Martín
2026-05-11 09:55:04 +02:00
committed by GitHub
parent 1ad329f9cf
commit 0e01e67257
11 changed files with 1475 additions and 26 deletions
+5 -1
View File
@@ -4,6 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file.
## [1.26.0] (Prowler v5.26.0)
### 🚀 Added
- ASD Essential Eight compliance framework support: AWS scans now surface the Essential Eight overview card, accordion view (Sections normalised to `N. <name>`, controls grouped per ML1 clause) and a dedicated requirement detail panel with maturity level, assessment, cloud applicability, mitigated threats and References [(#11071)](https://github.com/prowler-cloud/prowler/pull/11071)
### 🔄 Changed
- Standardized "Providers" wording across UI and documentation, replacing legacy "Cloud Providers" / "Accounts" / "Account Groups" copy [(#10971)](https://github.com/prowler-cloud/prowler/pull/10971)
@@ -48,7 +52,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🔄 Changed
- Redesign compliance page, client-side search for compliance frameworks, compact scan selector trigger, enhanced compliance cards [(#10767)](https://github.com/prowler-cloud/prowler/pull/10767)
- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
- Shared filter dropdowns now support local option search and auto-scroll to the first visible match across table and provider filters [(#10859)](https://github.com/prowler-cloud/prowler/pull/10859)
- Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly [(#10797)](https://github.com/prowler-cloud/prowler/pull/10797)
- Mutelist improvements: table now supports name/reason search and visual count badges for finding targets [(#10846)](https://github.com/prowler-cloud/prowler/pull/10846)
@@ -0,0 +1,226 @@
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
// `CustomLink` re-imports the `@/lib` barrel which transitively pulls in
// `next-auth` (server-only). Stub it with a plain anchor — we only need
// the `<a>` semantics here so the regex/extraction tests can assert on
// `href` and accessible name.
vi.mock("@/components/ui/custom/custom-link", () => ({
CustomLink: ({ href, children }: { href: string; children: ReactNode }) => (
<a href={href}>{children}</a>
),
}));
import {
type ASDEssentialEightRequirement,
type Requirement,
REQUIREMENT_STATUS,
} from "@/types/compliance";
import { ASDEssentialEightCustomDetails } from "./asd-essential-eight-details";
const fullRequirement: ASDEssentialEightRequirement = {
name: "E8-PA-1",
description: "Apply patches to internet-facing applications.",
status: REQUIREMENT_STATUS.PASS,
pass: 1,
fail: 0,
manual: 0,
check_ids: ["check_one"],
maturity_level: "ML1",
assessment_status: "Automated",
cloud_applicability: "full",
mitigated_threats: ["T1190", "T1059"],
implementation_notes: "Use SSM Patch Manager for AWS workloads.",
rationale_statement: "Unpatched apps are commonly exploited.",
impact_statement: "Increases blast radius of public-facing CVEs.",
remediation_procedure: "Run **patch baseline** weekly.",
audit_procedure: "Verify *baseline compliance*.",
additional_information: "Refer to internal SOPs.",
references: "https://example.com/a, https://example.com/b",
};
const emptyRequirement: Requirement = {
name: "E8-EMPTY",
description: "",
status: REQUIREMENT_STATUS.MANUAL,
pass: 0,
fail: 0,
manual: 1,
check_ids: [],
};
describe("ASDEssentialEightCustomDetails", () => {
describe("with a fully populated requirement", () => {
it("renders every textual section", () => {
render(<ASDEssentialEightCustomDetails requirement={fullRequirement} />);
expect(screen.getByText("Description")).toBeInTheDocument();
expect(
screen.getByText("Apply patches to internet-facing applications."),
).toBeInTheDocument();
expect(screen.getByText("Implementation Notes")).toBeInTheDocument();
expect(
screen.getByText("Use SSM Patch Manager for AWS workloads."),
).toBeInTheDocument();
expect(screen.getByText("Rationale Statement")).toBeInTheDocument();
expect(screen.getByText("Impact Statement")).toBeInTheDocument();
expect(screen.getByText("Additional Information")).toBeInTheDocument();
expect(screen.getByText("Refer to internal SOPs.")).toBeInTheDocument();
});
it("renders the three classification badges with their values", () => {
render(<ASDEssentialEightCustomDetails requirement={fullRequirement} />);
expect(screen.getByText("Maturity Level:")).toBeInTheDocument();
expect(screen.getByText("ML1")).toBeInTheDocument();
expect(screen.getByText("Assessment:")).toBeInTheDocument();
expect(screen.getByText("Automated")).toBeInTheDocument();
expect(screen.getByText("Cloud Applicability:")).toBeInTheDocument();
expect(screen.getByText("full")).toBeInTheDocument();
});
it("does not render invalid ASD classification values", () => {
render(
<ASDEssentialEightCustomDetails
requirement={{
...fullRequirement,
maturity_level: "ML4",
assessment_status: "Partially automated",
cloud_applicability: "hybrid",
}}
/>,
);
expect(screen.queryByText("Maturity Level:")).not.toBeInTheDocument();
expect(screen.queryByText("Assessment:")).not.toBeInTheDocument();
expect(
screen.queryByText("Cloud Applicability:"),
).not.toBeInTheDocument();
});
it("renders mitigated threats as individual chips", () => {
render(<ASDEssentialEightCustomDetails requirement={fullRequirement} />);
expect(screen.getByText("Mitigated Threats")).toBeInTheDocument();
expect(screen.getByText("T1190")).toBeInTheDocument();
expect(screen.getByText("T1059")).toBeInTheDocument();
});
it("renders Remediation and Audit procedures as markdown", () => {
render(<ASDEssentialEightCustomDetails requirement={fullRequirement} />);
// The markdown renderer transforms `**patch baseline**` into a <strong>
// and `*baseline compliance*` into an <em>. Asserting on the rendered
// tags is what makes this a behavioral test rather than a string grep.
expect(screen.getByText("Remediation Procedure")).toBeInTheDocument();
expect(screen.getByText("patch baseline").tagName).toBe("STRONG");
expect(screen.getByText("Audit Procedure")).toBeInTheDocument();
expect(screen.getByText("baseline compliance").tagName).toBe("EM");
});
it("extracts every URL from the comma-separated References field", () => {
render(<ASDEssentialEightCustomDetails requirement={fullRequirement} />);
expect(screen.getByText("References")).toBeInTheDocument();
const linkA = screen.getByRole("link", {
name: "https://example.com/a",
});
const linkB = screen.getByRole("link", {
name: "https://example.com/b",
});
expect(linkA).toHaveAttribute("href", "https://example.com/a");
expect(linkB).toHaveAttribute("href", "https://example.com/b");
});
it("preserves http:// references (regex must not silently drop plain HTTP)", () => {
render(
<ASDEssentialEightCustomDetails
requirement={{
...fullRequirement,
references:
"http://insecure.example.com/x https://secure.example.com/y",
}}
/>,
);
expect(
screen.getByRole("link", { name: "http://insecure.example.com/x" }),
).toHaveAttribute("href", "http://insecure.example.com/x");
expect(
screen.getByRole("link", { name: "https://secure.example.com/y" }),
).toHaveAttribute("href", "https://secure.example.com/y");
});
});
describe("with an empty requirement", () => {
it("renders nothing inside the container when every optional field is missing", () => {
const { container } = render(
<ASDEssentialEightCustomDetails requirement={emptyRequirement} />,
);
// No section headings should be rendered for an empty requirement.
for (const heading of [
"Description",
"Implementation Notes",
"Rationale Statement",
"Impact Statement",
"Remediation Procedure",
"Audit Procedure",
"Additional Information",
"Mitigated Threats",
"References",
]) {
expect(screen.queryByText(heading)).not.toBeInTheDocument();
}
// No badges either.
for (const label of [
"Maturity Level:",
"Assessment:",
"Cloud Applicability:",
]) {
expect(screen.queryByText(label)).not.toBeInTheDocument();
}
// The outer container still exists (an empty flex column) but it
// shouldn't carry any rendered children.
const outer = container.firstElementChild as HTMLElement | null;
expect(outer).not.toBeNull();
expect(outer?.children.length).toBe(1); // only the empty badge container
});
it("ignores a non-string References field (no broken link rendered)", () => {
render(
<ASDEssentialEightCustomDetails
requirement={{
...emptyRequirement,
references: undefined,
}}
/>,
);
expect(screen.queryByText("References")).not.toBeInTheDocument();
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
it("ignores a non-string-array `mitigated_threats` field", () => {
render(
<ASDEssentialEightCustomDetails
requirement={{
...emptyRequirement,
mitigated_threats: [{ not: "a string" }],
}}
/>,
);
expect(screen.queryByText("Mitigated Threats")).not.toBeInTheDocument();
});
});
});
@@ -0,0 +1,169 @@
import ReactMarkdown from "react-markdown";
import { CustomLink } from "@/components/ui/custom/custom-link";
import {
isASDAssessmentStatus,
isASDCloudApplicability,
isASDMaturityLevel,
type Requirement,
} from "@/types/compliance";
import {
ComplianceBadge,
ComplianceBadgeContainer,
ComplianceChipContainer,
ComplianceDetailContainer,
ComplianceDetailSection,
ComplianceDetailText,
} from "./shared-components";
interface ASDEssentialEightDetailsProps {
requirement: Requirement;
}
// Each requirement's References field is a single URL or a comma/space
// separated list of URLs. The regex matches both http:// and https:// so
// plain-http references aren't silently dropped.
const URL_REGEX = /https?:\/\/[^\s,]+/g;
const extractUrls = (references: unknown): string[] => {
if (typeof references !== "string") return [];
return references.match(URL_REGEX) ?? [];
};
const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.length > 0;
const isStringArray = (value: unknown): value is string[] =>
Array.isArray(value) && value.every((item) => typeof item === "string");
export const ASDEssentialEightCustomDetails = ({
requirement,
}: ASDEssentialEightDetailsProps) => {
const {
description,
implementation_notes,
maturity_level,
assessment_status,
cloud_applicability,
mitigated_threats,
rationale_statement,
impact_statement,
remediation_procedure,
audit_procedure,
additional_information,
references,
} = requirement;
const referenceUrls = extractUrls(references);
const maturityLevel = isASDMaturityLevel(maturity_level)
? maturity_level
: undefined;
const assessmentStatus = isASDAssessmentStatus(assessment_status)
? assessment_status
: undefined;
const cloudApplicability = isASDCloudApplicability(cloud_applicability)
? cloud_applicability
: undefined;
return (
<ComplianceDetailContainer>
{description && (
<ComplianceDetailSection title="Description">
<ComplianceDetailText>{description}</ComplianceDetailText>
</ComplianceDetailSection>
)}
{isNonEmptyString(implementation_notes) && (
<ComplianceDetailSection title="Implementation Notes">
<ComplianceDetailText>{implementation_notes}</ComplianceDetailText>
</ComplianceDetailSection>
)}
<ComplianceBadgeContainer>
{maturityLevel && (
<ComplianceBadge
label="Maturity Level"
value={maturityLevel}
color="purple"
/>
)}
{assessmentStatus && (
<ComplianceBadge
label="Assessment"
value={assessmentStatus}
color="blue"
/>
)}
{cloudApplicability && (
<ComplianceBadge
label="Cloud Applicability"
value={cloudApplicability}
color="orange"
/>
)}
</ComplianceBadgeContainer>
{/* `isStringArray` narrows the index-signature union to string[], so no cast is needed. `ComplianceChipContainer` returns null on empty arrays, so no length check is needed here either. */}
{isStringArray(mitigated_threats) && (
<ComplianceChipContainer
title="Mitigated Threats"
items={mitigated_threats}
/>
)}
{isNonEmptyString(rationale_statement) && (
<ComplianceDetailSection title="Rationale Statement">
<ComplianceDetailText>{rationale_statement}</ComplianceDetailText>
</ComplianceDetailSection>
)}
{isNonEmptyString(impact_statement) && (
<ComplianceDetailSection title="Impact Statement">
<ComplianceDetailText>{impact_statement}</ComplianceDetailText>
</ComplianceDetailSection>
)}
{isNonEmptyString(remediation_procedure) && (
<ComplianceDetailSection title="Remediation Procedure">
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown>{remediation_procedure}</ReactMarkdown>
</div>
</ComplianceDetailSection>
)}
{isNonEmptyString(audit_procedure) && (
<ComplianceDetailSection title="Audit Procedure">
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown>{audit_procedure}</ReactMarkdown>
</div>
</ComplianceDetailSection>
)}
{isNonEmptyString(additional_information) && (
<ComplianceDetailSection title="Additional Information">
<ComplianceDetailText className="whitespace-pre-wrap">
{additional_information}
</ComplianceDetailText>
</ComplianceDetailSection>
)}
{referenceUrls.length > 0 && (
<ComplianceDetailSection title="References">
<div className="flex flex-col gap-1">
{referenceUrls.map((url) => (
// URLs are unique within this list, so they outperform the
// positional index as a React key (avoids reconciliation
// glitches if the order ever shifts).
<div key={url}>
<CustomLink href={url}>{url}</CustomLink>
</div>
))}
</div>
</ComplianceDetailSection>
)}
</ComplianceDetailContainer>
);
};
@@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import { getComplianceIcon } from "./IconCompliance";
describe("getComplianceIcon", () => {
describe("framework name matching", () => {
it("resolves ASD Essential Eight via the `essential` keyword", () => {
expect(getComplianceIcon("ASD-Essential-Eight")).toBeDefined();
expect(getComplianceIcon("asd-essential-eight")).toBeDefined();
expect(getComplianceIcon("ASD Essential Eight Maturity Model")).toBe(
getComplianceIcon("ASD-Essential-Eight"),
);
});
it("returns undefined for an unknown framework name", () => {
expect(getComplianceIcon("Made-Up-Framework")).toBeUndefined();
});
it("returns undefined for an empty string", () => {
expect(getComplianceIcon("")).toBeUndefined();
});
});
describe("compliance_id matching (with provider suffix)", () => {
// Regression coverage for the icon-shadowing bug: every AWS-hosted
// compliance_id ends with `_aws`, so `getComplianceIcon` MUST resolve
// by the framework keyword (cis, iso, ...) before falling through to
// the provider-level `aws` keyword. If `aws` ever moves up in
// COMPLIANCE_LOGOS, every assertion below will flip and surface the
// regression.
it("resolves CIS variants by the framework keyword, not by `aws`", () => {
const cisLogo = getComplianceIcon("CIS");
expect(cisLogo).toBeDefined();
expect(getComplianceIcon("cis_4.0_aws")).toBe(cisLogo);
expect(getComplianceIcon("cis_5.0_aws")).toBe(cisLogo);
expect(getComplianceIcon("cis_6.0_aws")).toBe(cisLogo);
});
it("resolves CISA before falling back to CIS or AWS", () => {
const cisLogo = getComplianceIcon("CIS");
const cisaLogo = getComplianceIcon("cisa");
expect(cisaLogo).toBeDefined();
expect(cisaLogo).not.toBe(cisLogo);
expect(getComplianceIcon("cisa_aws")).toBe(cisaLogo);
});
it("resolves ISO 27001 by the framework keyword, not by `aws`", () => {
const isoLogo = getComplianceIcon("ISO27001");
expect(isoLogo).toBeDefined();
expect(getComplianceIcon("iso27001_2022_aws")).toBe(isoLogo);
expect(getComplianceIcon("iso27001_2013_aws")).toBe(isoLogo);
});
it("resolves Prowler ThreatScore by the framework keyword, not by `aws`", () => {
const threatLogo = getComplianceIcon("ProwlerThreatScore");
expect(threatLogo).toBeDefined();
expect(getComplianceIcon("prowler_threatscore_aws")).toBe(threatLogo);
});
it("resolves ASD Essential Eight by the framework keyword, not by `aws`", () => {
const essentialLogo = getComplianceIcon("ASD-Essential-Eight");
expect(essentialLogo).toBeDefined();
expect(getComplianceIcon("asd_essential_eight_aws")).toBe(essentialLogo);
});
it("resolves NIS2 distinctly from NIST", () => {
const nis2Logo = getComplianceIcon("NIS2");
const nistLogo = getComplianceIcon("NIST-800-53");
expect(nis2Logo).toBeDefined();
expect(nistLogo).toBeDefined();
expect(nis2Logo).not.toBe(nistLogo);
expect(getComplianceIcon("nis2_aws")).toBe(nis2Logo);
expect(getComplianceIcon("nist_800_53_revision_5_aws")).toBe(nistLogo);
});
it("resolves PCI/HIPAA/GDPR/SOC2/ENS/FedRAMP/MITRE/RBI/KISA/SecNumCloud by their framework keyword", () => {
// Spot-check the rest of the framework keywords against AWS-suffixed ids.
// Each must resolve to a distinct logo from `aws` so the watchlist
// surface (which keys icons by compliance_id) renders correctly.
const awsLogo = getComplianceIcon(
"AWS-Well-Architected-Framework-Security-Pillar",
);
const cases = [
"pci_4.0_aws",
"hipaa_aws",
"gdpr_aws",
"soc2_aws",
"ens_rd2022_aws",
"fedramp_low_revision_4_aws",
"mitre_attack_aws",
"rbi_cyber_security_framework_aws",
"kisa_isms_p_2023_aws",
"secnumcloud_3.2_aws",
];
for (const id of cases) {
const resolved = getComplianceIcon(id);
expect(
resolved,
`${id} should resolve to a framework-specific logo, not the AWS fallback`,
).toBeDefined();
expect(
resolved,
`${id} should not collapse to the generic AWS logo`,
).not.toBe(awsLogo);
}
});
});
describe("AWS-only frameworks fall through to the AWS logo", () => {
// These frameworks are genuinely AWS-specific and have no other matching
// keyword in the registry. They must resolve to the AWS logo via the
// tail-end fallback.
it("resolves AWS Well-Architected pillars to the AWS logo", () => {
const awsLogo = getComplianceIcon(
"AWS-Well-Architected-Framework-Security-Pillar",
);
expect(awsLogo).toBeDefined();
expect(
getComplianceIcon("AWS-Well-Architected-Framework-Reliability-Pillar"),
).toBe(awsLogo);
expect(
getComplianceIcon("aws_well_architected_framework_security_pillar_aws"),
).toBe(awsLogo);
});
it("resolves AWS Foundational frameworks to the AWS logo", () => {
const awsLogo = getComplianceIcon(
"AWS-Well-Architected-Framework-Security-Pillar",
);
expect(
getComplianceIcon("aws_foundational_security_best_practices_aws"),
).toBe(awsLogo);
expect(getComplianceIcon("aws_foundational_technical_review_aws")).toBe(
awsLogo,
);
expect(
getComplianceIcon("aws_audit_manager_control_tower_guardrails_aws"),
).toBe(awsLogo);
expect(getComplianceIcon("aws_account_security_onboarding_aws")).toBe(
awsLogo,
);
});
});
});
@@ -1,4 +1,5 @@
import ANSSILogo from "./anssi.png";
import ASDEssentialEightLogo from "./asd-essential-eight.svg";
import AWSLogo from "./aws.svg";
import C5Logo from "./c5.svg";
import CCCLogo from "./ccc.svg";
@@ -21,34 +22,58 @@ import PROWLERTHREATLogo from "./prowlerThreat.svg";
import RBILogo from "./rbi.svg";
import SOC2Logo from "./soc2.svg";
const COMPLIANCE_LOGOS = {
aws: AWSLogo,
cisa: CISALogo,
cis: CISLogo,
ens: ENSLogo,
ffiec: FFIECLogo,
fedramp: FedRAMPLogo,
gdpr: GDPRLogo,
gxp: GxPLogo,
hipaa: HIPAALogo,
iso: ISOLogo,
mitre: MITRELogo,
nist: NISTLogo,
pci: PCILogo,
rbi: RBILogo,
soc2: SOC2Logo,
kisa: KISALogo,
prowlerthreatscore: PROWLERTHREATLogo,
nis2: NIS2Logo,
c5: C5Logo,
ccc: CCCLogo,
csa: CSALogo,
secnumcloud: ANSSILogo,
} as const;
// Framework-specific keywords MUST come before the generic provider-level
// `aws` keyword. `getComplianceIcon` resolves by substring `includes`, and
// AWS compliance ids carry a `_aws` provider suffix (e.g. `cis_4.0_aws`,
// `iso27001_2022_aws`, `prowler_threatscore_aws`, `asd_essential_eight_aws`).
// Without this ordering the generic `aws` entry would shadow every
// framework-specific logo on watchlist surfaces that resolve by id. The
// list is a tuple array (rather than an object literal) because lookup
// order is semantically meaningful here — JavaScript engines preserve
// insertion order for string keys, but a tuple makes that contract
// explicit and prevents an accidental object-literal sort or
// `Object.fromEntries` round-trip from silently breaking resolution.
// `aws` is intentionally last so the framework keywords win, while genuinely
// AWS-only frameworks (Well-Architected, Audit Manager, Foundational Security
// Best Practices, Account Security Onboarding, Foundational Technical Review)
// fall through to it because they expose no other matching keyword.
const COMPLIANCE_LOGOS = [
["essential", ASDEssentialEightLogo],
["cisa", CISALogo],
["cis", CISLogo],
["ens", ENSLogo],
["ffiec", FFIECLogo],
["fedramp", FedRAMPLogo],
["gdpr", GDPRLogo],
["gxp", GxPLogo],
["hipaa", HIPAALogo],
["iso", ISOLogo],
["mitre", MITRELogo],
// `nist` comes before `nis2` because NIST 800-53 etc. would otherwise be
// checked after `nis2`; both are unambiguous, but pinning the order avoids
// surprises if a future id contains "nis2" inside a NIST acronym.
["nist", NISTLogo],
["nis2", NIS2Logo],
["pci", PCILogo],
["rbi", RBILogo],
["soc2", SOC2Logo],
["kisa", KISALogo],
// `threatscore` (not `prowlerthreatscore`) matches both the framework name
// `ProwlerThreatScore` (lowercased "prowlerthreatscore") AND the
// compliance_id `prowler_threatscore_aws` (which separates the words with
// an underscore). The previous one-word keyword silently failed for the
// watchlist surface — only fixed in concert with moving `aws` to the end.
["threatscore", PROWLERTHREATLogo],
["c5", C5Logo],
["ccc", CCCLogo],
["csa", CSALogo],
["secnumcloud", ANSSILogo],
["aws", AWSLogo],
] as const;
export const getComplianceIcon = (complianceTitle: string) => {
const lowerTitle = complianceTitle.toLowerCase();
return Object.entries(COMPLIANCE_LOGOS).find(([keyword]) =>
return COMPLIANCE_LOGOS.find(([keyword]) =>
lowerTitle.includes(keyword),
)?.[1];
};
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<path fill="#1f2937" fill-rule="evenodd" d="M22.343 4h19.314L56 18.343v27.314L41.657 60H22.343L8 45.657V18.343L22.343 4Zm.828 4L12 19.171v25.658L23.171 56h17.658L52 44.829V19.171L40.829 8H23.171ZM32 18.5c-3.59 0-6.5 2.91-6.5 6.5 0 1.792.726 3.415 1.9 4.59A7.498 7.498 0 0 0 24.5 35.5c0 4.142 3.358 7.5 7.5 7.5s7.5-3.358 7.5-7.5a7.498 7.498 0 0 0-2.9-5.91A6.476 6.476 0 0 0 38.5 25c0-3.59-2.91-6.5-6.5-6.5Zm0 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Zm0 9a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 604 B

@@ -0,0 +1,390 @@
import { isValidElement } from "react";
import { describe, expect, it, vi } from "vitest";
// `asd-essential-eight.tsx` re-exports `toAccordionItems` which builds JSX
// referencing 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 {
ASDEssentialEightAttributesMetadata,
AttributesData,
AttributesItemData,
REQUIREMENT_STATUS,
RequirementItemData,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
import { mapComplianceData, toAccordionItems } from "./asd-essential-eight";
const FRAMEWORK = "ASD-Essential-Eight";
const baseMetadata = (
overrides: Partial<ASDEssentialEightAttributesMetadata> = {},
): ASDEssentialEightAttributesMetadata => ({
Section: "1 Patch applications",
MaturityLevel: "ML1",
AssessmentStatus: "Automated",
CloudApplicability: "full",
MitigatedThreats: ["T1190"],
Description: "Provider-specific implementation note.",
RationaleStatement: "Why this matters.",
ImpactStatement: "Impact when not in place.",
RemediationProcedure: "Steps to remediate.",
AuditProcedure: "Steps to audit.",
AdditionalInformation: "Extra context.",
References: "https://example.com/a, https://example.com/b",
...overrides,
});
const buildAttribute = (
id: string,
description: string,
metadata: ASDEssentialEightAttributesMetadata,
checks: string[] = ["check_one"],
): AttributesItemData => ({
type: "compliance-requirements-attributes",
id,
attributes: {
framework_description: "ASD Essential Eight",
framework: FRAMEWORK,
version: "1.0",
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: "1.0",
description: "Canonical ASD 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) },
});
describe("mapComplianceData (ASD Essential Eight)", () => {
it("returns an empty list when there are no attributes", () => {
const { attributesData, requirementsData } = buildInputs([]);
expect(mapComplianceData(attributesData, requirementsData)).toEqual([]);
});
it("creates one framework with one category containing one control per requirement", () => {
const attribute = buildAttribute(
"E8-PA-1",
"Apply patches to applications.",
baseMetadata(),
);
const requirement = buildRequirement("E8-PA-1");
const { attributesData, requirementsData } = buildInputs([
{ attribute, requirement },
]);
const [framework] = mapComplianceData(attributesData, requirementsData);
expect(framework.name).toBe(FRAMEWORK);
expect(framework.categories).toHaveLength(1);
expect(framework.categories[0].controls).toHaveLength(1);
expect(framework.categories[0].controls[0].requirements).toHaveLength(1);
});
it("normalizes 'N Foo' Section names to 'N. Foo' for the accordion header", () => {
const attribute = buildAttribute(
"E8-PA-1",
"Apply patches.",
baseMetadata({ Section: "1 Patch applications" }),
);
const requirement = buildRequirement("E8-PA-1");
const { attributesData, requirementsData } = buildInputs([
{ attribute, requirement },
]);
const [framework] = mapComplianceData(attributesData, requirementsData);
expect(framework.categories[0].name).toBe("1. Patch applications");
});
it("uses the literal API description (not the metadata Description) for the requirement description", () => {
// Regression: an earlier draft surfaced `attrs.Description` (provider
// commentary) in place of the canonical clause. The literal API
// description must win.
const attribute = buildAttribute(
"E8-PA-1",
"Canonical clause text.",
baseMetadata({ Description: "Provider-specific commentary." }),
);
const requirement = buildRequirement("E8-PA-1");
const { attributesData, requirementsData } = buildInputs([
{ attribute, requirement },
]);
const [framework] = mapComplianceData(attributesData, requirementsData);
const requirementOut = framework.categories[0].controls[0].requirements[0];
expect(requirementOut.description).toBe("Canonical clause text.");
expect(framework.categories[0].controls[0].label).toBe(
"E8-PA-1 - Canonical clause text.",
);
});
it("exposes provider commentary as `implementation_notes` (not `aws_description`)", () => {
const attribute = buildAttribute(
"E8-PA-1",
"Canonical clause text.",
baseMetadata({ Description: "Provider commentary." }),
);
const requirement = buildRequirement("E8-PA-1");
const { attributesData, requirementsData } = buildInputs([
{ attribute, requirement },
]);
const [framework] = mapComplianceData(attributesData, requirementsData);
const requirementOut = framework.categories[0].controls[0].requirements[0];
expect(requirementOut.implementation_notes).toBe("Provider commentary.");
// The legacy field name must NOT be set, so a stale UI reading
// `aws_description` surfaces the regression instead of silently
// falling back to undefined.
expect(requirementOut.aws_description).toBeUndefined();
});
it("propagates every metadata field onto the requirement", () => {
const metadata = baseMetadata({
Section: "2 Patch operating systems",
MaturityLevel: "ML1",
AssessmentStatus: "Manual",
CloudApplicability: "partial",
MitigatedThreats: ["T1059", "T1190"],
RationaleStatement: "Rationale.",
ImpactStatement: "Impact.",
RemediationProcedure: "Remediate.",
AuditProcedure: "Audit.",
AdditionalInformation: "More info.",
References: "https://example.com/x",
});
const attribute = buildAttribute("E8-OS-1", "OS patching.", metadata);
const requirement = buildRequirement("E8-OS-1");
const { attributesData, requirementsData } = buildInputs([
{ attribute, requirement },
]);
const [framework] = mapComplianceData(attributesData, requirementsData);
const requirementOut = framework.categories[0].controls[0].requirements[0];
expect(requirementOut.maturity_level).toBe("ML1");
expect(requirementOut.assessment_status).toBe("Manual");
expect(requirementOut.cloud_applicability).toBe("partial");
expect(requirementOut.mitigated_threats).toEqual(["T1059", "T1190"]);
expect(requirementOut.rationale_statement).toBe("Rationale.");
expect(requirementOut.impact_statement).toBe("Impact.");
expect(requirementOut.remediation_procedure).toBe("Remediate.");
expect(requirementOut.audit_procedure).toBe("Audit.");
expect(requirementOut.additional_information).toBe("More info.");
expect(requirementOut.references).toBe("https://example.com/x");
});
it("skips attributes whose ASD metadata does not match the typed model", () => {
const attribute = buildAttribute("E8-BAD-1", "Invalid metadata.", {
...baseMetadata(),
MaturityLevel: "ML4",
} as unknown as ASDEssentialEightAttributesMetadata);
const requirement = buildRequirement("E8-BAD-1");
const { attributesData, requirementsData } = buildInputs([
{ attribute, requirement },
]);
expect(mapComplianceData(attributesData, requirementsData)).toEqual([]);
});
it("derives counters from RequirementStatus, not from metadata flags", () => {
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 attribute = buildAttribute(
`E8-${status}`,
"clause",
baseMetadata(),
);
const requirement = buildRequirement(`E8-${status}`, status);
const { attributesData, requirementsData } = buildInputs([
{ attribute, requirement },
]);
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("groups requirements with the same Section under one category", () => {
const attrA = buildAttribute(
"E8-PA-1",
"App patching A.",
baseMetadata({ Section: "1 Patch applications" }),
);
const attrB = buildAttribute(
"E8-PA-2",
"App patching B.",
baseMetadata({ Section: "1 Patch applications" }),
);
const attrC = buildAttribute(
"E8-OS-1",
"OS patching.",
baseMetadata({ Section: "2 Patch operating systems" }),
);
const { attributesData, requirementsData } = buildInputs([
{ attribute: attrA, requirement: buildRequirement("E8-PA-1") },
{ attribute: attrB, requirement: buildRequirement("E8-PA-2") },
{ attribute: attrC, requirement: buildRequirement("E8-OS-1") },
]);
const [framework] = mapComplianceData(attributesData, requirementsData);
expect(framework.categories.map((c) => c.name)).toEqual([
"1. Patch applications",
"2. Patch operating systems",
]);
expect(framework.categories[0].controls).toHaveLength(2);
expect(framework.categories[1].controls).toHaveLength(1);
});
it("skips attribute items whose metadata is missing", () => {
const valid = buildAttribute("E8-PA-1", "valid", baseMetadata());
const broken: AttributesItemData = {
...buildAttribute("E8-PA-2", "broken", baseMetadata()),
attributes: {
...buildAttribute("E8-PA-2", "broken", baseMetadata()).attributes,
attributes: {
metadata: [],
check_ids: [],
},
},
};
const { attributesData, requirementsData } = buildInputs([
{ attribute: valid, requirement: buildRequirement("E8-PA-1") },
{ attribute: broken, requirement: buildRequirement("E8-PA-2") },
]);
const [framework] = mapComplianceData(attributesData, requirementsData);
expect(framework.categories[0].controls).toHaveLength(1);
expect(framework.categories[0].controls[0].requirements[0].name).toBe(
"E8-PA-1",
);
});
it("skips attribute items without a matching requirement entry", () => {
const attribute = buildAttribute("E8-PA-1", "clause", baseMetadata());
const orphan = buildAttribute("E8-PA-2", "orphan", baseMetadata());
const result = mapComplianceData(
{ data: [attribute, orphan] },
{ data: [buildRequirement("E8-PA-1")] },
);
expect(result[0].categories[0].controls).toHaveLength(1);
});
it("accepts a `_filter` parameter without altering output (placeholder for ML2/ML3)", () => {
const attribute = buildAttribute("E8-PA-1", "clause", baseMetadata());
const { attributesData, requirementsData } = buildInputs([
{ attribute, requirement: buildRequirement("E8-PA-1") },
]);
const withoutFilter = mapComplianceData(attributesData, requirementsData);
const withFilter = mapComplianceData(
attributesData,
requirementsData,
"ML2",
);
expect(withFilter).toEqual(withoutFilter);
});
});
describe("toAccordionItems (ASD Essential Eight)", () => {
it("produces one accordion item per category", () => {
const attrA = buildAttribute(
"E8-PA-1",
"App patching.",
baseMetadata({ Section: "1 Patch applications" }),
);
const attrB = buildAttribute(
"E8-OS-1",
"OS patching.",
baseMetadata({ Section: "2 Patch operating systems" }),
);
const frameworks = mapComplianceData(
{ data: [attrA, attrB] },
{
data: [buildRequirement("E8-PA-1"), buildRequirement("E8-OS-1")],
},
);
const items = toAccordionItems(frameworks, "scan-1");
expect(items).toHaveLength(2);
expect(items[0].key).toBe(`${FRAMEWORK}-1. Patch applications`);
expect(items[1].key).toBe(`${FRAMEWORK}-2. Patch operating systems`);
// Every accordion item exposes a renderable React element title and
// children — both of which we assert structurally (we mocked the
// underlying components, but the elements themselves must exist).
expect(isValidElement(items[0].title)).toBe(true);
expect(items[0].items).toHaveLength(1);
});
it("returns an empty list when given no frameworks", () => {
expect(toAccordionItems([], "scan-1")).toEqual([]);
});
});
+172
View File
@@ -0,0 +1,172 @@
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/ui/accordion/Accordion";
import type { FindingStatus } from "@/components/ui/table/status-finding-badge";
import {
type ASDEssentialEightRequirement,
type AttributesData,
type Framework,
isASDEssentialEightAttributesMetadata,
type Requirement,
REQUIREMENT_STATUS,
type RequirementsData,
} from "@/types/compliance";
import {
calculateFrameworkCounters,
createRequirementsMap,
findOrCreateCategory,
findOrCreateFramework,
updateCounters,
} from "./commons";
// TODO(PROWLER-1470): `_filter` is reserved for future Maturity Level
// filtering (analogous to CIS's Profile filter). Today the JSON only
// contains ML1 requirements, so the parameter is a no-op; once ML2/ML3
// ship, mirror the CIS pattern of skipping requirements whose
// `attrs.MaturityLevel` !== filter. The leading underscore tells eslint
// and TypeScript-ESLint that the parameter is intentionally unused.
export const mapComplianceData = (
attributesData: AttributesData,
requirementsData: RequirementsData,
_filter?: string,
): Framework[] => {
const attributes = attributesData?.data || [];
const requirementsMap = createRequirementsMap(requirementsData);
const frameworks: Framework[] = [];
// Process attributes and merge with requirements data
for (const attributeItem of attributes) {
const id = attributeItem.id;
const metadataArray = attributeItem.attributes?.attributes?.metadata;
const attrs = metadataArray?.[0];
if (!isASDEssentialEightAttributesMetadata(attrs)) continue;
// Get corresponding requirement data
const requirementData = requirementsMap.get(id);
if (!requirementData) continue;
const frameworkName = attributeItem.attributes.framework;
const sectionName = attrs.Section;
const description = attributeItem.attributes.description;
const status = requirementData.attributes.status;
const checks = attributeItem.attributes.attributes.check_ids;
const requirementName = id;
// Find or create framework using common helper
const framework = findOrCreateFramework(frameworks, frameworkName);
// Sections in the source JSON are formatted "1 Patch applications";
// normalize to "1. Patch applications" so the leading clause number reads
// as a sentence in the accordion header. Order is preserved by JSON
// document order (categories materialize in insertion order via
// `findOrCreateCategory`); this rewrite is purely cosmetic.
const normalizedSectionName = sectionName.replace(/^(\d+)\s/, "$1. ");
const category = findOrCreateCategory(
framework.categories,
normalizedSectionName,
);
// Each requirement is its own control (matches CIS rendering): keeps
// the framework's clause-level granularity visible in the accordion.
// The accordion title and the requirement.description must surface the
// *literal ASD clause* (`description`, the canonical standard text).
// The Attributes[].Description field carries Prowler's
// provider-specific implementation note; we expose it separately as
// `implementation_notes` so the details panel can render it under
// "Implementation Notes" without coupling the field to a single
// provider.
const controlLabel = `${id} - ${description}`;
const control = {
label: controlLabel,
pass: 0,
fail: 0,
manual: 0,
requirements: [] as Requirement[],
};
const requirement = {
name: requirementName,
description: description,
status: status,
check_ids: checks,
pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0,
fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0,
manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
maturity_level: attrs.MaturityLevel,
assessment_status: attrs.AssessmentStatus,
cloud_applicability: attrs.CloudApplicability,
mitigated_threats: attrs.MitigatedThreats,
implementation_notes: attrs.Description,
rationale_statement: attrs.RationaleStatement,
impact_statement: attrs.ImpactStatement,
remediation_procedure: attrs.RemediationProcedure,
audit_procedure: attrs.AuditProcedure,
additional_information: attrs.AdditionalInformation,
references: attrs.References,
} satisfies ASDEssentialEightRequirement;
control.requirements.push(requirement);
// Update control counters using common helper
updateCounters(control, requirement.status);
category.controls.push(control);
}
// Calculate counters using common helper
calculateFrameworkCounters(frameworks);
return frameworks;
};
export const toAccordionItems = (
data: Framework[],
scanId: string | undefined,
): AccordionItemProps[] => {
return data.flatMap((framework) =>
framework.categories.map((category) => {
return {
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.map((control, i: number) => {
const requirement = control.requirements[0]; // Each control has one requirement
const itemKey = `${framework.name}-${category.name}-control-${i}`;
return {
key: itemKey,
title: (
<ComplianceAccordionRequirementTitle
type=""
name={control.label}
status={requirement.status as FindingStatus}
/>
),
content: (
<ClientAccordionContent
key={`content-${itemKey}`}
requirement={requirement}
scanId={scanId || ""}
framework={framework.name}
disableFindings={
requirement.check_ids.length === 0 && requirement.manual === 0
}
/>
),
items: [],
};
}),
};
}),
);
};
+196
View File
@@ -0,0 +1,196 @@
import { isValidElement, ReactElement } from "react";
import { describe, expect, it, vi } from "vitest";
// Custom-details components and the `ClientAccordionContent` chain
// transitively import server-only code (next-auth → next/server). Mocking
// them with identifiable stubs lets us load the registry under vitest and
// assert that `getDetailsComponent` returns the *correct* stub for each
// framework — i.e. that the wiring is actually behavioral.
type DetailsStubProps = { requirement: { name?: string } };
// `vi.hoisted` runs *before* the hoisted `vi.mock` factories, so we can
// safely close over `stubFactory` from inside each mock without tripping
// the temporal-dead-zone error vitest raises for top-level helpers.
const { stubFactory } = vi.hoisted(() => ({
stubFactory: (label: string) => {
const Stub = (_props: DetailsStubProps) => null;
Stub.displayName = label;
return Stub;
},
}));
vi.mock(
"@/components/compliance/compliance-custom-details/asd-essential-eight-details",
() => ({ ASDEssentialEightCustomDetails: stubFactory("ASDStub") }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/aws-well-architected-details",
() => ({ AWSWellArchitectedCustomDetails: stubFactory("AWSWAStub") }),
);
vi.mock("@/components/compliance/compliance-custom-details/c5-details", () => ({
C5CustomDetails: stubFactory("C5Stub"),
}));
vi.mock(
"@/components/compliance/compliance-custom-details/ccc-details",
() => ({ CCCCustomDetails: stubFactory("CCCStub") }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/cis-details",
() => ({ CISCustomDetails: stubFactory("CISStub") }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/csa-details",
() => ({ CSACustomDetails: stubFactory("CSAStub") }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/ens-details",
() => ({ ENSCustomDetails: stubFactory("ENSStub") }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/generic-details",
() => ({ GenericCustomDetails: stubFactory("GenericStub") }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/iso-details",
() => ({ ISOCustomDetails: stubFactory("ISOStub") }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/kisa-details",
() => ({ KISACustomDetails: stubFactory("KISAStub") }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/mitre-details",
() => ({ MITRECustomDetails: stubFactory("MITREStub") }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/threat-details",
() => ({ ThreatCustomDetails: stubFactory("ThreatStub") }),
);
// Each per-framework mapper file (cis.tsx, ens.tsx, etc.) re-exports JSX
// builders that pull in the same client-side accordion chain. Stub them
// out so the registry module can load without booting Next's server-only
// runtime — the registry is what we actually test here.
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 { Requirement } from "@/types/compliance";
import { getComplianceMapper } from "./compliance-mapper";
const fakeRequirement: Requirement = {
name: "test",
description: "test",
status: "PASS",
pass: 1,
fail: 0,
manual: 0,
check_ids: [],
};
const detailsStubName = (component: unknown): string | undefined => {
if (!isValidElement(component)) return undefined;
// `type` of a React element holds the component function (the stub we
// registered above); `displayName` is what we keyed each stub on.
const element = component as ReactElement<DetailsStubProps>;
const type = element.type as { displayName?: string };
return type.displayName;
};
describe("getComplianceMapper", () => {
it("falls back to the generic mapper when no framework is supplied", () => {
const mapper = getComplianceMapper(undefined);
expect(detailsStubName(mapper.getDetailsComponent(fakeRequirement))).toBe(
"GenericStub",
);
});
it("falls back to the generic mapper for an unknown framework", () => {
const mapper = getComplianceMapper("Made-Up-Framework");
expect(detailsStubName(mapper.getDetailsComponent(fakeRequirement))).toBe(
"GenericStub",
);
});
it("wires each registered framework to its dedicated details component", () => {
// The keys MUST match the `framework` field the API returns
// (case- and hyphen-sensitive).
const wiring: Array<{ framework: string; expected: string }> = [
{ framework: "ASD-Essential-Eight", expected: "ASDStub" },
{ framework: "C5", expected: "C5Stub" },
{ framework: "ENS", expected: "ENSStub" },
{ framework: "ISO27001", expected: "ISOStub" },
{ framework: "CIS", expected: "CISStub" },
{
framework: "AWS-Well-Architected-Framework-Security-Pillar",
expected: "AWSWAStub",
},
{
framework: "AWS-Well-Architected-Framework-Reliability-Pillar",
expected: "AWSWAStub",
},
{ framework: "KISA-ISMS-P", expected: "KISAStub" },
{ framework: "MITRE-ATTACK", expected: "MITREStub" },
{ framework: "ProwlerThreatScore", expected: "ThreatStub" },
{ framework: "CCC", expected: "CCCStub" },
{ framework: "CSA-CCM", expected: "CSAStub" },
];
for (const { framework, expected } of wiring) {
const mapper = getComplianceMapper(framework);
expect(
detailsStubName(mapper.getDetailsComponent(fakeRequirement)),
`framework "${framework}" should resolve to ${expected}`,
).toBe(expected);
}
});
it("exposes the four functions every consumer relies on", () => {
const mapper = getComplianceMapper("ASD-Essential-Eight");
expect(typeof mapper.mapComplianceData).toBe("function");
expect(typeof mapper.toAccordionItems).toBe("function");
expect(typeof mapper.getTopFailedSections).toBe("function");
expect(typeof mapper.calculateCategoryHeatmapData).toBe("function");
expect(typeof mapper.getDetailsComponent).toBe("function");
});
it("returns the same reference shape for every supported framework", () => {
// A regression sentinel: if a future entry forgets one of the five
// functions the registry contract requires, this assertion catches
// it before the runtime errors leak into the UI.
const expectedKeys = [
"mapComplianceData",
"toAccordionItems",
"getTopFailedSections",
"calculateCategoryHeatmapData",
"getDetailsComponent",
].sort();
for (const framework of [
"ASD-Essential-Eight",
"C5",
"ENS",
"ISO27001",
"CIS",
"AWS-Well-Architected-Framework-Security-Pillar",
"KISA-ISMS-P",
"MITRE-ATTACK",
"ProwlerThreatScore",
"CCC",
"CSA-CCM",
]) {
const mapper = getComplianceMapper(framework);
expect(Object.keys(mapper).sort(), framework).toEqual(expectedKeys);
}
});
});
+14
View File
@@ -1,5 +1,6 @@
import { createElement, ReactNode } from "react";
import { ASDEssentialEightCustomDetails } from "@/components/compliance/compliance-custom-details/asd-essential-eight-details";
import { AWSWellArchitectedCustomDetails } from "@/components/compliance/compliance-custom-details/aws-well-architected-details";
import { C5CustomDetails } from "@/components/compliance/compliance-custom-details/c5-details";
import { CCCCustomDetails } from "@/components/compliance/compliance-custom-details/ccc-details";
@@ -21,6 +22,10 @@ import {
TopFailedResult,
} from "@/types/compliance";
import {
mapComplianceData as mapASDEssentialEightComplianceData,
toAccordionItems as toASDEssentialEightAccordionItems,
} from "./asd-essential-eight";
import {
mapComplianceData as mapAWSWellArchitectedComplianceData,
toAccordionItems as toAWSWellArchitectedAccordionItems,
@@ -96,6 +101,15 @@ const getDefaultMapper = (): ComplianceMapper => ({
});
const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
"ASD-Essential-Eight": {
mapComplianceData: mapASDEssentialEightComplianceData,
toAccordionItems: toASDEssentialEightAccordionItems,
getTopFailedSections,
calculateCategoryHeatmapData: (data: Framework[]) =>
calculateCategoryHeatmapData(data),
getDetailsComponent: (requirement: Requirement) =>
createElement(ASDEssentialEightCustomDetails, { requirement }),
},
C5: {
mapComplianceData: mapC5ComplianceData,
toAccordionItems: toC5AccordionItems,
+104
View File
@@ -224,6 +224,109 @@ export interface CCCAttributesMetadata {
}>;
}
// ASD Essential Eight enums — modelled on the canonical Maturity Model
// (Nov 2023). Only ML1 ships today; ML2/ML3 are scoped out of the framework
// but kept here so the type covers any future expansion without a schema
// edit. AssessmentStatus and CloudApplicability are exhaustive per the JSON
// fixture; new variants must be added explicitly.
export const ASD_MATURITY_LEVEL = {
ML1: "ML1",
ML2: "ML2",
ML3: "ML3",
} as const;
export type ASDMaturityLevel =
(typeof ASD_MATURITY_LEVEL)[keyof typeof ASD_MATURITY_LEVEL];
export const ASD_ASSESSMENT_STATUS = {
AUTOMATED: "Automated",
MANUAL: "Manual",
} as const;
export type ASDAssessmentStatus =
(typeof ASD_ASSESSMENT_STATUS)[keyof typeof ASD_ASSESSMENT_STATUS];
export const ASD_CLOUD_APPLICABILITY = {
FULL: "full",
PARTIAL: "partial",
LIMITED: "limited",
NON_APPLICABLE: "non-applicable",
} as const;
export type ASDCloudApplicability =
(typeof ASD_CLOUD_APPLICABILITY)[keyof typeof ASD_CLOUD_APPLICABILITY];
export interface ASDEssentialEightAttributesMetadata {
Section: string;
MaturityLevel: ASDMaturityLevel;
AssessmentStatus: ASDAssessmentStatus;
CloudApplicability: ASDCloudApplicability;
MitigatedThreats: string[];
Description: string;
RationaleStatement: string;
ImpactStatement: string;
RemediationProcedure: string;
AuditProcedure: string;
AdditionalInformation: string;
References: string;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isOneOf = <T extends string>(
values: Record<string, T>,
value: unknown,
): value is T => (Object.values(values) as T[]).includes(value as T);
const isStringArray = (value: unknown): value is string[] =>
Array.isArray(value) && value.every((item) => typeof item === "string");
const ASD_METADATA_STRING_FIELDS = [
"Section",
"Description",
"RationaleStatement",
"ImpactStatement",
"RemediationProcedure",
"AuditProcedure",
"AdditionalInformation",
"References",
] as const satisfies readonly (keyof ASDEssentialEightAttributesMetadata)[];
export const isASDMaturityLevel = (value: unknown): value is ASDMaturityLevel =>
isOneOf(ASD_MATURITY_LEVEL, value);
export const isASDAssessmentStatus = (
value: unknown,
): value is ASDAssessmentStatus => isOneOf(ASD_ASSESSMENT_STATUS, value);
export const isASDCloudApplicability = (
value: unknown,
): value is ASDCloudApplicability => isOneOf(ASD_CLOUD_APPLICABILITY, value);
export const isASDEssentialEightAttributesMetadata = (
value: unknown,
): value is ASDEssentialEightAttributesMetadata =>
isRecord(value) &&
ASD_METADATA_STRING_FIELDS.every(
(field) => typeof value[field] === "string",
) &&
isASDMaturityLevel(value.MaturityLevel) &&
isASDAssessmentStatus(value.AssessmentStatus) &&
isASDCloudApplicability(value.CloudApplicability) &&
isStringArray(value.MitigatedThreats);
export interface ASDEssentialEightRequirement extends Requirement {
maturity_level: ASDEssentialEightAttributesMetadata["MaturityLevel"];
assessment_status: ASDEssentialEightAttributesMetadata["AssessmentStatus"];
cloud_applicability: ASDEssentialEightAttributesMetadata["CloudApplicability"];
mitigated_threats: ASDEssentialEightAttributesMetadata["MitigatedThreats"];
implementation_notes: ASDEssentialEightAttributesMetadata["Description"];
rationale_statement: ASDEssentialEightAttributesMetadata["RationaleStatement"];
impact_statement: ASDEssentialEightAttributesMetadata["ImpactStatement"];
remediation_procedure: ASDEssentialEightAttributesMetadata["RemediationProcedure"];
audit_procedure: ASDEssentialEightAttributesMetadata["AuditProcedure"];
additional_information: ASDEssentialEightAttributesMetadata["AdditionalInformation"];
references: ASDEssentialEightAttributesMetadata["References"];
}
export interface AttributesItemData {
type: "compliance-requirements-attributes";
id: string;
@@ -245,6 +348,7 @@ export interface AttributesItemData {
| MITREAttributesMetadata[]
| CCCAttributesMetadata[]
| CSAAttributesMetadata[]
| ASDEssentialEightAttributesMetadata[]
| GenericAttributesMetadata[];
check_ids: string[];
// MITRE structure