From 16024dc6c2cd7db0a1280fd170aef6448e0665e3 Mon Sep 17 00:00:00 2001 From: pedrooot Date: Tue, 30 Jun 2026 17:26:20 +0200 Subject: [PATCH] feat(ui): add cross-compliance provider view --- ui/actions/compliances/compliances.ts | 52 +++ .../compliance/[compliancetitle]/page.tsx | 69 ++- ui/app/(prowler)/compliance/page.tsx | 215 +++++++-- .../client-accordion-content.test.ts | 23 + .../client-accordion-content.tsx | 411 +++++++++++++++--- ...compliance-accordion-requeriment-title.tsx | 58 ++- .../compliance-custom-details/cis-details.tsx | 4 +- .../compliance/compliance-page-tabs.shared.ts | 25 ++ .../compliance/compliance-page-tabs.test.tsx | 42 ++ .../compliance/compliance-page-tabs.tsx | 146 +++++++ .../cross-provider-card.test.tsx | 37 ++ .../cross-provider/cross-provider-card.tsx | 209 +++++++++ .../cross-provider-detail-client.tsx | 95 ++++ .../cross-provider/cross-provider-detail.tsx | 22 + .../cross-provider-domain-title.tsx | 133 ++++++ .../cross-provider-explorer-card.tsx | 352 +++++++++++++++ .../cross-provider/cross-provider-grid.tsx | 72 +++ .../cross-provider/cross-provider-header.tsx | 107 +++++ .../compliance/cross-provider/index.ts | 4 + .../provider-coverage-panel.tsx | 101 +++++ .../compliance/cross-provider/score-donut.tsx | 166 +++++++ .../top-failing-domains-panel.tsx | 84 ++++ ui/components/compliance/index.ts | 3 + ui/components/shadcn/accordion.tsx | 77 ++++ ui/components/shadcn/index.ts | 1 + ui/dependency-log.json | 8 + .../compliance/cross-provider-adapter.test.ts | 135 ++++++ ui/lib/compliance/cross-provider-adapter.ts | 96 ++++ .../cross-provider-insights.test.ts | 190 ++++++++ ui/lib/compliance/cross-provider-insights.ts | 215 +++++++++ ui/lib/compliance/csa.tsx | 19 +- .../universal-frameworks.sync.test.ts | 121 ++++++ ui/lib/compliance/universal-frameworks.ts | 94 ++++ ui/lib/providers/provider-display.ts | 52 +++ ui/package.json | 1 + ui/pnpm-lock.yaml | 33 ++ ui/styles/globals.css | 32 ++ ui/types/compliance.ts | 100 ++++- 38 files changed, 3495 insertions(+), 109 deletions(-) create mode 100644 ui/components/compliance/compliance-page-tabs.shared.ts create mode 100644 ui/components/compliance/compliance-page-tabs.test.tsx create mode 100644 ui/components/compliance/compliance-page-tabs.tsx create mode 100644 ui/components/compliance/cross-provider/cross-provider-card.test.tsx create mode 100644 ui/components/compliance/cross-provider/cross-provider-card.tsx create mode 100644 ui/components/compliance/cross-provider/cross-provider-detail-client.tsx create mode 100644 ui/components/compliance/cross-provider/cross-provider-detail.tsx create mode 100644 ui/components/compliance/cross-provider/cross-provider-domain-title.tsx create mode 100644 ui/components/compliance/cross-provider/cross-provider-explorer-card.tsx create mode 100644 ui/components/compliance/cross-provider/cross-provider-grid.tsx create mode 100644 ui/components/compliance/cross-provider/cross-provider-header.tsx create mode 100644 ui/components/compliance/cross-provider/index.ts create mode 100644 ui/components/compliance/cross-provider/provider-coverage-panel.tsx create mode 100644 ui/components/compliance/cross-provider/score-donut.tsx create mode 100644 ui/components/compliance/cross-provider/top-failing-domains-panel.tsx create mode 100644 ui/components/shadcn/accordion.tsx create mode 100644 ui/lib/compliance/cross-provider-adapter.test.ts create mode 100644 ui/lib/compliance/cross-provider-adapter.ts create mode 100644 ui/lib/compliance/cross-provider-insights.test.ts create mode 100644 ui/lib/compliance/cross-provider-insights.ts create mode 100644 ui/lib/compliance/universal-frameworks.sync.test.ts create mode 100644 ui/lib/compliance/universal-frameworks.ts create mode 100644 ui/lib/providers/provider-display.ts diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index e06b06d29d..1dac947c63 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -140,3 +140,55 @@ export const getComplianceRequirements = async ({ return undefined; } }; + +/** + * Aggregate a universal compliance framework across one scan per compatible + * provider. Backed by ``GET /cross-provider-compliance-overviews/`` (Prowler + * Cloud only — the OSS API does not expose this endpoint). + * + * ``scanIds`` is optional: when omitted, the API auto-selects the most recent + * COMPLETED scan for every provider in the tenant whose type is declared + * compatible by the universal framework (further narrowed by ``providerTypes`` + * and by RBAC visibility). + */ +export const getCrossProviderComplianceOverview = async ({ + complianceId, + scanIds, + providerTypes, + regions, +}: { + complianceId: string; + scanIds?: string[]; + providerTypes?: string | string[]; + regions?: string | string[]; +}) => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL(`${apiBaseUrl}/cross-provider-compliance-overviews`); + + const setParam = (key: string, value?: string | string[]) => { + if (!value) return; + const serializedValue = Array.isArray(value) ? value.join(",") : value; + if (serializedValue.trim().length > 0) { + url.searchParams.set(key, serializedValue); + } + }; + + setParam("filter[compliance_id]", complianceId); + if (scanIds && scanIds.length > 0) { + setParam("filter[scan__in]", scanIds); + } + setParam("filter[provider_type__in]", providerTypes); + setParam("filter[region__in]", regions); + + try { + const response = await fetch(url.toString(), { headers }); + + if (response.status === 402) return { redirectTo: "/billing" }; + + return handleApiResponse(response); + } catch (error) { + console.error("Error fetching cross-provider compliance overview:", error); + return undefined; + } +}; diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index f5d42f8bc8..33e633b589 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -1,4 +1,6 @@ import { Spacer } from "@heroui/spacer"; +import { Info } from "lucide-react"; +import { redirect } from "next/navigation"; import { Suspense } from "react"; import { @@ -6,6 +8,7 @@ import { getComplianceOverviewMetadataInfo, getComplianceRequirements, getCompliancesOverview, + getCrossProviderComplianceOverview, } from "@/actions/compliances"; import { getThreatScore } from "@/actions/overview"; import { getScan } from "@/actions/scans"; @@ -14,6 +17,7 @@ import { ComplianceDownloadContainer, ComplianceHeader, ComplianceWarming, + CrossProviderDetail, RequirementsStatusCard, RequirementsStatusCardSkeleton, // SectionsFailureRateCard, @@ -25,15 +29,18 @@ import { TopFailedSectionsCardSkeleton, } from "@/components/compliance"; import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; +import { Alert, AlertDescription } from "@/components/shadcn/alert"; import { ContentLayout } from "@/components/ui"; import { getComplianceMapper } from "@/lib/compliance/compliance-mapper"; import { getReportTypeForCompliance, pickLatestCisPerProvider, } from "@/lib/compliance/compliance-report-types"; +import { isCloud } from "@/lib/shared/env"; import { cn } from "@/lib/utils"; import { AttributesData, + CrossProviderComplianceOverviewData, Framework, RequirementsTotals, } from "@/types/compliance"; @@ -44,7 +51,9 @@ interface ComplianceDetailSearchParams { version?: string; scanId?: string; section?: string; + mode?: string; "filter[region__in]"?: string; + "filter[provider_type__in]"?: string; "filter[cis_profile_level]"?: string; page?: string; pageSize?: string; @@ -59,11 +68,69 @@ export default async function ComplianceDetail({ }) { const { compliancetitle } = await params; const resolvedSearchParams = await searchParams; - const { complianceId, version, scanId, section } = resolvedSearchParams; + const { complianceId, version, scanId, section, mode } = resolvedSearchParams; const regionFilter = resolvedSearchParams["filter[region__in]"]; + const providerTypeFilter = resolvedSearchParams["filter[provider_type__in]"]; const cisProfileFilter = resolvedSearchParams["filter[cis_profile_level]"]; const logoPath = getComplianceIcon(compliancetitle); + // Cross-provider mode: skip the per-scan pipeline and render the + // cross-provider universal compliance roll-up instead. This is a Prowler + // Cloud-only feature (the OSS API has no cross-provider-compliance-overviews + // endpoint), so block the route in OSS the same way Alerts/Scan + // Configuration do. + if (mode === "cross-provider") { + if (!isCloud()) { + redirect("/compliance"); + } + + const crossProviderTitle = compliancetitle.split("-").join(" "); + const crossProviderResponse = await getCrossProviderComplianceOverview({ + complianceId, + providerTypes: providerTypeFilter, + regions: regionFilter, + }); + + if (!crossProviderResponse || "redirectTo" in crossProviderResponse) { + return ( + + + + + Cross-provider data is not available for this framework yet. + + + + ); + } + + const crossProviderData = ( + crossProviderResponse as { + data?: CrossProviderComplianceOverviewData; + } + ).data; + + if (!crossProviderData) { + return ( + + + + + No cross-provider compliance data was returned for this framework. + + + + ); + } + + const headerTitle = crossProviderData.attributes.name || crossProviderTitle; + return ( + + + + ); + } + // Create a key that excludes pagination parameters to preserve accordion state avoiding reloads with pagination const paramsForKey = Object.fromEntries( Object.entries(resolvedSearchParams).filter( diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index d4ee609a0c..5228120b11 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -4,11 +4,17 @@ import { Suspense } from "react"; import { getComplianceOverviewMetadataInfo, getCompliancesOverview, + getCrossProviderComplianceOverview, } from "@/actions/compliances"; import { getThreatScore } from "@/actions/overview"; import { getScans } from "@/actions/scans"; import { + COMPLIANCE_PAGE_TAB, + CompliancePageTabs, ComplianceSkeletonGrid, + type CrossProviderFrameworkSummary, + CrossProviderGrid, + getCompliancePageTab, NoScansAvailable, ThreatScoreBadge, } from "@/components/compliance"; @@ -18,13 +24,18 @@ import { Alert, AlertDescription } from "@/components/shadcn/alert"; import { Card, CardContent } from "@/components/shadcn/card/card"; import { ContentLayout } from "@/components/ui"; import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types"; +import { UNIVERSAL_FRAMEWORKS } from "@/lib/compliance/universal-frameworks"; +import { isCloud } from "@/lib/shared/env"; import { ExpandedScanData, ScanEntity, ScanProps, SearchParamsProps, } from "@/types"; -import { ComplianceOverviewData } from "@/types/compliance"; +import { + ComplianceOverviewData, + CrossProviderComplianceOverviewData, +} from "@/types/compliance"; export default async function Compliance({ searchParams, @@ -34,6 +45,14 @@ export default async function Compliance({ const resolvedSearchParams = await searchParams; const searchParamsKey = JSON.stringify(resolvedSearchParams || {}); + // Cross-Provider is a Prowler Cloud-only feature; the OSS API has no + // cross-provider-compliance-overviews endpoint. In OSS the tab is shown + // disabled with an upsell badge and the per-scan tab is forced active. + const crossProviderEnabled = isCloud(); + const activeTab = crossProviderEnabled + ? getCompliancePageTab(resolvedSearchParams.tab) + : COMPLIANCE_PAGE_TAB.PER_SCAN; + const scansData = await getScans({ filters: { "filter[state]": "completed", @@ -140,54 +159,76 @@ export default async function Compliance({ } } + const perScanContent = selectedScanId ? ( + <> +
+ +
+ + {threatScoreData && + typeof selectedScanId === "string" && + selectedScan && ( +
+ +
+ )} + + + + + } + > + + + + ) : ( + + ); + + // Only build (and thus fetch) the cross-provider grid in Cloud. In OSS the + // tab is disabled, so there is no content to render and no endpoint to hit. + const crossProviderContent = crossProviderEnabled ? ( + + + + } + > + + + ) : null; + return ( - {selectedScanId ? ( - <> -
- -
- - {threatScoreData && - typeof selectedScanId === "string" && - selectedScan && ( -
- -
- )} - - - - - } - > - - - - ) : ( - - )} +
); } @@ -280,3 +321,91 @@ const ComplianceOverviewPanel = ({ ); }; + +/** + * Server-side island for the Cross-Provider tab. + * + * Iterates the hardcoded ``UNIVERSAL_FRAMEWORKS`` catalogue and fetches the + * cross-provider roll-up for each one in parallel. The summaries hydrate the + * grid of cards so the user sees per-framework totals without an extra + * client round-trip. Today there is a single entry (CSA CCM 4.0); when the + * SDK ships more universal JSONs, only the catalogue file changes. + */ +const SSRCrossProviderGrid = async ({ + searchParams, +}: { + searchParams: SearchParamsProps; +}) => { + const providerTypes = + searchParams["filter[provider_type__in]"]?.toString() || undefined; + const regions = searchParams["filter[region__in]"]?.toString() || undefined; + + const responses = await Promise.all( + UNIVERSAL_FRAMEWORKS.map((entry) => + getCrossProviderComplianceOverview({ + complianceId: entry.id, + providerTypes, + regions, + }).then((response) => ({ entry, response })), + ), + ); + + const summaries: CrossProviderFrameworkSummary[] = []; + for (const { entry, response } of responses) { + if (!response || "redirectTo" in response) continue; + const data = (response as { data?: CrossProviderComplianceOverviewData }) + .data; + if (!data) { + // Catalogue entry exists but the API returned nothing usable — + // surface a zero-card so the user still sees the framework with all + // its compatible providers chips dimmed (no scan yet). + summaries.push({ + id: entry.id, + title: entry.title, + version: entry.version, + description: entry.description, + requirementsPassed: 0, + totalRequirements: 0, + contributingProviders: [], + compatibleProviders: entry.providers, + }); + continue; + } + const attrs = data.attributes; + // ``compatible_providers`` from the API is authoritative; fall back to + // the catalogue entry only if the response omitted it. + const compatible = + attrs.compatible_providers && attrs.compatible_providers.length > 0 + ? attrs.compatible_providers + : entry.providers; + summaries.push({ + id: entry.id, + title: attrs.framework || entry.title, + version: attrs.version || entry.version, + description: attrs.description || entry.description, + requirementsPassed: attrs.requirements_passed, + totalRequirements: attrs.total_requirements, + contributingProviders: attrs.providers, + compatibleProviders: compatible, + }); + } + + if (summaries.length === 0) { + return ( + + + + + No universal compliance frameworks are available yet. + + + + ); + } + + return ( + + + + ); +}; diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.test.ts b/ui/components/compliance/compliance-accordion/client-accordion-content.test.ts index 7f724e20b7..3de86c09fc 100644 --- a/ui/components/compliance/compliance-accordion/client-accordion-content.test.ts +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.test.ts @@ -13,4 +13,27 @@ describe("client accordion content", () => { expect(source).toContain("getStandaloneFindingColumns"); expect(source).not.toContain("getColumnFindings"); }); + + it("activates a cross-provider branch when scan_ids_by_provider is present", () => { + // The cross-provider mode is detected by the presence of the + // ``scan_ids_by_provider`` augmentation on the requirement. Guarding + // this contract in source keeps regressions visible without spinning a + // full DOM render. The reads happen through a single ``xprov`` cast + // (see ``CrossProviderRequirement``) so we look for both spellings. + expect(source).toMatch(/scan_ids_by_provider/); + expect(source).toMatch(/check_ids_by_provider/); + expect(source).toContain("CrossProviderRequirement"); + }); + + it("fetches findings per contributing scan in parallel when in cross-provider mode", () => { + expect(source).toContain("Promise.all"); + // Each parallel request scopes to a single scan and the + // requirement's per-provider check IDs. + expect(source).toMatch(/filter\[scan\]/); + expect(source).toMatch(/filter\[check_id__in\]/); + }); + + it("renders a per-provider breakdown table when providers are exposed", () => { + expect(source).toContain("Per-Provider Breakdown"); + }); }); diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx index 0ccd793c12..d0e8f4b9a7 100644 --- a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx @@ -2,7 +2,7 @@ import { AlertTriangle } from "lucide-react"; import { useSearchParams } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { getFindings } from "@/actions/findings/findings"; import { @@ -12,10 +12,19 @@ import { import { Alert, AlertDescription } from "@/components/shadcn"; import { Accordion } from "@/components/ui/accordion/Accordion"; import { DataTable } from "@/components/ui/table"; +import { StatusFindingBadge } from "@/components/ui/table/status-finding-badge"; import { createDict, FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib"; import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons"; import { getComplianceMapper } from "@/lib/compliance/compliance-mapper"; -import { Requirement } from "@/types/compliance"; +import { + getProviderBadge, + getProviderLabel, +} from "@/lib/providers/provider-display"; +import { + CrossProviderRequirement, + Requirement, + RequirementStatus, +} from "@/types/compliance"; import { FindingProps, FindingsResponse } from "@/types/components"; interface ClientAccordionContentProps { @@ -25,6 +34,19 @@ interface ClientAccordionContentProps { disableFindings?: boolean; } +// ``included`` is part of the JSON:API envelope but the ``FindingsResponse`` +// interface only models ``data`` + ``meta``. Carry it locally so ``createDict`` +// (which inspects ``data.included`` at runtime) can resolve the +// provider/scan/resource relationships per row. +type FindingsResponseLike = FindingsResponse & { + included?: { type: string; id: string }[]; +}; + +const toFindingStatus = (status: RequirementStatus) => { + // FindingStatus shares the same wire values for PASS/FAIL/MANUAL. + return status === "No findings" ? "MANUAL" : status; +}; + export const ClientAccordionContent = ({ requirement, framework, @@ -32,7 +54,6 @@ export const ClientAccordionContent = ({ disableFindings = false, }: ClientAccordionContentProps) => { const [findings, setFindings] = useState(null); - const [expandedFindings, setExpandedFindings] = useState([]); const searchParams = useSearchParams(); const pageNumber = searchParams.get("page") || "1"; const pageSize = searchParams.get("pageSize") || "10"; @@ -50,71 +71,148 @@ export const ClientAccordionContent = ({ // surface in the app (findings page, resource drawer, overview widgets). const mutedFilter = searchParams.get("filter[muted]") || MUTED_FILTER.EXCLUDE; + // Cross-provider requirements carry these augmentation maps; per-scan + // requirements leave them undefined. Narrow once at the seam so the + // hot paths below don't need repeated casts. + const xprov = requirement as CrossProviderRequirement; + const scanIdsByProvider = xprov.scan_ids_by_provider; + const checkIdsByProvider = xprov.check_ids_by_provider; + const providersBreakdown = xprov.providers; + const isCrossProvider = + !!scanIdsByProvider && Object.keys(scanIdsByProvider).length > 0; + useEffect(() => { + // Guard against a slower earlier request resolving after a newer one and + // clobbering the table (race on fast page/sort/filter changes). + let cancelled = false; + async function loadFindings() { + if (disableFindings || requirement.status === "No findings") return; if ( - !disableFindings && - requirement.check_ids?.length > 0 && - requirement.status !== "No findings" && - (loadedPageRef.current !== pageNumber || - loadedPageSizeRef.current !== pageSize || - loadedSortRef.current !== sort || - loadedMutedRef.current !== mutedFilter || - !isExpandedRef.current) + loadedPageRef.current === pageNumber && + loadedPageSizeRef.current === pageSize && + loadedSortRef.current === sort && + loadedMutedRef.current === mutedFilter && + isExpandedRef.current ) { - loadedPageRef.current = pageNumber; - loadedPageSizeRef.current = pageSize; - loadedSortRef.current = sort; - loadedMutedRef.current = mutedFilter; - isExpandedRef.current = true; + return; + } - try { - const checkIds = requirement.check_ids; - const encodedSort = sort.replace(/^\+/, ""); - const findingsData = await getFindings({ - filters: { - "filter[check_id__in]": checkIds.join(","), - "filter[scan]": scanId, - "filter[muted]": mutedFilter, - ...(region && { "filter[region__in]": region }), - }, - page: parseInt(pageNumber, 10), - pageSize: parseInt(pageSize, 10), - sort: encodedSort, + loadedPageRef.current = pageNumber; + loadedPageSizeRef.current = pageSize; + loadedSortRef.current = sort; + loadedMutedRef.current = mutedFilter; + isExpandedRef.current = true; + + try { + const encodedSort = sort.replace(/^\+/, ""); + + if (isCrossProvider) { + // Fetch findings scoped to each contributing scan in parallel + // and merge the JSON:API ``data`` + ``included`` arrays so + // the unified table can resolve the provider/scan/resource + // relationships per row. Server-side filters apply per scan + // (the API enforces RLS on each query individually). + // + // ``scanIdsByProvider[providerKey]`` is a list because a + // tenant can have N accounts of the same type — fan out one + // ``filter[scan]`` request per account, all under the same + // provider key. + const entries = Object.entries(scanIdsByProvider!); + const jobs = entries.flatMap(([providerKey, scanIds]) => { + const checks = (checkIdsByProvider?.[providerKey] ?? []).join(","); + if (!checks || !Array.isArray(scanIds) || scanIds.length === 0) { + return []; + } + return scanIds.map((scanIdForAccount) => ({ + providerKey, + scanIdForAccount, + checks, + })); }); + const responses = await Promise.all( + jobs.map(({ scanIdForAccount, checks }) => + getFindings({ + filters: { + "filter[check_id__in]": checks, + "filter[scan]": scanIdForAccount, + "filter[muted]": mutedFilter, + ...(region && { "filter[region__in]": region }), + }, + page: parseInt(pageNumber, 10), + sort: encodedSort, + }), + ), + ); - setFindings(findingsData); - - if (findingsData?.data) { - // Create dictionaries for resources, scans, and providers - const resourceDict = createDict("resources", findingsData); - const scanDict = createDict("scans", findingsData); - const providerDict = createDict("providers", findingsData); - - // Expand each finding with its corresponding resource, scan, and provider - const expandedData = findingsData.data.map( - (finding: FindingProps) => { - const scan = scanDict[finding.relationships?.scan?.data?.id]; - const resource = - resourceDict[finding.relationships?.resources?.data?.[0]?.id]; - const provider = - providerDict[scan?.relationships?.provider?.data?.id]; - - return { - ...finding, - relationships: { scan, resource, provider }, - }; - }, - ); - setExpandedFindings(expandedData); + const allData: FindingProps[] = []; + const allIncluded: { type: string; id: string }[] = []; + let totalCount = 0; + for (const r of responses) { + if (!r || !("data" in r)) continue; + const typedResponse = r as FindingsResponseLike; + allData.push(...(typedResponse.data || [])); + allIncluded.push(...(typedResponse.included || [])); + totalCount += typedResponse?.meta?.pagination?.count || 0; } - } catch (error) { - console.error("Error loading findings:", error); + + // Each scan response includes its provider/scan record; + // across N responses the same provider object appears N + // times. Dedupe by ``(type, id)`` so the subsequent + // ``createDict`` passes stop allocating duplicate entries. + const dedupedIncluded: typeof allIncluded = []; + const seenIncluded = new Set(); + for (const entry of allIncluded) { + const key = `${entry.type}|${entry.id}`; + if (seenIncluded.has(key)) continue; + seenIncluded.add(key); + dedupedIncluded.push(entry); + } + + const merged: FindingsResponseLike = { + data: allData, + included: dedupedIncluded, + meta: { + pagination: { + page: parseInt(pageNumber, 10), + pages: 1, + count: totalCount, + }, + version: "", + }, + }; + if (cancelled) return; + setFindings(merged); + return; } + + // Per-scan branch (existing behaviour). + if (!requirement.check_ids?.length) return; + const checkIds = requirement.check_ids; + const findingsData = await getFindings({ + filters: { + "filter[check_id__in]": checkIds.join(","), + "filter[scan]": scanId, + "filter[muted]": mutedFilter, + ...(region && { "filter[region__in]": region }), + }, + page: parseInt(pageNumber, 10), + pageSize: parseInt(pageSize, 10), + sort: encodedSort, + }); + + if (cancelled) return; + setFindings(findingsData); + } catch (error) { + console.error("Error loading findings:", error); } } loadFindings(); + + return () => { + cancelled = true; + }; }, [ requirement, scanId, @@ -124,8 +222,62 @@ export const ClientAccordionContent = ({ region, mutedFilter, disableFindings, + isCrossProvider, + scanIdsByProvider, + checkIdsByProvider, ]); + // Expand each finding with its resource/scan/provider. Derived from + // ``findings`` rather than stored as separate state so the table can never + // drift out of sync with the fetched rows. + const expandedFindings = useMemo(() => { + if (!findings?.data) return []; + const resourceDict = createDict("resources", findings); + const scanDict = createDict("scans", findings); + const providerDict = createDict("providers", findings); + return findings.data.map((finding: FindingProps) => { + const scan = scanDict[finding.relationships?.scan?.data?.id]; + const resource = + resourceDict[finding.relationships?.resources?.data?.[0]?.id]; + const provider = providerDict[scan?.relationships?.provider?.data?.id]; + return { + ...finding, + relationships: { scan, resource, provider }, + }; + }) as unknown as FindingProps[]; + }, [findings]); + + // Per-provider finding tallies for the cross-provider breakdown. Derived + // from the merged ``findings`` (mapping each row to its provider via + // ``scan_ids_by_provider``) so the counts always match the unified table. + const providerFindingStats = useMemo(() => { + const count: Record = {}; + const pass: Record = {}; + const fail: Record = {}; + if (!isCrossProvider || !scanIdsByProvider) { + return { count, pass, fail }; + } + const scanToProvider = new Map(); + for (const [providerKey, scanIds] of Object.entries(scanIdsByProvider)) { + count[providerKey] = 0; + pass[providerKey] = 0; + fail[providerKey] = 0; + if (Array.isArray(scanIds)) { + for (const sid of scanIds) scanToProvider.set(sid, providerKey); + } + } + for (const row of findings?.data ?? []) { + const sid = row.relationships?.scan?.data?.id; + const providerKey = sid ? scanToProvider.get(sid) : undefined; + if (!providerKey) continue; + count[providerKey] += 1; + const status = row.attributes?.status; + if (status === "PASS") pass[providerKey] += 1; + else if (status === "FAIL") fail[providerKey] += 1; + } + return { count, pass, fail }; + }, [findings, isCrossProvider, scanIdsByProvider]); + const renderDetails = () => { if (!complianceId) { return null; @@ -137,10 +289,104 @@ export const ClientAccordionContent = ({ return
{detailsComponent}
; }; + const renderProviderBreakdown = () => { + if (!providersBreakdown) return null; + const entries = Object.entries(providersBreakdown); + if (entries.length === 0) return null; + // ``findings`` is null until the lazy fetch resolves; in that case + // surface a neutral placeholder so the user does not see ``0`` and + // mistake it for "no findings". Once findings load, the count maps + // are the authoritative source — they match the unified table below + // row-for-row. + const findingsLoaded = findings !== null; + return ( +
+

Per-Provider Breakdown

+
+ + + + + + + + + + + + {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 ( + + + + + + + + ); + })} + +
ProviderStatusFindingsPass / FailScan ID
+
+ {label} + {accountCount > 1 && ( + + {accountCount} accounts + + )} +
+
+ + + {findingsLoaded ? (findingsCount ?? 0) : "—"} + + {findingsLoaded ? ( + + {passCount} + + {" / "} + + {failCount} + + ) : ( + + )} + + {accountCount === 0 ? ( + "—" + ) : ( +
    + {scanIdsForProvider.map((sid) => ( +
  • {sid}
  • + ))} +
+ )} +
+
+
+ ); + }; + if (disableFindings) { return (
{renderDetails()} + {renderProviderBreakdown()}

⚠️ This requirement has no checks; therefore, there are no findings.

@@ -149,7 +395,58 @@ export const ClientAccordionContent = ({ } const checks = requirement.check_ids || []; - const checksList = ( + // In cross-provider mode the universal framework declares the same + // requirement against multiple providers, often with disjoint check + // sets. Show a per-provider grouping so the user can audit which checks + // belong to which scan instead of staring at a flattened comma list. + // Per-scan mode keeps the original flat layout — there's only one + // provider, so a grouping would be visual noise. + const checkIdsByProviderEntries = checkIdsByProvider + ? Object.entries(checkIdsByProvider).filter( + ([, ids]) => Array.isArray(ids) && ids.length > 0, + ) + : []; + const showPerProviderChecks = + isCrossProvider && checkIdsByProviderEntries.length > 0; + + const checksList = showPerProviderChecks ? ( +
+ {checkIdsByProviderEntries.map(([providerKey, ids], idx) => { + const label = getProviderLabel(providerKey); + const Badge = getProviderBadge(providerKey); + return ( +
0 + ? "border-t border-gray-200 pt-3 dark:border-gray-800" + : "" + }`} + > +
+ {Badge ? : null} + + {label} + + + {ids.length} {ids.length === 1 ? "check" : "checks"} + +
+
+ {ids.map((id) => ( + + {id} + + ))} +
+
+ ); + })} +
+ ) : (
@@ -211,6 +508,8 @@ export const ClientAccordionContent = ({ {renderDetails()} + {renderProviderBreakdown()} + {checks.length > 0 && (
; } +const STATUS_DOT_CLASS_BY_STATUS: Record = { + PASS: "bg-bg-pass", + FAIL: "bg-bg-fail", + MANUAL: "bg-text-neutral-secondary", + "No findings": "bg-text-neutral-secondary", +}; + export const ComplianceAccordionRequirementTitle = ({ type, name, status, invalidConfig = false, + providers, }: ComplianceAccordionRequirementTitleProps) => { + const providerEntries = providers ? Object.entries(providers) : []; + return ( -
-
+
+
{type && ( {type} )} - {name} + {name} {invalidConfig && }
- +
+ {providerEntries.length > 0 && ( +
+ {providerEntries.map(([providerKey, providerStatus]) => { + const Badge = getProviderBadge(providerKey); + const label = getProviderLabel(providerKey); + return ( + + {Badge ? : null} + + {label} + + + + ); + })} +
+ )} + +
); }; diff --git a/ui/components/compliance/compliance-custom-details/cis-details.tsx b/ui/components/compliance/compliance-custom-details/cis-details.tsx index 25823038d5..d8a2d0fe25 100644 --- a/ui/components/compliance/compliance-custom-details/cis-details.tsx +++ b/ui/components/compliance/compliance-custom-details/cis-details.tsx @@ -16,9 +16,7 @@ interface CISDetailsProps { } export const CISCustomDetails = ({ requirement }: CISDetailsProps) => { - const processReferences = ( - references: string | number | boolean | string[] | object[] | undefined, - ): string[] => { + const processReferences = (references: unknown): string[] => { if (typeof references !== "string") return []; // Use regex to extract all URLs that start with https:// diff --git a/ui/components/compliance/compliance-page-tabs.shared.ts b/ui/components/compliance/compliance-page-tabs.shared.ts new file mode 100644 index 0000000000..57ec60b8e1 --- /dev/null +++ b/ui/components/compliance/compliance-page-tabs.shared.ts @@ -0,0 +1,25 @@ +const COMPLIANCE_PAGE_TAB = { + PER_SCAN: "per-scan", + CROSS_PROVIDER: "cross-provider", +} as const; + +type CompliancePageTab = + (typeof COMPLIANCE_PAGE_TAB)[keyof typeof COMPLIANCE_PAGE_TAB]; + +function isCompliancePageTab(value: string): value is CompliancePageTab { + return Object.values(COMPLIANCE_PAGE_TAB).includes( + value as CompliancePageTab, + ); +} + +function getCompliancePageTab( + value: string | string[] | undefined, +): CompliancePageTab { + if (typeof value !== "string") { + return COMPLIANCE_PAGE_TAB.PER_SCAN; + } + return isCompliancePageTab(value) ? value : COMPLIANCE_PAGE_TAB.PER_SCAN; +} + +export type { CompliancePageTab }; +export { COMPLIANCE_PAGE_TAB, getCompliancePageTab }; diff --git a/ui/components/compliance/compliance-page-tabs.test.tsx b/ui/components/compliance/compliance-page-tabs.test.tsx new file mode 100644 index 0000000000..05dd3ae45e --- /dev/null +++ b/ui/components/compliance/compliance-page-tabs.test.tsx @@ -0,0 +1,42 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("CompliancePageTabs", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const tabsSource = readFileSync( + path.join(currentDir, "compliance-page-tabs.tsx"), + "utf8", + ); + const sharedSource = readFileSync( + path.join(currentDir, "compliance-page-tabs.shared.ts"), + "utf8", + ); + + it("declares the two tab keys used across the page", () => { + expect(sharedSource).toContain("per-scan"); + expect(sharedSource).toContain("cross-provider"); + }); + + it("defaults to per-scan when the URL has no tab param", () => { + expect(sharedSource).toContain("PER_SCAN"); + expect(sharedSource).toMatch( + /getCompliancePageTab\([\s\S]*\): CompliancePageTab/, + ); + }); + + it("uses URL-based state via Next router push", () => { + expect(tabsSource).toContain("useRouter"); + expect(tabsSource).toContain("router.push"); + // Per-scan is the canonical default — leaving the tab param off keeps the + // existing bookmarks working. + expect(tabsSource).toContain('params.delete("tab")'); + }); + + it("exposes both content slots so RSC payload composes server-side", () => { + expect(tabsSource).toContain("perScanContent"); + expect(tabsSource).toContain("crossProviderContent"); + }); +}); diff --git a/ui/components/compliance/compliance-page-tabs.tsx b/ui/components/compliance/compliance-page-tabs.tsx new file mode 100644 index 0000000000..1b7940fa7f --- /dev/null +++ b/ui/components/compliance/compliance-page-tabs.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { ReactNode } from "react"; + +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/shadcn"; +import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge"; + +import { + COMPLIANCE_PAGE_TAB, + type CompliancePageTab, +} from "./compliance-page-tabs.shared"; + +interface CompliancePageTabsProps { + activeTab: CompliancePageTab; + perScanContent: ReactNode; + crossProviderContent: ReactNode; + /** Cross-Provider is a Prowler Cloud-only feature (the OSS API has no + * ``cross-provider-compliance-overviews`` endpoint). In OSS the tab is + * rendered disabled with the "Available in Prowler Cloud" upsell badge, + * mirroring how the sidebar gates Alerts/Scan Configuration. */ + crossProviderEnabled?: boolean; +} + +/** + * Top-level tab switcher for the compliance index page. + * + * URL-based state: the active tab is reflected in ``?tab=``. ``per-scan`` + * is the default and renders without the query param so existing bookmarks + * keep working. The ``cross-provider`` tab uses ``?tab=cross-provider``. + * + * Filter params unrelated to a specific tab (e.g. ``filter[region__in]``) + * survive tab switches; the per-scan-only ``scanId`` and the + * cross-provider-only ``filter[provider_type__in]`` are pruned when leaving + * their tab so the URL stays in sync with what the user can see. + */ +export const CompliancePageTabs = ({ + activeTab, + perScanContent, + crossProviderContent, + crossProviderEnabled = true, +}: CompliancePageTabsProps) => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const handleTabChange = (next: string) => { + const target = next as CompliancePageTab; + if (target === activeTab) return; + // Cross-Provider is Cloud-only; ignore any attempt to switch to it in OSS. + if ( + target === COMPLIANCE_PAGE_TAB.CROSS_PROVIDER && + !crossProviderEnabled + ) { + return; + } + + const params = new URLSearchParams(searchParams.toString()); + + if (target === COMPLIANCE_PAGE_TAB.PER_SCAN) { + params.delete("tab"); + // provider_type__in only applies to cross-provider; drop it. + params.delete("filter[provider_type__in]"); + } else { + params.set("tab", target); + // scanId is per-scan only; drop it when entering cross-provider. + params.delete("scanId"); + } + + const query = params.toString(); + router.push(query ? `/compliance?${query}` : "/compliance"); + }; + + return ( + + + + + + + Per Scan + + + + Detailed compliance results for a single scan against a specific + provider. + + + + + {/* The wrapper span (not the disabled TabsTrigger) is the hover + target so the tooltip still fires when the tab is disabled in + OSS. The span makes the trigger the first child of its own + element, which drops the TabsList's inter-tab ``pl-4`` and + keeps a trailing ``border-r``; restore the left padding so the + label clears the Per-Scan divider and drop the divider so the + cloud badge isn't crammed against it. */} + + + Cross-Provider + + {!crossProviderEnabled && ( + + )} + + + + {crossProviderEnabled + ? "Universal frameworks aggregated from the latest scan of every compatible provider in this tenant." + : "Available in Prowler Cloud"} + + + + + + + {perScanContent} + + + {crossProviderEnabled && ( + + {crossProviderContent} + + )} + + ); +}; diff --git a/ui/components/compliance/cross-provider/cross-provider-card.test.tsx b/ui/components/compliance/cross-provider/cross-provider-card.test.tsx new file mode 100644 index 0000000000..e813ba024e --- /dev/null +++ b/ui/components/compliance/cross-provider/cross-provider-card.test.tsx @@ -0,0 +1,37 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("CrossProviderCard", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "cross-provider-card.tsx"); + const source = readFileSync(filePath, "utf8"); + + it("navigates to the universal detail page in cross-provider mode", () => { + // The drill-down must emit ?mode=cross-provider so the detail page knows + // to render the universal-aggregated view instead of the per-scan one. + expect(source).toContain('"mode"'); + expect(source).toContain('"cross-provider"'); + }); + + it("does not depend on a scanId prop", () => { + // The cross-provider tab must not require a scan picker. + expect(source).not.toMatch(/scanId/); + }); + + it("preserves provider_type and region filters when drilling in", () => { + expect(source).toContain('"filter[region__in]"'); + expect(source).toContain('"filter[provider_type__in]"'); + }); + + it("surfaces the providers contribution ratio via chips", () => { + // The card renders one chip per compatible provider and dims the ones + // that did not contribute to the aggregation. The ratio is implicit in + // the active/inactive state of the chip set. + expect(source).toContain("contributingProviders"); + expect(source).toContain("compatibleProviders"); + expect(source).toContain("ProviderChip"); + }); +}); diff --git a/ui/components/compliance/cross-provider/cross-provider-card.tsx b/ui/components/compliance/cross-provider/cross-provider-card.tsx new file mode 100644 index 0000000000..d391c7466a --- /dev/null +++ b/ui/components/compliance/cross-provider/cross-provider-card.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { Check, Circle } from "lucide-react"; +import Image from "next/image"; +import { useRouter, useSearchParams } from "next/navigation"; + +import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; +import { Card, CardContent } from "@/components/shadcn/card/card"; +import { Progress } from "@/components/shadcn/progress"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import { + getScoreIndicatorClass, + type ScoreColorVariant, +} from "@/lib/compliance/score-utils"; +import { cn } from "@/lib/utils"; + +export interface CrossProviderCardProps { + /** Universal framework id (e.g. ``csa_ccm_4.0``). Used as the + * ``filter[compliance_id]`` value when navigating to the detail page. */ + complianceId: string; + /** Display title — the framework name (e.g. ``CSA-CCM``). Resolved by + * ``getComplianceIcon`` to pick the matching logo asset. */ + title: string; + version: string; + description?: string; + /** Roll-up totals returned by the API. */ + requirementsPassed: number; + totalRequirements: number; + /** Provider keys (lowercase, e.g. "aws") that actually contributed scans + * to the aggregated view. Rendered as "active" chips on the card. */ + contributingProviders: string[]; + /** Catalogue of provider keys the universal framework declares checks + * for. Rendered as chips; the ones missing from + * ``contributingProviders`` are dimmed to signal "no scan yet". */ + compatibleProviders: string[]; +} + +const formatTitle = (title: string) => title.split("-").join(" "); + +const getRatingVariant = (value: number): ScoreColorVariant => { + if (value <= 10) return "danger"; + if (value <= 40) return "warning"; + return "success"; +}; + +interface ProviderChipProps { + providerKey: string; + active: boolean; +} + +const ProviderChip = ({ providerKey, active }: ProviderChipProps) => { + const Icon = active ? Check : Circle; + return ( + + + {providerKey} + + ); +}; + +export const CrossProviderCard: React.FC = ({ + complianceId, + title, + version, + description, + requirementsPassed, + totalRequirements, + contributingProviders, + compatibleProviders, +}) => { + const searchParams = useSearchParams(); + const router = useRouter(); + + const ratingPercentage = + totalRequirements > 0 + ? Math.floor((requirementsPassed / totalRequirements) * 100) + : 0; + + const navigateToDetail = () => { + const formattedTitleForUrl = encodeURIComponent(title); + const path = `/compliance/${formattedTitleForUrl}`; + const params = new URLSearchParams(); + + params.set("complianceId", complianceId); + params.set("version", version); + params.set("mode", "cross-provider"); + + // Preserve provider/region filters when drilling in. + const region = searchParams.get("filter[region__in]"); + if (region) params.set("filter[region__in]", region); + const providerType = searchParams.get("filter[provider_type__in]"); + if (providerType) params.set("filter[provider_type__in]", providerType); + + router.push(`${path}?${params.toString()}`); + }; + + // Sorted, de-duplicated provider chip list. ``compatible_providers`` is + // authoritative; ``contributingProviders`` may include providers the + // framework does not declare (when callers pin scans via filter[scan__in]). + const contributingSet = new Set( + contributingProviders.map((p) => p.toLowerCase()), + ); + const allChips = Array.from( + new Set([ + ...compatibleProviders.map((p) => p.toLowerCase()), + ...contributingProviders.map((p) => p.toLowerCase()), + ]), + ).sort(); + + return ( + + +
+
+ {getComplianceIcon(title) && ( +
+ {`${title} +
+ )} +
+ + +

+ {formatTitle(title)} + {version ? ` - ${version}` : ""} +

+
+ + {formatTitle(title)} + {version ? ` - ${version}` : ""} + +
+ {description && ( + + {description} + + )} +
+
+ +
+
+ + Cross-Provider Score: + + + {ratingPercentage}% + +
+ + + + {requirementsPassed} / {totalRequirements} + + Passing Requirements + +
+ + {allChips.length > 0 && ( +
+ {allChips.map((providerKey) => ( + + ))} +
+ )} +
+
+
+ ); +}; diff --git a/ui/components/compliance/cross-provider/cross-provider-detail-client.tsx b/ui/components/compliance/cross-provider/cross-provider-detail-client.tsx new file mode 100644 index 0000000000..c9cf967e55 --- /dev/null +++ b/ui/components/compliance/cross-provider/cross-provider-detail-client.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useCallback, useMemo, useRef, useState } from "react"; + +import { computeCrossProviderInsights } from "@/lib/compliance/cross-provider-insights"; +import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance"; + +import { CrossProviderExplorerCard } from "./cross-provider-explorer-card"; +import { CrossProviderHeader } from "./cross-provider-header"; + +interface CrossProviderDetailClientProps { + attributes: CrossProviderComplianceOverviewAttributes; +} + +/** + * Client orchestrator that wires the redesigned 3-pane header to the + * explorer card hosting search + multiselect status + expand-all + + * shadcn accordion. + * + * Owns: + * - Memoised insights derived from the API response so the header + * and the accordion read pre-computed domain stats, score, + * provider coverage and top-failing rankings without re-iterating + * ``requirements`` themselves. + * - ``handleDomainSelect`` which turns a "Top Failing Domains" click into + * a forced-open section plus a scroll + flash on the matching anchor. + * Because it is a user action, the DOM work happens in the handler (a + * single ``requestAnimationFrame`` after the state update lets the + * target section commit) — not in a render-time effect. + * + * Drill-down stays inline (matching the per-scan compliance UX) so + * users get the same interaction across both tabs. + */ +export const CrossProviderDetailClient = ({ + attributes, +}: CrossProviderDetailClientProps) => { + const insights = useMemo( + () => computeCrossProviderInsights(attributes), + [attributes], + ); + + const accordionContainerRef = useRef(null); + const flashTimeoutRef = useRef | null>(null); + const [forcedExpandedSectionKey, setForcedExpandedSectionKey] = useState< + string | null + >(null); + + const handleDomainSelect = useCallback( + (domainName: string) => { + // Setting the forced key expands the target section. Selecting a domain + // is a user action, so the scroll + flash run here (not in a reactive + // effect): a single rAF lets the expansion commit before we locate the + // anchor and bring it into view. + setForcedExpandedSectionKey(`${attributes.framework}-${domainName}`); + + requestAnimationFrame(() => { + const container = accordionContainerRef.current; + if (!container) return; + const anchor = container.querySelector( + `[data-domain-anchor="${CSS.escape(domainName)}"]`, + ); + if (!(anchor instanceof HTMLElement)) return; + anchor.scrollIntoView({ behavior: "smooth", block: "start" }); + anchor.dataset.flash = "1"; + if (flashTimeoutRef.current) clearTimeout(flashTimeoutRef.current); + flashTimeoutRef.current = setTimeout(() => { + delete anchor.dataset.flash; + flashTimeoutRef.current = null; + }, 1200); + }); + }, + [attributes.framework], + ); + + return ( +
+ + +
+ +
+
+ ); +}; diff --git a/ui/components/compliance/cross-provider/cross-provider-detail.tsx b/ui/components/compliance/cross-provider/cross-provider-detail.tsx new file mode 100644 index 0000000000..f8272c22f9 --- /dev/null +++ b/ui/components/compliance/cross-provider/cross-provider-detail.tsx @@ -0,0 +1,22 @@ +import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance"; + +import { CrossProviderDetailClient } from "./cross-provider-detail-client"; + +interface CrossProviderDetailProps { + attributes: CrossProviderComplianceOverviewAttributes; +} + +/** + * Cross-provider compliance detail view. + * + * Server component shell — passes the API response straight through to + * the client orchestrator. The orchestrator owns interactive state + * (search term, status quick toggles, domain anchor scroll, drawer + * selection) so every panel of the redesigned 3-pane header stays in + * sync with the accordion below. + */ +export const CrossProviderDetail = ({ + attributes, +}: CrossProviderDetailProps) => { + return ; +}; diff --git a/ui/components/compliance/cross-provider/cross-provider-domain-title.tsx b/ui/components/compliance/cross-provider/cross-provider-domain-title.tsx new file mode 100644 index 0000000000..492bd6c42a --- /dev/null +++ b/ui/components/compliance/cross-provider/cross-provider-domain-title.tsx @@ -0,0 +1,133 @@ +"use client"; + +import type { + DomainProviderStatus, + DomainStats, +} from "@/lib/compliance/cross-provider-insights"; +import { + getProviderBadge, + getProviderLabel, +} from "@/lib/providers/provider-display"; +import { cn } from "@/lib/utils"; + +const HEATMAP_CELL_BY_STATUS: Record = { + PASS: "bg-bg-pass border-bg-pass", + FAIL: "bg-bg-fail border-bg-fail", + MANUAL: "bg-bg-warning/70 border-bg-warning", + NO_ROW: "bg-default-200 border-border-neutral-secondary opacity-40", +}; + +const STRIPE_CLASS_BY_DOMINANT = (stats: DomainStats): string => { + if (stats.fail > 0) return "bg-bg-fail"; + if (stats.pass > 0) return "bg-bg-pass"; + if (stats.manual > 0) return "bg-bg-warning"; + return "bg-default-300"; +}; + +interface CrossProviderDomainTitleProps { + name: string; + stats: DomainStats; + compatibleProviders: string[]; +} + +/** + * Domain (section) row title that surfaces the per-provider heatmap and + * an at-a-glance failure stripe so the user can read 17 rows in one + * pass instead of expanding each one. + * + * Layout: [colored stripe] [domain name] [heatmap matrix] [stack bar] + * [counts]. The stripe color reflects worst-case status (fail > pass > + * manual > no-row); the heatmap matrix shows one cell per compatible + * provider — dimmed for non-contributing ones — with the precise + * rolled-up status surfaced via the native ``title`` attribute. We + * deliberately avoid Radix Tooltip here: 5 providers × 17 sections = + * 85 cells per page would mount 85 ``TooltipProvider`` + ``Root`` + * pairs on initial render. + */ +export const CrossProviderDomainTitle = ({ + name, + stats, + compatibleProviders, +}: CrossProviderDomainTitleProps) => { + const total = Math.max(stats.total, 1); + const passPct = (stats.pass / total) * 100; + const failPct = (stats.fail / total) * 100; + const manualPct = (stats.manual / total) * 100; + + return ( +
+ + ); +}; diff --git a/ui/components/compliance/cross-provider/cross-provider-explorer-card.tsx b/ui/components/compliance/cross-provider/cross-provider-explorer-card.tsx new file mode 100644 index 0000000000..11745a6865 --- /dev/null +++ b/ui/components/compliance/cross-provider/cross-provider-explorer-card.tsx @@ -0,0 +1,352 @@ +"use client"; + +import { Maximize2, Minimize2, X } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; + +import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; +import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/shadcn/accordion"; +import { Button } from "@/components/shadcn/button/button"; +import { Card, CardContent } from "@/components/shadcn/card/card"; +import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select"; +import { DataTableSearch } from "@/components/ui/table/data-table-search"; +import { FindingStatus } from "@/components/ui/table/status-finding-badge"; +import { getComplianceMapper } from "@/lib/compliance/compliance-mapper"; +import { crossProviderToMapperInput } from "@/lib/compliance/cross-provider-adapter"; +import type { CrossProviderInsights } from "@/lib/compliance/cross-provider-insights"; +import { cn } from "@/lib/utils"; +import type { + CrossProviderComplianceOverviewAttributes, + CrossProviderRequirement, + CrossProviderRequirementStatus, + Requirement, +} from "@/types/compliance"; + +import { CrossProviderDomainTitle } from "./cross-provider-domain-title"; + +interface CrossProviderExplorerCardProps { + attributes: CrossProviderComplianceOverviewAttributes; + insights: CrossProviderInsights; + /** Section key (``${frameworkName}-${categoryName}``) the orchestrator + * wants forced-open. Driven by the "Top Failing Domains" panel so a + * click expands and scrolls to the target row. */ + forcedExpandedSectionKey?: string | null; +} + +const STATUS_OPTIONS: Array<{ + label: string; + value: CrossProviderRequirementStatus; +}> = [ + { label: "Failing", value: "FAIL" }, + { label: "Passing", value: "PASS" }, + { label: "Manual", value: "MANUAL" }, +]; + +/** + * One-stop explorer panel for a universal compliance framework. Wraps: + * + * - ``DataTableSearch`` (the same search component used by the per-scan + * compliance grid). + * - ``EnhancedMultiSelect`` for status quick-filters (no custom toggle + * pills — same component as roles/groups/findings forms). + * - Expand-all toggle that flips between expanding every section and + * collapsing back to user-selected state. + * - A shadcn ``Accordion`` (type=multiple) hosting Framework → + * Section → Requirement. + * + * The card owns the controlled state for search/filters/expansion so + * the orchestrator only feeds it ``attributes`` + ``insights`` plus an + * optional ``forcedExpandedSectionKey`` for the Top Failing Domains + * deep-link. + */ +export const CrossProviderExplorerCard = ({ + attributes, + insights, + forcedExpandedSectionKey, +}: CrossProviderExplorerCardProps) => { + const [searchTerm, setSearchTerm] = useState(""); + const [searchKey, setSearchKey] = useState(0); + const [statusFilters, setStatusFilters] = useState< + CrossProviderRequirementStatus[] + >([]); + const [openSectionKeys, setOpenSectionKeys] = useState([]); + const [allSectionsOpen, setAllSectionsOpen] = useState(false); + + // Filtered attributes drive both the match counter and the accordion + // content. The unfiltered ``insights`` continue to feed the heatmap + // matrix per section so global counts stay stable while the user + // narrows their search. + const filteredAttributes = useMemo(() => { + const lowerTerm = searchTerm.trim().toLowerCase(); + if (lowerTerm === "" && statusFilters.length === 0) { + return attributes; + } + const statusSet = new Set(statusFilters); + const filteredRequirements = attributes.requirements.filter((req) => { + if (statusSet.size > 0 && !statusSet.has(req.status)) return false; + if (lowerTerm === "") return true; + const haystack = + `${req.id} ${req.name ?? ""} ${req.description ?? ""}`.toLowerCase(); + return haystack.includes(lowerTerm); + }); + return { ...attributes, requirements: filteredRequirements }; + }, [attributes, searchTerm, statusFilters]); + + const matchCount = filteredAttributes.requirements.length; + const totalCount = attributes.requirements.length; + const hasFilters = searchTerm.length > 0 || statusFilters.length > 0; + + // Frameworks / categories / requirements derived from the mapper. We + // run this against the *filtered* attribute set so the list shrinks + // as the user types — sections with no surviving requirements + // disappear entirely instead of expanding to an empty body. + const { sections, allSectionKeys, statsByName } = useMemo(() => { + const mapper = getComplianceMapper(filteredAttributes.framework); + const { attributesData, requirementsData } = + crossProviderToMapperInput(filteredAttributes); + const frameworks = mapper.mapComplianceData( + attributesData, + requirementsData, + ); + const stats = new Map(insights.domainStats.map((d) => [d.name, d])); + const allSections = frameworks.flatMap((fw) => + fw.categories.map((category) => { + const requirements = category.controls.flatMap( + (control) => control.requirements, + ); + return { + key: `${fw.name}-${category.name}`, + frameworkName: fw.name, + categoryName: category.name, + requirements, + }; + }), + ); + return { + sections: allSections, + allSectionKeys: allSections.map((s) => s.key), + statsByName: stats, + }; + }, [filteredAttributes, insights]); + + const expandedKeys = useMemo(() => { + if (allSectionsOpen) return allSectionKeys; + const merged = new Set(openSectionKeys); + if (forcedExpandedSectionKey) merged.add(forcedExpandedSectionKey); + return Array.from(merged); + }, [ + allSectionsOpen, + allSectionKeys, + openSectionKeys, + forcedExpandedSectionKey, + ]); + + const handleAccordionChange = useCallback((next: string[]) => { + // Manually expanding/collapsing exits "all open" mode so the + // user's last interaction is the source of truth. + setAllSectionsOpen(false); + setOpenSectionKeys(next); + }, []); + + const handleToggleAll = useCallback(() => { + setAllSectionsOpen((prev) => { + const next = !prev; + setOpenSectionKeys(next ? allSectionKeys : []); + return next; + }); + }, [allSectionKeys]); + + const handleResetFilters = useCallback(() => { + setSearchTerm(""); + // Cycling the controlled-input key flushes ``DataTableSearch``'s + // internal debounce so a stale keystroke doesn't fire on top of + // the just-cleared term. + setSearchKey((k) => k + 1); + setStatusFilters([]); + }, []); + + return ( + + + {/* + Toolbar: search on the left, filters + expand-all on the right. + ``flex-wrap`` keeps every control inside the card on narrow + viewports — the "Expand all" button used to overflow because + the previous ``flex-row`` had no wrap fallback. + */} +
+
+ + + {matchCount}/{totalCount} + +
+ {/* + Right-hand block stays cohesive: Expand-all sits immediately + before the status multiselect so the user reads the action + ("expand") next to the data scope it acts on ("statuses"). + The whole block wraps to a new row as a unit when the + viewport is narrow — never the Expand-all on its own. + + ``min-w-0`` + ``max-w-full`` on the inner block make sure + the multiselect (which has an intrinsic min-width) stays + inside the Card padding instead of bleeding past the + right border on intermediate viewports. + */} +
+ + + 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 && ( + + )} +
+
+ + {sections.length === 0 ? ( +
+ No requirements match the current filters. +
+ ) : ( + + {sections.map((section) => { + const stats = statsByName.get(section.categoryName); + return ( + + + {stats ? ( + + ) : ( + {section.categoryName} + )} + + + + + + ); + })} + + )} +
+
+ ); +}; + +interface SectionRequirementsProps { + sectionKey: string; + framework: string; + requirements: Requirement[]; +} + +/** Inner accordion for the requirements of a section. Reuses + * ``ClientAccordionContent`` (the same expand body the per-scan tab + * renders) so per-provider breakdown table, checks list and findings + * table behave identically across both tabs. */ +const SectionRequirements = ({ + sectionKey, + framework, + requirements, +}: SectionRequirementsProps) => { + if (requirements.length === 0) return null; + return ( + + {requirements.map((requirement, idx) => { + const xprov = requirement as CrossProviderRequirement; + const itemKey = `${sectionKey}-req-${idx}`; + return ( + + + + + + + + + ); + })} + + ); +}; diff --git a/ui/components/compliance/cross-provider/cross-provider-grid.tsx b/ui/components/compliance/cross-provider/cross-provider-grid.tsx new file mode 100644 index 0000000000..9b84f3912a --- /dev/null +++ b/ui/components/compliance/cross-provider/cross-provider-grid.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useState } from "react"; + +import { DataTableSearch } from "@/components/ui/table/data-table-search"; + +import { CrossProviderCard } from "./cross-provider-card"; + +export interface CrossProviderFrameworkSummary { + id: string; + title: string; + version: string; + description?: string; + requirementsPassed: number; + totalRequirements: number; + /** Providers that actually contributed at least one scan to the + * aggregated view — rendered as "active" chips on the card. */ + contributingProviders: string[]; + /** Catalogue of providers the universal framework declares checks for — + * rendered as "compatible" chips on the card; the ones not in + * ``contributingProviders`` are shown dimmed to signal "no scan yet". */ + compatibleProviders: string[]; +} + +interface CrossProviderGridProps { + frameworks: CrossProviderFrameworkSummary[]; +} + +/** + * Grid of universal compliance frameworks shown under the "Cross-Provider" + * tab. Mirrors the per-scan ``ComplianceOverviewGrid`` layout (search input + * + responsive grid of cards) but each card aggregates the latest scan per + * compatible provider instead of showing a single scan. + */ +export const CrossProviderGrid = ({ frameworks }: CrossProviderGridProps) => { + const [searchTerm, setSearchTerm] = useState(""); + + const filteredFrameworks = frameworks.filter((framework) => + framework.title.toLowerCase().includes(searchTerm.toLowerCase()), + ); + + return ( + <> +
+ + + {filteredFrameworks.length.toLocaleString()} Total Entries + +
+ +
+ {filteredFrameworks.map((framework) => ( + + ))} +
+ + ); +}; diff --git a/ui/components/compliance/cross-provider/cross-provider-header.tsx b/ui/components/compliance/cross-provider/cross-provider-header.tsx new file mode 100644 index 0000000000..2c4233cb3f --- /dev/null +++ b/ui/components/compliance/cross-provider/cross-provider-header.tsx @@ -0,0 +1,107 @@ +"use client"; + +import Image from "next/image"; + +import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; +import { Card, CardContent } from "@/components/shadcn/card/card"; +import type { CrossProviderInsights } from "@/lib/compliance/cross-provider-insights"; + +import { ProviderCoveragePanel } from "./provider-coverage-panel"; +import { ScoreDonut } from "./score-donut"; +import { TopFailingDomainsPanel } from "./top-failing-domains-panel"; + +interface CrossProviderHeaderProps { + /** Universal framework key (``CSA-CCM``). Drives the icon lookup. */ + framework: string; + /** Display name. */ + name: string; + /** Version label (``4.0``). */ + version: string; + /** Long description from the universal JSON. */ + description: string; + insights: CrossProviderInsights; + /** Click handler when a top-failing-domain entry is selected. */ + onDomainSelect?: (domainName: string) => void; +} + +const formatTitle = (title: string) => title.split("-").join(" "); + +/** + * Three-pane operations header for the cross-provider compliance view. + * + * Replaces the old single-card stat grid + provider strip with: + * 1. Score donut + pass/fail/manual breakdown. + * 2. Provider coverage list (every compatible provider with its + * per-provider score and a clear "no scan yet" marker). + * 3. Top failing domains teaser — clickable anchors so the user can + * jump straight to where it hurts. + * + * On screens narrower than ``lg`` the panes stack vertically. The + * framework metadata strip (icon + title + description) sits above the + * three columns to keep the cards equal-height. + */ +export const CrossProviderHeader = ({ + framework, + name, + version, + description, + insights, + onDomainSelect, +}: CrossProviderHeaderProps) => { + return ( + + +
+ {getComplianceIcon(framework) && ( +
+ {`${framework} +
+ )} +
+
+

+ {name || formatTitle(framework)} +

+ {version && ( + + v{version} + + )} + + Universal + +
+ {description && ( +

+ {description} +

+ )} +
+
+ +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/ui/components/compliance/cross-provider/index.ts b/ui/components/compliance/cross-provider/index.ts new file mode 100644 index 0000000000..96b74492e3 --- /dev/null +++ b/ui/components/compliance/cross-provider/index.ts @@ -0,0 +1,4 @@ +export * from "./cross-provider-card"; +export * from "./cross-provider-detail"; +export * from "./cross-provider-explorer-card"; +export * from "./cross-provider-grid"; diff --git a/ui/components/compliance/cross-provider/provider-coverage-panel.tsx b/ui/components/compliance/cross-provider/provider-coverage-panel.tsx new file mode 100644 index 0000000000..885acd36ad --- /dev/null +++ b/ui/components/compliance/cross-provider/provider-coverage-panel.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { Check, Slash } from "lucide-react"; + +import type { ProviderCoverage } from "@/lib/compliance/cross-provider-insights"; +import { + getProviderBadge, + getProviderLabel, +} from "@/lib/providers/provider-display"; +import { cn } from "@/lib/utils"; + +interface ProviderCoveragePanelProps { + coverage: ProviderCoverage[]; +} + +/** + * Right-of-score panel listing every compatible provider with its + * per-provider score and a clear "scan available" / "no scan yet" + * marker. Keeps non-contributing providers visible (dimmed) instead of + * hiding them — the user needs to know coverage gaps as much as + * coverage itself. + */ +export const ProviderCoveragePanel = ({ + coverage, +}: ProviderCoveragePanelProps) => { + return ( +
+

+ Provider Coverage +

+
    + {coverage.map((entry) => { + const Badge = getProviderBadge(entry.key); + const label = getProviderLabel(entry.key); + const Icon = entry.contributing ? Check : Slash; + const dimmed = !entry.contributing; + return ( +
  • + {Badge ? : null} +
    +
    + + {label} + {entry.accountCount > 1 && ( + + {entry.accountCount} accounts + + )} + + {entry.contributing ? ( + + {entry.scorePercent}% + + ) : ( + + No scan + + )} +
    +
    + {entry.contributing && entry.total > 0 ? ( +
    + ) : null} +
    + {entry.contributing && ( + + {entry.pass} / {entry.total} pass + {entry.fail > 0 && ( + + {entry.fail} fail + + )} + + )} +
    +
  • + ); + })} +
+
+ ); +}; diff --git a/ui/components/compliance/cross-provider/score-donut.tsx b/ui/components/compliance/cross-provider/score-donut.tsx new file mode 100644 index 0000000000..bed565da85 --- /dev/null +++ b/ui/components/compliance/cross-provider/score-donut.tsx @@ -0,0 +1,166 @@ +interface ScoreDonutProps { + /** Integer 0-100. */ + scorePercent: number; + /** Pass count. */ + pass: number; + /** Fail count. */ + fail: number; + /** Manual count. */ + manual: number; + /** Total requirements. */ + total: number; + /** Pixel size of the SVG square. Defaults to 132. */ + size?: number; + /** Stroke thickness. Defaults to 12. */ + stroke?: number; +} + +/** + * Compact circular score with an explicit pass/fail/manual stack bar + * underneath. Pure SVG — no extra deps, scales cleanly in dark mode. + * + * The donut traces three arcs in succession (PASS → FAIL → MANUAL) so + * the user reads the breakdown directly off the ring instead of + * comparing it to a separate legend. The ring colors mirror the rest of + * the compliance UI (``bg-pass`` / ``bg-fail`` / ``bg-warning``). + */ +export const ScoreDonut = ({ + scorePercent, + pass, + fail, + manual, + total, + size = 132, + stroke = 12, +}: ScoreDonutProps) => { + const radius = (size - stroke) / 2; + const circumference = 2 * Math.PI * radius; + const safeTotal = total > 0 ? total : 1; + + const passLen = (pass / safeTotal) * circumference; + const failLen = (fail / safeTotal) * circumference; + const manualLen = (manual / safeTotal) * circumference; + const passOffset = 0; + const failOffset = passLen; + const manualOffset = passLen + failLen; + + const cx = size / 2; + const cy = size / 2; + + // Pick a contrasting color for the central percent so dark surfaces + // never wash it out, while doubling as a quick "how bad is it?" + // signal: the central number adopts the dominant ring color. + const centerColorClass = + scorePercent >= 70 + ? "fill-bg-pass" + : scorePercent >= 40 + ? "fill-bg-warning" + : "fill-bg-fail"; + + return ( +
+ + {/* Track */} + + {/* Arcs — rotated so the trace starts at 12 o'clock */} + + {pass > 0 && ( + + )} + {fail > 0 && ( + + )} + {manual > 0 && ( + + )} + + {/* Center label */} + + {scorePercent}% + + + {pass}/{total} + + +
    +
  • + + {pass} + + Pass +
  • +
  • + + {fail} + + Fail +
  • +
  • + + {manual} + + Manual +
  • +
+
+ ); +}; diff --git a/ui/components/compliance/cross-provider/top-failing-domains-panel.tsx b/ui/components/compliance/cross-provider/top-failing-domains-panel.tsx new file mode 100644 index 0000000000..04d6a7e053 --- /dev/null +++ b/ui/components/compliance/cross-provider/top-failing-domains-panel.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { AlertTriangle, ChevronRight } from "lucide-react"; + +import type { DomainStats } from "@/lib/compliance/cross-provider-insights"; + +interface TopFailingDomainsPanelProps { + domains: DomainStats[]; + /** Max entries to show. Defaults to 3. */ + limit?: number; + /** Click handler that scroll-anchors to the matching domain row. */ + onSelect?: (domainName: string) => void; +} + +/** + * Top-N failing domains as a quick teaser. Each entry is a click + * shortcut: anchors the accordion to the corresponding section so the + * user does not have to scroll-scan 17 domain rows to find the one + * worth investigating first. + * + * When ``onSelect`` is omitted the entries render as static rows — the + * panel is still useful as a read-only summary for screenshots and + * exports. + */ +export const TopFailingDomainsPanel = ({ + domains, + limit = 3, + onSelect, +}: TopFailingDomainsPanelProps) => { + const top = domains.filter((d) => d.fail > 0).slice(0, limit); + + return ( +
+

+ Top Failing Domains +

+ {top.length === 0 ? ( +
+ + No failing domains. Keep it up. + +
+ ) : ( +
    + {top.map((domain) => { + const Tag = onSelect ? "button" : "div"; + return ( +
  • + onSelect(domain.name) : undefined} + className={`border-border-neutral-secondary flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left transition-colors ${ + onSelect + ? "hover:border-bg-fail/40 hover:bg-bg-fail/5 cursor-pointer" + : "" + }`} + > + +
  • + ); + })} +
+ )} +
+ ); +}; diff --git a/ui/components/compliance/index.ts b/ui/components/compliance/index.ts index caea32e264..aabfe9b9c2 100644 --- a/ui/components/compliance/index.ts +++ b/ui/components/compliance/index.ts @@ -19,7 +19,10 @@ export * from "./compliance-header/compliance-scan-info"; export * from "./compliance-header/data-compliance"; export * from "./compliance-header/scan-selector"; export * from "./compliance-overview-grid"; +export * from "./compliance-page-tabs"; +export * from "./compliance-page-tabs.shared"; export * from "./compliance-warming"; +export * from "./cross-provider"; export * from "./no-scans-available"; export * from "./skeletons/bar-chart-skeleton"; export * from "./skeletons/compliance-accordion-skeleton"; diff --git a/ui/components/shadcn/accordion.tsx b/ui/components/shadcn/accordion.tsx new file mode 100644 index 0000000000..ca12466078 --- /dev/null +++ b/ui/components/shadcn/accordion.tsx @@ -0,0 +1,77 @@ +"use client"; + +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; +import type { ComponentProps } from "react"; + +import { cn } from "@/lib/utils"; + +/** + * Shadcn Accordion bound to Radix primitives. + * + * Imported once and styled with Tailwind utilities; consumers (e.g. + * the cross-provider compliance accordion) pass children — there is + * intentionally no ``variant`` prop because the project surfaces a + * single accordion style. Headers carry the rotating chevron, content + * uses Radix's data-state animation hooks (``data-[state=open]`` / + * ``data-[state=closed]``) so each row collapses smoothly without + * extra JS. + */ +function Accordion({ + ...props +}: ComponentProps) { + return ; +} + +function AccordionItem({ + className, + ...props +}: ComponentProps) { + return ( + + ); +} + +function AccordionTrigger({ + className, + children, + ...props +}: ComponentProps) { + return ( + + svg]:rotate-180", + className, + )} + {...props} + > + {children} + + + + ); +} + +function AccordionContent({ + className, + children, + ...props +}: ComponentProps) { + return ( + +
{children}
+
+ ); +} + +export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }; diff --git a/ui/components/shadcn/index.ts b/ui/components/shadcn/index.ts index 18ac317ef5..0c6dd274a2 100644 --- a/ui/components/shadcn/index.ts +++ b/ui/components/shadcn/index.ts @@ -1,3 +1,4 @@ +export * from "./accordion"; export * from "./alert"; export * from "./badge/badge"; export * from "./button/button"; diff --git a/ui/dependency-log.json b/ui/dependency-log.json index cabfa0b065..a17e1162d9 100644 --- a/ui/dependency-log.json +++ b/ui/dependency-log.json @@ -119,6 +119,14 @@ "strategy": "installed", "generatedAt": "2026-06-15T07:49:41.143Z" }, + { + "section": "dependencies", + "name": "@radix-ui/react-accordion", + "from": "1.2.12", + "to": "1.2.12", + "strategy": "installed", + "generatedAt": "2026-06-30T13:26:29.681Z" + }, { "section": "dependencies", "name": "@radix-ui/react-alert-dialog", diff --git a/ui/lib/compliance/cross-provider-adapter.test.ts b/ui/lib/compliance/cross-provider-adapter.test.ts new file mode 100644 index 0000000000..86dbb3531e --- /dev/null +++ b/ui/lib/compliance/cross-provider-adapter.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; + +import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance"; + +import { crossProviderToMapperInput } from "./cross-provider-adapter"; + +const ATTRIBUTES: CrossProviderComplianceOverviewAttributes = { + compliance_id: "csa_ccm_4.0", + framework: "CSA-CCM", + name: "CSA-CCM", + version: "4.0", + description: "Cloud Security Alliance Cloud Controls Matrix", + compatible_providers: ["aws", "azure", "gcp"], + requested_providers: ["aws", "azure"], + providers: ["aws", "azure"], + scan_ids: ["scan-aws-uuid", "scan-azure-uuid"], + scan_ids_by_provider: { + aws: ["scan-aws-uuid"], + azure: ["scan-azure-uuid"], + }, + requirements_passed: 1, + requirements_failed: 1, + requirements_manual: 0, + total_requirements: 2, + requirements: [ + { + id: "AAA-01", + name: "Access Control Policy", + description: "Establish access policies", + attributes: { + Section: "Audit & Assurance", + CCMLite: "Yes", + IaaS: "Shared", + PaaS: "Shared", + SaaS: "Shared", + ScopeApplicability: [], + }, + status: "PASS", + providers: { aws: "PASS", azure: "PASS" }, + check_ids_by_provider: { + aws: ["aws_check_a", "aws_check_b"], + azure: ["azure_check_a", "aws_check_a"], + }, + }, + { + id: "AAA-02", + name: "Audit Independence", + description: "Maintain auditor independence", + attributes: { + Section: "Audit & Assurance", + CCMLite: "No", + IaaS: "Customer-Owned", + PaaS: "Customer-Owned", + SaaS: "Customer-Owned", + ScopeApplicability: [], + }, + status: "FAIL", + providers: { aws: "FAIL" }, + check_ids_by_provider: { + aws: ["aws_check_c"], + }, + }, + ], +}; + +describe("crossProviderToMapperInput", () => { + it("produces a paired AttributesData / RequirementsData structure", () => { + const { attributesData, requirementsData } = + crossProviderToMapperInput(ATTRIBUTES); + expect(attributesData.data).toHaveLength(2); + expect(requirementsData.data).toHaveLength(2); + expect(attributesData.data[0].id).toBe("AAA-01"); + expect(requirementsData.data[0].id).toBe("AAA-01"); + }); + + it("wraps the flat attributes dict in a single-element metadata array", () => { + const { attributesData } = crossProviderToMapperInput(ATTRIBUTES); + const metadata = attributesData.data[0].attributes.attributes.metadata as { + Section: string; + }[]; + expect(metadata).toHaveLength(1); + expect(metadata[0].Section).toBe("Audit & Assurance"); + }); + + it("computes the deduplicated union of check IDs across contributing providers", () => { + const { attributesData } = crossProviderToMapperInput(ATTRIBUTES); + const checks = attributesData.data[0].attributes.attributes.check_ids; + expect(checks).toEqual( + expect.arrayContaining(["aws_check_a", "aws_check_b", "azure_check_a"]), + ); + // ``aws_check_a`` appears in both AWS and Azure lists; the union must + // emit it once so the inner ``Checks`` accordion does not duplicate. + expect(checks.filter((c) => c === "aws_check_a")).toHaveLength(1); + }); + + it("propagates per-provider context onto the inner attributes slot", () => { + const { attributesData } = crossProviderToMapperInput(ATTRIBUTES); + const inner = attributesData.data[0].attributes.attributes; + expect(inner.providers).toEqual({ aws: "PASS", azure: "PASS" }); + expect(inner.check_ids_by_provider).toEqual({ + aws: ["aws_check_a", "aws_check_b"], + azure: ["azure_check_a", "aws_check_a"], + }); + // The scan map is global (shared across all requirements) so the + // adapter copies it onto every attribute item — that matches how the + // shared accordion machinery resolves it through ``Requirement``. + expect(inner.scan_ids_by_provider).toEqual({ + aws: ["scan-aws-uuid"], + azure: ["scan-azure-uuid"], + }); + }); + + it("preserves the rolled-up requirement status as the input for the per-scan mapper", () => { + const { requirementsData } = crossProviderToMapperInput(ATTRIBUTES); + expect(requirementsData.data[0].attributes.status).toBe("PASS"); + expect(requirementsData.data[1].attributes.status).toBe("FAIL"); + }); + + it("falls back to empty maps when the API omits the augmentation fields", () => { + const { attributesData } = crossProviderToMapperInput({ + ...ATTRIBUTES, + scan_ids_by_provider: {}, + requirements: [ + { + ...ATTRIBUTES.requirements[0], + check_ids_by_provider: undefined, + }, + ], + }); + const inner = attributesData.data[0].attributes.attributes; + expect(inner.check_ids).toEqual([]); + expect(inner.check_ids_by_provider).toEqual({}); + expect(inner.scan_ids_by_provider).toEqual({}); + }); +}); diff --git a/ui/lib/compliance/cross-provider-adapter.ts b/ui/lib/compliance/cross-provider-adapter.ts new file mode 100644 index 0000000000..2f2b3c8412 --- /dev/null +++ b/ui/lib/compliance/cross-provider-adapter.ts @@ -0,0 +1,96 @@ +import type { + AttributesData, + AttributesItemData, + CrossProviderComplianceOverviewAttributes, + RequirementItemData, + RequirementsData, +} from "@/types/compliance"; + +/** + * Convert a cross-provider compliance overview into the shape the per-scan + * compliance mappers expect. + * + * The per-scan flow consumes two parallel JSON:API responses: + * - ``AttributesData`` — per-requirement metadata (Section/CCMLite/...) + * plus the list of check IDs the requirement runs. + * - ``RequirementsData`` — per-requirement status for the selected scan. + * + * The cross-provider endpoint already exposes everything in a single + * resource where each requirement carries: + * - flat ``attributes`` (one element of the universal JSON metadata array + * wrapped in a list — the mappers read ``metadata[0]`` so this works). + * - rolled-up ``status`` derived server-side. + * - ``providers`` map (per-provider statuses that fed the roll-up). + * - ``check_ids_by_provider`` map (universal-framework-declared check + * IDs per contributing provider). + * + * This adapter rebuilds the two-payload pair so the existing CSA-CCM (and + * any future framework) mapper renders a hierarchical accordion without + * needing a parallel cross-provider pipeline. + * + * The returned ``AttributesItemData`` carries the cross-provider context + * (``providers``, ``check_ids_by_provider``, ``scan_ids_by_provider``) on + * its inner ``attributes.attributes`` slot. Mappers then copy those + * augmentations onto the ``Requirement`` literal so the renderers can + * decide whether to expose per-provider chips, breakdown tables, and + * per-scan finding queries. + */ +export const crossProviderToMapperInput = ( + attrs: CrossProviderComplianceOverviewAttributes, +): { attributesData: AttributesData; requirementsData: RequirementsData } => { + const scanIdsByProvider = attrs.scan_ids_by_provider || {}; + + const attributeItems: AttributesItemData[] = []; + const requirementItems: RequirementItemData[] = []; + + for (const req of attrs.requirements) { + const checkIdsByProvider = req.check_ids_by_provider || {}; + const allCheckIds = Array.from( + new Set( + Object.values(checkIdsByProvider).flatMap((ids) => + Array.isArray(ids) ? ids : [], + ), + ), + ); + + attributeItems.push({ + type: "compliance-requirements-attributes", + id: req.id, + attributes: { + framework_description: attrs.description || "", + name: req.name, + framework: attrs.framework, + version: attrs.version || "", + description: req.description || "", + attributes: { + // The mappers (e.g. CSA) read ``metadata[0]`` and pull + // framework-specific fields off it. Wrap the flat dict in a + // single-element list to satisfy that contract. + metadata: [ + req.attributes as unknown as AttributesItemData["attributes"]["attributes"]["metadata"][number], + ] as AttributesItemData["attributes"]["attributes"]["metadata"], + check_ids: allCheckIds, + providers: req.providers, + check_ids_by_provider: checkIdsByProvider, + scan_ids_by_provider: scanIdsByProvider, + }, + }, + }); + + requirementItems.push({ + type: "compliance-requirements-details", + id: req.id, + attributes: { + framework: attrs.framework, + version: attrs.version || "", + description: req.description || "", + status: req.status, + }, + }); + } + + return { + attributesData: { data: attributeItems }, + requirementsData: { data: requirementItems }, + }; +}; diff --git a/ui/lib/compliance/cross-provider-insights.test.ts b/ui/lib/compliance/cross-provider-insights.test.ts new file mode 100644 index 0000000000..8ebe754a12 --- /dev/null +++ b/ui/lib/compliance/cross-provider-insights.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; + +import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance"; + +import { computeCrossProviderInsights } from "./cross-provider-insights"; + +const buildAttributes = (): CrossProviderComplianceOverviewAttributes => ({ + compliance_id: "csa_ccm_4.0", + framework: "CSA-CCM", + name: "CSA-CCM", + version: "4.0", + description: "test", + compatible_providers: ["aws", "azure", "gcp"], + requested_providers: ["aws", "azure", "gcp"], + providers: ["aws", "azure"], + scan_ids: ["scan-aws", "scan-azure", "scan-gcp"], + scan_ids_by_provider: { + aws: ["scan-aws"], + azure: ["scan-azure"], + gcp: ["scan-gcp"], + }, + requirements_passed: 2, + requirements_failed: 2, + requirements_manual: 1, + total_requirements: 5, + requirements: [ + { + id: "AAA-01", + name: "A", + description: "", + attributes: { Section: "Audit" }, + status: "PASS", + providers: { aws: "PASS", azure: "PASS" }, + }, + { + id: "AAA-02", + name: "B", + description: "", + attributes: { Section: "Audit" }, + status: "FAIL", + providers: { aws: "FAIL", azure: "PASS" }, + }, + { + id: "DSP-01", + name: "C", + description: "", + attributes: { Section: "Data Sec" }, + status: "FAIL", + providers: { aws: "FAIL" }, + }, + { + id: "DSP-02", + name: "D", + description: "", + attributes: { Section: "Data Sec" }, + status: "MANUAL", + providers: {}, + }, + { + id: "OTHER-01", + name: "E", + description: "", + // No Section attribute — must land in the ``Other`` bucket. + attributes: {}, + status: "PASS", + providers: { aws: "PASS" }, + }, + ], +}); + +describe("computeCrossProviderInsights", () => { + it("computes the score from passed/total", () => { + const insights = computeCrossProviderInsights(buildAttributes()); + // 2 / 5 = 40 + expect(insights.scorePercent).toBe(40); + expect(insights.pass).toBe(2); + expect(insights.fail).toBe(2); + expect(insights.manual).toBe(1); + expect(insights.total).toBe(5); + }); + + it("builds providerCoverage for every compatible provider, including non-contributing ones", () => { + const insights = computeCrossProviderInsights(buildAttributes()); + const byKey = Object.fromEntries( + insights.providerCoverage.map((c) => [c.key, c]), + ); + expect(byKey.aws.contributing).toBe(true); + expect(byKey.aws.scanIds).toEqual(["scan-aws"]); + expect(byKey.aws.accountCount).toBe(1); + // AWS contributed 4 rows: 2 PASS + 2 FAIL. + expect(byKey.aws.pass).toBe(2); + expect(byKey.aws.fail).toBe(2); + expect(byKey.aws.total).toBe(4); + expect(byKey.aws.scorePercent).toBe(50); + + // Azure contributed 2 rows: both PASS → 100%. + expect(byKey.azure.contributing).toBe(true); + expect(byKey.azure.pass).toBe(2); + expect(byKey.azure.total).toBe(2); + expect(byKey.azure.scorePercent).toBe(100); + + // GCP is in compatible_providers but the API marks it as + // non-contributing — surfaces with zeroed counts so the panel can + // dim it instead of hiding it. + expect(byKey.gcp.contributing).toBe(false); + expect(byKey.gcp.total).toBe(0); + expect(byKey.gcp.scorePercent).toBe(0); + }); + + it("aggregates per-domain stats by Section attribute, with an Other bucket fallback", () => { + const insights = computeCrossProviderInsights(buildAttributes()); + const byName = Object.fromEntries( + insights.domainStats.map((d) => [d.name, d]), + ); + expect(Object.keys(byName).sort()).toEqual(["Audit", "Data Sec", "Other"]); + expect(byName.Audit.total).toBe(2); + expect(byName.Audit.pass).toBe(1); + expect(byName.Audit.fail).toBe(1); + expect(byName["Data Sec"].fail).toBe(1); + expect(byName["Data Sec"].manual).toBe(1); + // Section-less requirements must not silently disappear. + expect(byName.Other.total).toBe(1); + }); + + it("rolls each domain's per-provider status with FAIL > PASS > MANUAL > NO_ROW", () => { + const insights = computeCrossProviderInsights(buildAttributes()); + const audit = insights.domainStats.find((d) => d.name === "Audit"); + if (!audit) throw new Error("Audit domain missing"); + // AWS contributed PASS + FAIL → FAIL. + expect(audit.byProvider.aws).toBe("FAIL"); + // Azure contributed PASS + PASS → PASS. + expect(audit.byProvider.azure).toBe("PASS"); + // GCP did not contribute any row in the Audit domain. + expect(audit.byProvider.gcp).toBe("NO_ROW"); + + const dataSec = insights.domainStats.find((d) => d.name === "Data Sec"); + if (!dataSec) throw new Error("Data Sec missing"); + expect(dataSec.byProvider.aws).toBe("FAIL"); + expect(dataSec.byProvider.azure).toBe("NO_ROW"); + }); + + it("orders domainsByFailCount descending", () => { + const insights = computeCrossProviderInsights(buildAttributes()); + expect(insights.domainsByFailCount.map((d) => d.name)).toEqual([ + "Audit", + "Data Sec", + "Other", + ]); + expect(insights.domainsByFailCount[0].fail).toBeGreaterThanOrEqual( + insights.domainsByFailCount[1].fail, + ); + }); + + it("surfaces accountCount > 1 when a provider type has N accounts", () => { + const multiAccount: CrossProviderComplianceOverviewAttributes = { + ...buildAttributes(), + // Same provider type, two distinct scan UUIDs (= two accounts). + scan_ids_by_provider: { + aws: ["scan-aws-prod", "scan-aws-dev"], + azure: ["scan-azure"], + gcp: ["scan-gcp"], + }, + scan_ids: ["scan-aws-prod", "scan-aws-dev", "scan-azure", "scan-gcp"], + }; + const insights = computeCrossProviderInsights(multiAccount); + const aws = insights.providerCoverage.find((c) => c.key === "aws"); + if (!aws) throw new Error("aws coverage missing"); + expect(aws.scanIds).toEqual(["scan-aws-prod", "scan-aws-dev"]); + expect(aws.accountCount).toBe(2); + }); + + it("returns a stable zero-state when total_requirements is 0", () => { + const empty: CrossProviderComplianceOverviewAttributes = { + ...buildAttributes(), + requirements: [], + requirements_passed: 0, + requirements_failed: 0, + requirements_manual: 0, + total_requirements: 0, + providers: [], + }; + const insights = computeCrossProviderInsights(empty); + expect(insights.scorePercent).toBe(0); + expect(insights.domainStats).toEqual([]); + expect(insights.domainsByFailCount).toEqual([]); + // Compatible providers still surface (empty stats) so the coverage + // panel doesn't disappear. + expect(insights.providerCoverage).toHaveLength(3); + }); +}); diff --git a/ui/lib/compliance/cross-provider-insights.ts b/ui/lib/compliance/cross-provider-insights.ts new file mode 100644 index 0000000000..59af692d4e --- /dev/null +++ b/ui/lib/compliance/cross-provider-insights.ts @@ -0,0 +1,215 @@ +import type { + CrossProviderComplianceOverviewAttributes, + CrossProviderRequirementStatus, +} from "@/types/compliance"; + +/** + * Status a provider contributes to a single domain when its rows are + * aggregated. ``NO_ROW`` means the universal framework does not declare + * any check for that provider in the domain (or the scan returned no + * rows for it) — visually rendered as dimmed so contributing-but-failing + * providers stay distinguishable from non-contributing ones. + */ +export type DomainProviderStatus = CrossProviderRequirementStatus | "NO_ROW"; + +export interface ProviderCoverage { + /** Lowercase provider key (``aws``, ``azure``, ...). */ + key: string; + /** Whether this provider contributed at least one row. */ + contributing: boolean; + /** Scan UUIDs associated with this provider type. A list because a + * tenant can have N accounts of the same type — the cross-provider + * endpoint aggregates one scan per Provider row. */ + scanIds: string[]; + /** PASS count across all requirements. */ + pass: number; + /** FAIL count across all requirements. */ + fail: number; + /** Total requirements for which the provider contributed a row. */ + total: number; + /** ``pass / total`` rounded to integer percent (0 when total = 0). */ + scorePercent: number; + /** Number of distinct accounts (Provider rows) of this type whose + * scans contribute to the aggregation. ``0`` for non-contributing + * providers. */ + accountCount: number; +} + +export interface DomainStats { + /** Section name (e.g. ``Audit & Assurance``). */ + name: string; + /** Total requirements grouped under this domain. */ + total: number; + pass: number; + fail: number; + manual: number; + /** Per-provider rolled-up status across all requirements in this + * domain. ``FAIL`` if any req under this provider failed; ``PASS`` + * if at least one passed and none failed; ``MANUAL`` if every + * contributing row is manual; ``NO_ROW`` if the provider never + * contributed a row to this domain. */ + byProvider: Record; +} + +export interface CrossProviderInsights { + /** ``requirements_passed / total_requirements`` as integer percent. */ + scorePercent: number; + pass: number; + fail: number; + manual: number; + total: number; + /** Compatible providers from the API, in display order. */ + compatibleProviders: string[]; + /** Provider keys that contributed at least one row. */ + contributingProviders: string[]; + /** One coverage entry per compatible provider (always present, even + * for non-contributing ones — coverage UI dims them rather than + * hiding them). */ + providerCoverage: ProviderCoverage[]; + /** Domain stats keyed by section name, in declared order. */ + domainStats: DomainStats[]; + /** Top N domains by ``fail`` count, descending. ``N`` defaults to 3 + * in the consumer but the helper exposes the full list. */ + domainsByFailCount: DomainStats[]; +} + +/** + * Roll-up rule for a single provider's contribution to a domain (one + * step coarser than the per-requirement roll-up the API computes). + * + * Mirrors the API's "FAIL > PASS > MANUAL" ordering so the domain row + * surfaces the worst-case provider status without re-fetching anything. + */ +const aggregateDomainProviderStatuses = ( + statuses: CrossProviderRequirementStatus[], +): DomainProviderStatus => { + if (statuses.length === 0) return "NO_ROW"; + if (statuses.some((s) => s === "FAIL")) return "FAIL"; + if (statuses.some((s) => s === "PASS")) return "PASS"; + return "MANUAL"; +}; + +/** + * Pull every derived figure the cross-provider header + accordion + * components need out of a single response payload. Centralises the + * iteration so each consumer (donut, coverage list, heatmap rows, top + * failing teaser) runs over ``requirements`` once instead of N times. + */ +export const computeCrossProviderInsights = ( + attributes: CrossProviderComplianceOverviewAttributes, +): CrossProviderInsights => { + const { + compatible_providers: compatible, + providers: contributing, + scan_ids_by_provider: scanIdsByProvider, + requirements, + requirements_passed: pass, + requirements_failed: fail, + requirements_manual: manual, + total_requirements: total, + } = attributes; + + const scorePercent = total > 0 ? Math.floor((pass / total) * 100) : 0; + + const providerPass = new Map(); + const providerFail = new Map(); + const providerTotal = new Map(); + + // Domain accumulators are keyed by section name. We only know the + // section once we read ``req.attributes.Section`` — fall back to a + // generic bucket so requirements without the field still surface + // somewhere instead of silently dropping out. + const domainAcc = new Map< + string, + { + pass: number; + fail: number; + manual: number; + total: number; + perProvider: Map; + } + >(); + + for (const req of requirements) { + const section = + (req.attributes as { Section?: string } | undefined)?.Section || "Other"; + let domain = domainAcc.get(section); + if (!domain) { + domain = { + pass: 0, + fail: 0, + manual: 0, + total: 0, + perProvider: new Map(), + }; + domainAcc.set(section, domain); + } + domain.total += 1; + if (req.status === "PASS") domain.pass += 1; + else if (req.status === "FAIL") domain.fail += 1; + else domain.manual += 1; + + for (const [providerKey, status] of Object.entries(req.providers)) { + providerTotal.set(providerKey, (providerTotal.get(providerKey) || 0) + 1); + if (status === "PASS") { + providerPass.set(providerKey, (providerPass.get(providerKey) || 0) + 1); + } else if (status === "FAIL") { + providerFail.set(providerKey, (providerFail.get(providerKey) || 0) + 1); + } + const list = domain.perProvider.get(providerKey) ?? []; + list.push(status); + domain.perProvider.set(providerKey, list); + } + } + + const contributingSet = new Set(contributing); + const providerCoverage: ProviderCoverage[] = compatible.map((key) => { + const total = providerTotal.get(key) || 0; + const pass = providerPass.get(key) || 0; + const scorePct = total > 0 ? Math.floor((pass / total) * 100) : 0; + const scanIds = scanIdsByProvider?.[key] ?? []; + return { + key, + contributing: contributingSet.has(key), + scanIds, + accountCount: scanIds.length, + pass, + fail: providerFail.get(key) || 0, + total, + scorePercent: scorePct, + }; + }); + + const domainStats: DomainStats[] = Array.from(domainAcc.entries()).map( + ([name, acc]) => { + const byProvider: Record = {}; + for (const providerKey of compatible) { + const statuses = acc.perProvider.get(providerKey) ?? []; + byProvider[providerKey] = aggregateDomainProviderStatuses(statuses); + } + return { + name, + total: acc.total, + pass: acc.pass, + fail: acc.fail, + manual: acc.manual, + byProvider, + }; + }, + ); + + const domainsByFailCount = [...domainStats].sort((a, b) => b.fail - a.fail); + + return { + scorePercent, + pass, + fail, + manual, + total, + compatibleProviders: compatible, + contributingProviders: contributing, + providerCoverage, + domainStats, + domainsByFailCount, + }; +}; diff --git a/ui/lib/compliance/csa.tsx b/ui/lib/compliance/csa.tsx index 1c82a30777..66e7301afe 100644 --- a/ui/lib/compliance/csa.tsx +++ b/ui/lib/compliance/csa.tsx @@ -6,6 +6,7 @@ import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; import { FindingStatus } from "@/components/ui/table/status-finding-badge"; import { AttributesData, + CrossProviderRequirement, CSAAttributesMetadata, Framework, Requirement, @@ -66,6 +67,14 @@ export const mapComplianceData = ( const description = attributeItem.attributes.description; const status = requirementData.attributes.status || ""; const checks = attributeItem.attributes.attributes.check_ids || []; + // Optional cross-provider augmentations (only present when the attribute + // item came from ``crossProviderToMapperInput``). + const providersForRequirement = + attributeItem.attributes.attributes.providers; + const checkIdsByProvider = + attributeItem.attributes.attributes.check_ids_by_provider; + const scanIdsByProvider = + attributeItem.attributes.attributes.scan_ids_by_provider; const framework = findOrCreateFramework(frameworks, frameworkName); const category = findOrCreateCategory(framework.categories, categoryName); @@ -73,7 +82,11 @@ export const mapComplianceData = ( const control = findOrCreateControl(category.controls, categoryName); const finalStatus: RequirementStatus = status as RequirementStatus; - const requirement: Requirement = { + // Build the cross-provider-augmented requirement; it structurally extends + // ``Requirement`` so the per-scan accordion pipeline accepts it without + // changes — the extra maps stay ``undefined`` when the input did not carry + // them. + const requirement: CrossProviderRequirement = { name: requirementName ? `${id} - ${requirementName}` : id, description, status: finalStatus, @@ -85,6 +98,9 @@ export const mapComplianceData = ( paas: attrs.PaaS, saas: attrs.SaaS, scope_applicability: attrs.ScopeApplicability, + providers: providersForRequirement, + check_ids_by_provider: checkIdsByProvider, + scan_ids_by_provider: scanIdsByProvider, }; control.requirements.push(requirement); @@ -124,6 +140,7 @@ export const toAccordionItems = ( name={requirement.name} status={requirement.status as FindingStatus} invalidConfig={requirement.invalid_config} + providers={(requirement as CrossProviderRequirement).providers} /> ), content: ( diff --git a/ui/lib/compliance/universal-frameworks.sync.test.ts b/ui/lib/compliance/universal-frameworks.sync.test.ts new file mode 100644 index 0000000000..ca67fa2690 --- /dev/null +++ b/ui/lib/compliance/universal-frameworks.sync.test.ts @@ -0,0 +1,121 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { UNIVERSAL_FRAMEWORKS } from "./universal-frameworks"; + +/** + * Guards the hardcoded ``UNIVERSAL_FRAMEWORKS`` catalogue against drifting out + * of sync with the SDK. The cross-provider tab iterates this catalogue to know + * which universal frameworks to roll up, but the SDK is the source of truth: + * every ``prowler/compliance/.json`` whose ``requirements[].checks`` + * is a per-provider dict is a universal framework. + * + * This test mirrors ``ComplianceFramework.get_providers()`` in + * ``prowler/lib/check/compliance_models.py`` (it derives the compatible + * providers from the union of ``checks`` keys) so a new universal JSON — or a + * provider added to an existing one — fails CI until the catalogue is updated. + * + * ``ui/`` and ``prowler/`` are siblings in the monorepo, so resolve the SDK + * compliance dir three levels up from ``ui/lib/compliance/``. + */ +const currentDir = path.dirname(fileURLToPath(import.meta.url)); +const sdkComplianceDir = path.resolve( + currentDir, + "../../../prowler/compliance", +); + +interface SdkUniversalFramework { + id: string; + providers: string[]; +} + +const readSdkUniversalFrameworks = (): SdkUniversalFramework[] => { + const frameworks: SdkUniversalFramework[] = []; + + for (const file of readdirSync(sdkComplianceDir)) { + if (!file.endsWith(".json")) continue; + + const raw = JSON.parse( + readFileSync(path.join(sdkComplianceDir, file), "utf8"), + ); + + // Universal frameworks use the lowercase ``requirements`` key (legacy + // per-provider JSONs live under prowler/compliance// and use + // ``Requirements``). A universal requirement carries ``checks`` as a dict + // keyed by provider. + const requirements = raw?.requirements; + if (!Array.isArray(requirements)) continue; + + const providers = new Set(); + let hasPerProviderChecks = false; + for (const req of requirements) { + const checks = req?.checks; + if (checks && typeof checks === "object" && !Array.isArray(checks)) { + hasPerProviderChecks = true; + for (const key of Object.keys(checks)) { + providers.add(key.toLowerCase()); + } + } + } + if (!hasPerProviderChecks) continue; + + frameworks.push({ + id: file.replace(/\.json$/, ""), + providers: Array.from(providers).sort(), + }); + } + + return frameworks; +}; + +describe("UNIVERSAL_FRAMEWORKS stays in sync with the SDK", () => { + it("can locate the SDK compliance directory", () => { + // A broken path would make every assertion below vacuously pass, so fail + // loudly instead. This test requires the full monorepo checkout. + expect( + existsSync(sdkComplianceDir), + `SDK compliance dir not found at ${sdkComplianceDir}. This sync test ` + + "requires the monorepo checkout (ui/ and prowler/ as siblings).", + ).toBe(true); + }); + + const sdkUniversals = existsSync(sdkComplianceDir) + ? readSdkUniversalFrameworks() + : []; + + it("discovers the known universal frameworks", () => { + // Backstop against the detection logic silently yielding an empty list. + expect(sdkUniversals.length).toBeGreaterThanOrEqual(3); + }); + + it.each(sdkUniversals.map((fw) => [fw.id, fw] as const))( + "lists %s with the providers the SDK declares", + (_id, fw) => { + const entry = UNIVERSAL_FRAMEWORKS.find((e) => e.id === fw.id); + expect( + entry, + `Universal framework "${fw.id}" exists in prowler/compliance/ but is ` + + "missing from UNIVERSAL_FRAMEWORKS " + + "(ui/lib/compliance/universal-frameworks.ts). Add an entry for it.", + ).toBeDefined(); + expect( + Array.from(entry!.providers).sort(), + `Providers for "${fw.id}" are out of sync with the SDK checks dict.`, + ).toEqual(fw.providers); + }, + ); + + it("does not list frameworks that no longer exist in the SDK", () => { + const sdkIds = new Set(sdkUniversals.map((fw) => fw.id)); + for (const entry of UNIVERSAL_FRAMEWORKS) { + expect( + sdkIds.has(entry.id), + `UNIVERSAL_FRAMEWORKS lists "${entry.id}" but no matching universal ` + + "JSON exists in prowler/compliance/. Remove the stale entry.", + ).toBe(true); + } + }); +}); diff --git a/ui/lib/compliance/universal-frameworks.ts b/ui/lib/compliance/universal-frameworks.ts new file mode 100644 index 0000000000..db29a29fd0 --- /dev/null +++ b/ui/lib/compliance/universal-frameworks.ts @@ -0,0 +1,94 @@ +/** + * Catalogue of universal compliance frameworks supported by the cross-provider + * compliance roll-up endpoint. + * + * A universal framework is a single compliance specification (e.g. CSA Cloud + * Controls Matrix v4.0) that declares per-provider check lists at the + * requirement level. The backend exposes one row per provider it covers under + * legacy ``_`` slugs while the universal id (without the + * provider suffix) is the canonical handle for cross-provider aggregation. + * + * The list is hardcoded today because there is no API endpoint listing + * universal framework ids. When a new universal JSON ships in + * ``prowler/compliance/.json`` (matching the schema described in + * ``docs/developer-guide/security-compliance-framework.mdx``), add an entry + * here. + */ +export interface UniversalFrameworkCatalogEntry { + /** Universal framework id used as ``filter[compliance_id]``. */ + id: string; + /** Display title — kept aligned with the ``framework`` field exposed by the + * ``cross_provider`` endpoint so existing helpers (``getComplianceIcon``) + * resolve the same icon as the per-scan tab uses for the same framework. */ + title: string; + /** Framework version surfaced in the card and detail header. */ + version: string; + /** Short marketing-style description for the card subtitle. */ + description: string; + /** Static list of providers the framework is documented to cover. The + * authoritative list at runtime is the ``compatible_providers`` field on + * the API response. */ + providers: string[]; +} + +export const UNIVERSAL_FRAMEWORKS: UniversalFrameworkCatalogEntry[] = [ + { + id: "csa_ccm_4.0", + title: "CSA-CCM", + version: "4.0", + description: + "CSA Cloud Controls Matrix (CCM) v4.0 — a cybersecurity control " + + "framework with 197 control objectives across 17 domains.", + providers: ["aws", "azure", "gcp", "alibabacloud", "oraclecloud"], + }, + { + id: "cis_controls_8.1", + title: "CIS-Controls", + version: "8.1", + description: + "CIS Critical Security Controls v8.1 — a prioritized set of " + + "safeguards organized into 18 controls to mitigate the most " + + "prevalent cyber-attacks against systems and networks.", + providers: [ + "aws", + "azure", + "gcp", + "m365", + "kubernetes", + "github", + "googleworkspace", + "okta", + "oraclecloud", + "alibabacloud", + "cloudflare", + "mongodbatlas", + "linode", + "openstack", + "stackit", + "nhn", + "scaleway", + "vercel", + ], + }, + { + id: "dora_2022_2554", + title: "DORA", + version: "2022/2554", + description: + "Digital Operational Resilience Act (Regulation (EU) 2022/2554) — " + + "the EU framework for the digital operational resilience of the " + + "financial sector.", + providers: ["aws", "azure", "gcp", "alibabacloud", "cloudflare"], + }, +]; + +export const UNIVERSAL_FRAMEWORK_IDS: ReadonlySet = new Set( + UNIVERSAL_FRAMEWORKS.map((f) => f.id), +); + +export const isUniversalFrameworkId = ( + complianceId: string | null | undefined, +): boolean => { + if (!complianceId) return false; + return UNIVERSAL_FRAMEWORK_IDS.has(complianceId); +}; diff --git a/ui/lib/providers/provider-display.ts b/ui/lib/providers/provider-display.ts new file mode 100644 index 0000000000..0bc1b7dcbb --- /dev/null +++ b/ui/lib/providers/provider-display.ts @@ -0,0 +1,52 @@ +import { type FC } from "react"; + +import { + AlibabaCloudProviderBadge, + AWSProviderBadge, + AzureProviderBadge, + GCPProviderBadge, + KS8ProviderBadge, + M365ProviderBadge, + OracleCloudProviderBadge, +} from "@/components/icons/providers-badge"; +import type { IconSvgProps } from "@/types/components"; + +/** + * Single source of truth for provider display metadata. Kept in + * ``ui/lib/providers`` (not ``ui/lib/compliance``) so non-compliance + * surfaces (settings, scan list, finding drawer) can adopt it without + * fanning out duplicates the way the compliance accordion did. + * + * Keys are the lowercase provider strings the API emits in + * ``provider_type`` and ``providers[*]``. New providers must be added + * here once and consumers will pick them up automatically. + */ +export const PROVIDER_BADGE_BY_KEY: Record> = { + aws: AWSProviderBadge, + azure: AzureProviderBadge, + gcp: GCPProviderBadge, + alibabacloud: AlibabaCloudProviderBadge, + oraclecloud: OracleCloudProviderBadge, + kubernetes: KS8ProviderBadge, + m365: M365ProviderBadge, +}; + +export const PROVIDER_LABEL_BY_KEY: Record = { + aws: "AWS", + azure: "Azure", + gcp: "GCP", + alibabacloud: "Alibaba Cloud", + oraclecloud: "Oracle Cloud", + kubernetes: "Kubernetes", + m365: "Microsoft 365", +}; + +/** Resolve a provider label, falling back to an uppercased key. */ +export const getProviderLabel = (providerKey: string): string => + PROVIDER_LABEL_BY_KEY[providerKey] ?? providerKey.toUpperCase(); + +/** Resolve the badge component, returning ``undefined`` when no icon + * is registered for the given key (the consumer renders a fallback). */ +export const getProviderBadge = ( + providerKey: string, +): FC | undefined => PROVIDER_BADGE_BY_KEY[providerKey]; diff --git a/ui/package.json b/ui/package.json index d65d329425..127f7cc787 100644 --- a/ui/package.json +++ b/ui/package.json @@ -50,6 +50,7 @@ "@langchain/openai": "1.4.5", "@lezer/highlight": "1.2.3", "@next/third-parties": "16.2.9", + "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.14", "@radix-ui/react-avatar": "1.1.11", "@radix-ui/react-checkbox": "1.3.3", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 2026014298..9f227072aa 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: '@next/third-parties': specifier: 16.2.9 version: 16.2.9(next@16.2.9(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + '@radix-ui/react-accordion': + specifier: 1.2.12 + version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-alert-dialog': specifier: 1.1.14 version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -2651,6 +2654,19 @@ packages: '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/react-accordion@1.2.12': + resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-alert-dialog@1.1.14': resolution: {integrity: sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ==} peerDependencies: @@ -11536,6 +11552,23 @@ snapshots: '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-alert-dialog@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.2 diff --git a/ui/styles/globals.css b/ui/styles/globals.css index 56d8dc1d9e..62147cbf6c 100644 --- a/ui/styles/globals.css +++ b/ui/styles/globals.css @@ -429,3 +429,35 @@ user-select: text; } } + +/* + * Shadcn Accordion (Radix) animations. + * + * The shadcn ``Accordion`` content uses ``data-[state=open]`` and + * ``data-[state=closed]`` selectors that reach for ``animate-accordion-down`` + * and ``animate-accordion-up``. Tailwind 4 picks them up via the + * ``--animate-*`` theme tokens declared in ``@theme``; the keyframes + * themselves live here so the project keeps a single source of truth. + */ +@theme { + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; +} + +@keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } +} + +@keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } +} diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts index 0ce94b2d17..d90dc9f1f8 100644 --- a/ui/types/compliance.ts +++ b/ui/types/compliance.ts @@ -43,11 +43,35 @@ export interface Requirement { check_ids: string[]; // True when the FAIL is caused solely by an invalid scan config. invalid_config?: boolean; + // Cross-provider augmentations — populated only when the requirement + // originates from the cross-provider mapper-input adapter; the per-scan + // mappers leave these undefined so existing renderers keep working + // untouched. + providers?: Record; + check_ids_by_provider?: Record; + scan_ids_by_provider?: Record; // This is to allow any key to be added to the requirement object // because each compliance has different keys - [key: string]: string | string[] | number | boolean | object[] | undefined; + [key: string]: + | string + | string[] + | number + | boolean + | object[] + | Record + | Record + | Record + | undefined; } +/** + * Alias preserved for call-sites that want to spell out their intent when they + * consume the cross-provider augmentations specifically. The augmentations live + * on ``Requirement`` so per-scan and cross-provider code share the same nominal + * type. + */ +export type CrossProviderRequirement = Requirement; + export interface Control { label: string; pass: number; @@ -444,6 +468,12 @@ export interface AttributesItemData { platforms: string[]; technique_url: string; }; + // Cross-provider augmentations: only populated by the cross-provider + // mapper-input adapter. Per-scan attribute responses leave them + // undefined. + providers?: Record; + check_ids_by_provider?: Record; + scan_ids_by_provider?: Record; }; }; } @@ -485,3 +515,71 @@ export interface CategoryData { totalRequirements: number; failedRequirements: number; } + +// Cross-provider compliance types +// +// Backed by GET /api/v1/cross-provider-compliance-overviews/ which aggregates +// rows from one scan per compatible provider under a universal compliance +// framework (e.g. csa_ccm_4.0). The roll-up is computed server-side +// (FAIL > PASS > MANUAL). + +export const CROSS_PROVIDER_COMPLIANCE_TYPE = + "cross-provider-compliance-overviews" as const; + +export type CrossProviderRequirementStatus = "PASS" | "FAIL" | "MANUAL"; + +export interface CrossProviderRequirementData { + id: string; + name: string; + description: string; + // Free-form metadata mirroring the universal JSON's per-requirement + // attributes (e.g. CSA CCM exposes Section, CCMLite, IaaS, PaaS, SaaS, + // ScopeApplicability). + attributes: Record; + // Rolled-up status for this requirement across providers. + status: CrossProviderRequirementStatus; + // Per-provider status that fed the roll-up. Keys match + // CrossProviderComplianceOverviewAttributes.providers. + providers: Record; + // Per-contributing-provider check IDs the universal framework declares + // for this requirement. The UI uses this map to scope + // ``filter[check_id__in]`` when fetching findings per scan. + check_ids_by_provider?: Record; +} + +export interface CrossProviderComplianceOverviewAttributes { + compliance_id: string; + framework: string; + name: string; + version: string; + description: string; + // Catalogue of providers the universal framework declares checks for. + compatible_providers: string[]; + // Provider types of the scans actually used as input. + requested_providers: string[]; + // Providers that contributed at least one row after RBAC + filters. + providers: string[]; + // Concrete scan UUIDs aggregated. + scan_ids: string[]; + // Provider type → list of scan UUIDs the response was aggregated + // from. A list (not a single UUID) because a tenant can have N + // accounts of the same type — e.g. three AWS accounts contribute + // three scans, all keyed under ``"aws"``. The UI fans out one + // ``filter[scan]`` query per UUID when drilling into a requirement. + scan_ids_by_provider: Record; + requirements_passed: number; + requirements_failed: number; + requirements_manual: number; + total_requirements: number; + requirements: CrossProviderRequirementData[]; +} + +export interface CrossProviderComplianceOverviewData { + type: typeof CROSS_PROVIDER_COMPLIANCE_TYPE; + id: string; // universal framework name (e.g. "csa_ccm_4.0") + attributes: CrossProviderComplianceOverviewAttributes; +} + +export interface CrossProviderComplianceOverviewResponse { + data: CrossProviderComplianceOverviewData; +}