feat(compliance): add DORA framework for AWS (#11131)

This commit is contained in:
Pedro Martín
2026-06-03 11:43:55 +02:00
committed by GitHub
parent d573af911d
commit f7f8747512
50 changed files with 2357 additions and 38277 deletions
+8
View File
@@ -2,6 +2,14 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.30.0] (Prowler UNRELEASED)
### 🚀 Added
- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
---
## [1.29.0] (Prowler v5.29.0)
### 🚀 Added
+21
View File
@@ -394,6 +394,27 @@ export const getComplianceCsv = async (scanId: string, complianceId: string) =>
"compliance report",
);
/**
* Get the OCSF JSON export for a universal compliance framework.
*
* Only universal frameworks that declare an ``outputs`` block (today: DORA,
* CSA CCM 4.0) produce a per-framework OCSF artifact. For any other framework
* the backend returns 404; callers should gate this download via
* ``isOcsfSupported(framework)``.
*
* NOTE: this is a dedicated path (``compliance/{id}/ocsf``), not a query
* param. The API's JSON:API ``QueryParameterValidationFilter`` rejects any
* non-JSON:API query param with 400, so ``?type=`` / ``?format=`` is not an
* option — the format must be encoded in the route.
*/
export const getComplianceOcsf = async (scanId: string, complianceId: string) =>
_fetchScanBinary(
scanId,
`compliance/${complianceId}/ocsf`,
`scan-${scanId}-compliance-${complianceId}.ocsf.json`,
"compliance OCSF report",
);
/**
* Get a compliance PDF report for any supported framework.
*
@@ -0,0 +1,49 @@
import { Requirement } from "@/types/compliance";
import {
ComplianceBadge,
ComplianceBadgeContainer,
ComplianceDetailContainer,
ComplianceDetailSection,
ComplianceDetailText,
} from "./shared-components";
interface DORADetailsProps {
requirement: Requirement;
}
export const DORACustomDetails = ({ requirement }: DORADetailsProps) => {
return (
<ComplianceDetailContainer>
{requirement.description && (
<ComplianceDetailSection title="Description">
<ComplianceDetailText>{requirement.description}</ComplianceDetailText>
</ComplianceDetailSection>
)}
<ComplianceBadgeContainer>
{requirement.pillar && (
<ComplianceBadge
label="Pillar"
value={requirement.pillar as string}
color="blue"
/>
)}
{requirement.article && (
<ComplianceBadge
label="Article"
value={requirement.article as string}
color="indigo"
/>
)}
{requirement.article_title && (
<ComplianceBadge
label="Article Title"
value={requirement.article_title as string}
color="gray"
/>
)}
</ComplianceBadgeContainer>
</ComplianceDetailContainer>
);
};
@@ -6,15 +6,19 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { downloadComplianceCsvMock, downloadCompliancePdfMock } = vi.hoisted(
() => ({
downloadComplianceCsvMock: vi.fn(),
downloadCompliancePdfMock: vi.fn(),
}),
);
const {
downloadComplianceCsvMock,
downloadComplianceOcsfMock,
downloadCompliancePdfMock,
} = vi.hoisted(() => ({
downloadComplianceCsvMock: vi.fn(),
downloadComplianceOcsfMock: vi.fn(),
downloadCompliancePdfMock: vi.fn(),
}));
vi.mock("@/lib/helper", () => ({
downloadComplianceCsv: downloadComplianceCsvMock,
downloadComplianceOcsf: downloadComplianceOcsfMock,
downloadCompliancePdf: downloadCompliancePdfMock,
}));
@@ -131,4 +135,51 @@ describe("ComplianceDownloadContainer", () => {
{},
);
});
it("should hide the OCSF action for frameworks without OCSF support", async () => {
const user = userEvent.setup();
render(
<ComplianceDownloadContainer
compact
presentation="dropdown"
scanId="scan-1"
complianceId="compliance-1"
/>,
);
await user.click(
screen.getByRole("button", { name: "Open compliance export actions" }),
);
expect(screen.queryByText("Download OCSF report")).not.toBeInTheDocument();
});
it("should surface and trigger the OCSF download for universal frameworks", async () => {
const user = userEvent.setup();
render(
<ComplianceDownloadContainer
compact
presentation="dropdown"
scanId="scan-1"
complianceId="dora"
/>,
);
await user.click(
screen.getByRole("button", { name: "Open compliance export actions" }),
);
expect(screen.getByText("Download OCSF report")).toBeInTheDocument();
await user.click(
screen.getByRole("menuitem", { name: /Download OCSF report/i }),
);
expect(downloadComplianceOcsfMock).toHaveBeenCalledWith(
"scan-1",
"dora",
{},
);
});
});
@@ -1,6 +1,6 @@
"use client";
import { DownloadIcon, FileTextIcon } from "lucide-react";
import { DownloadIcon, FileJsonIcon, FileTextIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/shadcn/button/button";
@@ -14,8 +14,15 @@ import {
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { toast } from "@/components/ui";
import type { ComplianceReportType } from "@/lib/compliance/compliance-report-types";
import { downloadComplianceCsv, downloadCompliancePdf } from "@/lib/helper";
import {
type ComplianceReportType,
isOcsfSupported,
} from "@/lib/compliance/compliance-report-types";
import {
downloadComplianceCsv,
downloadComplianceOcsf,
downloadCompliancePdf,
} from "@/lib/helper";
import { cn } from "@/lib/utils";
interface ComplianceDownloadContainerProps {
@@ -40,9 +47,14 @@ export const ComplianceDownloadContainer = ({
presentation = "buttons",
}: ComplianceDownloadContainerProps) => {
const [isDownloadingCsv, setIsDownloadingCsv] = useState(false);
const [isDownloadingOcsf, setIsDownloadingOcsf] = useState(false);
const [isDownloadingPdf, setIsDownloadingPdf] = useState(false);
const isIconWidth = buttonWidth === "icon";
const isDropdown = presentation === "dropdown";
// Only universal frameworks declaring an ``outputs`` block expose a
// per-framework OCSF artifact (today: DORA, CSA CCM 4.0). Hide the
// action everywhere else so the user never hits a guaranteed 404.
const ocsfAvailable = isOcsfSupported(complianceId);
const handleDownloadCsv = async () => {
if (isDownloadingCsv) return;
@@ -54,6 +66,16 @@ export const ComplianceDownloadContainer = ({
}
};
const handleDownloadOcsf = async () => {
if (!ocsfAvailable || isDownloadingOcsf) return;
setIsDownloadingOcsf(true);
try {
await downloadComplianceOcsf(scanId, complianceId, toast);
} finally {
setIsDownloadingOcsf(false);
}
};
const handleDownloadPdf = async () => {
if (!reportType || isDownloadingPdf) return;
setIsDownloadingPdf(true);
@@ -105,6 +127,18 @@ export const ComplianceDownloadContainer = ({
onSelect={handleDownloadCsv}
disabled={disabled || isDownloadingCsv}
/>
{ocsfAvailable && (
<ActionDropdownItem
icon={
<FileJsonIcon
className={isDownloadingOcsf ? "animate-download-icon" : ""}
/>
}
label="Download OCSF report"
onSelect={handleDownloadOcsf}
disabled={disabled || isDownloadingOcsf}
/>
)}
{reportType && (
<ActionDropdownItem
icon={
@@ -152,6 +186,29 @@ export const ComplianceDownloadContainer = ({
<TooltipContent>Download CSV report</TooltipContent>
)}
</Tooltip>
{ocsfAvailable && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className={buttonClassName}
onClick={handleDownloadOcsf}
disabled={disabled || isDownloadingOcsf}
aria-label="Download compliance OCSF report"
>
<FileJsonIcon
size={14}
className={isDownloadingOcsf ? "animate-download-icon" : ""}
/>
<span className={labelClassName}>OCSF</span>
</Button>
</TooltipTrigger>
{showTooltip && (
<TooltipContent>Download OCSF report</TooltipContent>
)}
</Tooltip>
)}
{reportType && (
<Tooltip>
<TooltipTrigger asChild>
@@ -6,6 +6,7 @@ import CCCLogo from "./ccc.svg";
import CISLogo from "./cis.svg";
import CISALogo from "./cisa.svg";
import CSALogo from "./csa.svg";
import DORALogo from "./dora.svg";
import ENSLogo from "./ens.png";
import FedRAMPLogo from "./fedramp.svg";
import FFIECLogo from "./ffiec.svg";
@@ -67,6 +68,9 @@ const COMPLIANCE_LOGOS = [
["c5", C5Logo],
["ccc", CCCLogo],
["csa", CSALogo],
// DORA — universal framework (`prowler/compliance/dora.json`). The
// compliance_id is just `dora`, no provider suffix.
["dora", DORALogo],
["secnumcloud", ANSSILogo],
["aws", AWSLogo],
] as const;
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 170" fill="none">
<defs>
<linearGradient id="doraGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#003399"/>
<stop offset="100%" style="stop-color:#0055A5"/>
</linearGradient>
</defs>
<g>
<rect x="0" y="20" width="400" height="130" rx="16" fill="url(#doraGradient)"/>
<text x="200" y="100" font-family="Helvetica, Arial, sans-serif" font-size="76" font-weight="700" fill="#FFFFFF" text-anchor="middle" letter-spacing="6">DORA</text>
<text x="200" y="135" font-family="Helvetica, Arial, sans-serif" font-size="14" font-weight="500" fill="#FFD700" text-anchor="middle" letter-spacing="3">EU 2022/2554</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 747 B

+18
View File
@@ -6,6 +6,7 @@ import { C5CustomDetails } from "@/components/compliance/compliance-custom-detai
import { CCCCustomDetails } from "@/components/compliance/compliance-custom-details/ccc-details";
import { CISCustomDetails } from "@/components/compliance/compliance-custom-details/cis-details";
import { CSACustomDetails } from "@/components/compliance/compliance-custom-details/csa-details";
import { DORACustomDetails } from "@/components/compliance/compliance-custom-details/dora-details";
import { ENSCustomDetails } from "@/components/compliance/compliance-custom-details/ens-details";
import { GenericCustomDetails } from "@/components/compliance/compliance-custom-details/generic-details";
import { ISOCustomDetails } from "@/components/compliance/compliance-custom-details/iso-details";
@@ -47,6 +48,10 @@ import {
mapComplianceData as mapCSAComplianceData,
toAccordionItems as toCSAAccordionItems,
} from "./csa";
import {
mapComplianceData as mapDORAComplianceData,
toAccordionItems as toDORAAccordionItems,
} from "./dora";
import {
mapComplianceData as mapENSComplianceData,
toAccordionItems as toENSAccordionItems,
@@ -208,6 +213,19 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
getDetailsComponent: (requirement: Requirement) =>
createElement(CSACustomDetails, { requirement }),
},
// DORA (Regulation (EU) 2022/2554) — universal framework keyed by the
// `framework` field of `prowler/compliance/dora.json` ("DORA"). Groups by
// Pillar (5 enum values) and surfaces Pillar / Article / ArticleTitle in
// the requirement detail drawer.
DORA: {
mapComplianceData: mapDORAComplianceData,
toAccordionItems: toDORAAccordionItems,
getTopFailedSections,
calculateCategoryHeatmapData: (data: Framework[]) =>
calculateCategoryHeatmapData(data),
getDetailsComponent: (requirement: Requirement) =>
createElement(DORACustomDetails, { requirement }),
},
});
/**
@@ -4,6 +4,7 @@ import {
COMPLIANCE_REPORT_TYPES,
getReportTypeForCompliance,
getReportTypeForFramework,
isOcsfSupported,
pickLatestCisPerProvider,
} from "./compliance-report-types";
@@ -34,6 +35,24 @@ describe("getReportTypeForFramework", () => {
});
});
describe("isOcsfSupported", () => {
it("returns true for universal frameworks shipping an OCSF artifact", () => {
expect(isOcsfSupported("dora")).toBe(true);
expect(isOcsfSupported("csa_ccm_4.0")).toBe(true);
});
it("returns false for legacy/per-provider frameworks without OCSF output", () => {
expect(isOcsfSupported("cis_5.0_aws")).toBe(false);
expect(isOcsfSupported("ens_rd2022_aws")).toBe(false);
expect(isOcsfSupported("nis2_aws")).toBe(false);
});
it("returns false for missing or empty inputs", () => {
expect(isOcsfSupported(undefined)).toBe(false);
expect(isOcsfSupported("")).toBe(false);
});
});
describe("pickLatestCisPerProvider", () => {
it("returns an empty set for an empty input", () => {
const latest = pickLatestCisPerProvider([]);
@@ -95,7 +114,7 @@ describe("pickLatestCisPerProvider", () => {
const latest = pickLatestCisPerProvider([
"ens_rd2022_aws",
"nis2_aws",
"csa_ccm_4.0_aws",
"csa_ccm_4.0",
"prowler_threatscore_aws",
"cis_5.0_aws",
]);
@@ -161,6 +161,30 @@ export const pickLatestCisPerProvider = (
return latest;
};
/**
* Compliance IDs that ship a per-framework OCSF JSON export.
*
* Only universal compliance frameworks that declare an ``outputs`` block in
* their schema (see ``prowler/compliance/<name>.json``) produce a dedicated
* OCSF artifact during scan output generation. Today that is DORA and
* CSA CCM 4.0. Any other framework only offers CSV (and, for the curated
* list above, PDF).
*
* Keep this Set in lock-step with the backend: ``get_prowler_provider_compliance``
* + ``ComplianceFramework.outputs`` is the source of truth. The API will
* 404 on ``GET /scans/{id}/compliance/{name}/ocsf`` for any framework not
* in this set, so showing the OCSF button for an unsupported framework
* would surface a broken download — gate every call site through
* ``isOcsfSupported``.
*/
const OCSF_SUPPORTED_COMPLIANCE_IDS: ReadonlySet<string> = new Set([
"dora",
"csa_ccm_4.0",
]);
export const isOcsfSupported = (complianceId: string | undefined): boolean =>
!!complianceId && OCSF_SUPPORTED_COMPLIANCE_IDS.has(complianceId);
/**
* Resolve the report type for a compliance card.
*
+154
View File
@@ -0,0 +1,154 @@
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title";
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
import { AccordionItemProps } from "@/components/ui/accordion/Accordion";
import { FindingStatus } from "@/components/ui/table/status-finding-badge";
import {
AttributesData,
DORAAttributesMetadata,
Framework,
Requirement,
REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
import {
calculateFrameworkCounters,
createRequirementsMap,
findOrCreateCategory,
findOrCreateControl,
findOrCreateFramework,
} from "./commons";
// Display order for DORA pillars in the accordion and any grouped chart. The
// regulation arranges them in this exact order (Articles 5-14, 17-19, 24-25,
// 28+30, 45) — preserving it here means the UI always renders pillars in the
// "logical" reading order regardless of how the API returns them.
export const DORA_PILLAR_ORDER: readonly string[] = [
"ICT Risk Management",
"ICT-Related Incident Reporting",
"Digital Operational Resilience Testing",
"ICT Third-Party Risk Management",
"Information Sharing",
];
const getStatusCounters = (status: RequirementStatus) => ({
pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0,
fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0,
manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
});
export const mapComplianceData = (
attributesData: AttributesData,
requirementsData: RequirementsData,
): Framework[] => {
const attributes = attributesData?.data || [];
const requirementsMap = createRequirementsMap(requirementsData);
const frameworks: Framework[] = [];
for (const attributeItem of attributes) {
const id = attributeItem.id;
const metadataArray = attributeItem.attributes?.attributes
?.metadata as unknown as DORAAttributesMetadata[];
const attrs = metadataArray?.[0];
if (!attrs) continue;
const requirementData = requirementsMap.get(id);
if (!requirementData) continue;
const frameworkName = attributeItem.attributes.framework;
// Group by Pillar (top-level accordion section). Article + ArticleTitle
// live inside the requirement so they show up on the detail drawer.
const categoryName = attrs.Pillar;
const requirementName = attributeItem.attributes.name || "";
const description = attributeItem.attributes.description;
const status = requirementData.attributes.status || "";
const checks = attributeItem.attributes.attributes.check_ids || [];
const framework = findOrCreateFramework(frameworks, frameworkName);
const category = findOrCreateCategory(framework.categories, categoryName);
// Flat 2-level structure: pillar → requirements (no intermediate control).
const control = findOrCreateControl(category.controls, categoryName);
const finalStatus: RequirementStatus = status as RequirementStatus;
const requirement: Requirement = {
name: requirementName ? `${id} - ${requirementName}` : id,
description,
status: finalStatus,
check_ids: checks,
...getStatusCounters(finalStatus),
pillar: attrs.Pillar,
article: attrs.Article,
article_title: attrs.ArticleTitle,
};
control.requirements.push(requirement);
}
// Sort categories by canonical pillar order so DORA always reads from "ICT
// Risk Management" down to "Information Sharing", regardless of map insertion
// order driven by the API response.
for (const framework of frameworks) {
framework.categories.sort((a, b) => {
const ia = DORA_PILLAR_ORDER.indexOf(a.name);
const ib = DORA_PILLAR_ORDER.indexOf(b.name);
// Unknown pillars (defensive — shouldn't happen) sink to the bottom.
const orderA = ia === -1 ? DORA_PILLAR_ORDER.length : ia;
const orderB = ib === -1 ? DORA_PILLAR_ORDER.length : ib;
return orderA - orderB;
});
}
calculateFrameworkCounters(frameworks);
return frameworks;
};
export const toAccordionItems = (
data: Framework[],
scanId: string | undefined,
): AccordionItemProps[] => {
const safeId = scanId || "";
return data.flatMap((framework) =>
framework.categories.map((category) => ({
key: `${framework.name}-${category.name}`,
title: (
<ComplianceAccordionTitle
label={category.name}
pass={category.pass}
fail={category.fail}
manual={category.manual}
isParentLevel={true}
/>
),
content: "",
// Pillar → requirements (flat, no intermediate "control" level).
items: category.controls.flatMap((control) =>
control.requirements.map((requirement, reqIndex) => ({
key: `${framework.name}-${category.name}-req-${reqIndex}`,
title: (
<ComplianceAccordionRequirementTitle
type=""
name={requirement.name}
status={requirement.status as FindingStatus}
/>
),
content: (
<ClientAccordionContent
key={`content-${framework.name}-${category.name}-req-${reqIndex}`}
requirement={requirement}
scanId={safeId}
framework={framework.name}
disableFindings={
requirement.check_ids.length === 0 && requirement.manual === 0
}
/>
),
items: [],
})),
),
})),
);
};
+27
View File
@@ -1,5 +1,6 @@
import {
getComplianceCsv,
getComplianceOcsf,
getCompliancePdfReport,
type ScanBinaryResult,
} from "@/actions/scans";
@@ -247,6 +248,32 @@ export const downloadComplianceCsv = async (
);
};
/**
* Download the per-framework OCSF JSON export.
*
* Only universal frameworks declaring an ``outputs`` block produce this
* artifact (currently DORA and CSA CCM 4.0); callers must gate the call
* via ``isOcsfSupported`` to avoid surfacing a broken download on
* frameworks the API will 404 on.
*/
export const downloadComplianceOcsf = async (
scanId: string,
complianceId: string,
toast: ReturnType<typeof useToast>["toast"],
): Promise<void> => {
toast({
title: "Download Started",
description: "Preparing the OCSF report. This may take a moment.",
});
const result = await getComplianceOcsf(scanId, complianceId);
await downloadFile(
result,
"application/json",
"The compliance OCSF report has been downloaded successfully.",
toast,
);
};
/**
* Download a compliance PDF report.
*
+26
View File
@@ -327,6 +327,31 @@ export interface ASDEssentialEightRequirement extends Requirement {
references: ASDEssentialEightAttributesMetadata["References"];
}
// DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554).
// Universal framework — flat attributes dict with Pillar/Article/ArticleTitle.
// `Pillar` is the canonical grouping key for tables and PDF; the enum mirrors
// the five DORA pillars declared in `prowler/compliance/dora.json`.
export const DORA_PILLAR = {
ICT_RISK_MANAGEMENT: "ICT Risk Management",
INCIDENT_REPORTING: "ICT-Related Incident Reporting",
RESILIENCE_TESTING: "Digital Operational Resilience Testing",
THIRD_PARTY_RISK: "ICT Third-Party Risk Management",
INFORMATION_SHARING: "Information Sharing",
} as const;
export type DORAPillar = (typeof DORA_PILLAR)[keyof typeof DORA_PILLAR];
export interface DORAAttributesMetadata {
Pillar: DORAPillar;
Article: string;
ArticleTitle: string;
}
export interface DORARequirement extends Requirement {
pillar: DORAAttributesMetadata["Pillar"];
article: DORAAttributesMetadata["Article"];
article_title: DORAAttributesMetadata["ArticleTitle"];
}
export interface AttributesItemData {
type: "compliance-requirements-attributes";
id: string;
@@ -349,6 +374,7 @@ export interface AttributesItemData {
| CCCAttributesMetadata[]
| CSAAttributesMetadata[]
| ASDEssentialEightAttributesMetadata[]
| DORAAttributesMetadata[]
| GenericAttributesMetadata[];
check_ids: string[];
// MITRE structure