mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
feat(ui): add cross-compliance provider view
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -4,11 +4,17 @@ import { Suspense } from "react";
|
||||
import {
|
||||
getComplianceOverviewMetadataInfo,
|
||||
getCompliancesOverview,
|
||||
getCrossProviderComplianceOverview,
|
||||
} from "@/actions/compliances";
|
||||
import { getThreatScore } from "@/actions/overview";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import {
|
||||
COMPLIANCE_PAGE_TAB,
|
||||
CompliancePageTabs,
|
||||
ComplianceSkeletonGrid,
|
||||
type CrossProviderFrameworkSummary,
|
||||
CrossProviderGrid,
|
||||
getCompliancePageTab,
|
||||
NoScansAvailable,
|
||||
ThreatScoreBadge,
|
||||
} from "@/components/compliance";
|
||||
@@ -18,13 +24,18 @@ import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
|
||||
import { UNIVERSAL_FRAMEWORKS } from "@/lib/compliance/universal-frameworks";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import {
|
||||
ExpandedScanData,
|
||||
ScanEntity,
|
||||
ScanProps,
|
||||
SearchParamsProps,
|
||||
} from "@/types";
|
||||
import { ComplianceOverviewData } from "@/types/compliance";
|
||||
import {
|
||||
ComplianceOverviewData,
|
||||
CrossProviderComplianceOverviewData,
|
||||
} from "@/types/compliance";
|
||||
|
||||
export default async function Compliance({
|
||||
searchParams,
|
||||
@@ -34,6 +45,14 @@ export default async function Compliance({
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
|
||||
|
||||
// Cross-Provider is a Prowler Cloud-only feature; the OSS API has no
|
||||
// cross-provider-compliance-overviews endpoint. In OSS the tab is shown
|
||||
// disabled with an upsell badge and the per-scan tab is forced active.
|
||||
const crossProviderEnabled = isCloud();
|
||||
const activeTab = crossProviderEnabled
|
||||
? getCompliancePageTab(resolvedSearchParams.tab)
|
||||
: COMPLIANCE_PAGE_TAB.PER_SCAN;
|
||||
|
||||
const scansData = await getScans({
|
||||
filters: {
|
||||
"filter[state]": "completed",
|
||||
@@ -140,54 +159,76 @@ export default async function Compliance({
|
||||
}
|
||||
}
|
||||
|
||||
const perScanContent = selectedScanId ? (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<ComplianceFilters
|
||||
scans={expandedScansData}
|
||||
uniqueRegions={uniqueRegions}
|
||||
selectedScanId={selectedScanId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{threatScoreData &&
|
||||
typeof selectedScanId === "string" &&
|
||||
selectedScan && (
|
||||
<div className="mb-6">
|
||||
<ThreatScoreBadge
|
||||
score={threatScoreData.score}
|
||||
scanId={selectedScanId}
|
||||
provider={selectedScan.providerInfo.provider}
|
||||
selectedScan={selectedScanData}
|
||||
sectionScores={threatScoreData.sectionScores}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Suspense
|
||||
key={searchParamsKey}
|
||||
fallback={
|
||||
<ComplianceOverviewPanel>
|
||||
<ComplianceSkeletonGrid />
|
||||
</ComplianceOverviewPanel>
|
||||
}
|
||||
>
|
||||
<SSRComplianceGrid
|
||||
searchParams={resolvedSearchParams}
|
||||
scanId={selectedScanId}
|
||||
selectedScan={selectedScanData}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
) : (
|
||||
<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}
|
||||
>
|
||||
{selectedScanId ? (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<ComplianceFilters
|
||||
scans={expandedScansData}
|
||||
uniqueRegions={uniqueRegions}
|
||||
selectedScanId={selectedScanId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{threatScoreData &&
|
||||
typeof selectedScanId === "string" &&
|
||||
selectedScan && (
|
||||
<div className="mb-6">
|
||||
<ThreatScoreBadge
|
||||
score={threatScoreData.score}
|
||||
scanId={selectedScanId}
|
||||
provider={selectedScan.providerInfo.provider}
|
||||
selectedScan={selectedScanData}
|
||||
sectionScores={threatScoreData.sectionScores}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Suspense
|
||||
key={searchParamsKey}
|
||||
fallback={
|
||||
<ComplianceOverviewPanel>
|
||||
<ComplianceSkeletonGrid />
|
||||
</ComplianceOverviewPanel>
|
||||
}
|
||||
>
|
||||
<SSRComplianceGrid
|
||||
searchParams={resolvedSearchParams}
|
||||
scanId={selectedScanId}
|
||||
selectedScan={selectedScanData}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
) : (
|
||||
<NoScansAvailable />
|
||||
)}
|
||||
<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,71 +71,148 @@ export const ClientAccordionContent = ({
|
||||
// surface in the app (findings page, resource drawer, overview widgets).
|
||||
const mutedFilter = searchParams.get("filter[muted]") || MUTED_FILTER.EXCLUDE;
|
||||
|
||||
// Cross-provider requirements carry these augmentation maps; per-scan
|
||||
// requirements leave them undefined. Narrow once at the seam so the
|
||||
// hot paths below don't need repeated casts.
|
||||
const xprov = requirement as CrossProviderRequirement;
|
||||
const scanIdsByProvider = xprov.scan_ids_by_provider;
|
||||
const checkIdsByProvider = xprov.check_ids_by_provider;
|
||||
const providersBreakdown = xprov.providers;
|
||||
const isCrossProvider =
|
||||
!!scanIdsByProvider && Object.keys(scanIdsByProvider).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
// Guard against a slower earlier request resolving after a newer one and
|
||||
// clobbering the table (race on fast page/sort/filter changes).
|
||||
let cancelled = false;
|
||||
|
||||
async function loadFindings() {
|
||||
if (disableFindings || requirement.status === "No findings") return;
|
||||
if (
|
||||
!disableFindings &&
|
||||
requirement.check_ids?.length > 0 &&
|
||||
requirement.status !== "No findings" &&
|
||||
(loadedPageRef.current !== pageNumber ||
|
||||
loadedPageSizeRef.current !== pageSize ||
|
||||
loadedSortRef.current !== sort ||
|
||||
loadedMutedRef.current !== mutedFilter ||
|
||||
!isExpandedRef.current)
|
||||
loadedPageRef.current === pageNumber &&
|
||||
loadedPageSizeRef.current === pageSize &&
|
||||
loadedSortRef.current === sort &&
|
||||
loadedMutedRef.current === mutedFilter &&
|
||||
isExpandedRef.current
|
||||
) {
|
||||
loadedPageRef.current = pageNumber;
|
||||
loadedPageSizeRef.current = pageSize;
|
||||
loadedSortRef.current = sort;
|
||||
loadedMutedRef.current = mutedFilter;
|
||||
isExpandedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const checkIds = requirement.check_ids;
|
||||
const encodedSort = sort.replace(/^\+/, "");
|
||||
const findingsData = await getFindings({
|
||||
filters: {
|
||||
"filter[check_id__in]": checkIds.join(","),
|
||||
"filter[scan]": scanId,
|
||||
"filter[muted]": mutedFilter,
|
||||
...(region && { "filter[region__in]": region }),
|
||||
},
|
||||
page: parseInt(pageNumber, 10),
|
||||
pageSize: parseInt(pageSize, 10),
|
||||
sort: encodedSort,
|
||||
loadedPageRef.current = pageNumber;
|
||||
loadedPageSizeRef.current = pageSize;
|
||||
loadedSortRef.current = sort;
|
||||
loadedMutedRef.current = mutedFilter;
|
||||
isExpandedRef.current = true;
|
||||
|
||||
try {
|
||||
const encodedSort = sort.replace(/^\+/, "");
|
||||
|
||||
if (isCrossProvider) {
|
||||
// Fetch findings scoped to each contributing scan in parallel
|
||||
// and merge the JSON:API ``data`` + ``included`` arrays so
|
||||
// the unified table can resolve the provider/scan/resource
|
||||
// relationships per row. Server-side filters apply per scan
|
||||
// (the API enforces RLS on each query individually).
|
||||
//
|
||||
// ``scanIdsByProvider[providerKey]`` is a list because a
|
||||
// tenant can have N accounts of the same type — fan out one
|
||||
// ``filter[scan]`` request per account, all under the same
|
||||
// provider key.
|
||||
const entries = Object.entries(scanIdsByProvider!);
|
||||
const jobs = entries.flatMap(([providerKey, scanIds]) => {
|
||||
const checks = (checkIdsByProvider?.[providerKey] ?? []).join(",");
|
||||
if (!checks || !Array.isArray(scanIds) || scanIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return scanIds.map((scanIdForAccount) => ({
|
||||
providerKey,
|
||||
scanIdForAccount,
|
||||
checks,
|
||||
}));
|
||||
});
|
||||
const responses = await Promise.all(
|
||||
jobs.map(({ scanIdForAccount, checks }) =>
|
||||
getFindings({
|
||||
filters: {
|
||||
"filter[check_id__in]": checks,
|
||||
"filter[scan]": scanIdForAccount,
|
||||
"filter[muted]": mutedFilter,
|
||||
...(region && { "filter[region__in]": region }),
|
||||
},
|
||||
page: parseInt(pageNumber, 10),
|
||||
sort: encodedSort,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
setFindings(findingsData);
|
||||
|
||||
if (findingsData?.data) {
|
||||
// Create dictionaries for resources, scans, and providers
|
||||
const resourceDict = createDict("resources", findingsData);
|
||||
const scanDict = createDict("scans", findingsData);
|
||||
const providerDict = createDict("providers", findingsData);
|
||||
|
||||
// Expand each finding with its corresponding resource, scan, and provider
|
||||
const expandedData = findingsData.data.map(
|
||||
(finding: FindingProps) => {
|
||||
const scan = scanDict[finding.relationships?.scan?.data?.id];
|
||||
const resource =
|
||||
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
|
||||
const provider =
|
||||
providerDict[scan?.relationships?.provider?.data?.id];
|
||||
|
||||
return {
|
||||
...finding,
|
||||
relationships: { scan, resource, provider },
|
||||
};
|
||||
},
|
||||
);
|
||||
setExpandedFindings(expandedData);
|
||||
const allData: FindingProps[] = [];
|
||||
const allIncluded: { type: string; id: string }[] = [];
|
||||
let totalCount = 0;
|
||||
for (const r of responses) {
|
||||
if (!r || !("data" in r)) continue;
|
||||
const typedResponse = r as FindingsResponseLike;
|
||||
allData.push(...(typedResponse.data || []));
|
||||
allIncluded.push(...(typedResponse.included || []));
|
||||
totalCount += typedResponse?.meta?.pagination?.count || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading findings:", error);
|
||||
|
||||
// Each scan response includes its provider/scan record;
|
||||
// across N responses the same provider object appears N
|
||||
// times. Dedupe by ``(type, id)`` so the subsequent
|
||||
// ``createDict`` passes stop allocating duplicate entries.
|
||||
const dedupedIncluded: typeof allIncluded = [];
|
||||
const seenIncluded = new Set<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(","),
|
||||
"filter[scan]": scanId,
|
||||
"filter[muted]": mutedFilter,
|
||||
...(region && { "filter[region__in]": region }),
|
||||
},
|
||||
page: parseInt(pageNumber, 10),
|
||||
pageSize: parseInt(pageSize, 10),
|
||||
sort: encodedSort,
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
setFindings(findingsData);
|
||||
} catch (error) {
|
||||
console.error("Error loading findings:", error);
|
||||
}
|
||||
}
|
||||
|
||||
loadFindings();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
requirement,
|
||||
scanId,
|
||||
@@ -124,8 +222,62 @@ export const ClientAccordionContent = ({
|
||||
region,
|
||||
mutedFilter,
|
||||
disableFindings,
|
||||
isCrossProvider,
|
||||
scanIdsByProvider,
|
||||
checkIdsByProvider,
|
||||
]);
|
||||
|
||||
// Expand each finding with its resource/scan/provider. Derived from
|
||||
// ``findings`` rather than stored as separate state so the table can never
|
||||
// drift out of sync with the fetched rows.
|
||||
const expandedFindings = useMemo<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
|
||||
|
||||
+54
-4
@@ -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>
|
||||
<StatusFindingBadge status={status} />
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from "./accordion";
|
||||
export * from "./alert";
|
||||
export * from "./badge/badge";
|
||||
export * from "./button/button";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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];
|
||||
@@ -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",
|
||||
|
||||
Generated
+33
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user