diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts
index e06b06d29d..1dac947c63 100644
--- a/ui/actions/compliances/compliances.ts
+++ b/ui/actions/compliances/compliances.ts
@@ -140,3 +140,55 @@ export const getComplianceRequirements = async ({
return undefined;
}
};
+
+/**
+ * Aggregate a universal compliance framework across one scan per compatible
+ * provider. Backed by ``GET /cross-provider-compliance-overviews/`` (Prowler
+ * Cloud only — the OSS API does not expose this endpoint).
+ *
+ * ``scanIds`` is optional: when omitted, the API auto-selects the most recent
+ * COMPLETED scan for every provider in the tenant whose type is declared
+ * compatible by the universal framework (further narrowed by ``providerTypes``
+ * and by RBAC visibility).
+ */
+export const getCrossProviderComplianceOverview = async ({
+ complianceId,
+ scanIds,
+ providerTypes,
+ regions,
+}: {
+ complianceId: string;
+ scanIds?: string[];
+ providerTypes?: string | string[];
+ regions?: string | string[];
+}) => {
+ const headers = await getAuthHeaders({ contentType: false });
+
+ const url = new URL(`${apiBaseUrl}/cross-provider-compliance-overviews`);
+
+ const setParam = (key: string, value?: string | string[]) => {
+ if (!value) return;
+ const serializedValue = Array.isArray(value) ? value.join(",") : value;
+ if (serializedValue.trim().length > 0) {
+ url.searchParams.set(key, serializedValue);
+ }
+ };
+
+ setParam("filter[compliance_id]", complianceId);
+ if (scanIds && scanIds.length > 0) {
+ setParam("filter[scan__in]", scanIds);
+ }
+ setParam("filter[provider_type__in]", providerTypes);
+ setParam("filter[region__in]", regions);
+
+ try {
+ const response = await fetch(url.toString(), { headers });
+
+ if (response.status === 402) return { redirectTo: "/billing" };
+
+ return handleApiResponse(response);
+ } catch (error) {
+ console.error("Error fetching cross-provider compliance overview:", error);
+ return undefined;
+ }
+};
diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx
index f5d42f8bc8..33e633b589 100644
--- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx
+++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx
@@ -1,4 +1,6 @@
import { Spacer } from "@heroui/spacer";
+import { Info } from "lucide-react";
+import { redirect } from "next/navigation";
import { Suspense } from "react";
import {
@@ -6,6 +8,7 @@ import {
getComplianceOverviewMetadataInfo,
getComplianceRequirements,
getCompliancesOverview,
+ getCrossProviderComplianceOverview,
} from "@/actions/compliances";
import { getThreatScore } from "@/actions/overview";
import { getScan } from "@/actions/scans";
@@ -14,6 +17,7 @@ import {
ComplianceDownloadContainer,
ComplianceHeader,
ComplianceWarming,
+ CrossProviderDetail,
RequirementsStatusCard,
RequirementsStatusCardSkeleton,
// SectionsFailureRateCard,
@@ -25,15 +29,18 @@ import {
TopFailedSectionsCardSkeleton,
} from "@/components/compliance";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
+import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { ContentLayout } from "@/components/ui";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
import {
getReportTypeForCompliance,
pickLatestCisPerProvider,
} from "@/lib/compliance/compliance-report-types";
+import { isCloud } from "@/lib/shared/env";
import { cn } from "@/lib/utils";
import {
AttributesData,
+ CrossProviderComplianceOverviewData,
Framework,
RequirementsTotals,
} from "@/types/compliance";
@@ -44,7 +51,9 @@ interface ComplianceDetailSearchParams {
version?: string;
scanId?: string;
section?: string;
+ mode?: string;
"filter[region__in]"?: string;
+ "filter[provider_type__in]"?: string;
"filter[cis_profile_level]"?: string;
page?: string;
pageSize?: string;
@@ -59,11 +68,69 @@ export default async function ComplianceDetail({
}) {
const { compliancetitle } = await params;
const resolvedSearchParams = await searchParams;
- const { complianceId, version, scanId, section } = resolvedSearchParams;
+ const { complianceId, version, scanId, section, mode } = resolvedSearchParams;
const regionFilter = resolvedSearchParams["filter[region__in]"];
+ const providerTypeFilter = resolvedSearchParams["filter[provider_type__in]"];
const cisProfileFilter = resolvedSearchParams["filter[cis_profile_level]"];
const logoPath = getComplianceIcon(compliancetitle);
+ // Cross-provider mode: skip the per-scan pipeline and render the
+ // cross-provider universal compliance roll-up instead. This is a Prowler
+ // Cloud-only feature (the OSS API has no cross-provider-compliance-overviews
+ // endpoint), so block the route in OSS the same way Alerts/Scan
+ // Configuration do.
+ if (mode === "cross-provider") {
+ if (!isCloud()) {
+ redirect("/compliance");
+ }
+
+ const crossProviderTitle = compliancetitle.split("-").join(" ");
+ const crossProviderResponse = await getCrossProviderComplianceOverview({
+ complianceId,
+ providerTypes: providerTypeFilter,
+ regions: regionFilter,
+ });
+
+ if (!crossProviderResponse || "redirectTo" in crossProviderResponse) {
+ return (
+
+
+
+
+ Cross-provider data is not available for this framework yet.
+
+
+
+ );
+ }
+
+ const crossProviderData = (
+ crossProviderResponse as {
+ data?: CrossProviderComplianceOverviewData;
+ }
+ ).data;
+
+ if (!crossProviderData) {
+ return (
+
+
+
+
+ No cross-provider compliance data was returned for this framework.
+
+
+
+ );
+ }
+
+ const headerTitle = crossProviderData.attributes.name || crossProviderTitle;
+ return (
+
+
+
+ );
+ }
+
// Create a key that excludes pagination parameters to preserve accordion state avoiding reloads with pagination
const paramsForKey = Object.fromEntries(
Object.entries(resolvedSearchParams).filter(
diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx
index d4ee609a0c..5228120b11 100644
--- a/ui/app/(prowler)/compliance/page.tsx
+++ b/ui/app/(prowler)/compliance/page.tsx
@@ -4,11 +4,17 @@ import { Suspense } from "react";
import {
getComplianceOverviewMetadataInfo,
getCompliancesOverview,
+ getCrossProviderComplianceOverview,
} from "@/actions/compliances";
import { getThreatScore } from "@/actions/overview";
import { getScans } from "@/actions/scans";
import {
+ COMPLIANCE_PAGE_TAB,
+ CompliancePageTabs,
ComplianceSkeletonGrid,
+ type CrossProviderFrameworkSummary,
+ CrossProviderGrid,
+ getCompliancePageTab,
NoScansAvailable,
ThreatScoreBadge,
} from "@/components/compliance";
@@ -18,13 +24,18 @@ import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Card, CardContent } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/ui";
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
+import { UNIVERSAL_FRAMEWORKS } from "@/lib/compliance/universal-frameworks";
+import { isCloud } from "@/lib/shared/env";
import {
ExpandedScanData,
ScanEntity,
ScanProps,
SearchParamsProps,
} from "@/types";
-import { ComplianceOverviewData } from "@/types/compliance";
+import {
+ ComplianceOverviewData,
+ CrossProviderComplianceOverviewData,
+} from "@/types/compliance";
export default async function Compliance({
searchParams,
@@ -34,6 +45,14 @@ export default async function Compliance({
const resolvedSearchParams = await searchParams;
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
+ // Cross-Provider is a Prowler Cloud-only feature; the OSS API has no
+ // cross-provider-compliance-overviews endpoint. In OSS the tab is shown
+ // disabled with an upsell badge and the per-scan tab is forced active.
+ const crossProviderEnabled = isCloud();
+ const activeTab = crossProviderEnabled
+ ? getCompliancePageTab(resolvedSearchParams.tab)
+ : COMPLIANCE_PAGE_TAB.PER_SCAN;
+
const scansData = await getScans({
filters: {
"filter[state]": "completed",
@@ -140,54 +159,76 @@ export default async function Compliance({
}
}
+ const perScanContent = selectedScanId ? (
+ <>
+
+
+
+
+ {threatScoreData &&
+ typeof selectedScanId === "string" &&
+ selectedScan && (
+
+
+
+ )}
+
+
+
+
+ }
+ >
+
+
+ >
+ ) : (
+
+ );
+
+ // Only build (and thus fetch) the cross-provider grid in Cloud. In OSS the
+ // tab is disabled, so there is no content to render and no endpoint to hit.
+ const crossProviderContent = crossProviderEnabled ? (
+
+
+
+ }
+ >
+
+
+ ) : null;
+
return (
- {selectedScanId ? (
- <>
-
-
-
-
- {threatScoreData &&
- typeof selectedScanId === "string" &&
- selectedScan && (
-
-
-
- )}
-
-
-
-
- }
- >
-
-
- >
- ) : (
-
- )}
+
);
}
@@ -280,3 +321,91 @@ const ComplianceOverviewPanel = ({
);
};
+
+/**
+ * Server-side island for the Cross-Provider tab.
+ *
+ * Iterates the hardcoded ``UNIVERSAL_FRAMEWORKS`` catalogue and fetches the
+ * cross-provider roll-up for each one in parallel. The summaries hydrate the
+ * grid of cards so the user sees per-framework totals without an extra
+ * client round-trip. Today there is a single entry (CSA CCM 4.0); when the
+ * SDK ships more universal JSONs, only the catalogue file changes.
+ */
+const SSRCrossProviderGrid = async ({
+ searchParams,
+}: {
+ searchParams: SearchParamsProps;
+}) => {
+ const providerTypes =
+ searchParams["filter[provider_type__in]"]?.toString() || undefined;
+ const regions = searchParams["filter[region__in]"]?.toString() || undefined;
+
+ const responses = await Promise.all(
+ UNIVERSAL_FRAMEWORKS.map((entry) =>
+ getCrossProviderComplianceOverview({
+ complianceId: entry.id,
+ providerTypes,
+ regions,
+ }).then((response) => ({ entry, response })),
+ ),
+ );
+
+ const summaries: CrossProviderFrameworkSummary[] = [];
+ for (const { entry, response } of responses) {
+ if (!response || "redirectTo" in response) continue;
+ const data = (response as { data?: CrossProviderComplianceOverviewData })
+ .data;
+ if (!data) {
+ // Catalogue entry exists but the API returned nothing usable —
+ // surface a zero-card so the user still sees the framework with all
+ // its compatible providers chips dimmed (no scan yet).
+ summaries.push({
+ id: entry.id,
+ title: entry.title,
+ version: entry.version,
+ description: entry.description,
+ requirementsPassed: 0,
+ totalRequirements: 0,
+ contributingProviders: [],
+ compatibleProviders: entry.providers,
+ });
+ continue;
+ }
+ const attrs = data.attributes;
+ // ``compatible_providers`` from the API is authoritative; fall back to
+ // the catalogue entry only if the response omitted it.
+ const compatible =
+ attrs.compatible_providers && attrs.compatible_providers.length > 0
+ ? attrs.compatible_providers
+ : entry.providers;
+ summaries.push({
+ id: entry.id,
+ title: attrs.framework || entry.title,
+ version: attrs.version || entry.version,
+ description: attrs.description || entry.description,
+ requirementsPassed: attrs.requirements_passed,
+ totalRequirements: attrs.total_requirements,
+ contributingProviders: attrs.providers,
+ compatibleProviders: compatible,
+ });
+ }
+
+ if (summaries.length === 0) {
+ return (
+
+
+
+
+ No universal compliance frameworks are available yet.
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.test.ts b/ui/components/compliance/compliance-accordion/client-accordion-content.test.ts
index 7f724e20b7..3de86c09fc 100644
--- a/ui/components/compliance/compliance-accordion/client-accordion-content.test.ts
+++ b/ui/components/compliance/compliance-accordion/client-accordion-content.test.ts
@@ -13,4 +13,27 @@ describe("client accordion content", () => {
expect(source).toContain("getStandaloneFindingColumns");
expect(source).not.toContain("getColumnFindings");
});
+
+ it("activates a cross-provider branch when scan_ids_by_provider is present", () => {
+ // The cross-provider mode is detected by the presence of the
+ // ``scan_ids_by_provider`` augmentation on the requirement. Guarding
+ // this contract in source keeps regressions visible without spinning a
+ // full DOM render. The reads happen through a single ``xprov`` cast
+ // (see ``CrossProviderRequirement``) so we look for both spellings.
+ expect(source).toMatch(/scan_ids_by_provider/);
+ expect(source).toMatch(/check_ids_by_provider/);
+ expect(source).toContain("CrossProviderRequirement");
+ });
+
+ it("fetches findings per contributing scan in parallel when in cross-provider mode", () => {
+ expect(source).toContain("Promise.all");
+ // Each parallel request scopes to a single scan and the
+ // requirement's per-provider check IDs.
+ expect(source).toMatch(/filter\[scan\]/);
+ expect(source).toMatch(/filter\[check_id__in\]/);
+ });
+
+ it("renders a per-provider breakdown table when providers are exposed", () => {
+ expect(source).toContain("Per-Provider Breakdown");
+ });
});
diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx
index 0ccd793c12..d0e8f4b9a7 100644
--- a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx
+++ b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx
@@ -2,7 +2,7 @@
import { AlertTriangle } from "lucide-react";
import { useSearchParams } from "next/navigation";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { getFindings } from "@/actions/findings/findings";
import {
@@ -12,10 +12,19 @@ import {
import { Alert, AlertDescription } from "@/components/shadcn";
import { Accordion } from "@/components/ui/accordion/Accordion";
import { DataTable } from "@/components/ui/table";
+import { StatusFindingBadge } from "@/components/ui/table/status-finding-badge";
import { createDict, FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
-import { Requirement } from "@/types/compliance";
+import {
+ getProviderBadge,
+ getProviderLabel,
+} from "@/lib/providers/provider-display";
+import {
+ CrossProviderRequirement,
+ Requirement,
+ RequirementStatus,
+} from "@/types/compliance";
import { FindingProps, FindingsResponse } from "@/types/components";
interface ClientAccordionContentProps {
@@ -25,6 +34,19 @@ interface ClientAccordionContentProps {
disableFindings?: boolean;
}
+// ``included`` is part of the JSON:API envelope but the ``FindingsResponse``
+// interface only models ``data`` + ``meta``. Carry it locally so ``createDict``
+// (which inspects ``data.included`` at runtime) can resolve the
+// provider/scan/resource relationships per row.
+type FindingsResponseLike = FindingsResponse & {
+ included?: { type: string; id: string }[];
+};
+
+const toFindingStatus = (status: RequirementStatus) => {
+ // FindingStatus shares the same wire values for PASS/FAIL/MANUAL.
+ return status === "No findings" ? "MANUAL" : status;
+};
+
export const ClientAccordionContent = ({
requirement,
framework,
@@ -32,7 +54,6 @@ export const ClientAccordionContent = ({
disableFindings = false,
}: ClientAccordionContentProps) => {
const [findings, setFindings] = useState(null);
- const [expandedFindings, setExpandedFindings] = useState([]);
const searchParams = useSearchParams();
const pageNumber = searchParams.get("page") || "1";
const pageSize = searchParams.get("pageSize") || "10";
@@ -50,71 +71,148 @@ export const ClientAccordionContent = ({
// surface in the app (findings page, resource drawer, overview widgets).
const mutedFilter = searchParams.get("filter[muted]") || MUTED_FILTER.EXCLUDE;
+ // Cross-provider requirements carry these augmentation maps; per-scan
+ // requirements leave them undefined. Narrow once at the seam so the
+ // hot paths below don't need repeated casts.
+ const xprov = requirement as CrossProviderRequirement;
+ const scanIdsByProvider = xprov.scan_ids_by_provider;
+ const checkIdsByProvider = xprov.check_ids_by_provider;
+ const providersBreakdown = xprov.providers;
+ const isCrossProvider =
+ !!scanIdsByProvider && Object.keys(scanIdsByProvider).length > 0;
+
useEffect(() => {
+ // Guard against a slower earlier request resolving after a newer one and
+ // clobbering the table (race on fast page/sort/filter changes).
+ let cancelled = false;
+
async function loadFindings() {
+ if (disableFindings || requirement.status === "No findings") return;
if (
- !disableFindings &&
- requirement.check_ids?.length > 0 &&
- requirement.status !== "No findings" &&
- (loadedPageRef.current !== pageNumber ||
- loadedPageSizeRef.current !== pageSize ||
- loadedSortRef.current !== sort ||
- loadedMutedRef.current !== mutedFilter ||
- !isExpandedRef.current)
+ loadedPageRef.current === pageNumber &&
+ loadedPageSizeRef.current === pageSize &&
+ loadedSortRef.current === sort &&
+ loadedMutedRef.current === mutedFilter &&
+ isExpandedRef.current
) {
- loadedPageRef.current = pageNumber;
- loadedPageSizeRef.current = pageSize;
- loadedSortRef.current = sort;
- loadedMutedRef.current = mutedFilter;
- isExpandedRef.current = true;
+ return;
+ }
- try {
- const checkIds = requirement.check_ids;
- const encodedSort = sort.replace(/^\+/, "");
- const findingsData = await getFindings({
- filters: {
- "filter[check_id__in]": checkIds.join(","),
- "filter[scan]": scanId,
- "filter[muted]": mutedFilter,
- ...(region && { "filter[region__in]": region }),
- },
- page: parseInt(pageNumber, 10),
- pageSize: parseInt(pageSize, 10),
- sort: encodedSort,
+ loadedPageRef.current = pageNumber;
+ loadedPageSizeRef.current = pageSize;
+ loadedSortRef.current = sort;
+ loadedMutedRef.current = mutedFilter;
+ isExpandedRef.current = true;
+
+ try {
+ const encodedSort = sort.replace(/^\+/, "");
+
+ if (isCrossProvider) {
+ // Fetch findings scoped to each contributing scan in parallel
+ // and merge the JSON:API ``data`` + ``included`` arrays so
+ // the unified table can resolve the provider/scan/resource
+ // relationships per row. Server-side filters apply per scan
+ // (the API enforces RLS on each query individually).
+ //
+ // ``scanIdsByProvider[providerKey]`` is a list because a
+ // tenant can have N accounts of the same type — fan out one
+ // ``filter[scan]`` request per account, all under the same
+ // provider key.
+ const entries = Object.entries(scanIdsByProvider!);
+ const jobs = entries.flatMap(([providerKey, scanIds]) => {
+ const checks = (checkIdsByProvider?.[providerKey] ?? []).join(",");
+ if (!checks || !Array.isArray(scanIds) || scanIds.length === 0) {
+ return [];
+ }
+ return scanIds.map((scanIdForAccount) => ({
+ providerKey,
+ scanIdForAccount,
+ checks,
+ }));
});
+ const responses = await Promise.all(
+ jobs.map(({ scanIdForAccount, checks }) =>
+ getFindings({
+ filters: {
+ "filter[check_id__in]": checks,
+ "filter[scan]": scanIdForAccount,
+ "filter[muted]": mutedFilter,
+ ...(region && { "filter[region__in]": region }),
+ },
+ page: parseInt(pageNumber, 10),
+ sort: encodedSort,
+ }),
+ ),
+ );
- setFindings(findingsData);
-
- if (findingsData?.data) {
- // Create dictionaries for resources, scans, and providers
- const resourceDict = createDict("resources", findingsData);
- const scanDict = createDict("scans", findingsData);
- const providerDict = createDict("providers", findingsData);
-
- // Expand each finding with its corresponding resource, scan, and provider
- const expandedData = findingsData.data.map(
- (finding: FindingProps) => {
- const scan = scanDict[finding.relationships?.scan?.data?.id];
- const resource =
- resourceDict[finding.relationships?.resources?.data?.[0]?.id];
- const provider =
- providerDict[scan?.relationships?.provider?.data?.id];
-
- return {
- ...finding,
- relationships: { scan, resource, provider },
- };
- },
- );
- setExpandedFindings(expandedData);
+ const allData: FindingProps[] = [];
+ const allIncluded: { type: string; id: string }[] = [];
+ let totalCount = 0;
+ for (const r of responses) {
+ if (!r || !("data" in r)) continue;
+ const typedResponse = r as FindingsResponseLike;
+ allData.push(...(typedResponse.data || []));
+ allIncluded.push(...(typedResponse.included || []));
+ totalCount += typedResponse?.meta?.pagination?.count || 0;
}
- } catch (error) {
- console.error("Error loading findings:", error);
+
+ // Each scan response includes its provider/scan record;
+ // across N responses the same provider object appears N
+ // times. Dedupe by ``(type, id)`` so the subsequent
+ // ``createDict`` passes stop allocating duplicate entries.
+ const dedupedIncluded: typeof allIncluded = [];
+ const seenIncluded = new Set();
+ for (const entry of allIncluded) {
+ const key = `${entry.type}|${entry.id}`;
+ if (seenIncluded.has(key)) continue;
+ seenIncluded.add(key);
+ dedupedIncluded.push(entry);
+ }
+
+ const merged: FindingsResponseLike = {
+ data: allData,
+ included: dedupedIncluded,
+ meta: {
+ pagination: {
+ page: parseInt(pageNumber, 10),
+ pages: 1,
+ count: totalCount,
+ },
+ version: "",
+ },
+ };
+ if (cancelled) return;
+ setFindings(merged);
+ return;
}
+
+ // Per-scan branch (existing behaviour).
+ if (!requirement.check_ids?.length) return;
+ const checkIds = requirement.check_ids;
+ const findingsData = await getFindings({
+ filters: {
+ "filter[check_id__in]": checkIds.join(","),
+ "filter[scan]": scanId,
+ "filter[muted]": mutedFilter,
+ ...(region && { "filter[region__in]": region }),
+ },
+ page: parseInt(pageNumber, 10),
+ pageSize: parseInt(pageSize, 10),
+ sort: encodedSort,
+ });
+
+ if (cancelled) return;
+ setFindings(findingsData);
+ } catch (error) {
+ console.error("Error loading findings:", error);
}
}
loadFindings();
+
+ return () => {
+ cancelled = true;
+ };
}, [
requirement,
scanId,
@@ -124,8 +222,62 @@ export const ClientAccordionContent = ({
region,
mutedFilter,
disableFindings,
+ isCrossProvider,
+ scanIdsByProvider,
+ checkIdsByProvider,
]);
+ // Expand each finding with its resource/scan/provider. Derived from
+ // ``findings`` rather than stored as separate state so the table can never
+ // drift out of sync with the fetched rows.
+ const expandedFindings = useMemo(() => {
+ if (!findings?.data) return [];
+ const resourceDict = createDict("resources", findings);
+ const scanDict = createDict("scans", findings);
+ const providerDict = createDict("providers", findings);
+ return findings.data.map((finding: FindingProps) => {
+ const scan = scanDict[finding.relationships?.scan?.data?.id];
+ const resource =
+ resourceDict[finding.relationships?.resources?.data?.[0]?.id];
+ const provider = providerDict[scan?.relationships?.provider?.data?.id];
+ return {
+ ...finding,
+ relationships: { scan, resource, provider },
+ };
+ }) as unknown as FindingProps[];
+ }, [findings]);
+
+ // Per-provider finding tallies for the cross-provider breakdown. Derived
+ // from the merged ``findings`` (mapping each row to its provider via
+ // ``scan_ids_by_provider``) so the counts always match the unified table.
+ const providerFindingStats = useMemo(() => {
+ const count: Record = {};
+ const pass: Record = {};
+ const fail: Record = {};
+ if (!isCrossProvider || !scanIdsByProvider) {
+ return { count, pass, fail };
+ }
+ const scanToProvider = new Map();
+ for (const [providerKey, scanIds] of Object.entries(scanIdsByProvider)) {
+ count[providerKey] = 0;
+ pass[providerKey] = 0;
+ fail[providerKey] = 0;
+ if (Array.isArray(scanIds)) {
+ for (const sid of scanIds) scanToProvider.set(sid, providerKey);
+ }
+ }
+ for (const row of findings?.data ?? []) {
+ const sid = row.relationships?.scan?.data?.id;
+ const providerKey = sid ? scanToProvider.get(sid) : undefined;
+ if (!providerKey) continue;
+ count[providerKey] += 1;
+ const status = row.attributes?.status;
+ if (status === "PASS") pass[providerKey] += 1;
+ else if (status === "FAIL") fail[providerKey] += 1;
+ }
+ return { count, pass, fail };
+ }, [findings, isCrossProvider, scanIdsByProvider]);
+
const renderDetails = () => {
if (!complianceId) {
return null;
@@ -137,10 +289,104 @@ export const ClientAccordionContent = ({
return {detailsComponent}
;
};
+ const renderProviderBreakdown = () => {
+ if (!providersBreakdown) return null;
+ const entries = Object.entries(providersBreakdown);
+ if (entries.length === 0) return null;
+ // ``findings`` is null until the lazy fetch resolves; in that case
+ // surface a neutral placeholder so the user does not see ``0`` and
+ // mistake it for "no findings". Once findings load, the count maps
+ // are the authoritative source — they match the unified table below
+ // row-for-row.
+ const findingsLoaded = findings !== null;
+ return (
+
+
Per-Provider Breakdown
+
+
+
+
+ Provider
+ Status
+ Findings
+ Pass / Fail
+ Scan ID
+
+
+
+ {entries.map(([providerKey, providerStatus]) => {
+ const label = getProviderLabel(providerKey);
+ const scanIdsForProvider =
+ scanIdsByProvider?.[providerKey] ?? [];
+ const accountCount = scanIdsForProvider.length;
+ const findingsCount = providerFindingStats.count[providerKey];
+ const passCount = providerFindingStats.pass[providerKey] ?? 0;
+ const failCount = providerFindingStats.fail[providerKey] ?? 0;
+ return (
+
+
+
+ {label}
+ {accountCount > 1 && (
+
+ {accountCount} accounts
+
+ )}
+
+
+
+
+
+
+ {findingsLoaded ? (findingsCount ?? 0) : "—"}
+
+
+ {findingsLoaded ? (
+
+ {passCount}
+
+ {" / "}
+
+ {failCount}
+
+ ) : (
+ —
+ )}
+
+
+ {accountCount === 0 ? (
+ "—"
+ ) : (
+
+ {scanIdsForProvider.map((sid) => (
+ {sid}
+ ))}
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+ );
+ };
+
if (disableFindings) {
return (
{renderDetails()}
+ {renderProviderBreakdown()}
⚠️ This requirement has no checks; therefore, there are no findings.
@@ -149,7 +395,58 @@ export const ClientAccordionContent = ({
}
const checks = requirement.check_ids || [];
- const checksList = (
+ // In cross-provider mode the universal framework declares the same
+ // requirement against multiple providers, often with disjoint check
+ // sets. Show a per-provider grouping so the user can audit which checks
+ // belong to which scan instead of staring at a flattened comma list.
+ // Per-scan mode keeps the original flat layout — there's only one
+ // provider, so a grouping would be visual noise.
+ const checkIdsByProviderEntries = checkIdsByProvider
+ ? Object.entries(checkIdsByProvider).filter(
+ ([, ids]) => Array.isArray(ids) && ids.length > 0,
+ )
+ : [];
+ const showPerProviderChecks =
+ isCrossProvider && checkIdsByProviderEntries.length > 0;
+
+ const checksList = showPerProviderChecks ? (
+
+ {checkIdsByProviderEntries.map(([providerKey, ids], idx) => {
+ const label = getProviderLabel(providerKey);
+ const Badge = getProviderBadge(providerKey);
+ return (
+
0
+ ? "border-t border-gray-200 pt-3 dark:border-gray-800"
+ : ""
+ }`}
+ >
+
+ {Badge ? : null}
+
+ {label}
+
+
+ {ids.length} {ids.length === 1 ? "check" : "checks"}
+
+
+
+ {ids.map((id) => (
+
+ {id}
+
+ ))}
+
+
+ );
+ })}
+
+ ) : (
@@ -211,6 +508,8 @@ export const ClientAccordionContent = ({
{renderDetails()}
+ {renderProviderBreakdown()}
+
{checks.length > 0 && (
;
}
+const STATUS_DOT_CLASS_BY_STATUS: Record = {
+ PASS: "bg-bg-pass",
+ FAIL: "bg-bg-fail",
+ MANUAL: "bg-text-neutral-secondary",
+ "No findings": "bg-text-neutral-secondary",
+};
+
export const ComplianceAccordionRequirementTitle = ({
type,
name,
status,
invalidConfig = false,
+ providers,
}: ComplianceAccordionRequirementTitleProps) => {
+ const providerEntries = providers ? Object.entries(providers) : [];
+
return (
-
-
+
+
{type && (
{type}
)}
- {name}
+ {name}
{invalidConfig && }
-
+
+ {providerEntries.length > 0 && (
+
+ {providerEntries.map(([providerKey, providerStatus]) => {
+ const Badge = getProviderBadge(providerKey);
+ const label = getProviderLabel(providerKey);
+ return (
+
+ {Badge ? : null}
+
+ {label}
+
+
+
+ );
+ })}
+
+ )}
+
+
);
};
diff --git a/ui/components/compliance/compliance-custom-details/cis-details.tsx b/ui/components/compliance/compliance-custom-details/cis-details.tsx
index 25823038d5..d8a2d0fe25 100644
--- a/ui/components/compliance/compliance-custom-details/cis-details.tsx
+++ b/ui/components/compliance/compliance-custom-details/cis-details.tsx
@@ -16,9 +16,7 @@ interface CISDetailsProps {
}
export const CISCustomDetails = ({ requirement }: CISDetailsProps) => {
- const processReferences = (
- references: string | number | boolean | string[] | object[] | undefined,
- ): string[] => {
+ const processReferences = (references: unknown): string[] => {
if (typeof references !== "string") return [];
// Use regex to extract all URLs that start with https://
diff --git a/ui/components/compliance/compliance-page-tabs.shared.ts b/ui/components/compliance/compliance-page-tabs.shared.ts
new file mode 100644
index 0000000000..57ec60b8e1
--- /dev/null
+++ b/ui/components/compliance/compliance-page-tabs.shared.ts
@@ -0,0 +1,25 @@
+const COMPLIANCE_PAGE_TAB = {
+ PER_SCAN: "per-scan",
+ CROSS_PROVIDER: "cross-provider",
+} as const;
+
+type CompliancePageTab =
+ (typeof COMPLIANCE_PAGE_TAB)[keyof typeof COMPLIANCE_PAGE_TAB];
+
+function isCompliancePageTab(value: string): value is CompliancePageTab {
+ return Object.values(COMPLIANCE_PAGE_TAB).includes(
+ value as CompliancePageTab,
+ );
+}
+
+function getCompliancePageTab(
+ value: string | string[] | undefined,
+): CompliancePageTab {
+ if (typeof value !== "string") {
+ return COMPLIANCE_PAGE_TAB.PER_SCAN;
+ }
+ return isCompliancePageTab(value) ? value : COMPLIANCE_PAGE_TAB.PER_SCAN;
+}
+
+export type { CompliancePageTab };
+export { COMPLIANCE_PAGE_TAB, getCompliancePageTab };
diff --git a/ui/components/compliance/compliance-page-tabs.test.tsx b/ui/components/compliance/compliance-page-tabs.test.tsx
new file mode 100644
index 0000000000..05dd3ae45e
--- /dev/null
+++ b/ui/components/compliance/compliance-page-tabs.test.tsx
@@ -0,0 +1,42 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+describe("CompliancePageTabs", () => {
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
+ const tabsSource = readFileSync(
+ path.join(currentDir, "compliance-page-tabs.tsx"),
+ "utf8",
+ );
+ const sharedSource = readFileSync(
+ path.join(currentDir, "compliance-page-tabs.shared.ts"),
+ "utf8",
+ );
+
+ it("declares the two tab keys used across the page", () => {
+ expect(sharedSource).toContain("per-scan");
+ expect(sharedSource).toContain("cross-provider");
+ });
+
+ it("defaults to per-scan when the URL has no tab param", () => {
+ expect(sharedSource).toContain("PER_SCAN");
+ expect(sharedSource).toMatch(
+ /getCompliancePageTab\([\s\S]*\): CompliancePageTab/,
+ );
+ });
+
+ it("uses URL-based state via Next router push", () => {
+ expect(tabsSource).toContain("useRouter");
+ expect(tabsSource).toContain("router.push");
+ // Per-scan is the canonical default — leaving the tab param off keeps the
+ // existing bookmarks working.
+ expect(tabsSource).toContain('params.delete("tab")');
+ });
+
+ it("exposes both content slots so RSC payload composes server-side", () => {
+ expect(tabsSource).toContain("perScanContent");
+ expect(tabsSource).toContain("crossProviderContent");
+ });
+});
diff --git a/ui/components/compliance/compliance-page-tabs.tsx b/ui/components/compliance/compliance-page-tabs.tsx
new file mode 100644
index 0000000000..1b7940fa7f
--- /dev/null
+++ b/ui/components/compliance/compliance-page-tabs.tsx
@@ -0,0 +1,146 @@
+"use client";
+
+import { useRouter, useSearchParams } from "next/navigation";
+import { ReactNode } from "react";
+
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/shadcn";
+import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge";
+
+import {
+ COMPLIANCE_PAGE_TAB,
+ type CompliancePageTab,
+} from "./compliance-page-tabs.shared";
+
+interface CompliancePageTabsProps {
+ activeTab: CompliancePageTab;
+ perScanContent: ReactNode;
+ crossProviderContent: ReactNode;
+ /** Cross-Provider is a Prowler Cloud-only feature (the OSS API has no
+ * ``cross-provider-compliance-overviews`` endpoint). In OSS the tab is
+ * rendered disabled with the "Available in Prowler Cloud" upsell badge,
+ * mirroring how the sidebar gates Alerts/Scan Configuration. */
+ crossProviderEnabled?: boolean;
+}
+
+/**
+ * Top-level tab switcher for the compliance index page.
+ *
+ * URL-based state: the active tab is reflected in ``?tab=
``. ``per-scan``
+ * is the default and renders without the query param so existing bookmarks
+ * keep working. The ``cross-provider`` tab uses ``?tab=cross-provider``.
+ *
+ * Filter params unrelated to a specific tab (e.g. ``filter[region__in]``)
+ * survive tab switches; the per-scan-only ``scanId`` and the
+ * cross-provider-only ``filter[provider_type__in]`` are pruned when leaving
+ * their tab so the URL stays in sync with what the user can see.
+ */
+export const CompliancePageTabs = ({
+ activeTab,
+ perScanContent,
+ crossProviderContent,
+ crossProviderEnabled = true,
+}: CompliancePageTabsProps) => {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const handleTabChange = (next: string) => {
+ const target = next as CompliancePageTab;
+ if (target === activeTab) return;
+ // Cross-Provider is Cloud-only; ignore any attempt to switch to it in OSS.
+ if (
+ target === COMPLIANCE_PAGE_TAB.CROSS_PROVIDER &&
+ !crossProviderEnabled
+ ) {
+ return;
+ }
+
+ const params = new URLSearchParams(searchParams.toString());
+
+ if (target === COMPLIANCE_PAGE_TAB.PER_SCAN) {
+ params.delete("tab");
+ // provider_type__in only applies to cross-provider; drop it.
+ params.delete("filter[provider_type__in]");
+ } else {
+ params.set("tab", target);
+ // scanId is per-scan only; drop it when entering cross-provider.
+ params.delete("scanId");
+ }
+
+ const query = params.toString();
+ router.push(query ? `/compliance?${query}` : "/compliance");
+ };
+
+ return (
+
+
+
+
+
+
+ Per Scan
+
+
+
+ Detailed compliance results for a single scan against a specific
+ provider.
+
+
+
+
+ {/* The wrapper span (not the disabled TabsTrigger) is the hover
+ target so the tooltip still fires when the tab is disabled in
+ OSS. The span makes the trigger the first child of its own
+ element, which drops the TabsList's inter-tab ``pl-4`` and
+ keeps a trailing ``border-r``; restore the left padding so the
+ label clears the Per-Scan divider and drop the divider so the
+ cloud badge isn't crammed against it. */}
+
+
+ Cross-Provider
+
+ {!crossProviderEnabled && (
+
+ )}
+
+
+
+ {crossProviderEnabled
+ ? "Universal frameworks aggregated from the latest scan of every compatible provider in this tenant."
+ : "Available in Prowler Cloud"}
+
+
+
+
+
+
+ {perScanContent}
+
+
+ {crossProviderEnabled && (
+
+ {crossProviderContent}
+
+ )}
+
+ );
+};
diff --git a/ui/components/compliance/cross-provider/cross-provider-card.test.tsx b/ui/components/compliance/cross-provider/cross-provider-card.test.tsx
new file mode 100644
index 0000000000..e813ba024e
--- /dev/null
+++ b/ui/components/compliance/cross-provider/cross-provider-card.test.tsx
@@ -0,0 +1,37 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+describe("CrossProviderCard", () => {
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
+ const filePath = path.join(currentDir, "cross-provider-card.tsx");
+ const source = readFileSync(filePath, "utf8");
+
+ it("navigates to the universal detail page in cross-provider mode", () => {
+ // The drill-down must emit ?mode=cross-provider so the detail page knows
+ // to render the universal-aggregated view instead of the per-scan one.
+ expect(source).toContain('"mode"');
+ expect(source).toContain('"cross-provider"');
+ });
+
+ it("does not depend on a scanId prop", () => {
+ // The cross-provider tab must not require a scan picker.
+ expect(source).not.toMatch(/scanId/);
+ });
+
+ it("preserves provider_type and region filters when drilling in", () => {
+ expect(source).toContain('"filter[region__in]"');
+ expect(source).toContain('"filter[provider_type__in]"');
+ });
+
+ it("surfaces the providers contribution ratio via chips", () => {
+ // The card renders one chip per compatible provider and dims the ones
+ // that did not contribute to the aggregation. The ratio is implicit in
+ // the active/inactive state of the chip set.
+ expect(source).toContain("contributingProviders");
+ expect(source).toContain("compatibleProviders");
+ expect(source).toContain("ProviderChip");
+ });
+});
diff --git a/ui/components/compliance/cross-provider/cross-provider-card.tsx b/ui/components/compliance/cross-provider/cross-provider-card.tsx
new file mode 100644
index 0000000000..d391c7466a
--- /dev/null
+++ b/ui/components/compliance/cross-provider/cross-provider-card.tsx
@@ -0,0 +1,209 @@
+"use client";
+
+import { Check, Circle } from "lucide-react";
+import Image from "next/image";
+import { useRouter, useSearchParams } from "next/navigation";
+
+import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
+import { Card, CardContent } from "@/components/shadcn/card/card";
+import { Progress } from "@/components/shadcn/progress";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/shadcn/tooltip";
+import {
+ getScoreIndicatorClass,
+ type ScoreColorVariant,
+} from "@/lib/compliance/score-utils";
+import { cn } from "@/lib/utils";
+
+export interface CrossProviderCardProps {
+ /** Universal framework id (e.g. ``csa_ccm_4.0``). Used as the
+ * ``filter[compliance_id]`` value when navigating to the detail page. */
+ complianceId: string;
+ /** Display title — the framework name (e.g. ``CSA-CCM``). Resolved by
+ * ``getComplianceIcon`` to pick the matching logo asset. */
+ title: string;
+ version: string;
+ description?: string;
+ /** Roll-up totals returned by the API. */
+ requirementsPassed: number;
+ totalRequirements: number;
+ /** Provider keys (lowercase, e.g. "aws") that actually contributed scans
+ * to the aggregated view. Rendered as "active" chips on the card. */
+ contributingProviders: string[];
+ /** Catalogue of provider keys the universal framework declares checks
+ * for. Rendered as chips; the ones missing from
+ * ``contributingProviders`` are dimmed to signal "no scan yet". */
+ compatibleProviders: string[];
+}
+
+const formatTitle = (title: string) => title.split("-").join(" ");
+
+const getRatingVariant = (value: number): ScoreColorVariant => {
+ if (value <= 10) return "danger";
+ if (value <= 40) return "warning";
+ return "success";
+};
+
+interface ProviderChipProps {
+ providerKey: string;
+ active: boolean;
+}
+
+const ProviderChip = ({ providerKey, active }: ProviderChipProps) => {
+ const Icon = active ? Check : Circle;
+ return (
+
+
+ {providerKey}
+
+ );
+};
+
+export const CrossProviderCard: React.FC = ({
+ complianceId,
+ title,
+ version,
+ description,
+ requirementsPassed,
+ totalRequirements,
+ contributingProviders,
+ compatibleProviders,
+}) => {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+
+ const ratingPercentage =
+ totalRequirements > 0
+ ? Math.floor((requirementsPassed / totalRequirements) * 100)
+ : 0;
+
+ const navigateToDetail = () => {
+ const formattedTitleForUrl = encodeURIComponent(title);
+ const path = `/compliance/${formattedTitleForUrl}`;
+ const params = new URLSearchParams();
+
+ params.set("complianceId", complianceId);
+ params.set("version", version);
+ params.set("mode", "cross-provider");
+
+ // Preserve provider/region filters when drilling in.
+ const region = searchParams.get("filter[region__in]");
+ if (region) params.set("filter[region__in]", region);
+ const providerType = searchParams.get("filter[provider_type__in]");
+ if (providerType) params.set("filter[provider_type__in]", providerType);
+
+ router.push(`${path}?${params.toString()}`);
+ };
+
+ // Sorted, de-duplicated provider chip list. ``compatible_providers`` is
+ // authoritative; ``contributingProviders`` may include providers the
+ // framework does not declare (when callers pin scans via filter[scan__in]).
+ const contributingSet = new Set(
+ contributingProviders.map((p) => p.toLowerCase()),
+ );
+ const allChips = Array.from(
+ new Set([
+ ...compatibleProviders.map((p) => p.toLowerCase()),
+ ...contributingProviders.map((p) => p.toLowerCase()),
+ ]),
+ ).sort();
+
+ return (
+
+
+
+
+ {getComplianceIcon(title) && (
+
+
+
+ )}
+
+
+
+
+ {formatTitle(title)}
+ {version ? ` - ${version}` : ""}
+
+
+
+ {formatTitle(title)}
+ {version ? ` - ${version}` : ""}
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+
+
+
+ Cross-Provider Score:
+
+
+ {ratingPercentage}%
+
+
+
+
+
+ {requirementsPassed} / {totalRequirements}
+
+ Passing Requirements
+
+
+
+ {allChips.length > 0 && (
+
+ {allChips.map((providerKey) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+};
diff --git a/ui/components/compliance/cross-provider/cross-provider-detail-client.tsx b/ui/components/compliance/cross-provider/cross-provider-detail-client.tsx
new file mode 100644
index 0000000000..c9cf967e55
--- /dev/null
+++ b/ui/components/compliance/cross-provider/cross-provider-detail-client.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { useCallback, useMemo, useRef, useState } from "react";
+
+import { computeCrossProviderInsights } from "@/lib/compliance/cross-provider-insights";
+import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance";
+
+import { CrossProviderExplorerCard } from "./cross-provider-explorer-card";
+import { CrossProviderHeader } from "./cross-provider-header";
+
+interface CrossProviderDetailClientProps {
+ attributes: CrossProviderComplianceOverviewAttributes;
+}
+
+/**
+ * Client orchestrator that wires the redesigned 3-pane header to the
+ * explorer card hosting search + multiselect status + expand-all +
+ * shadcn accordion.
+ *
+ * Owns:
+ * - Memoised insights derived from the API response so the header
+ * and the accordion read pre-computed domain stats, score,
+ * provider coverage and top-failing rankings without re-iterating
+ * ``requirements`` themselves.
+ * - ``handleDomainSelect`` which turns a "Top Failing Domains" click into
+ * a forced-open section plus a scroll + flash on the matching anchor.
+ * Because it is a user action, the DOM work happens in the handler (a
+ * single ``requestAnimationFrame`` after the state update lets the
+ * target section commit) — not in a render-time effect.
+ *
+ * Drill-down stays inline (matching the per-scan compliance UX) so
+ * users get the same interaction across both tabs.
+ */
+export const CrossProviderDetailClient = ({
+ attributes,
+}: CrossProviderDetailClientProps) => {
+ const insights = useMemo(
+ () => computeCrossProviderInsights(attributes),
+ [attributes],
+ );
+
+ const accordionContainerRef = useRef(null);
+ const flashTimeoutRef = useRef | null>(null);
+ const [forcedExpandedSectionKey, setForcedExpandedSectionKey] = useState<
+ string | null
+ >(null);
+
+ const handleDomainSelect = useCallback(
+ (domainName: string) => {
+ // Setting the forced key expands the target section. Selecting a domain
+ // is a user action, so the scroll + flash run here (not in a reactive
+ // effect): a single rAF lets the expansion commit before we locate the
+ // anchor and bring it into view.
+ setForcedExpandedSectionKey(`${attributes.framework}-${domainName}`);
+
+ requestAnimationFrame(() => {
+ const container = accordionContainerRef.current;
+ if (!container) return;
+ const anchor = container.querySelector(
+ `[data-domain-anchor="${CSS.escape(domainName)}"]`,
+ );
+ if (!(anchor instanceof HTMLElement)) return;
+ anchor.scrollIntoView({ behavior: "smooth", block: "start" });
+ anchor.dataset.flash = "1";
+ if (flashTimeoutRef.current) clearTimeout(flashTimeoutRef.current);
+ flashTimeoutRef.current = setTimeout(() => {
+ delete anchor.dataset.flash;
+ flashTimeoutRef.current = null;
+ }, 1200);
+ });
+ },
+ [attributes.framework],
+ );
+
+ return (
+
+ );
+};
diff --git a/ui/components/compliance/cross-provider/cross-provider-detail.tsx b/ui/components/compliance/cross-provider/cross-provider-detail.tsx
new file mode 100644
index 0000000000..f8272c22f9
--- /dev/null
+++ b/ui/components/compliance/cross-provider/cross-provider-detail.tsx
@@ -0,0 +1,22 @@
+import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance";
+
+import { CrossProviderDetailClient } from "./cross-provider-detail-client";
+
+interface CrossProviderDetailProps {
+ attributes: CrossProviderComplianceOverviewAttributes;
+}
+
+/**
+ * Cross-provider compliance detail view.
+ *
+ * Server component shell — passes the API response straight through to
+ * the client orchestrator. The orchestrator owns interactive state
+ * (search term, status quick toggles, domain anchor scroll, drawer
+ * selection) so every panel of the redesigned 3-pane header stays in
+ * sync with the accordion below.
+ */
+export const CrossProviderDetail = ({
+ attributes,
+}: CrossProviderDetailProps) => {
+ return ;
+};
diff --git a/ui/components/compliance/cross-provider/cross-provider-domain-title.tsx b/ui/components/compliance/cross-provider/cross-provider-domain-title.tsx
new file mode 100644
index 0000000000..492bd6c42a
--- /dev/null
+++ b/ui/components/compliance/cross-provider/cross-provider-domain-title.tsx
@@ -0,0 +1,133 @@
+"use client";
+
+import type {
+ DomainProviderStatus,
+ DomainStats,
+} from "@/lib/compliance/cross-provider-insights";
+import {
+ getProviderBadge,
+ getProviderLabel,
+} from "@/lib/providers/provider-display";
+import { cn } from "@/lib/utils";
+
+const HEATMAP_CELL_BY_STATUS: Record = {
+ PASS: "bg-bg-pass border-bg-pass",
+ FAIL: "bg-bg-fail border-bg-fail",
+ MANUAL: "bg-bg-warning/70 border-bg-warning",
+ NO_ROW: "bg-default-200 border-border-neutral-secondary opacity-40",
+};
+
+const STRIPE_CLASS_BY_DOMINANT = (stats: DomainStats): string => {
+ if (stats.fail > 0) return "bg-bg-fail";
+ if (stats.pass > 0) return "bg-bg-pass";
+ if (stats.manual > 0) return "bg-bg-warning";
+ return "bg-default-300";
+};
+
+interface CrossProviderDomainTitleProps {
+ name: string;
+ stats: DomainStats;
+ compatibleProviders: string[];
+}
+
+/**
+ * Domain (section) row title that surfaces the per-provider heatmap and
+ * an at-a-glance failure stripe so the user can read 17 rows in one
+ * pass instead of expanding each one.
+ *
+ * Layout: [colored stripe] [domain name] [heatmap matrix] [stack bar]
+ * [counts]. The stripe color reflects worst-case status (fail > pass >
+ * manual > no-row); the heatmap matrix shows one cell per compatible
+ * provider — dimmed for non-contributing ones — with the precise
+ * rolled-up status surfaced via the native ``title`` attribute. We
+ * deliberately avoid Radix Tooltip here: 5 providers × 17 sections =
+ * 85 cells per page would mount 85 ``TooltipProvider`` + ``Root``
+ * pairs on initial render.
+ */
+export const CrossProviderDomainTitle = ({
+ name,
+ stats,
+ compatibleProviders,
+}: CrossProviderDomainTitleProps) => {
+ const total = Math.max(stats.total, 1);
+ const passPct = (stats.pass / total) * 100;
+ const failPct = (stats.fail / total) * 100;
+ const manualPct = (stats.manual / total) * 100;
+
+ return (
+
+
+
+
+
+ {name}
+
+
+ {stats.total} {stats.total === 1 ? "requirement" : "requirements"}
+
+
+
+
+ {compatibleProviders.map((providerKey) => {
+ const status: DomainProviderStatus =
+ stats.byProvider[providerKey] ?? "NO_ROW";
+ const Badge = getProviderBadge(providerKey);
+ const label = getProviderLabel(providerKey);
+ const tooltip = `${label}: ${status === "NO_ROW" ? "no scan" : status}`;
+ return (
+
+ {Badge ? : null}
+
+ );
+ })}
+
+
+
+ {stats.pass > 0 && (
+
+ )}
+ {stats.fail > 0 && (
+
+ )}
+ {stats.manual > 0 && (
+
+ )}
+
+
+
+ {stats.pass}
+ ·
+ {stats.fail}
+ ·
+ {stats.manual}
+
+
+
+ );
+};
diff --git a/ui/components/compliance/cross-provider/cross-provider-explorer-card.tsx b/ui/components/compliance/cross-provider/cross-provider-explorer-card.tsx
new file mode 100644
index 0000000000..11745a6865
--- /dev/null
+++ b/ui/components/compliance/cross-provider/cross-provider-explorer-card.tsx
@@ -0,0 +1,352 @@
+"use client";
+
+import { Maximize2, Minimize2, X } from "lucide-react";
+import { useCallback, useMemo, useState } from "react";
+
+import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
+import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/shadcn/accordion";
+import { Button } from "@/components/shadcn/button/button";
+import { Card, CardContent } from "@/components/shadcn/card/card";
+import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
+import { DataTableSearch } from "@/components/ui/table/data-table-search";
+import { FindingStatus } from "@/components/ui/table/status-finding-badge";
+import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
+import { crossProviderToMapperInput } from "@/lib/compliance/cross-provider-adapter";
+import type { CrossProviderInsights } from "@/lib/compliance/cross-provider-insights";
+import { cn } from "@/lib/utils";
+import type {
+ CrossProviderComplianceOverviewAttributes,
+ CrossProviderRequirement,
+ CrossProviderRequirementStatus,
+ Requirement,
+} from "@/types/compliance";
+
+import { CrossProviderDomainTitle } from "./cross-provider-domain-title";
+
+interface CrossProviderExplorerCardProps {
+ attributes: CrossProviderComplianceOverviewAttributes;
+ insights: CrossProviderInsights;
+ /** Section key (``${frameworkName}-${categoryName}``) the orchestrator
+ * wants forced-open. Driven by the "Top Failing Domains" panel so a
+ * click expands and scrolls to the target row. */
+ forcedExpandedSectionKey?: string | null;
+}
+
+const STATUS_OPTIONS: Array<{
+ label: string;
+ value: CrossProviderRequirementStatus;
+}> = [
+ { label: "Failing", value: "FAIL" },
+ { label: "Passing", value: "PASS" },
+ { label: "Manual", value: "MANUAL" },
+];
+
+/**
+ * One-stop explorer panel for a universal compliance framework. Wraps:
+ *
+ * - ``DataTableSearch`` (the same search component used by the per-scan
+ * compliance grid).
+ * - ``EnhancedMultiSelect`` for status quick-filters (no custom toggle
+ * pills — same component as roles/groups/findings forms).
+ * - Expand-all toggle that flips between expanding every section and
+ * collapsing back to user-selected state.
+ * - A shadcn ``Accordion`` (type=multiple) hosting Framework →
+ * Section → Requirement.
+ *
+ * The card owns the controlled state for search/filters/expansion so
+ * the orchestrator only feeds it ``attributes`` + ``insights`` plus an
+ * optional ``forcedExpandedSectionKey`` for the Top Failing Domains
+ * deep-link.
+ */
+export const CrossProviderExplorerCard = ({
+ attributes,
+ insights,
+ forcedExpandedSectionKey,
+}: CrossProviderExplorerCardProps) => {
+ const [searchTerm, setSearchTerm] = useState("");
+ const [searchKey, setSearchKey] = useState(0);
+ const [statusFilters, setStatusFilters] = useState<
+ CrossProviderRequirementStatus[]
+ >([]);
+ const [openSectionKeys, setOpenSectionKeys] = useState([]);
+ const [allSectionsOpen, setAllSectionsOpen] = useState(false);
+
+ // Filtered attributes drive both the match counter and the accordion
+ // content. The unfiltered ``insights`` continue to feed the heatmap
+ // matrix per section so global counts stay stable while the user
+ // narrows their search.
+ const filteredAttributes = useMemo(() => {
+ const lowerTerm = searchTerm.trim().toLowerCase();
+ if (lowerTerm === "" && statusFilters.length === 0) {
+ return attributes;
+ }
+ const statusSet = new Set(statusFilters);
+ const filteredRequirements = attributes.requirements.filter((req) => {
+ if (statusSet.size > 0 && !statusSet.has(req.status)) return false;
+ if (lowerTerm === "") return true;
+ const haystack =
+ `${req.id} ${req.name ?? ""} ${req.description ?? ""}`.toLowerCase();
+ return haystack.includes(lowerTerm);
+ });
+ return { ...attributes, requirements: filteredRequirements };
+ }, [attributes, searchTerm, statusFilters]);
+
+ const matchCount = filteredAttributes.requirements.length;
+ const totalCount = attributes.requirements.length;
+ const hasFilters = searchTerm.length > 0 || statusFilters.length > 0;
+
+ // Frameworks / categories / requirements derived from the mapper. We
+ // run this against the *filtered* attribute set so the list shrinks
+ // as the user types — sections with no surviving requirements
+ // disappear entirely instead of expanding to an empty body.
+ const { sections, allSectionKeys, statsByName } = useMemo(() => {
+ const mapper = getComplianceMapper(filteredAttributes.framework);
+ const { attributesData, requirementsData } =
+ crossProviderToMapperInput(filteredAttributes);
+ const frameworks = mapper.mapComplianceData(
+ attributesData,
+ requirementsData,
+ );
+ const stats = new Map(insights.domainStats.map((d) => [d.name, d]));
+ const allSections = frameworks.flatMap((fw) =>
+ fw.categories.map((category) => {
+ const requirements = category.controls.flatMap(
+ (control) => control.requirements,
+ );
+ return {
+ key: `${fw.name}-${category.name}`,
+ frameworkName: fw.name,
+ categoryName: category.name,
+ requirements,
+ };
+ }),
+ );
+ return {
+ sections: allSections,
+ allSectionKeys: allSections.map((s) => s.key),
+ statsByName: stats,
+ };
+ }, [filteredAttributes, insights]);
+
+ const expandedKeys = useMemo(() => {
+ if (allSectionsOpen) return allSectionKeys;
+ const merged = new Set(openSectionKeys);
+ if (forcedExpandedSectionKey) merged.add(forcedExpandedSectionKey);
+ return Array.from(merged);
+ }, [
+ allSectionsOpen,
+ allSectionKeys,
+ openSectionKeys,
+ forcedExpandedSectionKey,
+ ]);
+
+ const handleAccordionChange = useCallback((next: string[]) => {
+ // Manually expanding/collapsing exits "all open" mode so the
+ // user's last interaction is the source of truth.
+ setAllSectionsOpen(false);
+ setOpenSectionKeys(next);
+ }, []);
+
+ const handleToggleAll = useCallback(() => {
+ setAllSectionsOpen((prev) => {
+ const next = !prev;
+ setOpenSectionKeys(next ? allSectionKeys : []);
+ return next;
+ });
+ }, [allSectionKeys]);
+
+ const handleResetFilters = useCallback(() => {
+ setSearchTerm("");
+ // Cycling the controlled-input key flushes ``DataTableSearch``'s
+ // internal debounce so a stale keystroke doesn't fire on top of
+ // the just-cleared term.
+ setSearchKey((k) => k + 1);
+ setStatusFilters([]);
+ }, []);
+
+ return (
+
+
+ {/*
+ Toolbar: search on the left, filters + expand-all on the right.
+ ``flex-wrap`` keeps every control inside the card on narrow
+ viewports — the "Expand all" button used to overflow because
+ the previous ``flex-row`` had no wrap fallback.
+ */}
+
+
+
+
+ {matchCount}/{totalCount}
+
+
+ {/*
+ Right-hand block stays cohesive: Expand-all sits immediately
+ before the status multiselect so the user reads the action
+ ("expand") next to the data scope it acts on ("statuses").
+ The whole block wraps to a new row as a unit when the
+ viewport is narrow — never the Expand-all on its own.
+
+ ``min-w-0`` + ``max-w-full`` on the inner block make sure
+ the multiselect (which has an intrinsic min-width) stays
+ inside the Card padding instead of bleeding past the
+ right border on intermediate viewports.
+ */}
+
+
+ {allSectionsOpen ? (
+
+ ) : (
+
+ )}
+ {allSectionsOpen ? "Collapse all" : "Expand all"}
+
+
+ setStatusFilters(next as CrossProviderRequirementStatus[])
+ }
+ placeholder="All statuses"
+ hideSelectAll
+ maxCount={3}
+ searchable={false}
+ className="w-[180px] max-w-full"
+ aria-label="Filter requirements by status"
+ />
+ {hasFilters && (
+
+
+ Reset
+
+ )}
+
+
+
+ {sections.length === 0 ? (
+
+ No requirements match the current filters.
+
+ ) : (
+
+ {sections.map((section) => {
+ const stats = statsByName.get(section.categoryName);
+ return (
+
+
+ {stats ? (
+
+ ) : (
+ {section.categoryName}
+ )}
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+};
+
+interface SectionRequirementsProps {
+ sectionKey: string;
+ framework: string;
+ requirements: Requirement[];
+}
+
+/** Inner accordion for the requirements of a section. Reuses
+ * ``ClientAccordionContent`` (the same expand body the per-scan tab
+ * renders) so per-provider breakdown table, checks list and findings
+ * table behave identically across both tabs. */
+const SectionRequirements = ({
+ sectionKey,
+ framework,
+ requirements,
+}: SectionRequirementsProps) => {
+ if (requirements.length === 0) return null;
+ return (
+
+ {requirements.map((requirement, idx) => {
+ const xprov = requirement as CrossProviderRequirement;
+ const itemKey = `${sectionKey}-req-${idx}`;
+ return (
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ );
+};
diff --git a/ui/components/compliance/cross-provider/cross-provider-grid.tsx b/ui/components/compliance/cross-provider/cross-provider-grid.tsx
new file mode 100644
index 0000000000..9b84f3912a
--- /dev/null
+++ b/ui/components/compliance/cross-provider/cross-provider-grid.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { useState } from "react";
+
+import { DataTableSearch } from "@/components/ui/table/data-table-search";
+
+import { CrossProviderCard } from "./cross-provider-card";
+
+export interface CrossProviderFrameworkSummary {
+ id: string;
+ title: string;
+ version: string;
+ description?: string;
+ requirementsPassed: number;
+ totalRequirements: number;
+ /** Providers that actually contributed at least one scan to the
+ * aggregated view — rendered as "active" chips on the card. */
+ contributingProviders: string[];
+ /** Catalogue of providers the universal framework declares checks for —
+ * rendered as "compatible" chips on the card; the ones not in
+ * ``contributingProviders`` are shown dimmed to signal "no scan yet". */
+ compatibleProviders: string[];
+}
+
+interface CrossProviderGridProps {
+ frameworks: CrossProviderFrameworkSummary[];
+}
+
+/**
+ * Grid of universal compliance frameworks shown under the "Cross-Provider"
+ * tab. Mirrors the per-scan ``ComplianceOverviewGrid`` layout (search input
+ * + responsive grid of cards) but each card aggregates the latest scan per
+ * compatible provider instead of showing a single scan.
+ */
+export const CrossProviderGrid = ({ frameworks }: CrossProviderGridProps) => {
+ const [searchTerm, setSearchTerm] = useState("");
+
+ const filteredFrameworks = frameworks.filter((framework) =>
+ framework.title.toLowerCase().includes(searchTerm.toLowerCase()),
+ );
+
+ return (
+ <>
+
+
+
+ {filteredFrameworks.length.toLocaleString()} Total Entries
+
+
+
+
+ {filteredFrameworks.map((framework) => (
+
+ ))}
+
+ >
+ );
+};
diff --git a/ui/components/compliance/cross-provider/cross-provider-header.tsx b/ui/components/compliance/cross-provider/cross-provider-header.tsx
new file mode 100644
index 0000000000..2c4233cb3f
--- /dev/null
+++ b/ui/components/compliance/cross-provider/cross-provider-header.tsx
@@ -0,0 +1,107 @@
+"use client";
+
+import Image from "next/image";
+
+import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
+import { Card, CardContent } from "@/components/shadcn/card/card";
+import type { CrossProviderInsights } from "@/lib/compliance/cross-provider-insights";
+
+import { ProviderCoveragePanel } from "./provider-coverage-panel";
+import { ScoreDonut } from "./score-donut";
+import { TopFailingDomainsPanel } from "./top-failing-domains-panel";
+
+interface CrossProviderHeaderProps {
+ /** Universal framework key (``CSA-CCM``). Drives the icon lookup. */
+ framework: string;
+ /** Display name. */
+ name: string;
+ /** Version label (``4.0``). */
+ version: string;
+ /** Long description from the universal JSON. */
+ description: string;
+ insights: CrossProviderInsights;
+ /** Click handler when a top-failing-domain entry is selected. */
+ onDomainSelect?: (domainName: string) => void;
+}
+
+const formatTitle = (title: string) => title.split("-").join(" ");
+
+/**
+ * Three-pane operations header for the cross-provider compliance view.
+ *
+ * Replaces the old single-card stat grid + provider strip with:
+ * 1. Score donut + pass/fail/manual breakdown.
+ * 2. Provider coverage list (every compatible provider with its
+ * per-provider score and a clear "no scan yet" marker).
+ * 3. Top failing domains teaser — clickable anchors so the user can
+ * jump straight to where it hurts.
+ *
+ * On screens narrower than ``lg`` the panes stack vertically. The
+ * framework metadata strip (icon + title + description) sits above the
+ * three columns to keep the cards equal-height.
+ */
+export const CrossProviderHeader = ({
+ framework,
+ name,
+ version,
+ description,
+ insights,
+ onDomainSelect,
+}: CrossProviderHeaderProps) => {
+ return (
+
+
+
+ {getComplianceIcon(framework) && (
+
+
+
+ )}
+
+
+
+ {name || formatTitle(framework)}
+
+ {version && (
+
+ v{version}
+
+ )}
+
+ Universal
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/ui/components/compliance/cross-provider/index.ts b/ui/components/compliance/cross-provider/index.ts
new file mode 100644
index 0000000000..96b74492e3
--- /dev/null
+++ b/ui/components/compliance/cross-provider/index.ts
@@ -0,0 +1,4 @@
+export * from "./cross-provider-card";
+export * from "./cross-provider-detail";
+export * from "./cross-provider-explorer-card";
+export * from "./cross-provider-grid";
diff --git a/ui/components/compliance/cross-provider/provider-coverage-panel.tsx b/ui/components/compliance/cross-provider/provider-coverage-panel.tsx
new file mode 100644
index 0000000000..885acd36ad
--- /dev/null
+++ b/ui/components/compliance/cross-provider/provider-coverage-panel.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { Check, Slash } from "lucide-react";
+
+import type { ProviderCoverage } from "@/lib/compliance/cross-provider-insights";
+import {
+ getProviderBadge,
+ getProviderLabel,
+} from "@/lib/providers/provider-display";
+import { cn } from "@/lib/utils";
+
+interface ProviderCoveragePanelProps {
+ coverage: ProviderCoverage[];
+}
+
+/**
+ * Right-of-score panel listing every compatible provider with its
+ * per-provider score and a clear "scan available" / "no scan yet"
+ * marker. Keeps non-contributing providers visible (dimmed) instead of
+ * hiding them — the user needs to know coverage gaps as much as
+ * coverage itself.
+ */
+export const ProviderCoveragePanel = ({
+ coverage,
+}: ProviderCoveragePanelProps) => {
+ return (
+
+
+ Provider Coverage
+
+
+ {coverage.map((entry) => {
+ const Badge = getProviderBadge(entry.key);
+ const label = getProviderLabel(entry.key);
+ const Icon = entry.contributing ? Check : Slash;
+ const dimmed = !entry.contributing;
+ return (
+
+ {Badge ? : null}
+
+
+
+ {label}
+ {entry.accountCount > 1 && (
+
+ {entry.accountCount} accounts
+
+ )}
+
+ {entry.contributing ? (
+
+ {entry.scorePercent}%
+
+ ) : (
+
+ No scan
+
+ )}
+
+
+ {entry.contributing && entry.total > 0 ? (
+
+ ) : null}
+
+ {entry.contributing && (
+
+ {entry.pass} / {entry.total} pass
+ {entry.fail > 0 && (
+
+ {entry.fail} fail
+
+ )}
+
+ )}
+
+
+
+ );
+ })}
+
+
+ );
+};
diff --git a/ui/components/compliance/cross-provider/score-donut.tsx b/ui/components/compliance/cross-provider/score-donut.tsx
new file mode 100644
index 0000000000..bed565da85
--- /dev/null
+++ b/ui/components/compliance/cross-provider/score-donut.tsx
@@ -0,0 +1,166 @@
+interface ScoreDonutProps {
+ /** Integer 0-100. */
+ scorePercent: number;
+ /** Pass count. */
+ pass: number;
+ /** Fail count. */
+ fail: number;
+ /** Manual count. */
+ manual: number;
+ /** Total requirements. */
+ total: number;
+ /** Pixel size of the SVG square. Defaults to 132. */
+ size?: number;
+ /** Stroke thickness. Defaults to 12. */
+ stroke?: number;
+}
+
+/**
+ * Compact circular score with an explicit pass/fail/manual stack bar
+ * underneath. Pure SVG — no extra deps, scales cleanly in dark mode.
+ *
+ * The donut traces three arcs in succession (PASS → FAIL → MANUAL) so
+ * the user reads the breakdown directly off the ring instead of
+ * comparing it to a separate legend. The ring colors mirror the rest of
+ * the compliance UI (``bg-pass`` / ``bg-fail`` / ``bg-warning``).
+ */
+export const ScoreDonut = ({
+ scorePercent,
+ pass,
+ fail,
+ manual,
+ total,
+ size = 132,
+ stroke = 12,
+}: ScoreDonutProps) => {
+ const radius = (size - stroke) / 2;
+ const circumference = 2 * Math.PI * radius;
+ const safeTotal = total > 0 ? total : 1;
+
+ const passLen = (pass / safeTotal) * circumference;
+ const failLen = (fail / safeTotal) * circumference;
+ const manualLen = (manual / safeTotal) * circumference;
+ const passOffset = 0;
+ const failOffset = passLen;
+ const manualOffset = passLen + failLen;
+
+ const cx = size / 2;
+ const cy = size / 2;
+
+ // Pick a contrasting color for the central percent so dark surfaces
+ // never wash it out, while doubling as a quick "how bad is it?"
+ // signal: the central number adopts the dominant ring color.
+ const centerColorClass =
+ scorePercent >= 70
+ ? "fill-bg-pass"
+ : scorePercent >= 40
+ ? "fill-bg-warning"
+ : "fill-bg-fail";
+
+ return (
+
+
+ {/* Track */}
+
+ {/* Arcs — rotated so the trace starts at 12 o'clock */}
+
+ {pass > 0 && (
+
+ )}
+ {fail > 0 && (
+
+ )}
+ {manual > 0 && (
+
+ )}
+
+ {/* Center label */}
+
+ {scorePercent}%
+
+
+ {pass}/{total}
+
+
+
+
+
+ {pass}
+
+ Pass
+
+
+
+ {fail}
+
+ Fail
+
+
+
+ {manual}
+
+ Manual
+
+
+
+ );
+};
diff --git a/ui/components/compliance/cross-provider/top-failing-domains-panel.tsx b/ui/components/compliance/cross-provider/top-failing-domains-panel.tsx
new file mode 100644
index 0000000000..04d6a7e053
--- /dev/null
+++ b/ui/components/compliance/cross-provider/top-failing-domains-panel.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import { AlertTriangle, ChevronRight } from "lucide-react";
+
+import type { DomainStats } from "@/lib/compliance/cross-provider-insights";
+
+interface TopFailingDomainsPanelProps {
+ domains: DomainStats[];
+ /** Max entries to show. Defaults to 3. */
+ limit?: number;
+ /** Click handler that scroll-anchors to the matching domain row. */
+ onSelect?: (domainName: string) => void;
+}
+
+/**
+ * Top-N failing domains as a quick teaser. Each entry is a click
+ * shortcut: anchors the accordion to the corresponding section so the
+ * user does not have to scroll-scan 17 domain rows to find the one
+ * worth investigating first.
+ *
+ * When ``onSelect`` is omitted the entries render as static rows — the
+ * panel is still useful as a read-only summary for screenshots and
+ * exports.
+ */
+export const TopFailingDomainsPanel = ({
+ domains,
+ limit = 3,
+ onSelect,
+}: TopFailingDomainsPanelProps) => {
+ const top = domains.filter((d) => d.fail > 0).slice(0, limit);
+
+ return (
+
+
+ Top Failing Domains
+
+ {top.length === 0 ? (
+
+
+ No failing domains. Keep it up.
+
+
+ ) : (
+
+ {top.map((domain) => {
+ const Tag = onSelect ? "button" : "div";
+ return (
+
+ onSelect(domain.name) : undefined}
+ className={`border-border-neutral-secondary flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left transition-colors ${
+ onSelect
+ ? "hover:border-bg-fail/40 hover:bg-bg-fail/5 cursor-pointer"
+ : ""
+ }`}
+ >
+
+
+
+ {domain.name}
+
+
+ {domain.fail} fail · {domain.total} total
+
+
+ {onSelect && (
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+ );
+};
diff --git a/ui/components/compliance/index.ts b/ui/components/compliance/index.ts
index caea32e264..aabfe9b9c2 100644
--- a/ui/components/compliance/index.ts
+++ b/ui/components/compliance/index.ts
@@ -19,7 +19,10 @@ export * from "./compliance-header/compliance-scan-info";
export * from "./compliance-header/data-compliance";
export * from "./compliance-header/scan-selector";
export * from "./compliance-overview-grid";
+export * from "./compliance-page-tabs";
+export * from "./compliance-page-tabs.shared";
export * from "./compliance-warming";
+export * from "./cross-provider";
export * from "./no-scans-available";
export * from "./skeletons/bar-chart-skeleton";
export * from "./skeletons/compliance-accordion-skeleton";
diff --git a/ui/components/shadcn/accordion.tsx b/ui/components/shadcn/accordion.tsx
new file mode 100644
index 0000000000..ca12466078
--- /dev/null
+++ b/ui/components/shadcn/accordion.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import * as AccordionPrimitive from "@radix-ui/react-accordion";
+import { ChevronDown } from "lucide-react";
+import type { ComponentProps } from "react";
+
+import { cn } from "@/lib/utils";
+
+/**
+ * Shadcn Accordion bound to Radix primitives.
+ *
+ * Imported once and styled with Tailwind utilities; consumers (e.g.
+ * the cross-provider compliance accordion) pass children — there is
+ * intentionally no ``variant`` prop because the project surfaces a
+ * single accordion style. Headers carry the rotating chevron, content
+ * uses Radix's data-state animation hooks (``data-[state=open]`` /
+ * ``data-[state=closed]``) so each row collapses smoothly without
+ * extra JS.
+ */
+function Accordion({
+ ...props
+}: ComponentProps) {
+ return ;
+}
+
+function AccordionItem({
+ className,
+ ...props
+}: ComponentProps) {
+ return (
+
+ );
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: ComponentProps) {
+ return (
+
+ svg]:rotate-180",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+ );
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: ComponentProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
diff --git a/ui/components/shadcn/index.ts b/ui/components/shadcn/index.ts
index 18ac317ef5..0c6dd274a2 100644
--- a/ui/components/shadcn/index.ts
+++ b/ui/components/shadcn/index.ts
@@ -1,3 +1,4 @@
+export * from "./accordion";
export * from "./alert";
export * from "./badge/badge";
export * from "./button/button";
diff --git a/ui/dependency-log.json b/ui/dependency-log.json
index cabfa0b065..a17e1162d9 100644
--- a/ui/dependency-log.json
+++ b/ui/dependency-log.json
@@ -119,6 +119,14 @@
"strategy": "installed",
"generatedAt": "2026-06-15T07:49:41.143Z"
},
+ {
+ "section": "dependencies",
+ "name": "@radix-ui/react-accordion",
+ "from": "1.2.12",
+ "to": "1.2.12",
+ "strategy": "installed",
+ "generatedAt": "2026-06-30T13:26:29.681Z"
+ },
{
"section": "dependencies",
"name": "@radix-ui/react-alert-dialog",
diff --git a/ui/lib/compliance/cross-provider-adapter.test.ts b/ui/lib/compliance/cross-provider-adapter.test.ts
new file mode 100644
index 0000000000..86dbb3531e
--- /dev/null
+++ b/ui/lib/compliance/cross-provider-adapter.test.ts
@@ -0,0 +1,135 @@
+import { describe, expect, it } from "vitest";
+
+import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance";
+
+import { crossProviderToMapperInput } from "./cross-provider-adapter";
+
+const ATTRIBUTES: CrossProviderComplianceOverviewAttributes = {
+ compliance_id: "csa_ccm_4.0",
+ framework: "CSA-CCM",
+ name: "CSA-CCM",
+ version: "4.0",
+ description: "Cloud Security Alliance Cloud Controls Matrix",
+ compatible_providers: ["aws", "azure", "gcp"],
+ requested_providers: ["aws", "azure"],
+ providers: ["aws", "azure"],
+ scan_ids: ["scan-aws-uuid", "scan-azure-uuid"],
+ scan_ids_by_provider: {
+ aws: ["scan-aws-uuid"],
+ azure: ["scan-azure-uuid"],
+ },
+ requirements_passed: 1,
+ requirements_failed: 1,
+ requirements_manual: 0,
+ total_requirements: 2,
+ requirements: [
+ {
+ id: "AAA-01",
+ name: "Access Control Policy",
+ description: "Establish access policies",
+ attributes: {
+ Section: "Audit & Assurance",
+ CCMLite: "Yes",
+ IaaS: "Shared",
+ PaaS: "Shared",
+ SaaS: "Shared",
+ ScopeApplicability: [],
+ },
+ status: "PASS",
+ providers: { aws: "PASS", azure: "PASS" },
+ check_ids_by_provider: {
+ aws: ["aws_check_a", "aws_check_b"],
+ azure: ["azure_check_a", "aws_check_a"],
+ },
+ },
+ {
+ id: "AAA-02",
+ name: "Audit Independence",
+ description: "Maintain auditor independence",
+ attributes: {
+ Section: "Audit & Assurance",
+ CCMLite: "No",
+ IaaS: "Customer-Owned",
+ PaaS: "Customer-Owned",
+ SaaS: "Customer-Owned",
+ ScopeApplicability: [],
+ },
+ status: "FAIL",
+ providers: { aws: "FAIL" },
+ check_ids_by_provider: {
+ aws: ["aws_check_c"],
+ },
+ },
+ ],
+};
+
+describe("crossProviderToMapperInput", () => {
+ it("produces a paired AttributesData / RequirementsData structure", () => {
+ const { attributesData, requirementsData } =
+ crossProviderToMapperInput(ATTRIBUTES);
+ expect(attributesData.data).toHaveLength(2);
+ expect(requirementsData.data).toHaveLength(2);
+ expect(attributesData.data[0].id).toBe("AAA-01");
+ expect(requirementsData.data[0].id).toBe("AAA-01");
+ });
+
+ it("wraps the flat attributes dict in a single-element metadata array", () => {
+ const { attributesData } = crossProviderToMapperInput(ATTRIBUTES);
+ const metadata = attributesData.data[0].attributes.attributes.metadata as {
+ Section: string;
+ }[];
+ expect(metadata).toHaveLength(1);
+ expect(metadata[0].Section).toBe("Audit & Assurance");
+ });
+
+ it("computes the deduplicated union of check IDs across contributing providers", () => {
+ const { attributesData } = crossProviderToMapperInput(ATTRIBUTES);
+ const checks = attributesData.data[0].attributes.attributes.check_ids;
+ expect(checks).toEqual(
+ expect.arrayContaining(["aws_check_a", "aws_check_b", "azure_check_a"]),
+ );
+ // ``aws_check_a`` appears in both AWS and Azure lists; the union must
+ // emit it once so the inner ``Checks`` accordion does not duplicate.
+ expect(checks.filter((c) => c === "aws_check_a")).toHaveLength(1);
+ });
+
+ it("propagates per-provider context onto the inner attributes slot", () => {
+ const { attributesData } = crossProviderToMapperInput(ATTRIBUTES);
+ const inner = attributesData.data[0].attributes.attributes;
+ expect(inner.providers).toEqual({ aws: "PASS", azure: "PASS" });
+ expect(inner.check_ids_by_provider).toEqual({
+ aws: ["aws_check_a", "aws_check_b"],
+ azure: ["azure_check_a", "aws_check_a"],
+ });
+ // The scan map is global (shared across all requirements) so the
+ // adapter copies it onto every attribute item — that matches how the
+ // shared accordion machinery resolves it through ``Requirement``.
+ expect(inner.scan_ids_by_provider).toEqual({
+ aws: ["scan-aws-uuid"],
+ azure: ["scan-azure-uuid"],
+ });
+ });
+
+ it("preserves the rolled-up requirement status as the input for the per-scan mapper", () => {
+ const { requirementsData } = crossProviderToMapperInput(ATTRIBUTES);
+ expect(requirementsData.data[0].attributes.status).toBe("PASS");
+ expect(requirementsData.data[1].attributes.status).toBe("FAIL");
+ });
+
+ it("falls back to empty maps when the API omits the augmentation fields", () => {
+ const { attributesData } = crossProviderToMapperInput({
+ ...ATTRIBUTES,
+ scan_ids_by_provider: {},
+ requirements: [
+ {
+ ...ATTRIBUTES.requirements[0],
+ check_ids_by_provider: undefined,
+ },
+ ],
+ });
+ const inner = attributesData.data[0].attributes.attributes;
+ expect(inner.check_ids).toEqual([]);
+ expect(inner.check_ids_by_provider).toEqual({});
+ expect(inner.scan_ids_by_provider).toEqual({});
+ });
+});
diff --git a/ui/lib/compliance/cross-provider-adapter.ts b/ui/lib/compliance/cross-provider-adapter.ts
new file mode 100644
index 0000000000..2f2b3c8412
--- /dev/null
+++ b/ui/lib/compliance/cross-provider-adapter.ts
@@ -0,0 +1,96 @@
+import type {
+ AttributesData,
+ AttributesItemData,
+ CrossProviderComplianceOverviewAttributes,
+ RequirementItemData,
+ RequirementsData,
+} from "@/types/compliance";
+
+/**
+ * Convert a cross-provider compliance overview into the shape the per-scan
+ * compliance mappers expect.
+ *
+ * The per-scan flow consumes two parallel JSON:API responses:
+ * - ``AttributesData`` — per-requirement metadata (Section/CCMLite/...)
+ * plus the list of check IDs the requirement runs.
+ * - ``RequirementsData`` — per-requirement status for the selected scan.
+ *
+ * The cross-provider endpoint already exposes everything in a single
+ * resource where each requirement carries:
+ * - flat ``attributes`` (one element of the universal JSON metadata array
+ * wrapped in a list — the mappers read ``metadata[0]`` so this works).
+ * - rolled-up ``status`` derived server-side.
+ * - ``providers`` map (per-provider statuses that fed the roll-up).
+ * - ``check_ids_by_provider`` map (universal-framework-declared check
+ * IDs per contributing provider).
+ *
+ * This adapter rebuilds the two-payload pair so the existing CSA-CCM (and
+ * any future framework) mapper renders a hierarchical accordion without
+ * needing a parallel cross-provider pipeline.
+ *
+ * The returned ``AttributesItemData`` carries the cross-provider context
+ * (``providers``, ``check_ids_by_provider``, ``scan_ids_by_provider``) on
+ * its inner ``attributes.attributes`` slot. Mappers then copy those
+ * augmentations onto the ``Requirement`` literal so the renderers can
+ * decide whether to expose per-provider chips, breakdown tables, and
+ * per-scan finding queries.
+ */
+export const crossProviderToMapperInput = (
+ attrs: CrossProviderComplianceOverviewAttributes,
+): { attributesData: AttributesData; requirementsData: RequirementsData } => {
+ const scanIdsByProvider = attrs.scan_ids_by_provider || {};
+
+ const attributeItems: AttributesItemData[] = [];
+ const requirementItems: RequirementItemData[] = [];
+
+ for (const req of attrs.requirements) {
+ const checkIdsByProvider = req.check_ids_by_provider || {};
+ const allCheckIds = Array.from(
+ new Set(
+ Object.values(checkIdsByProvider).flatMap((ids) =>
+ Array.isArray(ids) ? ids : [],
+ ),
+ ),
+ );
+
+ attributeItems.push({
+ type: "compliance-requirements-attributes",
+ id: req.id,
+ attributes: {
+ framework_description: attrs.description || "",
+ name: req.name,
+ framework: attrs.framework,
+ version: attrs.version || "",
+ description: req.description || "",
+ attributes: {
+ // The mappers (e.g. CSA) read ``metadata[0]`` and pull
+ // framework-specific fields off it. Wrap the flat dict in a
+ // single-element list to satisfy that contract.
+ metadata: [
+ req.attributes as unknown as AttributesItemData["attributes"]["attributes"]["metadata"][number],
+ ] as AttributesItemData["attributes"]["attributes"]["metadata"],
+ check_ids: allCheckIds,
+ providers: req.providers,
+ check_ids_by_provider: checkIdsByProvider,
+ scan_ids_by_provider: scanIdsByProvider,
+ },
+ },
+ });
+
+ requirementItems.push({
+ type: "compliance-requirements-details",
+ id: req.id,
+ attributes: {
+ framework: attrs.framework,
+ version: attrs.version || "",
+ description: req.description || "",
+ status: req.status,
+ },
+ });
+ }
+
+ return {
+ attributesData: { data: attributeItems },
+ requirementsData: { data: requirementItems },
+ };
+};
diff --git a/ui/lib/compliance/cross-provider-insights.test.ts b/ui/lib/compliance/cross-provider-insights.test.ts
new file mode 100644
index 0000000000..8ebe754a12
--- /dev/null
+++ b/ui/lib/compliance/cross-provider-insights.test.ts
@@ -0,0 +1,190 @@
+import { describe, expect, it } from "vitest";
+
+import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance";
+
+import { computeCrossProviderInsights } from "./cross-provider-insights";
+
+const buildAttributes = (): CrossProviderComplianceOverviewAttributes => ({
+ compliance_id: "csa_ccm_4.0",
+ framework: "CSA-CCM",
+ name: "CSA-CCM",
+ version: "4.0",
+ description: "test",
+ compatible_providers: ["aws", "azure", "gcp"],
+ requested_providers: ["aws", "azure", "gcp"],
+ providers: ["aws", "azure"],
+ scan_ids: ["scan-aws", "scan-azure", "scan-gcp"],
+ scan_ids_by_provider: {
+ aws: ["scan-aws"],
+ azure: ["scan-azure"],
+ gcp: ["scan-gcp"],
+ },
+ requirements_passed: 2,
+ requirements_failed: 2,
+ requirements_manual: 1,
+ total_requirements: 5,
+ requirements: [
+ {
+ id: "AAA-01",
+ name: "A",
+ description: "",
+ attributes: { Section: "Audit" },
+ status: "PASS",
+ providers: { aws: "PASS", azure: "PASS" },
+ },
+ {
+ id: "AAA-02",
+ name: "B",
+ description: "",
+ attributes: { Section: "Audit" },
+ status: "FAIL",
+ providers: { aws: "FAIL", azure: "PASS" },
+ },
+ {
+ id: "DSP-01",
+ name: "C",
+ description: "",
+ attributes: { Section: "Data Sec" },
+ status: "FAIL",
+ providers: { aws: "FAIL" },
+ },
+ {
+ id: "DSP-02",
+ name: "D",
+ description: "",
+ attributes: { Section: "Data Sec" },
+ status: "MANUAL",
+ providers: {},
+ },
+ {
+ id: "OTHER-01",
+ name: "E",
+ description: "",
+ // No Section attribute — must land in the ``Other`` bucket.
+ attributes: {},
+ status: "PASS",
+ providers: { aws: "PASS" },
+ },
+ ],
+});
+
+describe("computeCrossProviderInsights", () => {
+ it("computes the score from passed/total", () => {
+ const insights = computeCrossProviderInsights(buildAttributes());
+ // 2 / 5 = 40
+ expect(insights.scorePercent).toBe(40);
+ expect(insights.pass).toBe(2);
+ expect(insights.fail).toBe(2);
+ expect(insights.manual).toBe(1);
+ expect(insights.total).toBe(5);
+ });
+
+ it("builds providerCoverage for every compatible provider, including non-contributing ones", () => {
+ const insights = computeCrossProviderInsights(buildAttributes());
+ const byKey = Object.fromEntries(
+ insights.providerCoverage.map((c) => [c.key, c]),
+ );
+ expect(byKey.aws.contributing).toBe(true);
+ expect(byKey.aws.scanIds).toEqual(["scan-aws"]);
+ expect(byKey.aws.accountCount).toBe(1);
+ // AWS contributed 4 rows: 2 PASS + 2 FAIL.
+ expect(byKey.aws.pass).toBe(2);
+ expect(byKey.aws.fail).toBe(2);
+ expect(byKey.aws.total).toBe(4);
+ expect(byKey.aws.scorePercent).toBe(50);
+
+ // Azure contributed 2 rows: both PASS → 100%.
+ expect(byKey.azure.contributing).toBe(true);
+ expect(byKey.azure.pass).toBe(2);
+ expect(byKey.azure.total).toBe(2);
+ expect(byKey.azure.scorePercent).toBe(100);
+
+ // GCP is in compatible_providers but the API marks it as
+ // non-contributing — surfaces with zeroed counts so the panel can
+ // dim it instead of hiding it.
+ expect(byKey.gcp.contributing).toBe(false);
+ expect(byKey.gcp.total).toBe(0);
+ expect(byKey.gcp.scorePercent).toBe(0);
+ });
+
+ it("aggregates per-domain stats by Section attribute, with an Other bucket fallback", () => {
+ const insights = computeCrossProviderInsights(buildAttributes());
+ const byName = Object.fromEntries(
+ insights.domainStats.map((d) => [d.name, d]),
+ );
+ expect(Object.keys(byName).sort()).toEqual(["Audit", "Data Sec", "Other"]);
+ expect(byName.Audit.total).toBe(2);
+ expect(byName.Audit.pass).toBe(1);
+ expect(byName.Audit.fail).toBe(1);
+ expect(byName["Data Sec"].fail).toBe(1);
+ expect(byName["Data Sec"].manual).toBe(1);
+ // Section-less requirements must not silently disappear.
+ expect(byName.Other.total).toBe(1);
+ });
+
+ it("rolls each domain's per-provider status with FAIL > PASS > MANUAL > NO_ROW", () => {
+ const insights = computeCrossProviderInsights(buildAttributes());
+ const audit = insights.domainStats.find((d) => d.name === "Audit");
+ if (!audit) throw new Error("Audit domain missing");
+ // AWS contributed PASS + FAIL → FAIL.
+ expect(audit.byProvider.aws).toBe("FAIL");
+ // Azure contributed PASS + PASS → PASS.
+ expect(audit.byProvider.azure).toBe("PASS");
+ // GCP did not contribute any row in the Audit domain.
+ expect(audit.byProvider.gcp).toBe("NO_ROW");
+
+ const dataSec = insights.domainStats.find((d) => d.name === "Data Sec");
+ if (!dataSec) throw new Error("Data Sec missing");
+ expect(dataSec.byProvider.aws).toBe("FAIL");
+ expect(dataSec.byProvider.azure).toBe("NO_ROW");
+ });
+
+ it("orders domainsByFailCount descending", () => {
+ const insights = computeCrossProviderInsights(buildAttributes());
+ expect(insights.domainsByFailCount.map((d) => d.name)).toEqual([
+ "Audit",
+ "Data Sec",
+ "Other",
+ ]);
+ expect(insights.domainsByFailCount[0].fail).toBeGreaterThanOrEqual(
+ insights.domainsByFailCount[1].fail,
+ );
+ });
+
+ it("surfaces accountCount > 1 when a provider type has N accounts", () => {
+ const multiAccount: CrossProviderComplianceOverviewAttributes = {
+ ...buildAttributes(),
+ // Same provider type, two distinct scan UUIDs (= two accounts).
+ scan_ids_by_provider: {
+ aws: ["scan-aws-prod", "scan-aws-dev"],
+ azure: ["scan-azure"],
+ gcp: ["scan-gcp"],
+ },
+ scan_ids: ["scan-aws-prod", "scan-aws-dev", "scan-azure", "scan-gcp"],
+ };
+ const insights = computeCrossProviderInsights(multiAccount);
+ const aws = insights.providerCoverage.find((c) => c.key === "aws");
+ if (!aws) throw new Error("aws coverage missing");
+ expect(aws.scanIds).toEqual(["scan-aws-prod", "scan-aws-dev"]);
+ expect(aws.accountCount).toBe(2);
+ });
+
+ it("returns a stable zero-state when total_requirements is 0", () => {
+ const empty: CrossProviderComplianceOverviewAttributes = {
+ ...buildAttributes(),
+ requirements: [],
+ requirements_passed: 0,
+ requirements_failed: 0,
+ requirements_manual: 0,
+ total_requirements: 0,
+ providers: [],
+ };
+ const insights = computeCrossProviderInsights(empty);
+ expect(insights.scorePercent).toBe(0);
+ expect(insights.domainStats).toEqual([]);
+ expect(insights.domainsByFailCount).toEqual([]);
+ // Compatible providers still surface (empty stats) so the coverage
+ // panel doesn't disappear.
+ expect(insights.providerCoverage).toHaveLength(3);
+ });
+});
diff --git a/ui/lib/compliance/cross-provider-insights.ts b/ui/lib/compliance/cross-provider-insights.ts
new file mode 100644
index 0000000000..59af692d4e
--- /dev/null
+++ b/ui/lib/compliance/cross-provider-insights.ts
@@ -0,0 +1,215 @@
+import type {
+ CrossProviderComplianceOverviewAttributes,
+ CrossProviderRequirementStatus,
+} from "@/types/compliance";
+
+/**
+ * Status a provider contributes to a single domain when its rows are
+ * aggregated. ``NO_ROW`` means the universal framework does not declare
+ * any check for that provider in the domain (or the scan returned no
+ * rows for it) — visually rendered as dimmed so contributing-but-failing
+ * providers stay distinguishable from non-contributing ones.
+ */
+export type DomainProviderStatus = CrossProviderRequirementStatus | "NO_ROW";
+
+export interface ProviderCoverage {
+ /** Lowercase provider key (``aws``, ``azure``, ...). */
+ key: string;
+ /** Whether this provider contributed at least one row. */
+ contributing: boolean;
+ /** Scan UUIDs associated with this provider type. A list because a
+ * tenant can have N accounts of the same type — the cross-provider
+ * endpoint aggregates one scan per Provider row. */
+ scanIds: string[];
+ /** PASS count across all requirements. */
+ pass: number;
+ /** FAIL count across all requirements. */
+ fail: number;
+ /** Total requirements for which the provider contributed a row. */
+ total: number;
+ /** ``pass / total`` rounded to integer percent (0 when total = 0). */
+ scorePercent: number;
+ /** Number of distinct accounts (Provider rows) of this type whose
+ * scans contribute to the aggregation. ``0`` for non-contributing
+ * providers. */
+ accountCount: number;
+}
+
+export interface DomainStats {
+ /** Section name (e.g. ``Audit & Assurance``). */
+ name: string;
+ /** Total requirements grouped under this domain. */
+ total: number;
+ pass: number;
+ fail: number;
+ manual: number;
+ /** Per-provider rolled-up status across all requirements in this
+ * domain. ``FAIL`` if any req under this provider failed; ``PASS``
+ * if at least one passed and none failed; ``MANUAL`` if every
+ * contributing row is manual; ``NO_ROW`` if the provider never
+ * contributed a row to this domain. */
+ byProvider: Record;
+}
+
+export interface CrossProviderInsights {
+ /** ``requirements_passed / total_requirements`` as integer percent. */
+ scorePercent: number;
+ pass: number;
+ fail: number;
+ manual: number;
+ total: number;
+ /** Compatible providers from the API, in display order. */
+ compatibleProviders: string[];
+ /** Provider keys that contributed at least one row. */
+ contributingProviders: string[];
+ /** One coverage entry per compatible provider (always present, even
+ * for non-contributing ones — coverage UI dims them rather than
+ * hiding them). */
+ providerCoverage: ProviderCoverage[];
+ /** Domain stats keyed by section name, in declared order. */
+ domainStats: DomainStats[];
+ /** Top N domains by ``fail`` count, descending. ``N`` defaults to 3
+ * in the consumer but the helper exposes the full list. */
+ domainsByFailCount: DomainStats[];
+}
+
+/**
+ * Roll-up rule for a single provider's contribution to a domain (one
+ * step coarser than the per-requirement roll-up the API computes).
+ *
+ * Mirrors the API's "FAIL > PASS > MANUAL" ordering so the domain row
+ * surfaces the worst-case provider status without re-fetching anything.
+ */
+const aggregateDomainProviderStatuses = (
+ statuses: CrossProviderRequirementStatus[],
+): DomainProviderStatus => {
+ if (statuses.length === 0) return "NO_ROW";
+ if (statuses.some((s) => s === "FAIL")) return "FAIL";
+ if (statuses.some((s) => s === "PASS")) return "PASS";
+ return "MANUAL";
+};
+
+/**
+ * Pull every derived figure the cross-provider header + accordion
+ * components need out of a single response payload. Centralises the
+ * iteration so each consumer (donut, coverage list, heatmap rows, top
+ * failing teaser) runs over ``requirements`` once instead of N times.
+ */
+export const computeCrossProviderInsights = (
+ attributes: CrossProviderComplianceOverviewAttributes,
+): CrossProviderInsights => {
+ const {
+ compatible_providers: compatible,
+ providers: contributing,
+ scan_ids_by_provider: scanIdsByProvider,
+ requirements,
+ requirements_passed: pass,
+ requirements_failed: fail,
+ requirements_manual: manual,
+ total_requirements: total,
+ } = attributes;
+
+ const scorePercent = total > 0 ? Math.floor((pass / total) * 100) : 0;
+
+ const providerPass = new Map();
+ const providerFail = new Map();
+ const providerTotal = new Map();
+
+ // Domain accumulators are keyed by section name. We only know the
+ // section once we read ``req.attributes.Section`` — fall back to a
+ // generic bucket so requirements without the field still surface
+ // somewhere instead of silently dropping out.
+ const domainAcc = new Map<
+ string,
+ {
+ pass: number;
+ fail: number;
+ manual: number;
+ total: number;
+ perProvider: Map;
+ }
+ >();
+
+ for (const req of requirements) {
+ const section =
+ (req.attributes as { Section?: string } | undefined)?.Section || "Other";
+ let domain = domainAcc.get(section);
+ if (!domain) {
+ domain = {
+ pass: 0,
+ fail: 0,
+ manual: 0,
+ total: 0,
+ perProvider: new Map(),
+ };
+ domainAcc.set(section, domain);
+ }
+ domain.total += 1;
+ if (req.status === "PASS") domain.pass += 1;
+ else if (req.status === "FAIL") domain.fail += 1;
+ else domain.manual += 1;
+
+ for (const [providerKey, status] of Object.entries(req.providers)) {
+ providerTotal.set(providerKey, (providerTotal.get(providerKey) || 0) + 1);
+ if (status === "PASS") {
+ providerPass.set(providerKey, (providerPass.get(providerKey) || 0) + 1);
+ } else if (status === "FAIL") {
+ providerFail.set(providerKey, (providerFail.get(providerKey) || 0) + 1);
+ }
+ const list = domain.perProvider.get(providerKey) ?? [];
+ list.push(status);
+ domain.perProvider.set(providerKey, list);
+ }
+ }
+
+ const contributingSet = new Set(contributing);
+ const providerCoverage: ProviderCoverage[] = compatible.map((key) => {
+ const total = providerTotal.get(key) || 0;
+ const pass = providerPass.get(key) || 0;
+ const scorePct = total > 0 ? Math.floor((pass / total) * 100) : 0;
+ const scanIds = scanIdsByProvider?.[key] ?? [];
+ return {
+ key,
+ contributing: contributingSet.has(key),
+ scanIds,
+ accountCount: scanIds.length,
+ pass,
+ fail: providerFail.get(key) || 0,
+ total,
+ scorePercent: scorePct,
+ };
+ });
+
+ const domainStats: DomainStats[] = Array.from(domainAcc.entries()).map(
+ ([name, acc]) => {
+ const byProvider: Record = {};
+ for (const providerKey of compatible) {
+ const statuses = acc.perProvider.get(providerKey) ?? [];
+ byProvider[providerKey] = aggregateDomainProviderStatuses(statuses);
+ }
+ return {
+ name,
+ total: acc.total,
+ pass: acc.pass,
+ fail: acc.fail,
+ manual: acc.manual,
+ byProvider,
+ };
+ },
+ );
+
+ const domainsByFailCount = [...domainStats].sort((a, b) => b.fail - a.fail);
+
+ return {
+ scorePercent,
+ pass,
+ fail,
+ manual,
+ total,
+ compatibleProviders: compatible,
+ contributingProviders: contributing,
+ providerCoverage,
+ domainStats,
+ domainsByFailCount,
+ };
+};
diff --git a/ui/lib/compliance/csa.tsx b/ui/lib/compliance/csa.tsx
index 1c82a30777..66e7301afe 100644
--- a/ui/lib/compliance/csa.tsx
+++ b/ui/lib/compliance/csa.tsx
@@ -6,6 +6,7 @@ import { AccordionItemProps } from "@/components/ui/accordion/Accordion";
import { FindingStatus } from "@/components/ui/table/status-finding-badge";
import {
AttributesData,
+ CrossProviderRequirement,
CSAAttributesMetadata,
Framework,
Requirement,
@@ -66,6 +67,14 @@ export const mapComplianceData = (
const description = attributeItem.attributes.description;
const status = requirementData.attributes.status || "";
const checks = attributeItem.attributes.attributes.check_ids || [];
+ // Optional cross-provider augmentations (only present when the attribute
+ // item came from ``crossProviderToMapperInput``).
+ const providersForRequirement =
+ attributeItem.attributes.attributes.providers;
+ const checkIdsByProvider =
+ attributeItem.attributes.attributes.check_ids_by_provider;
+ const scanIdsByProvider =
+ attributeItem.attributes.attributes.scan_ids_by_provider;
const framework = findOrCreateFramework(frameworks, frameworkName);
const category = findOrCreateCategory(framework.categories, categoryName);
@@ -73,7 +82,11 @@ export const mapComplianceData = (
const control = findOrCreateControl(category.controls, categoryName);
const finalStatus: RequirementStatus = status as RequirementStatus;
- const requirement: Requirement = {
+ // Build the cross-provider-augmented requirement; it structurally extends
+ // ``Requirement`` so the per-scan accordion pipeline accepts it without
+ // changes — the extra maps stay ``undefined`` when the input did not carry
+ // them.
+ const requirement: CrossProviderRequirement = {
name: requirementName ? `${id} - ${requirementName}` : id,
description,
status: finalStatus,
@@ -85,6 +98,9 @@ export const mapComplianceData = (
paas: attrs.PaaS,
saas: attrs.SaaS,
scope_applicability: attrs.ScopeApplicability,
+ providers: providersForRequirement,
+ check_ids_by_provider: checkIdsByProvider,
+ scan_ids_by_provider: scanIdsByProvider,
};
control.requirements.push(requirement);
@@ -124,6 +140,7 @@ export const toAccordionItems = (
name={requirement.name}
status={requirement.status as FindingStatus}
invalidConfig={requirement.invalid_config}
+ providers={(requirement as CrossProviderRequirement).providers}
/>
),
content: (
diff --git a/ui/lib/compliance/universal-frameworks.sync.test.ts b/ui/lib/compliance/universal-frameworks.sync.test.ts
new file mode 100644
index 0000000000..ca67fa2690
--- /dev/null
+++ b/ui/lib/compliance/universal-frameworks.sync.test.ts
@@ -0,0 +1,121 @@
+import { existsSync, readdirSync, readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+import { UNIVERSAL_FRAMEWORKS } from "./universal-frameworks";
+
+/**
+ * Guards the hardcoded ``UNIVERSAL_FRAMEWORKS`` catalogue against drifting out
+ * of sync with the SDK. The cross-provider tab iterates this catalogue to know
+ * which universal frameworks to roll up, but the SDK is the source of truth:
+ * every ``prowler/compliance/.json`` whose ``requirements[].checks``
+ * is a per-provider dict is a universal framework.
+ *
+ * This test mirrors ``ComplianceFramework.get_providers()`` in
+ * ``prowler/lib/check/compliance_models.py`` (it derives the compatible
+ * providers from the union of ``checks`` keys) so a new universal JSON — or a
+ * provider added to an existing one — fails CI until the catalogue is updated.
+ *
+ * ``ui/`` and ``prowler/`` are siblings in the monorepo, so resolve the SDK
+ * compliance dir three levels up from ``ui/lib/compliance/``.
+ */
+const currentDir = path.dirname(fileURLToPath(import.meta.url));
+const sdkComplianceDir = path.resolve(
+ currentDir,
+ "../../../prowler/compliance",
+);
+
+interface SdkUniversalFramework {
+ id: string;
+ providers: string[];
+}
+
+const readSdkUniversalFrameworks = (): SdkUniversalFramework[] => {
+ const frameworks: SdkUniversalFramework[] = [];
+
+ for (const file of readdirSync(sdkComplianceDir)) {
+ if (!file.endsWith(".json")) continue;
+
+ const raw = JSON.parse(
+ readFileSync(path.join(sdkComplianceDir, file), "utf8"),
+ );
+
+ // Universal frameworks use the lowercase ``requirements`` key (legacy
+ // per-provider JSONs live under prowler/compliance// and use
+ // ``Requirements``). A universal requirement carries ``checks`` as a dict
+ // keyed by provider.
+ const requirements = raw?.requirements;
+ if (!Array.isArray(requirements)) continue;
+
+ const providers = new Set();
+ let hasPerProviderChecks = false;
+ for (const req of requirements) {
+ const checks = req?.checks;
+ if (checks && typeof checks === "object" && !Array.isArray(checks)) {
+ hasPerProviderChecks = true;
+ for (const key of Object.keys(checks)) {
+ providers.add(key.toLowerCase());
+ }
+ }
+ }
+ if (!hasPerProviderChecks) continue;
+
+ frameworks.push({
+ id: file.replace(/\.json$/, ""),
+ providers: Array.from(providers).sort(),
+ });
+ }
+
+ return frameworks;
+};
+
+describe("UNIVERSAL_FRAMEWORKS stays in sync with the SDK", () => {
+ it("can locate the SDK compliance directory", () => {
+ // A broken path would make every assertion below vacuously pass, so fail
+ // loudly instead. This test requires the full monorepo checkout.
+ expect(
+ existsSync(sdkComplianceDir),
+ `SDK compliance dir not found at ${sdkComplianceDir}. This sync test ` +
+ "requires the monorepo checkout (ui/ and prowler/ as siblings).",
+ ).toBe(true);
+ });
+
+ const sdkUniversals = existsSync(sdkComplianceDir)
+ ? readSdkUniversalFrameworks()
+ : [];
+
+ it("discovers the known universal frameworks", () => {
+ // Backstop against the detection logic silently yielding an empty list.
+ expect(sdkUniversals.length).toBeGreaterThanOrEqual(3);
+ });
+
+ it.each(sdkUniversals.map((fw) => [fw.id, fw] as const))(
+ "lists %s with the providers the SDK declares",
+ (_id, fw) => {
+ const entry = UNIVERSAL_FRAMEWORKS.find((e) => e.id === fw.id);
+ expect(
+ entry,
+ `Universal framework "${fw.id}" exists in prowler/compliance/ but is ` +
+ "missing from UNIVERSAL_FRAMEWORKS " +
+ "(ui/lib/compliance/universal-frameworks.ts). Add an entry for it.",
+ ).toBeDefined();
+ expect(
+ Array.from(entry!.providers).sort(),
+ `Providers for "${fw.id}" are out of sync with the SDK checks dict.`,
+ ).toEqual(fw.providers);
+ },
+ );
+
+ it("does not list frameworks that no longer exist in the SDK", () => {
+ const sdkIds = new Set(sdkUniversals.map((fw) => fw.id));
+ for (const entry of UNIVERSAL_FRAMEWORKS) {
+ expect(
+ sdkIds.has(entry.id),
+ `UNIVERSAL_FRAMEWORKS lists "${entry.id}" but no matching universal ` +
+ "JSON exists in prowler/compliance/. Remove the stale entry.",
+ ).toBe(true);
+ }
+ });
+});
diff --git a/ui/lib/compliance/universal-frameworks.ts b/ui/lib/compliance/universal-frameworks.ts
new file mode 100644
index 0000000000..db29a29fd0
--- /dev/null
+++ b/ui/lib/compliance/universal-frameworks.ts
@@ -0,0 +1,94 @@
+/**
+ * Catalogue of universal compliance frameworks supported by the cross-provider
+ * compliance roll-up endpoint.
+ *
+ * A universal framework is a single compliance specification (e.g. CSA Cloud
+ * Controls Matrix v4.0) that declares per-provider check lists at the
+ * requirement level. The backend exposes one row per provider it covers under
+ * legacy ``_`` slugs while the universal id (without the
+ * provider suffix) is the canonical handle for cross-provider aggregation.
+ *
+ * The list is hardcoded today because there is no API endpoint listing
+ * universal framework ids. When a new universal JSON ships in
+ * ``prowler/compliance/.json`` (matching the schema described in
+ * ``docs/developer-guide/security-compliance-framework.mdx``), add an entry
+ * here.
+ */
+export interface UniversalFrameworkCatalogEntry {
+ /** Universal framework id used as ``filter[compliance_id]``. */
+ id: string;
+ /** Display title — kept aligned with the ``framework`` field exposed by the
+ * ``cross_provider`` endpoint so existing helpers (``getComplianceIcon``)
+ * resolve the same icon as the per-scan tab uses for the same framework. */
+ title: string;
+ /** Framework version surfaced in the card and detail header. */
+ version: string;
+ /** Short marketing-style description for the card subtitle. */
+ description: string;
+ /** Static list of providers the framework is documented to cover. The
+ * authoritative list at runtime is the ``compatible_providers`` field on
+ * the API response. */
+ providers: string[];
+}
+
+export const UNIVERSAL_FRAMEWORKS: UniversalFrameworkCatalogEntry[] = [
+ {
+ id: "csa_ccm_4.0",
+ title: "CSA-CCM",
+ version: "4.0",
+ description:
+ "CSA Cloud Controls Matrix (CCM) v4.0 — a cybersecurity control " +
+ "framework with 197 control objectives across 17 domains.",
+ providers: ["aws", "azure", "gcp", "alibabacloud", "oraclecloud"],
+ },
+ {
+ id: "cis_controls_8.1",
+ title: "CIS-Controls",
+ version: "8.1",
+ description:
+ "CIS Critical Security Controls v8.1 — a prioritized set of " +
+ "safeguards organized into 18 controls to mitigate the most " +
+ "prevalent cyber-attacks against systems and networks.",
+ providers: [
+ "aws",
+ "azure",
+ "gcp",
+ "m365",
+ "kubernetes",
+ "github",
+ "googleworkspace",
+ "okta",
+ "oraclecloud",
+ "alibabacloud",
+ "cloudflare",
+ "mongodbatlas",
+ "linode",
+ "openstack",
+ "stackit",
+ "nhn",
+ "scaleway",
+ "vercel",
+ ],
+ },
+ {
+ id: "dora_2022_2554",
+ title: "DORA",
+ version: "2022/2554",
+ description:
+ "Digital Operational Resilience Act (Regulation (EU) 2022/2554) — " +
+ "the EU framework for the digital operational resilience of the " +
+ "financial sector.",
+ providers: ["aws", "azure", "gcp", "alibabacloud", "cloudflare"],
+ },
+];
+
+export const UNIVERSAL_FRAMEWORK_IDS: ReadonlySet = new Set(
+ UNIVERSAL_FRAMEWORKS.map((f) => f.id),
+);
+
+export const isUniversalFrameworkId = (
+ complianceId: string | null | undefined,
+): boolean => {
+ if (!complianceId) return false;
+ return UNIVERSAL_FRAMEWORK_IDS.has(complianceId);
+};
diff --git a/ui/lib/providers/provider-display.ts b/ui/lib/providers/provider-display.ts
new file mode 100644
index 0000000000..0bc1b7dcbb
--- /dev/null
+++ b/ui/lib/providers/provider-display.ts
@@ -0,0 +1,52 @@
+import { type FC } from "react";
+
+import {
+ AlibabaCloudProviderBadge,
+ AWSProviderBadge,
+ AzureProviderBadge,
+ GCPProviderBadge,
+ KS8ProviderBadge,
+ M365ProviderBadge,
+ OracleCloudProviderBadge,
+} from "@/components/icons/providers-badge";
+import type { IconSvgProps } from "@/types/components";
+
+/**
+ * Single source of truth for provider display metadata. Kept in
+ * ``ui/lib/providers`` (not ``ui/lib/compliance``) so non-compliance
+ * surfaces (settings, scan list, finding drawer) can adopt it without
+ * fanning out duplicates the way the compliance accordion did.
+ *
+ * Keys are the lowercase provider strings the API emits in
+ * ``provider_type`` and ``providers[*]``. New providers must be added
+ * here once and consumers will pick them up automatically.
+ */
+export const PROVIDER_BADGE_BY_KEY: Record> = {
+ aws: AWSProviderBadge,
+ azure: AzureProviderBadge,
+ gcp: GCPProviderBadge,
+ alibabacloud: AlibabaCloudProviderBadge,
+ oraclecloud: OracleCloudProviderBadge,
+ kubernetes: KS8ProviderBadge,
+ m365: M365ProviderBadge,
+};
+
+export const PROVIDER_LABEL_BY_KEY: Record = {
+ aws: "AWS",
+ azure: "Azure",
+ gcp: "GCP",
+ alibabacloud: "Alibaba Cloud",
+ oraclecloud: "Oracle Cloud",
+ kubernetes: "Kubernetes",
+ m365: "Microsoft 365",
+};
+
+/** Resolve a provider label, falling back to an uppercased key. */
+export const getProviderLabel = (providerKey: string): string =>
+ PROVIDER_LABEL_BY_KEY[providerKey] ?? providerKey.toUpperCase();
+
+/** Resolve the badge component, returning ``undefined`` when no icon
+ * is registered for the given key (the consumer renders a fallback). */
+export const getProviderBadge = (
+ providerKey: string,
+): FC | undefined => PROVIDER_BADGE_BY_KEY[providerKey];
diff --git a/ui/package.json b/ui/package.json
index d65d329425..127f7cc787 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -50,6 +50,7 @@
"@langchain/openai": "1.4.5",
"@lezer/highlight": "1.2.3",
"@next/third-parties": "16.2.9",
+ "@radix-ui/react-accordion": "1.2.12",
"@radix-ui/react-alert-dialog": "1.1.14",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index 2026014298..9f227072aa 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -80,6 +80,9 @@ importers:
'@next/third-parties':
specifier: 16.2.9
version: 16.2.9(next@16.2.9(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
+ '@radix-ui/react-accordion':
+ specifier: 1.2.12
+ version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-alert-dialog':
specifier: 1.1.14
version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -2651,6 +2654,19 @@ packages:
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
+ '@radix-ui/react-accordion@1.2.12':
+ resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-alert-dialog@1.1.14':
resolution: {integrity: sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ==}
peerDependencies:
@@ -11536,6 +11552,23 @@ snapshots:
'@radix-ui/primitive@1.1.3': {}
+ '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.17)(react@19.2.7)
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ optionalDependencies:
+ '@types/react': 19.2.17
+ '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
'@radix-ui/react-alert-dialog@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.2
diff --git a/ui/styles/globals.css b/ui/styles/globals.css
index 56d8dc1d9e..62147cbf6c 100644
--- a/ui/styles/globals.css
+++ b/ui/styles/globals.css
@@ -429,3 +429,35 @@
user-select: text;
}
}
+
+/*
+ * Shadcn Accordion (Radix) animations.
+ *
+ * The shadcn ``Accordion`` content uses ``data-[state=open]`` and
+ * ``data-[state=closed]`` selectors that reach for ``animate-accordion-down``
+ * and ``animate-accordion-up``. Tailwind 4 picks them up via the
+ * ``--animate-*`` theme tokens declared in ``@theme``; the keyframes
+ * themselves live here so the project keeps a single source of truth.
+ */
+@theme {
+ --animate-accordion-down: accordion-down 0.2s ease-out;
+ --animate-accordion-up: accordion-up 0.2s ease-out;
+}
+
+@keyframes accordion-down {
+ from {
+ height: 0;
+ }
+ to {
+ height: var(--radix-accordion-content-height);
+ }
+}
+
+@keyframes accordion-up {
+ from {
+ height: var(--radix-accordion-content-height);
+ }
+ to {
+ height: 0;
+ }
+}
diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts
index 0ce94b2d17..d90dc9f1f8 100644
--- a/ui/types/compliance.ts
+++ b/ui/types/compliance.ts
@@ -43,11 +43,35 @@ export interface Requirement {
check_ids: string[];
// True when the FAIL is caused solely by an invalid scan config.
invalid_config?: boolean;
+ // Cross-provider augmentations — populated only when the requirement
+ // originates from the cross-provider mapper-input adapter; the per-scan
+ // mappers leave these undefined so existing renderers keep working
+ // untouched.
+ providers?: Record;
+ check_ids_by_provider?: Record;
+ scan_ids_by_provider?: Record;
// This is to allow any key to be added to the requirement object
// because each compliance has different keys
- [key: string]: string | string[] | number | boolean | object[] | undefined;
+ [key: string]:
+ | string
+ | string[]
+ | number
+ | boolean
+ | object[]
+ | Record
+ | Record
+ | Record
+ | undefined;
}
+/**
+ * Alias preserved for call-sites that want to spell out their intent when they
+ * consume the cross-provider augmentations specifically. The augmentations live
+ * on ``Requirement`` so per-scan and cross-provider code share the same nominal
+ * type.
+ */
+export type CrossProviderRequirement = Requirement;
+
export interface Control {
label: string;
pass: number;
@@ -444,6 +468,12 @@ export interface AttributesItemData {
platforms: string[];
technique_url: string;
};
+ // Cross-provider augmentations: only populated by the cross-provider
+ // mapper-input adapter. Per-scan attribute responses leave them
+ // undefined.
+ providers?: Record;
+ check_ids_by_provider?: Record;
+ scan_ids_by_provider?: Record;
};
};
}
@@ -485,3 +515,71 @@ export interface CategoryData {
totalRequirements: number;
failedRequirements: number;
}
+
+// Cross-provider compliance types
+//
+// Backed by GET /api/v1/cross-provider-compliance-overviews/ which aggregates
+// rows from one scan per compatible provider under a universal compliance
+// framework (e.g. csa_ccm_4.0). The roll-up is computed server-side
+// (FAIL > PASS > MANUAL).
+
+export const CROSS_PROVIDER_COMPLIANCE_TYPE =
+ "cross-provider-compliance-overviews" as const;
+
+export type CrossProviderRequirementStatus = "PASS" | "FAIL" | "MANUAL";
+
+export interface CrossProviderRequirementData {
+ id: string;
+ name: string;
+ description: string;
+ // Free-form metadata mirroring the universal JSON's per-requirement
+ // attributes (e.g. CSA CCM exposes Section, CCMLite, IaaS, PaaS, SaaS,
+ // ScopeApplicability).
+ attributes: Record;
+ // Rolled-up status for this requirement across providers.
+ status: CrossProviderRequirementStatus;
+ // Per-provider status that fed the roll-up. Keys match
+ // CrossProviderComplianceOverviewAttributes.providers.
+ providers: Record;
+ // Per-contributing-provider check IDs the universal framework declares
+ // for this requirement. The UI uses this map to scope
+ // ``filter[check_id__in]`` when fetching findings per scan.
+ check_ids_by_provider?: Record;
+}
+
+export interface CrossProviderComplianceOverviewAttributes {
+ compliance_id: string;
+ framework: string;
+ name: string;
+ version: string;
+ description: string;
+ // Catalogue of providers the universal framework declares checks for.
+ compatible_providers: string[];
+ // Provider types of the scans actually used as input.
+ requested_providers: string[];
+ // Providers that contributed at least one row after RBAC + filters.
+ providers: string[];
+ // Concrete scan UUIDs aggregated.
+ scan_ids: string[];
+ // Provider type → list of scan UUIDs the response was aggregated
+ // from. A list (not a single UUID) because a tenant can have N
+ // accounts of the same type — e.g. three AWS accounts contribute
+ // three scans, all keyed under ``"aws"``. The UI fans out one
+ // ``filter[scan]`` query per UUID when drilling into a requirement.
+ scan_ids_by_provider: Record;
+ requirements_passed: number;
+ requirements_failed: number;
+ requirements_manual: number;
+ total_requirements: number;
+ requirements: CrossProviderRequirementData[];
+}
+
+export interface CrossProviderComplianceOverviewData {
+ type: typeof CROSS_PROVIDER_COMPLIANCE_TYPE;
+ id: string; // universal framework name (e.g. "csa_ccm_4.0")
+ attributes: CrossProviderComplianceOverviewAttributes;
+}
+
+export interface CrossProviderComplianceOverviewResponse {
+ data: CrossProviderComplianceOverviewData;
+}