feat(ui): add cross-compliance provider view

This commit is contained in:
pedrooot
2026-06-30 17:26:20 +02:00
parent ed1fec8866
commit 16024dc6c2
38 changed files with 3495 additions and 109 deletions
+52
View File
@@ -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;
}
};
@@ -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 (
<ContentLayout title={crossProviderTitle}>
<Alert variant="info">
<Info className="size-4" />
<AlertDescription>
Cross-provider data is not available for this framework yet.
</AlertDescription>
</Alert>
</ContentLayout>
);
}
const crossProviderData = (
crossProviderResponse as {
data?: CrossProviderComplianceOverviewData;
}
).data;
if (!crossProviderData) {
return (
<ContentLayout title={crossProviderTitle}>
<Alert variant="info">
<Info className="size-4" />
<AlertDescription>
No cross-provider compliance data was returned for this framework.
</AlertDescription>
</Alert>
</ContentLayout>
);
}
const headerTitle = crossProviderData.attributes.name || crossProviderTitle;
return (
<ContentLayout title={headerTitle}>
<CrossProviderDetail attributes={crossProviderData.attributes} />
</ContentLayout>
);
}
// Create a key that excludes pagination parameters to preserve accordion state avoiding reloads with pagination
const paramsForKey = Object.fromEntries(
Object.entries(resolvedSearchParams).filter(
+138 -9
View File
@@ -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,13 +159,7 @@ export default async function Compliance({
}
}
return (
<ContentLayout
title="Compliance"
icon="lucide:shield-check"
onboardingAction={onboardingAction}
>
{selectedScanId ? (
const perScanContent = selectedScanId ? (
<>
<div className="mb-6">
<ComplianceFilters
@@ -187,7 +200,35 @@ export default async function Compliance({
</>
) : (
<NoScansAvailable />
)}
);
// 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 ? (
<Suspense
key={`cross-provider-${searchParamsKey}`}
fallback={
<ComplianceOverviewPanel>
<ComplianceSkeletonGrid />
</ComplianceOverviewPanel>
}
>
<SSRCrossProviderGrid searchParams={resolvedSearchParams} />
</Suspense>
) : null;
return (
<ContentLayout
title="Compliance"
icon="lucide:shield-check"
onboardingAction={onboardingAction}
>
<CompliancePageTabs
activeTab={activeTab}
perScanContent={perScanContent}
crossProviderContent={crossProviderContent}
crossProviderEnabled={crossProviderEnabled}
/>
</ContentLayout>
);
}
@@ -280,3 +321,91 @@ const ComplianceOverviewPanel = ({
</Card>
);
};
/**
* 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 (
<ComplianceOverviewPanel>
<Alert variant="info">
<Info className="size-4" />
<AlertDescription>
No universal compliance frameworks are available yet.
</AlertDescription>
</Alert>
</ComplianceOverviewPanel>
);
}
return (
<ComplianceOverviewPanel>
<CrossProviderGrid frameworks={summaries} />
</ComplianceOverviewPanel>
);
};
@@ -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");
});
});
@@ -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<FindingsResponse | null>(null);
const [expandedFindings, setExpandedFindings] = useState<FindingProps[]>([]);
const searchParams = useSearchParams();
const pageNumber = searchParams.get("page") || "1";
const pageSize = searchParams.get("pageSize") || "10";
@@ -50,18 +71,33 @@ 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
) {
return;
}
loadedPageRef.current = pageNumber;
loadedPageSizeRef.current = pageSize;
loadedSortRef.current = sort;
@@ -69,8 +105,90 @@ export const ClientAccordionContent = ({
isExpandedRef.current = true;
try {
const checkIds = requirement.check_ids;
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,
}),
),
);
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;
}
// 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<string>();
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(","),
@@ -83,38 +201,18 @@ export const ClientAccordionContent = ({
sort: encodedSort,
});
if (cancelled) return;
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);
}
} 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<FindingProps[]>(() => {
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<string, number> = {};
const pass: Record<string, number> = {};
const fail: Record<string, number> = {};
if (!isCrossProvider || !scanIdsByProvider) {
return { count, pass, fail };
}
const scanToProvider = new Map<string, string>();
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 <div className="w-full">{detailsComponent}</div>;
};
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 (
<div className="my-4">
<h4 className="mb-2 text-sm font-medium">Per-Provider Breakdown</h4>
<div className="border-border-neutral-secondary overflow-hidden rounded-md border">
<table className="w-full text-xs">
<thead className="bg-default-100">
<tr className="text-text-neutral-secondary text-left">
<th className="px-3 py-2 font-semibold">Provider</th>
<th className="px-3 py-2 font-semibold">Status</th>
<th className="px-3 py-2 font-semibold">Findings</th>
<th className="px-3 py-2 font-semibold">Pass / Fail</th>
<th className="px-3 py-2 font-semibold">Scan ID</th>
</tr>
</thead>
<tbody>
{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 (
<tr
key={providerKey}
className="border-border-neutral-secondary border-t"
>
<td className="px-3 py-2 align-top font-medium">
<div className="flex flex-col gap-0.5">
<span>{label}</span>
{accountCount > 1 && (
<span className="text-text-neutral-secondary text-[10px] tracking-wider uppercase">
{accountCount} accounts
</span>
)}
</div>
</td>
<td className="px-3 py-2 align-top">
<StatusFindingBadge
status={toFindingStatus(providerStatus)}
/>
</td>
<td className="text-text-neutral-secondary px-3 py-2 align-top">
{findingsLoaded ? (findingsCount ?? 0) : "—"}
</td>
<td className="px-3 py-2 align-top">
{findingsLoaded ? (
<span className="font-mono">
<span className="text-bg-pass">{passCount}</span>
<span className="text-text-neutral-secondary">
{" / "}
</span>
<span className="text-bg-fail">{failCount}</span>
</span>
) : (
<span className="text-text-neutral-secondary"></span>
)}
</td>
<td
className="text-text-neutral-secondary px-3 py-2 align-top font-mono text-[11px] break-all"
title={scanIdsForProvider.join("\n")}
>
{accountCount === 0 ? (
"—"
) : (
<ul className="flex flex-col gap-0.5">
{scanIdsForProvider.map((sid) => (
<li key={sid}>{sid}</li>
))}
</ul>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
};
if (disableFindings) {
return (
<div className="w-full">
{renderDetails()}
{renderProviderBreakdown()}
<p className="mt-3 mb-1 text-sm font-medium text-gray-800 dark:text-gray-200">
This requirement has no checks; therefore, there are no findings.
</p>
@@ -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 ? (
<div className="flex flex-col gap-3 px-3 pb-2">
{checkIdsByProviderEntries.map(([providerKey, ids], idx) => {
const label = getProviderLabel(providerKey);
const Badge = getProviderBadge(providerKey);
return (
<div
key={providerKey}
className={`flex flex-col gap-2 ${
idx > 0
? "border-t border-gray-200 pt-3 dark:border-gray-800"
: ""
}`}
>
<div className="flex items-center gap-2">
{Badge ? <Badge size={16} /> : null}
<span className="text-text-default text-xs font-semibold tracking-wider uppercase">
{label}
</span>
<span className="text-text-neutral-secondary text-xs">
{ids.length} {ids.length === 1 ? "check" : "checks"}
</span>
</div>
<div className="flex flex-wrap gap-1.5 pl-6">
{ids.map((id) => (
<span
key={id}
className="border-border-neutral-secondary inline-block rounded border bg-gray-50 px-2 py-0.5 font-mono text-[11px] text-gray-700 dark:bg-gray-900/50 dark:text-gray-200"
>
{id}
</span>
))}
</div>
</div>
);
})}
</div>
) : (
<div className="flex items-center px-2 text-sm">
<div className="w-full flex-col">
<div className="mt-[-8px] mb-1 h-1 w-full border-b border-gray-200 dark:border-gray-800" />
@@ -211,6 +508,8 @@ export const ClientAccordionContent = ({
{renderDetails()}
{renderProviderBreakdown()}
{checks.length > 0 && (
<div className="my-4">
<Accordion
@@ -1,32 +1,82 @@
import { InfoTooltip } from "@/components/shadcn/info-field/info-field";
import { FindingStatus, StatusFindingBadge } from "@/components/ui/table";
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
import {
getProviderBadge,
getProviderLabel,
} from "@/lib/providers/provider-display";
import { RequirementStatus } from "@/types/compliance";
interface ComplianceAccordionRequirementTitleProps {
type: string;
name: string;
status: FindingStatus;
invalidConfig?: boolean;
/** Cross-provider mode only: per-provider statuses contributing to the
* rolled-up ``status``. When provided, a row of compact chips is rendered
* next to the rolled-up badge so the user can spot per-provider drift
* without expanding the requirement. Per-scan mode leaves this
* ``undefined`` and the header remains identical to before. */
providers?: Record<string, RequirementStatus>;
}
const STATUS_DOT_CLASS_BY_STATUS: Record<RequirementStatus, string> = {
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 (
<div className="flex w-full items-center justify-between gap-2">
<div className="flex w-5/6 items-center gap-2">
<div className="flex w-full flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
{type && (
<span className="bg-primary/10 text-primary rounded-md px-2 py-0.5 text-xs font-medium">
{type}
</span>
)}
<span>{name}</span>
<span className="truncate">{name}</span>
{invalidConfig && <InfoTooltip content={INVALID_CONFIG_NOTE} />}
</div>
<div className="flex flex-wrap items-center gap-2">
{providerEntries.length > 0 && (
<div className="flex flex-wrap items-center gap-1">
{providerEntries.map(([providerKey, providerStatus]) => {
const Badge = getProviderBadge(providerKey);
const label = getProviderLabel(providerKey);
return (
<span
key={providerKey}
className="border-border-neutral-secondary inline-flex items-center gap-1 rounded border px-1.5 py-0.5"
title={`${label}: ${providerStatus}`}
>
{Badge ? <Badge size={12} /> : null}
<span className="text-[10px] font-semibold uppercase">
{label}
</span>
<span
className={`size-1.5 rounded-full ${
STATUS_DOT_CLASS_BY_STATUS[providerStatus] ??
"bg-text-neutral-secondary"
}`}
aria-label={providerStatus}
/>
</span>
);
})}
</div>
)}
<StatusFindingBadge status={status} />
</div>
</div>
);
};
@@ -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://
@@ -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 };
@@ -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");
});
});
@@ -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=<id>``. ``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 (
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="flex w-full flex-col gap-6"
>
<TooltipProvider delayDuration={200}>
<TabsList>
<Tooltip>
<TooltipTrigger asChild>
<TabsTrigger value={COMPLIANCE_PAGE_TAB.PER_SCAN}>
Per Scan
</TabsTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Detailed compliance results for a single scan against a specific
provider.
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
{/* 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. */}
<span className="inline-flex items-center">
<TabsTrigger
value={COMPLIANCE_PAGE_TAB.CROSS_PROVIDER}
disabled={!crossProviderEnabled}
className="border-r-0 pl-4"
>
Cross-Provider
</TabsTrigger>
{!crossProviderEnabled && (
<CloudFeatureBadgeLink size="sm" className="ml-3" />
)}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{crossProviderEnabled
? "Universal frameworks aggregated from the latest scan of every compatible provider in this tenant."
: "Available in Prowler Cloud"}
</TooltipContent>
</Tooltip>
</TabsList>
</TooltipProvider>
<TabsContent value={COMPLIANCE_PAGE_TAB.PER_SCAN} className="mt-0">
{perScanContent}
</TabsContent>
{crossProviderEnabled && (
<TabsContent
value={COMPLIANCE_PAGE_TAB.CROSS_PROVIDER}
className="mt-0"
>
{crossProviderContent}
</TabsContent>
)}
</Tabs>
);
};
@@ -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");
});
});
@@ -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 (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5",
"text-[10px] font-semibold tracking-wide uppercase",
active
? "border-bg-pass/40 bg-bg-pass/10 text-bg-pass"
: "border-border-neutral-secondary text-text-neutral-secondary",
)}
title={
active
? `${providerKey.toUpperCase()}: scan available`
: `${providerKey.toUpperCase()}: no scan yet`
}
>
<Icon className="size-3 shrink-0" strokeWidth={active ? 3 : 2} />
{providerKey}
</span>
);
};
export const CrossProviderCard: React.FC<CrossProviderCardProps> = ({
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 (
<Card
variant="base"
padding="md"
className="relative cursor-pointer transition-shadow hover:shadow-md"
onClick={navigateToDetail}
>
<CardContent className="p-0">
<div className="flex w-full flex-col gap-3">
<div className="flex items-start gap-3">
{getComplianceIcon(title) && (
<div className="flex h-10 w-10 min-w-10 shrink-0 items-center justify-center rounded-md border border-gray-300 bg-white">
<Image
src={getComplianceIcon(title)}
alt={`${title} logo`}
width={32}
height={32}
className="h-8 w-8 object-contain"
/>
</div>
)}
<div className="flex min-w-0 flex-1 flex-col">
<Tooltip>
<TooltipTrigger asChild>
<h4 className="text-small truncate leading-5 font-bold">
{formatTitle(title)}
{version ? ` - ${version}` : ""}
</h4>
</TooltipTrigger>
<TooltipContent>
{formatTitle(title)}
{version ? ` - ${version}` : ""}
</TooltipContent>
</Tooltip>
{description && (
<small className="text-text-neutral-secondary mt-0.5 line-clamp-2 text-xs">
{description}
</small>
)}
</div>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="text-text-neutral-secondary font-medium tracking-wider">
Cross-Provider Score:
</span>
<span className="text-text-neutral-secondary font-semibold">
{ratingPercentage}%
</span>
</div>
<Progress
aria-label="Cross-provider compliance score"
value={ratingPercentage}
className="border-border-neutral-secondary h-2.5 border drop-shadow-sm"
indicatorClassName={getScoreIndicatorClass(
getRatingVariant(ratingPercentage),
)}
/>
<small className="mt-0.5 truncate">
<span className="mr-1 text-xs font-semibold">
{requirementsPassed} / {totalRequirements}
</span>
Passing Requirements
</small>
</div>
{allChips.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{allChips.map((providerKey) => (
<ProviderChip
key={providerKey}
providerKey={providerKey}
active={contributingSet.has(providerKey)}
/>
))}
</div>
)}
</div>
</CardContent>
</Card>
);
};
@@ -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<HTMLDivElement>(null);
const flashTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="flex flex-col gap-6">
<CrossProviderHeader
framework={attributes.framework}
name={attributes.name}
version={attributes.version}
description={attributes.description}
insights={insights}
onDomainSelect={handleDomainSelect}
/>
<div ref={accordionContainerRef}>
<CrossProviderExplorerCard
attributes={attributes}
insights={insights}
forcedExpandedSectionKey={forcedExpandedSectionKey}
/>
</div>
</div>
);
};
@@ -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 <CrossProviderDetailClient attributes={attributes} />;
};
@@ -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<DomainProviderStatus, string> = {
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 (
<div
data-domain-anchor={name}
className="data-[flash=1]:bg-bg-fail/10 flex w-full items-stretch gap-3 transition-colors"
>
<div
className={cn(
"w-1 shrink-0 rounded-sm",
STRIPE_CLASS_BY_DOMINANT(stats),
)}
aria-hidden="true"
/>
<div className="flex w-full min-w-0 flex-wrap items-center gap-3 py-1">
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-text-default truncate text-sm font-semibold">
{name}
</span>
<span className="text-text-neutral-secondary mt-0.5 font-mono text-[10px]">
{stats.total} {stats.total === 1 ? "requirement" : "requirements"}
</span>
</div>
<div className="flex shrink-0 items-center gap-1.5">
{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 (
<span
key={providerKey}
title={tooltip}
aria-label={tooltip}
className={cn(
"flex size-5 items-center justify-center rounded border",
HEATMAP_CELL_BY_STATUS[status],
)}
>
{Badge ? <Badge size={12} className="opacity-90" /> : null}
</span>
);
})}
</div>
<div className="bg-default-200 dark:bg-default-100/30 flex h-1.5 w-32 shrink-0 overflow-hidden rounded-full sm:w-44">
{stats.pass > 0 && (
<span
className="bg-bg-pass h-full"
style={{ width: `${passPct}%` }}
/>
)}
{stats.fail > 0 && (
<span
className="bg-bg-fail h-full"
style={{ width: `${failPct}%` }}
/>
)}
{stats.manual > 0 && (
<span
className="bg-bg-warning h-full"
style={{ width: `${manualPct}%` }}
/>
)}
</div>
<div className="text-text-neutral-secondary flex shrink-0 items-center gap-2 font-mono text-[11px] tabular-nums">
<span className="text-bg-pass">{stats.pass}</span>
<span aria-hidden="true">·</span>
<span className="text-bg-fail">{stats.fail}</span>
<span aria-hidden="true">·</span>
<span className="text-bg-warning">{stats.manual}</span>
</div>
</div>
</div>
);
};
@@ -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<string[]>([]);
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 (
<Card variant="base" padding="md">
<CardContent className="flex w-full min-w-0 flex-col gap-4 p-0">
{/*
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.
*/}
<div className="flex w-full min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
<div className="flex min-w-0 flex-1 items-center gap-3">
<DataTableSearch
key={searchKey}
controlledValue={searchTerm}
onSearchChange={setSearchTerm}
placeholder="Search requirements by id, name or description..."
/>
<span className="text-text-neutral-secondary shrink-0 font-mono text-xs tabular-nums">
{matchCount}/{totalCount}
</span>
</div>
{/*
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.
*/}
<div className="ml-auto flex max-w-full min-w-0 shrink-0 flex-wrap items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleToggleAll}
aria-label={allSectionsOpen ? "Collapse all" : "Expand all"}
aria-expanded={allSectionsOpen}
className="h-8 px-2 text-xs"
>
{allSectionsOpen ? (
<Minimize2 className="size-3" />
) : (
<Maximize2 className="size-3" />
)}
{allSectionsOpen ? "Collapse all" : "Expand all"}
</Button>
<EnhancedMultiSelect
options={STATUS_OPTIONS}
defaultValue={statusFilters}
onValueChange={(next) =>
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 && (
<Button
variant="ghost"
size="sm"
onClick={handleResetFilters}
className="h-8 px-2 text-xs"
>
<X className="size-3" />
Reset
</Button>
)}
</div>
</div>
{sections.length === 0 ? (
<div className="text-text-neutral-secondary py-6 text-center text-sm italic">
No requirements match the current filters.
</div>
) : (
<Accordion
type="multiple"
value={expandedKeys}
onValueChange={handleAccordionChange}
className="border-border-neutral-secondary divide-border-neutral-secondary divide-y rounded-md border"
>
{sections.map((section) => {
const stats = statsByName.get(section.categoryName);
return (
<AccordionItem
key={section.key}
value={section.key}
className="px-3"
>
<AccordionTrigger
className={cn(
"py-3",
forcedExpandedSectionKey === section.key &&
"data-[flash=1]:bg-bg-fail/10",
)}
>
{stats ? (
<CrossProviderDomainTitle
name={section.categoryName}
stats={stats}
compatibleProviders={insights.compatibleProviders}
/>
) : (
<span>{section.categoryName}</span>
)}
</AccordionTrigger>
<AccordionContent>
<SectionRequirements
sectionKey={section.key}
framework={attributes.framework}
requirements={section.requirements}
/>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
)}
</CardContent>
</Card>
);
};
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 (
<Accordion type="multiple" className="flex flex-col">
{requirements.map((requirement, idx) => {
const xprov = requirement as CrossProviderRequirement;
const itemKey = `${sectionKey}-req-${idx}`;
return (
<AccordionItem
key={itemKey}
value={itemKey}
className="border-border-neutral-secondary border-t first:border-t-0"
>
<AccordionTrigger className="px-1 py-2">
<ComplianceAccordionRequirementTitle
type=""
name={requirement.name}
status={requirement.status as FindingStatus}
providers={xprov.providers}
/>
</AccordionTrigger>
<AccordionContent className="px-1">
<ClientAccordionContent
requirement={requirement}
scanId=""
framework={framework}
disableFindings={
requirement.check_ids.length === 0 && requirement.manual === 0
}
/>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
);
};
@@ -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 (
<>
<div className="flex items-center justify-between gap-4">
<DataTableSearch
controlledValue={searchTerm}
onSearchChange={setSearchTerm}
placeholder="Search universal frameworks..."
/>
<span className="text-text-neutral-secondary shrink-0 text-sm">
{filteredFrameworks.length.toLocaleString()} Total Entries
</span>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{filteredFrameworks.map((framework) => (
<CrossProviderCard
key={framework.id}
complianceId={framework.id}
title={framework.title}
version={framework.version}
description={framework.description}
requirementsPassed={framework.requirementsPassed}
totalRequirements={framework.totalRequirements}
contributingProviders={framework.contributingProviders}
compatibleProviders={framework.compatibleProviders}
/>
))}
</div>
</>
);
};
@@ -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 (
<Card variant="base" padding="md">
<CardContent className="flex flex-col gap-5 p-0">
<div className="flex flex-wrap items-center gap-3">
{getComplianceIcon(framework) && (
<div className="flex h-12 w-12 min-w-12 shrink-0 items-center justify-center rounded-md border border-gray-300 bg-white">
<Image
src={getComplianceIcon(framework)}
alt={`${framework} logo`}
width={40}
height={40}
className="h-10 w-10 object-contain"
/>
</div>
)}
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex flex-wrap items-baseline gap-2">
<h2 className="truncate text-lg leading-6 font-bold">
{name || formatTitle(framework)}
</h2>
{version && (
<span className="text-text-neutral-secondary font-mono text-xs">
v{version}
</span>
)}
<span className="border-border-neutral-secondary text-text-neutral-secondary rounded border px-1.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase">
Universal
</span>
</div>
{description && (
<p className="text-text-neutral-secondary mt-1 line-clamp-2 text-xs">
{description}
</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[auto_1fr_1fr]">
<div className="border-border-neutral-secondary flex flex-col items-center justify-center rounded-lg border bg-gray-50 px-4 py-4 dark:bg-gray-900/30">
<ScoreDonut
scorePercent={insights.scorePercent}
pass={insights.pass}
fail={insights.fail}
manual={insights.manual}
total={insights.total}
/>
</div>
<ProviderCoveragePanel coverage={insights.providerCoverage} />
<TopFailingDomainsPanel
domains={insights.domainsByFailCount}
onSelect={onDomainSelect}
/>
</div>
</CardContent>
</Card>
);
};
@@ -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";
@@ -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 (
<div className="flex h-full flex-col gap-3">
<h3 className="text-text-neutral-secondary text-[11px] font-semibold tracking-wider uppercase">
Provider Coverage
</h3>
<ul className="flex flex-col gap-2">
{coverage.map((entry) => {
const Badge = getProviderBadge(entry.key);
const label = getProviderLabel(entry.key);
const Icon = entry.contributing ? Check : Slash;
const dimmed = !entry.contributing;
return (
<li
key={entry.key}
className={cn(
"border-border-neutral-secondary flex items-center gap-3 rounded-md border px-3 py-2",
dimmed && "opacity-60",
)}
>
{Badge ? <Badge size={20} /> : null}
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-text-default flex min-w-0 items-baseline gap-1.5 truncate text-xs font-semibold">
{label}
{entry.accountCount > 1 && (
<span className="text-text-neutral-secondary text-[10px] font-medium tracking-wider uppercase">
{entry.accountCount} accounts
</span>
)}
</span>
{entry.contributing ? (
<span className="font-mono text-xs font-bold tabular-nums">
{entry.scorePercent}%
</span>
) : (
<span className="text-text-neutral-secondary text-[10px] tracking-wider uppercase">
No scan
</span>
)}
</div>
<div className="bg-default-200 dark:bg-default-100/30 h-1.5 w-full overflow-hidden rounded-full">
{entry.contributing && entry.total > 0 ? (
<div
className="bg-bg-pass h-full"
style={{ width: `${entry.scorePercent}%` }}
/>
) : null}
</div>
{entry.contributing && (
<span className="text-text-neutral-secondary font-mono text-[10px]">
{entry.pass} / {entry.total} pass
{entry.fail > 0 && (
<span className="text-bg-fail ml-2">
{entry.fail} fail
</span>
)}
</span>
)}
</div>
<Icon
className={cn(
"size-4 shrink-0",
entry.contributing
? "text-bg-pass"
: "text-text-neutral-secondary",
)}
strokeWidth={entry.contributing ? 3 : 2}
aria-hidden="true"
/>
</li>
);
})}
</ul>
</div>
);
};
@@ -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 (
<div className="flex flex-col items-center gap-3">
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
role="img"
aria-label={`Compliance score ${scorePercent}%, ${pass} pass, ${fail} fail, ${manual} manual, ${total} total requirements`}
>
{/* Track */}
<circle
cx={cx}
cy={cy}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
className="text-default-200 dark:text-default-100/30"
/>
{/* Arcs — rotated so the trace starts at 12 o'clock */}
<g transform={`rotate(-90 ${cx} ${cy})`}>
{pass > 0 && (
<circle
cx={cx}
cy={cy}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="butt"
strokeDasharray={`${passLen} ${circumference - passLen}`}
strokeDashoffset={-passOffset}
className="text-bg-pass"
/>
)}
{fail > 0 && (
<circle
cx={cx}
cy={cy}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="butt"
strokeDasharray={`${failLen} ${circumference - failLen}`}
strokeDashoffset={-failOffset}
className="text-bg-fail"
/>
)}
{manual > 0 && (
<circle
cx={cx}
cy={cy}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="butt"
strokeDasharray={`${manualLen} ${circumference - manualLen}`}
strokeDashoffset={-manualOffset}
className="text-bg-warning"
/>
)}
</g>
{/* Center label */}
<text
x={cx}
y={cy - 4}
textAnchor="middle"
dominantBaseline="central"
className={`${centerColorClass} font-mono text-2xl font-bold`}
>
{scorePercent}%
</text>
<text
x={cx}
y={cy + 18}
textAnchor="middle"
dominantBaseline="central"
className="fill-gray-500 font-mono text-[10px] tracking-wider uppercase dark:fill-gray-300"
>
{pass}/{total}
</text>
</svg>
<ul className="grid w-full grid-cols-3 gap-2 text-[10px] tracking-wider uppercase">
<li className="flex flex-col items-center gap-0.5">
<span className="text-bg-pass font-mono text-base font-bold">
{pass}
</span>
<span className="text-text-neutral-secondary">Pass</span>
</li>
<li className="flex flex-col items-center gap-0.5">
<span className="text-bg-fail font-mono text-base font-bold">
{fail}
</span>
<span className="text-text-neutral-secondary">Fail</span>
</li>
<li className="flex flex-col items-center gap-0.5">
<span className="text-bg-warning font-mono text-base font-bold">
{manual}
</span>
<span className="text-text-neutral-secondary">Manual</span>
</li>
</ul>
</div>
);
};
@@ -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 (
<div className="flex h-full flex-col gap-3">
<h3 className="text-text-neutral-secondary text-[11px] font-semibold tracking-wider uppercase">
Top Failing Domains
</h3>
{top.length === 0 ? (
<div className="border-border-neutral-secondary flex flex-1 items-center justify-center rounded-md border border-dashed px-3 py-6">
<span className="text-text-neutral-secondary text-xs">
No failing domains. Keep it up.
</span>
</div>
) : (
<ul className="flex flex-col gap-2">
{top.map((domain) => {
const Tag = onSelect ? "button" : "div";
return (
<li key={domain.name}>
<Tag
type={onSelect ? "button" : undefined}
onClick={onSelect ? () => 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"
: ""
}`}
>
<AlertTriangle
className="text-bg-fail size-4 shrink-0"
aria-hidden="true"
/>
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-text-default truncate text-xs font-semibold">
{domain.name}
</span>
<span className="text-text-neutral-secondary font-mono text-[10px]">
{domain.fail} fail · {domain.total} total
</span>
</div>
{onSelect && (
<ChevronRight
className="text-text-neutral-secondary size-3.5 shrink-0"
aria-hidden="true"
/>
)}
</Tag>
</li>
);
})}
</ul>
)}
</div>
);
};
+3
View File
@@ -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";
+77
View File
@@ -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<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-3 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
+1
View File
@@ -1,3 +1,4 @@
export * from "./accordion";
export * from "./alert";
export * from "./badge/badge";
export * from "./button/button";
+8
View File
@@ -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",
@@ -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({});
});
});
@@ -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 },
};
};
@@ -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);
});
});
@@ -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<string, DomainProviderStatus>;
}
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<string, number>();
const providerFail = new Map<string, number>();
const providerTotal = new Map<string, number>();
// 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<string, CrossProviderRequirementStatus[]>;
}
>();
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<string, DomainProviderStatus> = {};
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,
};
};
+18 -1
View File
@@ -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: (
@@ -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/<framework>.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/<provider>/ 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<string>();
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);
}
});
});
+94
View File
@@ -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 ``<framework>_<provider>`` 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/<framework>.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<string> = 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);
};
+52
View File
@@ -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<string, FC<IconSvgProps>> = {
aws: AWSProviderBadge,
azure: AzureProviderBadge,
gcp: GCPProviderBadge,
alibabacloud: AlibabaCloudProviderBadge,
oraclecloud: OracleCloudProviderBadge,
kubernetes: KS8ProviderBadge,
m365: M365ProviderBadge,
};
export const PROVIDER_LABEL_BY_KEY: Record<string, string> = {
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<IconSvgProps> | undefined => PROVIDER_BADGE_BY_KEY[providerKey];
+1
View File
@@ -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",
+33
View File
@@ -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
+32
View File
@@ -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;
}
}
+99 -1
View File
@@ -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<string, RequirementStatus>;
check_ids_by_provider?: Record<string, string[]>;
scan_ids_by_provider?: Record<string, string[]>;
// 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<string, string>
| Record<string, string[]>
| Record<string, RequirementStatus>
| 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<string, RequirementStatus>;
check_ids_by_provider?: Record<string, string[]>;
scan_ids_by_provider?: Record<string, string[]>;
};
};
}
@@ -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<string, unknown>;
// Rolled-up status for this requirement across providers.
status: CrossProviderRequirementStatus;
// Per-provider status that fed the roll-up. Keys match
// CrossProviderComplianceOverviewAttributes.providers.
providers: Record<string, CrossProviderRequirementStatus>;
// 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<string, string[]>;
}
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<string, string[]>;
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;
}