diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index eade2eb169..6186f3be16 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -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. `, 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) diff --git a/ui/components/compliance/compliance-custom-details/asd-essential-eight-details.test.tsx b/ui/components/compliance/compliance-custom-details/asd-essential-eight-details.test.tsx new file mode 100644 index 0000000000..816b62a262 --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/asd-essential-eight-details.test.tsx @@ -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 `` 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 }) => ( + {children} + ), +})); + +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(); + + 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(); + + 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( + , + ); + + 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(); + + 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(); + + // The markdown renderer transforms `**patch baseline**` into a + // and `*baseline compliance*` into an . 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(); + + 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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + expect(screen.queryByText("References")).not.toBeInTheDocument(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); + + it("ignores a non-string-array `mitigated_threats` field", () => { + render( + , + ); + + expect(screen.queryByText("Mitigated Threats")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/ui/components/compliance/compliance-custom-details/asd-essential-eight-details.tsx b/ui/components/compliance/compliance-custom-details/asd-essential-eight-details.tsx new file mode 100644 index 0000000000..363eba2b10 --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/asd-essential-eight-details.tsx @@ -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 ( + + {description && ( + + {description} + + )} + + {isNonEmptyString(implementation_notes) && ( + + {implementation_notes} + + )} + + + {maturityLevel && ( + + )} + + {assessmentStatus && ( + + )} + + {cloudApplicability && ( + + )} + + + {/* `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) && ( + + )} + + {isNonEmptyString(rationale_statement) && ( + + {rationale_statement} + + )} + + {isNonEmptyString(impact_statement) && ( + + {impact_statement} + + )} + + {isNonEmptyString(remediation_procedure) && ( + +
+ {remediation_procedure} +
+
+ )} + + {isNonEmptyString(audit_procedure) && ( + +
+ {audit_procedure} +
+
+ )} + + {isNonEmptyString(additional_information) && ( + + + {additional_information} + + + )} + + {referenceUrls.length > 0 && ( + +
+ {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). +
+ {url} +
+ ))} +
+
+ )} +
+ ); +}; diff --git a/ui/components/icons/compliance/IconCompliance.test.ts b/ui/components/icons/compliance/IconCompliance.test.ts new file mode 100644 index 0000000000..ca5f5a363e --- /dev/null +++ b/ui/components/icons/compliance/IconCompliance.test.ts @@ -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, + ); + }); + }); +}); diff --git a/ui/components/icons/compliance/IconCompliance.tsx b/ui/components/icons/compliance/IconCompliance.tsx index 7c39580014..d9e7d0ce1c 100644 --- a/ui/components/icons/compliance/IconCompliance.tsx +++ b/ui/components/icons/compliance/IconCompliance.tsx @@ -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]; }; diff --git a/ui/components/icons/compliance/asd-essential-eight.svg b/ui/components/icons/compliance/asd-essential-eight.svg new file mode 100644 index 0000000000..bbcb8e6fbb --- /dev/null +++ b/ui/components/icons/compliance/asd-essential-eight.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/lib/compliance/asd-essential-eight.test.ts b/ui/lib/compliance/asd-essential-eight.test.ts new file mode 100644 index 0000000000..213c6a997a --- /dev/null +++ b/ui/lib/compliance/asd-essential-eight.test.ts @@ -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 => ({ + 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([]); + }); +}); diff --git a/ui/lib/compliance/asd-essential-eight.tsx b/ui/lib/compliance/asd-essential-eight.tsx new file mode 100644 index 0000000000..0ceaa393c3 --- /dev/null +++ b/ui/lib/compliance/asd-essential-eight.tsx @@ -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: ( + + ), + 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: ( + + ), + content: ( + + ), + items: [], + }; + }), + }; + }), + ); +}; diff --git a/ui/lib/compliance/compliance-mapper.test.ts b/ui/lib/compliance/compliance-mapper.test.ts new file mode 100644 index 0000000000..9526b50b07 --- /dev/null +++ b/ui/lib/compliance/compliance-mapper.test.ts @@ -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; + 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); + } + }); +}); diff --git a/ui/lib/compliance/compliance-mapper.ts b/ui/lib/compliance/compliance-mapper.ts index 2886ea460c..c42c548695 100644 --- a/ui/lib/compliance/compliance-mapper.ts +++ b/ui/lib/compliance/compliance-mapper.ts @@ -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 => ({ + "ASD-Essential-Eight": { + mapComplianceData: mapASDEssentialEightComplianceData, + toAccordionItems: toASDEssentialEightAccordionItems, + getTopFailedSections, + calculateCategoryHeatmapData: (data: Framework[]) => + calculateCategoryHeatmapData(data), + getDetailsComponent: (requirement: Requirement) => + createElement(ASDEssentialEightCustomDetails, { requirement }), + }, C5: { mapComplianceData: mapC5ComplianceData, toAccordionItems: toC5AccordionItems, diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts index e625cedf5a..9ea851c2d4 100644 --- a/ui/types/compliance.ts +++ b/ui/types/compliance.ts @@ -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 => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isOneOf = ( + values: Record, + 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