diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index 1dac947c63..8d75b91458 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -1,8 +1,35 @@ "use server"; -import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import type { ScanBinaryResult } from "@/actions/scans"; +import { + apiBaseUrl, + GENERIC_SERVER_ERROR_MESSAGE, + getAuthHeaders, + getErrorMessage, +} from "@/lib"; import { handleApiResponse } from "@/lib/server-actions-helper"; +async function getCrossProviderPdfErrorMessage( + response: Response, + fallbackMessage: string, +): Promise { + const contentType = response.headers.get("content-type")?.toLowerCase() || ""; + + if (contentType.includes("text/html")) { + return GENERIC_SERVER_ERROR_MESSAGE; + } + + const errorData = await response.json().catch(() => null); + + return ( + errorData?.errors?.[0]?.detail || + errorData?.errors?.detail || + errorData?.error || + errorData?.message || + (response.status >= 500 ? GENERIC_SERVER_ERROR_MESSAGE : fallbackMessage) + ); +} + export const getCompliancesOverview = async ({ scanId, region, @@ -34,7 +61,7 @@ export const getCompliancesOverview = async ({ headers, }); - return handleApiResponse(response); + return await handleApiResponse(response); } catch (error) { console.error("Error fetching compliances overview:", error); return undefined; @@ -66,7 +93,7 @@ export const getComplianceOverviewMetadataInfo = async ({ headers, }); - return handleApiResponse(response); + return await handleApiResponse(response); } catch (error) { console.error("Error fetching compliance overview metadata info:", error); return undefined; @@ -100,7 +127,7 @@ export const getComplianceAttributes = async ( return { warming: true as const, status: 503 }; } - return handleApiResponse(response); + return await handleApiResponse(response); } catch (error) { console.error("Error fetching compliance attributes:", error); return undefined; @@ -134,7 +161,7 @@ export const getComplianceRequirements = async ({ headers, }); - return handleApiResponse(response); + return await handleApiResponse(response); } catch (error) { console.error("Error fetching compliance requirements:", error); return undefined; @@ -155,11 +182,15 @@ export const getCrossProviderComplianceOverview = async ({ complianceId, scanIds, providerTypes, + providerIds, + providerGroups, regions, }: { complianceId: string; scanIds?: string[]; providerTypes?: string | string[]; + providerIds?: string | string[]; + providerGroups?: string | string[]; regions?: string | string[]; }) => { const headers = await getAuthHeaders({ contentType: false }); @@ -179,6 +210,8 @@ export const getCrossProviderComplianceOverview = async ({ setParam("filter[scan__in]", scanIds); } setParam("filter[provider_type__in]", providerTypes); + setParam("filter[provider_id__in]", providerIds); + setParam("filter[provider_groups__in]", providerGroups); setParam("filter[region__in]", regions); try { @@ -186,9 +219,267 @@ export const getCrossProviderComplianceOverview = async ({ if (response.status === 402) return { redirectTo: "/billing" }; - return handleApiResponse(response); + return await handleApiResponse(response); } catch (error) { console.error("Error fetching cross-provider compliance overview:", error); return undefined; } }; + +/** + * Trigger ad-hoc generation of the combined cross-provider compliance PDF. + * + * Backed by ``POST /cross-provider-compliance-overviews/generate-pdf``. The + * PDF itself is built asynchronously by a Celery task and is not ready when + * this call returns — it dispatches the job and returns the ``Task`` id so + * the caller can poll it (via ``getTask``) and then fetch the finished file + * with {@link getCrossProviderCompliancePdf}. + * + * ``scanIds`` should be the exact scan ids the caller currently has on + * screen (``attributes.scan_ids`` from the overview response) so the PDF + * matches what the user is looking at instead of re-resolving "latest scan + * per provider" a second time, which could race a scan that completes + * in between. + * + * ``providerTypes``/``providerIds``/``providerGroups`` should be the same + * ``filter[provider_type__in]`` / ``filter[provider_id__in]`` / + * ``filter[provider_groups__in]`` values currently applied to the on-screen + * cross-provider view (same shape ``getCrossProviderComplianceOverview`` + * takes) so the generated PDF only includes the providers the user has + * filtered down to, instead of silently widening back out to every + * compatible provider. + */ +export const generateCrossProviderCompliancePdf = async ({ + complianceId, + scanIds, + providerTypes, + providerIds, + providerGroups, + onlyFailed, + includeManual, + reportName, +}: { + complianceId: string; + scanIds?: string[]; + providerTypes?: string | string[]; + providerIds?: string | string[]; + providerGroups?: string | string[]; + onlyFailed?: boolean; + includeManual?: boolean; + /** Optional user-chosen download filename. Sanitized server-side (a + * `.pdf` extension is enforced); omit for a unique timestamped default. */ + reportName?: string; +}): Promise<{ taskId: string } | { error: string }> => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL( + `${apiBaseUrl}/cross-provider-compliance-overviews/generate-pdf`, + ); + + 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); + } + }; + + url.searchParams.set("filter[compliance_id]", complianceId); + if (scanIds && scanIds.length > 0) { + url.searchParams.set("filter[scan__in]", scanIds.join(",")); + } + setParam("filter[provider_type__in]", providerTypes); + setParam("filter[provider_id__in]", providerIds); + setParam("filter[provider_groups__in]", providerGroups); + setParam("report_name", reportName); + if (onlyFailed !== undefined) { + url.searchParams.set("only_failed", String(onlyFailed)); + } + if (includeManual !== undefined) { + url.searchParams.set("include_manual", String(includeManual)); + } + + try { + const response = await fetch(url.toString(), { + method: "POST", + headers, + }); + + if (!response.ok) { + throw new Error( + await getCrossProviderPdfErrorMessage( + response, + "Unable to start PDF generation. Contact support if the issue continues.", + ), + ); + } + + const json = await response.json(); + const taskId = json?.data?.id; + if (!taskId) { + throw new Error("Unexpected response starting PDF generation."); + } + + return { taskId }; + } catch (error) { + return { error: getErrorMessage(error) }; + } +}; + +/** + * Fetch the finished cross-provider compliance PDF for a task started by + * {@link generateCrossProviderCompliancePdf}. + * + * Backed by ``GET .../generate-pdf/{taskId}/download``, which mirrors the + * same 202-pending / 2xx-binary / error-JSON protocol as the per-scan report + * endpoints — returns a {@link ScanBinaryResult} so callers can reuse + * ``downloadFile`` from ``lib/helper.ts`` unchanged. + */ +export const getCrossProviderCompliancePdf = async ( + taskId: string, +): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL( + `${apiBaseUrl}/cross-provider-compliance-overviews/generate-pdf/${taskId}/download`, + ); + + try { + const response = await fetch(url.toString(), { headers }); + + if (response.status === 202) { + const json = await response.json(); + return { + pending: true, + state: json?.data?.attributes?.state, + taskId: json?.data?.id, + }; + } + + if (!response.ok) { + throw new Error( + await getCrossProviderPdfErrorMessage( + response, + "Unable to retrieve the compliance PDF report. Contact support if the issue continues.", + ), + ); + } + + const contentDisposition = + response.headers.get("content-disposition") || ""; + const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/i); + const filename = filenameMatch?.[1] || "compliance-report.pdf"; + + const arrayBuffer = await response.arrayBuffer(); + const base64 = Buffer.from(arrayBuffer).toString("base64"); + + return { success: true, data: base64, filename }; + } catch (error) { + return { error: getErrorMessage(error) }; + } +}; + +/** A previously-generated cross-provider PDF report matching a given set + * of filters, ready to download immediately (no polling required). */ +export interface LatestCrossProviderPdfReport { + taskId: string; + filename?: string; + /** ISO timestamp the report finished generating (``completed_at``), if + * the backend provided one. */ + generatedAt?: string; +} + +export type LatestCrossProviderPdfResult = + | ({ available: true } & LatestCrossProviderPdfReport) + | { available: false } + | { error: string }; + +/** + * Check whether a cross-provider compliance PDF has already been generated + * for the given filters, so the UI can offer "Download" immediately instead + * of always making the user click "Generate" and wait. + * + * Backed by ``GET .../generate-pdf/latest``: 200 with a Task body means a + * matching, still-current report exists (the backend only matches reports + * generated from the exact scan set these filters currently resolve to — + * a report goes stale and this starts returning "not available" the moment + * any contributing provider completes a new scan); 404 means "not generated + * yet" (a normal, expected state — not surfaced as an error). + * + * Same param shape as {@link generateCrossProviderCompliancePdf} so callers + * can pass through the exact same filter values to both. + */ +export const getLatestCrossProviderCompliancePdf = async ({ + complianceId, + scanIds, + providerTypes, + providerIds, + providerGroups, +}: { + complianceId: string; + scanIds?: string[]; + providerTypes?: string | string[]; + providerIds?: string | string[]; + providerGroups?: string | string[]; +}): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL( + `${apiBaseUrl}/cross-provider-compliance-overviews/generate-pdf/latest`, + ); + + 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); + } + }; + + url.searchParams.set("filter[compliance_id]", complianceId); + if (scanIds && scanIds.length > 0) { + url.searchParams.set("filter[scan__in]", scanIds.join(",")); + } + setParam("filter[provider_type__in]", providerTypes); + setParam("filter[provider_id__in]", providerIds); + setParam("filter[provider_groups__in]", providerGroups); + + try { + const response = await fetch(url.toString(), { headers }); + + if (response.status === 404) { + return { available: false }; + } + + if (!response.ok) { + throw new Error( + await getCrossProviderPdfErrorMessage( + response, + "Unable to check for an existing PDF report. Contact support if the issue continues.", + ), + ); + } + + const json = await response.json(); + const taskId = json?.data?.id; + if (!taskId) { + return { available: false }; + } + + return { + available: true, + taskId, + filename: json?.data?.attributes?.result?.filename, + generatedAt: json?.data?.attributes?.completed_at, + }; + } catch (error) { + // Logged here because the caller (the compliance detail page) degrades + // an ``{error}`` to "no report available" by design — the right UX for + // an optional availability check, but without this log a systematically + // failing endpoint would be indistinguishable from "never generated". + console.error( + "Error checking for an existing cross-provider compliance PDF:", + error, + ); + return { error: getErrorMessage(error) }; + } +}; diff --git a/ui/actions/task/tasks.ts b/ui/actions/task/tasks.ts index bfe7d907b5..5dbc075ec4 100644 --- a/ui/actions/task/tasks.ts +++ b/ui/actions/task/tasks.ts @@ -13,7 +13,12 @@ export const getTask = async (taskId: string) => { headers, }); - return handleApiResponse(response); + // Must be awaited here, not just returned: ``handleApiResponse`` is + // itself async and throws on non-2xx — a bare `return` hands back its + // promise without this function's own `try` ever observing a rejection, + // so `catch` below never runs and callers get an unhandled rejection + // instead of the documented `{error}` shape. + return await handleApiResponse(response); } catch (error) { return handleApiError(error); } diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index 33e633b589..92195c0c1e 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -9,8 +9,11 @@ import { getComplianceRequirements, getCompliancesOverview, getCrossProviderComplianceOverview, + getLatestCrossProviderCompliancePdf, } from "@/actions/compliances"; +import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; import { getThreatScore } from "@/actions/overview"; +import { getAllProviders } from "@/actions/providers"; import { getScan } from "@/actions/scans"; import { ClientAccordionWrapper, @@ -54,6 +57,8 @@ interface ComplianceDetailSearchParams { mode?: string; "filter[region__in]"?: string; "filter[provider_type__in]"?: string; + "filter[provider_id__in]"?: string; + "filter[provider_groups__in]"?: string; "filter[cis_profile_level]"?: string; page?: string; pageSize?: string; @@ -71,6 +76,9 @@ export default async function ComplianceDetail({ const { complianceId, version, scanId, section, mode } = resolvedSearchParams; const regionFilter = resolvedSearchParams["filter[region__in]"]; const providerTypeFilter = resolvedSearchParams["filter[provider_type__in]"]; + const providerIdFilter = resolvedSearchParams["filter[provider_id__in]"]; + const providerGroupsFilter = + resolvedSearchParams["filter[provider_groups__in]"]; const cisProfileFilter = resolvedSearchParams["filter[cis_profile_level]"]; const logoPath = getComplianceIcon(compliancetitle); @@ -85,11 +93,32 @@ export default async function ComplianceDetail({ } const crossProviderTitle = compliancetitle.split("-").join(" "); - const crossProviderResponse = await getCrossProviderComplianceOverview({ - complianceId, - providerTypes: providerTypeFilter, - regions: regionFilter, - }); + const [ + crossProviderResponse, + providersData, + providerGroupsData, + latestPdfReport, + ] = await Promise.all([ + getCrossProviderComplianceOverview({ + complianceId, + providerTypes: providerTypeFilter, + providerIds: providerIdFilter, + providerGroups: providerGroupsFilter, + regions: regionFilter, + }), + getAllProviders(), + getAllProviderGroups(), + // Independent of the overview fetch above — the backend resolves the + // same "latest scan per filtered provider" rule from these same raw + // filters, so this can run in parallel instead of waterfalling behind + // the overview response just to read its resolved scan_ids. + getLatestCrossProviderCompliancePdf({ + complianceId, + providerTypes: providerTypeFilter, + providerIds: providerIdFilter, + providerGroups: providerGroupsFilter, + }), + ]); if (!crossProviderResponse || "redirectTo" in crossProviderResponse) { return ( @@ -124,9 +153,27 @@ export default async function ComplianceDetail({ } const headerTitle = crossProviderData.attributes.name || crossProviderTitle; + const compatibleProviderTypes = new Set( + crossProviderData.attributes.compatible_providers, + ); + const compatibleProviders = (providersData?.data || []).filter((provider) => + compatibleProviderTypes.has(provider.attributes.provider), + ); return ( - + ); } diff --git a/ui/app/(prowler)/layout.tsx b/ui/app/(prowler)/layout.tsx index 2554ab02b9..47c4bfdd4a 100644 --- a/ui/app/(prowler)/layout.tsx +++ b/ui/app/(prowler)/layout.tsx @@ -6,6 +6,7 @@ import { ReactNode, Suspense } from "react"; import { getProviders } from "@/actions/providers"; import { getScansByState } from "@/actions/scans/scans"; +import { CrossProviderPdfWatcher } from "@/components/compliance/cross-provider/cross-provider-pdf-watcher"; import { OnboardingCheckpointWatcher, OnboardingGate, @@ -105,6 +106,10 @@ export default async function RootLayout({ )} {children} + {/* Single mount point so cross-provider PDF generations keep being + polled — and their "ready" toast still fires — after the user + navigates away from the page they were generated on. */} + 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 3de86c09fc..009345c6a0 100644 --- a/ui/components/compliance/compliance-accordion/client-accordion-content.test.ts +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.test.ts @@ -36,4 +36,81 @@ describe("client accordion content", () => { it("renders a per-provider breakdown table when providers are exposed", () => { expect(source).toContain("Per-Provider Breakdown"); }); + + describe("scope-aware cache invalidation", () => { + // Regression coverage for a real bug: switching the provider-type / + // provider-id / region filters on the cross-provider compliance page + // re-renders an already-expanded requirement with a *different* scan + // scope, but page/pageSize/sort/mutedFilter can stay identical — so a + // guard keyed only on those would wrongly treat the new scope as + // "already loaded" and keep showing findings from the filtered-out + // provider/region. + + it("derives the fetch scope from scan/check identity AND region, not just pagination/sort/mute", () => { + expect(source).toMatch( + /const scopeSignature = isCrossProvider\s*\n\s*\?\s*JSON\.stringify\(\{\s*scanIdsByProvider,\s*checkIdsByProvider,\s*region\s*\}\)/, + ); + // The per-scan branch's signature must also fold in ``region`` — a + // requirement with the same scanId/check_ids but a different region + // filter is a different fetch. + expect(source).toMatch( + /:\s*`\$\{scanId\}\|\$\{\(requirement\.check_ids \|\| \[\]\)\.join\(","\)\}\|\$\{region\}`/, + ); + }); + + it("includes the scope signature in the already-loaded skip guard", () => { + const guardMatch = source.match( + /if \(\s*\n([\s\S]*?)\n\s*\) \{\s*\n\s*return;/, + ); + expect(guardMatch, "expected to find the skip-fetch guard").not.toBe( + null, + ); + const guardBody = guardMatch![1]; + expect(guardBody).toContain("loadedScopeRef.current === scopeSignature"); + // The pre-existing guard members must still be present — this test + // should fail if a refactor drops one of them, not just if scope is + // missing. + expect(guardBody).toContain("loadedPageRef.current === pageNumber"); + expect(guardBody).toContain("loadedMutedRef.current === mutedFilter"); + }); + + it("only marks the scope as loaded once the fetch actually commits, for both branches", () => { + // Marking it eagerly (before the fetch resolves) would let a + // superseded in-flight request's cleanup permanently strand the + // component at ``findings === null`` — see the neighboring comment + // in source for the full race explanation. + const commitPattern = /loadedScopeRef\.current = scopeSignature;/g; + const commitIndices = Array.from(source.matchAll(commitPattern)).map( + (match) => match.index!, + ); + expect(commitIndices).toHaveLength(2); // cross-provider branch + per-scan branch + + for (const index of commitIndices) { + // A cancellation check must precede the commit nearby, and nothing + // awaits in between — i.e. the ref is only written synchronously + // after the fetch has already resolved and the in-flight request + // wasn't superseded by a newer one. + const window = source.slice(Math.max(0, index - 250), index); + const guardIndex = window.search(/if \(cancelled\) return;/); + expect( + guardIndex, + "expected a nearby cancellation check", + ).toBeGreaterThanOrEqual(0); + expect(window.slice(guardIndex)).not.toContain("await "); + } + }); + + it("re-runs the effect when the scope signature changes", () => { + // scopeSignature is derived from requirement/scanIdsByProvider/ + // checkIdsByProvider/region, which are already effect dependencies, + // so this mostly guards against someone deleting the dependency + // outright under the (wrong) assumption it's redundant. + const depsMatch = source.match(/\}, \[([\s\S]*?)\]\);/); + expect( + depsMatch, + "expected to find the effect's dependency array", + ).not.toBe(null); + expect(depsMatch![1]).toContain("scopeSignature"); + }); + }); }); diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx index d0e8f4b9a7..70b10fa307 100644 --- a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx @@ -9,6 +9,7 @@ import { getStandaloneFindingColumns, SkeletonTableFindings, } from "@/components/findings/table"; +import { ProviderBadgeIcon } from "@/components/icons/providers-badge/provider-badge-icon"; import { Alert, AlertDescription } from "@/components/shadcn"; import { Accordion } from "@/components/ui/accordion/Accordion"; import { DataTable } from "@/components/ui/table"; @@ -16,10 +17,7 @@ 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 { - getProviderBadge, - getProviderLabel, -} from "@/lib/providers/provider-display"; +import { getProviderLabel } from "@/lib/providers/provider-display"; import { CrossProviderRequirement, Requirement, @@ -64,6 +62,7 @@ export const ClientAccordionContent = ({ const loadedPageSizeRef = useRef(null); const loadedSortRef = useRef(null); const loadedMutedRef = useRef(null); + const loadedScopeRef = useRef(null); const isExpandedRef = useRef(false); const region = searchParams.get("filter[region__in]") || ""; // Respect the user's muted preference from the URL; default to EXCLUDE @@ -80,6 +79,15 @@ export const ClientAccordionContent = ({ const providersBreakdown = xprov.providers; const isCrossProvider = !!scanIdsByProvider && Object.keys(scanIdsByProvider).length > 0; + // Identifies *what* findings this requirement should show — which scans, + // checks and region it's scoped to. Provider-type/account/region filters + // on the page narrow the fetch server-side without necessarily changing + // page/sort/mute, so those alone aren't enough to tell a stale fetch from + // a fresh one: an already-expanded row would otherwise keep showing + // findings from providers or regions the user just filtered out. + const scopeSignature = isCrossProvider + ? JSON.stringify({ scanIdsByProvider, checkIdsByProvider, region }) + : `${scanId}|${(requirement.check_ids || []).join(",")}|${region}`; useEffect(() => { // Guard against a slower earlier request resolving after a newer one and @@ -93,16 +101,20 @@ export const ClientAccordionContent = ({ loadedPageSizeRef.current === pageSize && loadedSortRef.current === sort && loadedMutedRef.current === mutedFilter && + loadedScopeRef.current === scopeSignature && isExpandedRef.current ) { return; } - loadedPageRef.current = pageNumber; - loadedPageSizeRef.current = pageSize; - loadedSortRef.current = sort; - loadedMutedRef.current = mutedFilter; - isExpandedRef.current = true; + // Mark "loaded" for these exact params only once the fetch actually + // commits (below, right before each ``setFindings``) — not here. If a + // dependency changes (e.g. a sibling re-render gives ``requirement`` a + // new identity) while this fetch is in flight, the effect cleanup + // flips ``cancelled`` and the commit is skipped; marking the refs + // upfront would make the *next* effect run see "already loaded" and + // skip re-fetching too, permanently stranding the component with + // ``findings`` stuck at ``null``. try { const encodedSort = sort.replace(/^\+/, ""); @@ -182,6 +194,12 @@ export const ClientAccordionContent = ({ }, }; if (cancelled) return; + loadedPageRef.current = pageNumber; + loadedPageSizeRef.current = pageSize; + loadedSortRef.current = sort; + loadedMutedRef.current = mutedFilter; + loadedScopeRef.current = scopeSignature; + isExpandedRef.current = true; setFindings(merged); return; } @@ -202,6 +220,12 @@ export const ClientAccordionContent = ({ }); if (cancelled) return; + loadedPageRef.current = pageNumber; + loadedPageSizeRef.current = pageSize; + loadedSortRef.current = sort; + loadedMutedRef.current = mutedFilter; + loadedScopeRef.current = scopeSignature; + isExpandedRef.current = true; setFindings(findingsData); } catch (error) { console.error("Error loading findings:", error); @@ -221,6 +245,7 @@ export const ClientAccordionContent = ({ sort, region, mutedFilter, + scopeSignature, disableFindings, isCrossProvider, scanIdsByProvider, @@ -413,7 +438,6 @@ export const ClientAccordionContent = ({
{checkIdsByProviderEntries.map(([providerKey, ids], idx) => { const label = getProviderLabel(providerKey); - const Badge = getProviderBadge(providerKey); return (
- {Badge ? : null} - + + {label} {ids.length} {ids.length === 1 ? "check" : "checks"}
-
+ {/* Soft filled chips, no border: bordered pills read as a "wall + of boxes" once there are 5-10 of them, but bare text (no + fill at all) reads as unstyled/unfinished and a fixed-width + grid left ragged gaps for the shorter ids. A tinted + background gives each check enough visual weight to look + intentional while staying quiet — flex-wrap sizes every + chip to its own content so nothing is cramped or stranded + in dead space regardless of how long the check id is. */} +
    {ids.map((id) => ( - {id} - + ))} -
+
); })} diff --git a/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx b/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx index f63fa36bce..432438cd45 100644 --- a/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx +++ b/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx @@ -1,10 +1,8 @@ +import { ProviderBadgeIcon } from "@/components/icons/providers-badge/provider-badge-icon"; import { InfoTooltip } from "@/components/shadcn/info-field/info-field"; import { FindingStatus, StatusFindingBadge } from "@/components/ui/table"; import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons"; -import { - getProviderBadge, - getProviderLabel, -} from "@/lib/providers/provider-display"; +import { getProviderLabel } from "@/lib/providers/provider-display"; import { RequirementStatus } from "@/types/compliance"; interface ComplianceAccordionRequirementTitleProps { @@ -44,14 +42,17 @@ export const ComplianceAccordionRequirementTitle = ({ {type} )} - {name} + {name} {invalidConfig && }
-
+ {/* The accordion trigger this title lives in applies ``hover:underline`` + to all its text — fine for the plain requirement name, but it paints + straight through the provider chips and status pill below. Opt this + whole cluster out so hovering the row never underlines a badge. */} +
{providerEntries.length > 0 && (
{providerEntries.map(([providerKey, providerStatus]) => { - const Badge = getProviderBadge(providerKey); const label = getProviderLabel(providerKey); return ( - {Badge ? : null} - - {label} - + + {label} )} - + {providerEntries.length > 0 ? ( + // Fixed width, not just the badge's own ``min-w`` — "Manual" is + // visibly wider than "Pass"/"Fail", and this cluster is right- + // justified, so a variable-width badge shifts the provider chips + // left/right depending on which status happens to render. Locking + // the slot width keeps the chips at the same x across every row. + // Scoped to cross-provider mode only (``providerEntries.length``) + // — every other framework has no chips to keep aligned, so it + // keeps the badge at its natural width instead of losing space + // to an empty fixed slot. +
+ +
+ ) : ( + + )}
); diff --git a/ui/components/compliance/compliance-page-tabs.test.tsx b/ui/components/compliance/compliance-page-tabs.test.tsx index 05dd3ae45e..752bdf9846 100644 --- a/ui/components/compliance/compliance-page-tabs.test.tsx +++ b/ui/components/compliance/compliance-page-tabs.test.tsx @@ -39,4 +39,54 @@ describe("CompliancePageTabs", () => { expect(tabsSource).toContain("perScanContent"); expect(tabsSource).toContain("crossProviderContent"); }); + + describe("Cross-Provider tab trigger structure", () => { + // The active-tab underline sizes itself off ``:not(:first-child)`` / + // ``:last-child`` selectors keyed on the trigger's OWN position among + // its siblings (see components/shadcn/tabs/tabs.tsx). Wrapping the + // trigger in an extra element breaks that and silently drops both the + // inter-tab padding AND the underline's left inset, which previously + // produced a visibly misaligned (then, when only half-fixed, a + // vanished) active indicator on this tab alone. The fix: only pay that + // wrapper tax on the OSS/disabled branch where it's structurally + // required (tooltip-on-disabled + upsell badge) — the common, enabled + // case renders a plain trigger exactly like every other tab in the app + // (see app/(prowler)/providers/provider-page-tabs.tsx) and needs no + // compensating classes at all. + const ternaryMatch = tabsSource.match( + /\{crossProviderEnabled \? \(([\s\S]*?)\) : \(([\s\S]*?)\)\}/, + ); + + it("finds the enabled/disabled branches", () => { + expect( + ternaryMatch, + "expected to find the crossProviderEnabled ternary", + ).not.toBe(null); + }); + + const enabledBranch = ternaryMatch?.[1] ?? ""; + const disabledBranch = ternaryMatch?.[2] ?? ""; + + it("renders a plain, unwrapped trigger when the tab is enabled — same shape as every other tab", () => { + expect(enabledBranch).toContain(" { + expect(disabledBranch).toContain(" - {/* 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. */} - - + {crossProviderEnabled ? ( + // Plain trigger, direct child of TabsList — same shape as + // "Per Scan" and as ProviderPageTabs' tabs. shadcn's active + // underline sizes itself off ``:not(:first-child)`` / + // ``:last-child`` selectors on the trigger's OWN position + // among its siblings; wrapping it in an extra element (as + // the disabled branch below has to, for the tooltip-on- + // disabled + badge requirements) breaks that and requires + // hand-restoring every dropped padding/inset rule. Skipping + // the wrapper whenever it isn't structurally necessary + // keeps the common (enabled) case correct for free. + Cross-Provider - {!crossProviderEnabled && ( + ) : ( + // Disabled in OSS: the wrapper span (not the disabled + // TabsTrigger) is the hover target so the tooltip still + // fires, and it also hosts the upsell badge next to the + // label. That makes the trigger the first/only child of the + // span instead of TabsList, dropping the + // ``[&:not(:first-child)]`` padding and the matching + // ``[&:not(:first-child)]:after:left-4`` underline inset — + // restore both by hand or the (invisible, disabled) active + // underline would sit misaligned if this tab were ever + // reachable. + + + Cross-Provider + - )} - + + )} {crossProviderEnabled diff --git a/ui/components/compliance/cross-provider/cross-provider-card.test.tsx b/ui/components/compliance/cross-provider/cross-provider-card.test.tsx index e813ba024e..c348d52b10 100644 --- a/ui/components/compliance/cross-provider/cross-provider-card.test.tsx +++ b/ui/components/compliance/cross-provider/cross-provider-card.test.tsx @@ -34,4 +34,30 @@ describe("CrossProviderCard", () => { expect(source).toContain("compatibleProviders"); expect(source).toContain("ProviderChip"); }); + + it("renders each provider as a compact icon, not a full uppercase-label pill", () => { + // Regression guard: frameworks can declare 15+ compatible providers (CIS + // Controls 8.1 has 18) — one full-width uppercase-label pill per + // provider used to wrap across 4-5 rows, dwarfing every other card in + // the same grid. The fix reuses the same compact icon-square language + // as the per-domain provider heatmap on the detail page + // (CrossProviderDomainTitle) instead of spelling out each provider name. + expect(source).toContain("ProviderBadgeIcon"); + // ``{providerKey}`` as JSX *text content* (old pill label) vs. as a prop + // value (``providerKey={providerKey}``, still expected) — only the + // former is the regression this guards against. + expect(source).not.toMatch(/>\s*\{providerKey\}\s* { + expect(source).toContain("getProwlerHubComplianceUrl(complianceId)"); + expect(source).toContain('target="_blank"'); + }); + + it("stops the info button's click from also triggering the card's own drill-down navigation", () => { + // Regression guard: the whole card is a click target (onClick={navigateToDetail}). + // Without stopPropagation, opening the Hub link would also navigate the + // card to the detail page underneath the new tab. + expect(source).toContain("e.stopPropagation()"); + }); }); diff --git a/ui/components/compliance/cross-provider/cross-provider-card.tsx b/ui/components/compliance/cross-provider/cross-provider-card.tsx index d391c7466a..c1ec27a349 100644 --- a/ui/components/compliance/cross-provider/cross-provider-card.tsx +++ b/ui/components/compliance/cross-provider/cross-provider-card.tsx @@ -1,10 +1,13 @@ "use client"; -import { Check, Circle } from "lucide-react"; +import { Info } from "lucide-react"; import Image from "next/image"; +import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; +import { ProviderBadgeIcon } from "@/components/icons/providers-badge/provider-badge-icon"; +import { Button } from "@/components/shadcn/button/button"; import { Card, CardContent } from "@/components/shadcn/card/card"; import { Progress } from "@/components/shadcn/progress"; import { @@ -12,10 +15,12 @@ import { TooltipContent, TooltipTrigger, } from "@/components/shadcn/tooltip"; +import { getProwlerHubComplianceUrl } from "@/lib/compliance/prowler-hub"; import { getScoreIndicatorClass, type ScoreColorVariant, } from "@/lib/compliance/score-utils"; +import { getProviderLabel } from "@/lib/providers/provider-display"; import { cn } from "@/lib/utils"; export interface CrossProviderCardProps { @@ -52,25 +57,31 @@ interface ProviderChipProps { active: boolean; } +/** + * Icon-only chip, not a text pill: universal frameworks can declare 15+ + * compatible providers (CIS Controls 8.1 alone has 18), and one full-width + * uppercase-label pill per provider used to wrap across 4-5 rows on a card + * that also has to fit a title, score bar and description — every other + * card in the same grid stayed a fraction of that height. Same compact + * heatmap-cell language as the per-domain provider row on the detail page + * (``CrossProviderDomainTitle``): one row of small icon squares regardless + * of provider count, with the full name available on hover/focus. + */ const ProviderChip = ({ providerKey, active }: ProviderChipProps) => { - const Icon = active ? Check : Circle; + const label = getProviderLabel(providerKey); + const tooltip = active ? `${label}: scan available` : `${label}: no scan yet`; return ( - - {providerKey} + ); }; @@ -146,18 +157,40 @@ export const CrossProviderCard: React.FC = ({
)}
- - -

+
+ + +

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

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

-
- - {formatTitle(title)} - {version ? ` - ${version}` : ""} - -
+ + + + + + + View on Prowler Hub + +
{description && ( {description} diff --git a/ui/components/compliance/cross-provider/cross-provider-detail-client.tsx b/ui/components/compliance/cross-provider/cross-provider-detail-client.tsx index c9cf967e55..bec5e48837 100644 --- a/ui/components/compliance/cross-provider/cross-provider-detail-client.tsx +++ b/ui/components/compliance/cross-provider/cross-provider-detail-client.tsx @@ -2,14 +2,28 @@ import { useCallback, useMemo, useRef, useState } from "react"; +import type { LatestCrossProviderPdfReport } from "@/actions/compliances"; import { computeCrossProviderInsights } from "@/lib/compliance/cross-provider-insights"; import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance"; +import type { ProviderGroup } from "@/types/components"; +import type { ProviderProps } from "@/types/providers"; import { CrossProviderExplorerCard } from "./cross-provider-explorer-card"; +import { CrossProviderFilters } from "./cross-provider-filters"; import { CrossProviderHeader } from "./cross-provider-header"; interface CrossProviderDetailClientProps { attributes: CrossProviderComplianceOverviewAttributes; + providers: ProviderProps[]; + providerGroups: ProviderGroup[]; + /** Raw ``filter[provider_type__in]`` / ``filter[provider_id__in]`` / + * ``filter[provider_groups__in]`` values from the URL — threaded down to + * the "Generate PDF" button so it respects the same narrowing as this + * already-filtered ``attributes`` payload. */ + providerTypeFilter?: string; + providerIdFilter?: string; + providerGroupsFilter?: string; + latestPdfReport: LatestCrossProviderPdfReport | null; } /** @@ -33,6 +47,12 @@ interface CrossProviderDetailClientProps { */ export const CrossProviderDetailClient = ({ attributes, + providers, + providerGroups, + providerTypeFilter, + providerIdFilter, + providerGroupsFilter, + latestPdfReport, }: CrossProviderDetailClientProps) => { const insights = useMemo( () => computeCrossProviderInsights(attributes), @@ -74,11 +94,17 @@ export const CrossProviderDetailClient = ({ return (
+ + @@ -88,6 +114,10 @@ export const CrossProviderDetailClient = ({ attributes={attributes} insights={insights} forcedExpandedSectionKey={forcedExpandedSectionKey} + providerTypeFilter={providerTypeFilter} + providerIdFilter={providerIdFilter} + providerGroupsFilter={providerGroupsFilter} + latestPdfReport={latestPdfReport} />
diff --git a/ui/components/compliance/cross-provider/cross-provider-detail.tsx b/ui/components/compliance/cross-provider/cross-provider-detail.tsx index f8272c22f9..bc120ef790 100644 --- a/ui/components/compliance/cross-provider/cross-provider-detail.tsx +++ b/ui/components/compliance/cross-provider/cross-provider-detail.tsx @@ -1,9 +1,31 @@ +import type { LatestCrossProviderPdfReport } from "@/actions/compliances"; import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance"; +import type { ProviderGroup } from "@/types/components"; +import type { ProviderProps } from "@/types/providers"; import { CrossProviderDetailClient } from "./cross-provider-detail-client"; interface CrossProviderDetailProps { attributes: CrossProviderComplianceOverviewAttributes; + /** Provider accounts of a type compatible with this framework (not + * filtered by connection status) — feeds the provider type / account + * filter controls. */ + providers: ProviderProps[]; + /** Every provider group in the tenant (not narrowed by framework + * compatibility — groups can span multiple provider types, same as + * every other provider-group filter in the app). */ + providerGroups: ProviderGroup[]; + /** Raw ``filter[provider_type__in]`` / ``filter[provider_id__in]`` / + * ``filter[provider_groups__in]`` values from the URL — threaded down to + * the "Generate PDF" button. */ + providerTypeFilter?: string; + providerIdFilter?: string; + providerGroupsFilter?: string; + /** A previously-generated PDF matching these exact filters, resolved + * server-side alongside ``attributes`` — ``null`` means none exists yet + * (or it went stale) for the current filters, so the "Generate PDF" + * button shows "Generate" instead of "Download". */ + latestPdfReport: LatestCrossProviderPdfReport | null; } /** @@ -17,6 +39,22 @@ interface CrossProviderDetailProps { */ export const CrossProviderDetail = ({ attributes, + providers, + providerGroups, + providerTypeFilter, + providerIdFilter, + providerGroupsFilter, + latestPdfReport, }: CrossProviderDetailProps) => { - return ; + return ( + + ); }; diff --git a/ui/components/compliance/cross-provider/cross-provider-domain-title.test.tsx b/ui/components/compliance/cross-provider/cross-provider-domain-title.test.tsx new file mode 100644 index 0000000000..be76ea23ea --- /dev/null +++ b/ui/components/compliance/cross-provider/cross-provider-domain-title.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { DomainStats } from "@/lib/compliance/cross-provider-insights"; + +import { CrossProviderDomainTitle } from "./cross-provider-domain-title"; + +const makeStats = (overrides: Partial = {}): DomainStats => ({ + name: "1. Inventory and Control of Enterprise Assets", + total: 5, + pass: 1, + fail: 2, + manual: 2, + byProvider: {}, + ...overrides, +}); + +describe("CrossProviderDomainTitle", () => { + it("reserves a fixed width for the counts column regardless of digit count", () => { + // Regression guard: the name column is flex-1 (grows to fill whatever's + // left on the line) — if the counts column were only ``shrink-0`` + // instead of a fixed width, a row with double/triple-digit counts would + // shrink the name column by the difference, shifting the heatmap that + // follows it to a different x than on rows with single-digit counts. + const { container } = render( + , + ); + + const countsContainer = screen.getByText("1").closest("div"); + expect(countsContainer).toHaveClass("w-[104px]"); + expect(container).toBeTruthy(); + }); + + it("renders the same fixed-width class for triple-digit counts", () => { + render( + , + ); + + const countsContainer = screen.getByText("100").closest("div"); + expect(countsContainer).toHaveClass("w-[104px]"); + }); + + it("renders one heatmap cell per provider with the rolled-up status in its accessible name", () => { + render( + , + ); + + expect(screen.getByLabelText(/AWS: PASS/)).toBeInTheDocument(); + expect(screen.getByLabelText(/Azure: FAIL/)).toBeInTheDocument(); + // gcp has no entry in byProvider — falls back to "no scan". + expect(screen.getByLabelText(/Google Cloud.*no scan/)).toBeInTheDocument(); + }); + + it("shows the pass/fail/manual counts", () => { + render( + , + ); + + expect(screen.getByText("7")).toBeInTheDocument(); + expect(screen.getByText("3")).toBeInTheDocument(); + expect(screen.getByText("1")).toBeInTheDocument(); + }); +}); diff --git a/ui/components/compliance/cross-provider/cross-provider-domain-title.tsx b/ui/components/compliance/cross-provider/cross-provider-domain-title.tsx index 492bd6c42a..0c431edfb5 100644 --- a/ui/components/compliance/cross-provider/cross-provider-domain-title.tsx +++ b/ui/components/compliance/cross-provider/cross-provider-domain-title.tsx @@ -1,13 +1,11 @@ "use client"; +import { ProviderBadgeIcon } from "@/components/icons/providers-badge/provider-badge-icon"; import type { DomainProviderStatus, DomainStats, } from "@/lib/compliance/cross-provider-insights"; -import { - getProviderBadge, - getProviderLabel, -} from "@/lib/providers/provider-display"; +import { getProviderLabel } from "@/lib/providers/provider-display"; import { cn } from "@/lib/utils"; const HEATMAP_CELL_BY_STATUS: Record = { @@ -27,7 +25,10 @@ const STRIPE_CLASS_BY_DOMINANT = (stats: DomainStats): string => { interface CrossProviderDomainTitleProps { name: string; stats: DomainStats; - compatibleProviders: string[]; + /** Providers to render as heatmap columns — the detail view passes the + * SCANNED providers (framework-compatible providers with no scan are + * hidden here; they only appear, dimmed, on the overview). */ + providers: string[]; } /** @@ -37,9 +38,9 @@ interface CrossProviderDomainTitleProps { * * 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 + * manual > no-row); the heatmap matrix shows one cell per scanned + * provider, 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. @@ -47,7 +48,7 @@ interface CrossProviderDomainTitleProps { export const CrossProviderDomainTitle = ({ name, stats, - compatibleProviders, + providers, }: CrossProviderDomainTitleProps) => { const total = Math.max(stats.total, 1); const passPct = (stats.pass / total) * 100; @@ -57,7 +58,10 @@ export const CrossProviderDomainTitle = ({ return (
- + {name} @@ -77,10 +81,9 @@ export const CrossProviderDomainTitle = ({
- {compatibleProviders.map((providerKey) => { + {providers.map((providerKey) => { const status: DomainProviderStatus = stats.byProvider[providerKey] ?? "NO_ROW"; - const Badge = getProviderBadge(providerKey); const label = getProviderLabel(providerKey); const tooltip = `${label}: ${status === "NO_ROW" ? "no scan" : status}`; return ( @@ -93,7 +96,11 @@ export const CrossProviderDomainTitle = ({ HEATMAP_CELL_BY_STATUS[status], )} > - {Badge ? : null} + ); })} @@ -120,7 +127,15 @@ export const CrossProviderDomainTitle = ({ )}
-
+ {/* Fixed width, not just ``shrink-0``: the name column above is + ``flex-1`` (grows to fill whatever's left on the line), so a + pass/fail/manual count that's wider on one row than another + (e.g. double- vs triple-digit) shrinks or grows the name column + by the difference — which shifts every ``shrink-0`` sibling that + comes after it, including the heatmap. Locking this slot's width + keeps the heatmap starting at the same x on every domain row + regardless of how many digits its counts happen to have. */} +
{stats.pass} {stats.fail} diff --git a/ui/components/compliance/cross-provider/cross-provider-explorer-card.tsx b/ui/components/compliance/cross-provider/cross-provider-explorer-card.tsx index 11745a6865..e4428d9b24 100644 --- a/ui/components/compliance/cross-provider/cross-provider-explorer-card.tsx +++ b/ui/components/compliance/cross-provider/cross-provider-explorer-card.tsx @@ -3,6 +3,7 @@ import { Maximize2, Minimize2, X } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; +import type { LatestCrossProviderPdfReport } from "@/actions/compliances"; import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; import { @@ -28,6 +29,7 @@ import type { } from "@/types/compliance"; import { CrossProviderDomainTitle } from "./cross-provider-domain-title"; +import { GeneratePdfButton } from "./generate-pdf-button"; interface CrossProviderExplorerCardProps { attributes: CrossProviderComplianceOverviewAttributes; @@ -36,6 +38,18 @@ interface CrossProviderExplorerCardProps { * wants forced-open. Driven by the "Top Failing Domains" panel so a * click expands and scrolls to the target row. */ forcedExpandedSectionKey?: string | null; + /** Raw ``filter[provider_type__in]`` / ``filter[provider_id__in]`` / + * ``filter[provider_groups__in]`` values currently applied via + * ``CrossProviderFilters`` — threaded through to ``GeneratePdfButton`` + * so the generated PDF respects the same narrowing as the on-screen + * view. */ + providerTypeFilter?: string; + providerIdFilter?: string; + providerGroupsFilter?: string; + /** A previously-generated PDF matching the current filters, resolved + * server-side — ``null`` means "Generate PDF" should show, not + * "Download PDF". */ + latestPdfReport: LatestCrossProviderPdfReport | null; } const STATUS_OPTIONS: Array<{ @@ -68,6 +82,10 @@ export const CrossProviderExplorerCard = ({ attributes, insights, forcedExpandedSectionKey, + providerTypeFilter, + providerIdFilter, + providerGroupsFilter, + latestPdfReport, }: CrossProviderExplorerCardProps) => { const [searchTerm, setSearchTerm] = useState(""); const [searchKey, setSearchKey] = useState(0); @@ -204,6 +222,19 @@ export const CrossProviderExplorerCard = ({ right border on intermediate viewports. */}
+ + ), +})); + +const makeProvider = (id: string, provider: string): ProviderProps => + ({ + id, + type: "providers", + attributes: { provider }, + }) as unknown as ProviderProps; + +const makeGroup = (id: string, name: string): ProviderGroup => + ({ + id, + type: "provider-groups", + attributes: { name }, + }) as unknown as ProviderGroup; + +describe("CrossProviderFilters", () => { + it("renders nothing when there are no compatible providers", () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it("renders the provider account selectors, group selector and clear-filters button when providers exist", () => { + const providers = [makeProvider("azure-1", "azure")]; + + render(); + + expect(screen.getByText("Provider account selectors")).toBeInTheDocument(); + expect(screen.getByText("Provider group selector")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /clear all/i }), + ).toBeInTheDocument(); + }); + + it("passes the compatible providers straight through to the account selectors", () => { + const providers = [ + makeProvider("azure-1", "azure"), + makeProvider("gcp-1", "gcp"), + ]; + + render(); + + expect(providerAccountSelectorsProps.value?.providers).toEqual(providers); + }); + + it("passes every tenant provider group straight through to the group selector", () => { + // Unlike ``providers``, groups are NOT narrowed to the framework's + // compatible provider types — a group can span multiple types, same as + // every other provider-group filter in the app. + const providers = [makeProvider("azure-1", "azure")]; + const providerGroups = [makeGroup("group-1", "Production")]; + + render( + , + ); + + expect(providerGroupSelectorProps.value?.groups).toEqual(providerGroups); + }); + + it("shows the active-filter count on the clear button", () => { + // Regression guard: dropping ``showCount`` would silently hide how many + // filters are active, matching how every other filter bar in the app + // surfaces it (findings, resources, scans). + render( + , + ); + + expect(screen.getByText(/with count/)).toBeInTheDocument(); + }); +}); diff --git a/ui/components/compliance/cross-provider/cross-provider-filters.tsx b/ui/components/compliance/cross-provider/cross-provider-filters.tsx new file mode 100644 index 0000000000..e104cc09eb --- /dev/null +++ b/ui/components/compliance/cross-provider/cross-provider-filters.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { ClearFiltersButton } from "@/components/filters/clear-filters-button"; +import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors"; +import { ProviderGroupSelector } from "@/components/filters/provider-group-selector"; +import type { ProviderGroup } from "@/types/components"; +import type { ProviderProps } from "@/types/providers"; + +interface CrossProviderFiltersProps { + /** Every provider account of a type compatible with the current + * framework — already narrowed by the caller to ``compatible_providers``. + * Not filtered by connection status: disconnected accounts still show up + * so the user can see (and filter to) them, same as the account + * selectors elsewhere in the app. */ + providers: ProviderProps[]; + /** Every provider group in the tenant — not narrowed by framework + * compatibility, same as every other provider-group filter in the app + * (a group can span multiple provider types). */ + providerGroups: ProviderGroup[]; +} + +const FILTER_CONTROL_CLASS = + "w-full sm:max-w-[240px] sm:min-w-[180px] sm:flex-1"; + +/** + * Provider type / account / group narrowing for the cross-provider + * compliance detail page. Instant mode: picking a value pushes + * ``filter[provider_type__in]`` / ``filter[provider_id__in]`` / + * ``filter[provider_groups__in]`` straight into the URL, which the server + * component re-reads on navigation and re-runs the aggregation against just + * the selected providers (unlike the search/status controls in + * ``CrossProviderExplorerCard``, which only filter the already-fetched + * payload client-side). + */ +export const CrossProviderFilters = ({ + providers, + providerGroups, +}: CrossProviderFiltersProps) => { + if (providers.length === 0) return null; + + return ( +
+ +
+ +
+ +
+ ); +}; diff --git a/ui/components/compliance/cross-provider/cross-provider-header.test.tsx b/ui/components/compliance/cross-provider/cross-provider-header.test.tsx new file mode 100644 index 0000000000..dc2095d3bb --- /dev/null +++ b/ui/components/compliance/cross-provider/cross-provider-header.test.tsx @@ -0,0 +1,26 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("CrossProviderHeader", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "cross-provider-header.tsx"); + const source = readFileSync(filePath, "utf8"); + + it("accepts a complianceId prop to build the Prowler Hub link", () => { + expect(source).toContain("complianceId"); + expect(source).toContain("getProwlerHubComplianceUrl(complianceId)"); + }); + + it("opens the Prowler Hub link in a new, safely-referrer-stripped tab", () => { + // ``noopener`` prevents the opened tab from reaching back into this + // window via ``window.opener``; ``noreferrer`` also strips the + // referrer header. Both matter for any ``target="_blank"`` link to an + // external origin. + expect(source).toMatch( + /target="_blank"[\s\S]{0,40}rel="noopener noreferrer"/, + ); + }); +}); diff --git a/ui/components/compliance/cross-provider/cross-provider-header.tsx b/ui/components/compliance/cross-provider/cross-provider-header.tsx index 2c4233cb3f..e1622155f7 100644 --- a/ui/components/compliance/cross-provider/cross-provider-header.tsx +++ b/ui/components/compliance/cross-provider/cross-provider-header.tsx @@ -1,10 +1,14 @@ "use client"; +import { SquareArrowOutUpRight } from "lucide-react"; import Image from "next/image"; +import Link from "next/link"; import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; +import { Button } from "@/components/shadcn/button/button"; import { Card, CardContent } from "@/components/shadcn/card/card"; import type { CrossProviderInsights } from "@/lib/compliance/cross-provider-insights"; +import { getProwlerHubComplianceUrl } from "@/lib/compliance/prowler-hub"; import { ProviderCoveragePanel } from "./provider-coverage-panel"; import { ScoreDonut } from "./score-donut"; @@ -19,6 +23,9 @@ interface CrossProviderHeaderProps { version: string; /** Long description from the universal JSON. */ description: string; + /** Universal framework id (e.g. ``cis_controls_8.1``) — builds the + * Prowler Hub reference link. */ + complianceId: string; insights: CrossProviderInsights; /** Click handler when a top-failing-domain entry is selected. */ onDomainSelect?: (domainName: string) => void; @@ -45,6 +52,7 @@ export const CrossProviderHeader = ({ name, version, description, + complianceId, insights, onDomainSelect, }: CrossProviderHeaderProps) => { @@ -76,6 +84,22 @@ export const CrossProviderHeader = ({ Universal +
{description && (

diff --git a/ui/components/compliance/cross-provider/cross-provider-pdf-watcher.test.tsx b/ui/components/compliance/cross-provider/cross-provider-pdf-watcher.test.tsx new file mode 100644 index 0000000000..db8aa4256c --- /dev/null +++ b/ui/components/compliance/cross-provider/cross-provider-pdf-watcher.test.tsx @@ -0,0 +1,158 @@ +import { cleanup, render } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useCrossProviderPdfStore } from "@/store/cross-provider-pdf/store"; + +import { CrossProviderPdfWatcher } from "./cross-provider-pdf-watcher"; + +const { getTaskMock, toastMock } = vi.hoisted(() => ({ + getTaskMock: vi.fn(), + toastMock: vi.fn(), +})); + +vi.mock("@/actions/task", () => ({ + getTask: getTaskMock, +})); + +vi.mock("@/components/ui/toast", () => ({ + ToastAction: ({ children, ...props }: ComponentProps<"button">) => ( + + ), + toast: toastMock, +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + }: { + href: string; + children: React.ReactNode; + }) => {children}, +})); + +const POLL_INTERVAL_MS = 3000; + +// Reset the (module-level) store between tests so tracked generations from +// one test never leak into the next. +const resetStore = () => useCrossProviderPdfStore.setState({ generations: {} }); + +describe("CrossProviderPdfWatcher", () => { + beforeEach(() => { + vi.useFakeTimers(); + getTaskMock.mockReset(); + toastMock.mockReset(); + resetStore(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + resetStore(); + }); + + it("fires the ready toast for a tracked generation even with no button mounted", async () => { + getTaskMock.mockResolvedValue({ + data: { attributes: { state: "completed" } }, + }); + + // The button is NOT rendered here — only the app-wide watcher. This is + // exactly the "user navigated away from the generating page" case: the + // notification must still arrive. + render(); + + useCrossProviderPdfStore.getState().trackGeneration({ + taskId: "task-123", + signature: "csa_ccm_4.0|scan-1|||", + reportUrl: "/compliance/csa_ccm_4.0?filter=x", + }); + + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS); + + expect(getTaskMock).toHaveBeenCalledWith("task-123"); + expect(toastMock).toHaveBeenCalledTimes(1); + const payload = toastMock.mock.calls[0]?.[0]; + expect(payload.title).toBe("PDF report ready"); + // The action links back to the page the report was generated from. + expect(payload.action.props.children.props.href).toBe( + "/compliance/csa_ccm_4.0?filter=x", + ); + // Completion is recorded so a still-mounted button can flip to Download. + expect( + useCrossProviderPdfStore.getState().generations["task-123"].status, + ).toBe("completed"); + }); + + it("keeps polling while the task is still running and does not toast", async () => { + getTaskMock.mockResolvedValue({ + data: { attributes: { state: "executing" } }, + }); + render(); + + useCrossProviderPdfStore.getState().trackGeneration({ + taskId: "task-run", + signature: "sig", + reportUrl: "/x", + }); + + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2); + + expect(getTaskMock).toHaveBeenCalled(); + expect(toastMock).not.toHaveBeenCalled(); + expect( + useCrossProviderPdfStore.getState().generations["task-run"].status, + ).toBe("running"); + }); + + it("tolerates transient getTask errors and gives up only after several", async () => { + getTaskMock.mockResolvedValue({ error: "boom" }); + render(); + + useCrossProviderPdfStore.getState().trackGeneration({ + taskId: "task-err", + signature: "sig", + reportUrl: "/x", + }); + + // Two consecutive errors: still below the threshold — no failure toast. + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2); + expect(toastMock).not.toHaveBeenCalled(); + expect( + useCrossProviderPdfStore.getState().generations["task-err"].status, + ).toBe("running"); + + // Third consecutive error crosses the threshold — fail and stop. + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS); + expect(toastMock).toHaveBeenCalledTimes(1); + expect(toastMock.mock.calls[0]?.[0].variant).toBe("destructive"); + expect( + useCrossProviderPdfStore.getState().generations["task-err"].status, + ).toBe("failed"); + }); + + it("surfaces a terminal failure state with the task's error message", async () => { + getTaskMock.mockResolvedValue({ + data: { + attributes: { state: "failed", result: { error: "no scans" } }, + }, + }); + render(); + + useCrossProviderPdfStore.getState().trackGeneration({ + taskId: "task-fail", + signature: "sig", + reportUrl: "/x", + }); + + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS); + + expect(toastMock).toHaveBeenCalledTimes(1); + const payload = toastMock.mock.calls[0]?.[0]; + expect(payload.variant).toBe("destructive"); + expect(payload.description).toContain("no scans"); + expect( + useCrossProviderPdfStore.getState().generations["task-fail"].status, + ).toBe("failed"); + }); +}); diff --git a/ui/components/compliance/cross-provider/cross-provider-pdf-watcher.tsx b/ui/components/compliance/cross-provider/cross-provider-pdf-watcher.tsx new file mode 100644 index 0000000000..ead4218a57 --- /dev/null +++ b/ui/components/compliance/cross-provider/cross-provider-pdf-watcher.tsx @@ -0,0 +1,150 @@ +"use client"; + +import Link from "next/link"; +import { useRef } from "react"; + +import { getTask } from "@/actions/task"; +import { toast, ToastAction } from "@/components/ui/toast"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import { useCrossProviderPdfStore } from "@/store/cross-provider-pdf/store"; +import type { GetTaskResponse, TaskState } from "@/types/tasks"; + +type PdfTaskResult = { error?: string }; + +const POLL_INTERVAL_MS = 3000; + +// Allowlist of "still running" states rather than hardcoding "done" states: +// treating anything NOT in this set as terminal means an unhandled state +// (e.g. "cancelled", or a future state we don't know about yet) stops +// polling and surfaces an error instead of spinning forever. +const RUNNING_STATES = new Set([ + "available", + "scheduled", + "executing", +]); + +// Transient getTask failures (network blip, one 5xx) must not abort a +// generation that is still running server-side — give up only after several +// consecutive failures. +const MAX_CONSECUTIVE_POLL_ERRORS = 3; + +/** + * Single, app-wide poller for cross-provider PDF generations. + * + * Mounted once in the ``(prowler)`` layout so it survives navigation between + * views. When the user clicks "Generate PDF" the button registers the task in + * ``useCrossProviderPdfStore`` and unmounts freely; this watcher keeps polling + * every tracked generation and fires the "ready"/"failed" toast when each one + * settles — so the notification arrives even if the user has since switched to + * the overview or another framework. The toast's action links back to the + * exact page the report was generated from (where the button now offers + * "Download PDF"). + */ +export const CrossProviderPdfWatcher = () => { + const generations = useCrossProviderPdfStore((state) => state.generations); + const markCompleted = useCrossProviderPdfStore( + (state) => state.markCompleted, + ); + const markFailed = useCrossProviderPdfStore((state) => state.markFailed); + + // Latest generations snapshot for the interval callback to read without + // being re-created (and re-scheduling the interval) on every store change. + const generationsRef = useRef(generations); + generationsRef.current = generations; + + // Per-task bookkeeping (in-flight tick + consecutive error count) that must + // NOT trigger React re-renders — a plain ref keyed by task id. + const pollStateRef = useRef>( + new Map(), + ); + + // A single mount-time poller (setup on mount, cleanup on unmount). It reads + // the live generations snapshot from ``generationsRef`` and the store + // actions (``markCompleted``/``markFailed``) are stable Zustand references, + // so capturing them at mount is safe — no dependency array needed. + useMountEffect(() => { + const interval = setInterval(() => { + const running = Object.values(generationsRef.current).filter( + (generation) => generation.status === "running", + ); + + // Prune bookkeeping for tasks that are no longer running so the map + // doesn't grow unbounded across a long session. + const runningIds = new Set( + running.map((generation) => generation.taskId), + ); + for (const trackedId of Array.from(pollStateRef.current.keys())) { + if (!runningIds.has(trackedId)) pollStateRef.current.delete(trackedId); + } + + for (const generation of running) { + let pollState = pollStateRef.current.get(generation.taskId); + if (!pollState) { + pollState = { busy: false, errors: 0 }; + pollStateRef.current.set(generation.taskId, pollState); + } + // Skip a task whose previous poll (a whole-task fetch) is still in + // flight — otherwise a slow tick could double-fire the ready toast. + if (pollState.busy) continue; + pollState.busy = true; + + const state = pollState; + void (async () => { + try { + const task = (await getTask( + generation.taskId, + )) as GetTaskResponse; + + if ("error" in task) { + state.errors += 1; + if (state.errors < MAX_CONSECUTIVE_POLL_ERRORS) return; + markFailed(generation.taskId); + toast({ + variant: "destructive", + title: "PDF generation failed", + description: task.error, + }); + return; + } + state.errors = 0; + + const taskState = task.data?.attributes?.state; + if (taskState && RUNNING_STATES.has(taskState)) return; + + if (taskState === "completed") { + markCompleted(generation.taskId); + toast({ + title: "PDF report ready", + description: + "Your combined compliance PDF has been generated and is ready to download.", + action: generation.reportUrl ? ( + + View report + + ) : undefined, + }); + return; + } + + // Any other terminal state ("failed", "cancelled", or an + // unrecognized one) — stop tracking rather than spin forever. + markFailed(generation.taskId); + toast({ + variant: "destructive", + title: "PDF generation failed", + description: + task.data?.attributes?.result?.error || + `The report generation task did not complete (state: ${taskState ?? "unknown"}).`, + }); + } finally { + state.busy = false; + } + })(); + } + }, POLL_INTERVAL_MS); + + return () => clearInterval(interval); + }); + + return null; +}; diff --git a/ui/components/compliance/cross-provider/generate-pdf-button.tsx b/ui/components/compliance/cross-provider/generate-pdf-button.tsx new file mode 100644 index 0000000000..60ff8a774c --- /dev/null +++ b/ui/components/compliance/cross-provider/generate-pdf-button.tsx @@ -0,0 +1,429 @@ +"use client"; + +import { DownloadIcon, FileDown, Loader2 } from "lucide-react"; +import type { ReactNode } from "react"; +import { useCallback, useRef, useState } from "react"; + +import type { LatestCrossProviderPdfReport } from "@/actions/compliances"; +import { + generateCrossProviderCompliancePdf, + getCrossProviderCompliancePdf, +} from "@/actions/compliances"; +import { Button } from "@/components/shadcn/button/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/shadcn/dialog"; +import { Input } from "@/components/shadcn/input/input"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import { toast } from "@/components/ui/toast"; +import { downloadFile } from "@/lib/helper"; +import { cn } from "@/lib/utils"; +import { useCrossProviderPdfStore } from "@/store/cross-provider-pdf/store"; + +interface GeneratePdfButtonProps { + /** e.g. "csa_ccm_4.0" — the universal framework currently on screen. */ + complianceId: string; + /** + * The exact scan ids backing the overview currently rendered + * (``attributes.scan_ids``), so the PDF matches what the user sees + * instead of re-resolving "latest scan per provider" a second time. + */ + scanIds: string[]; + /** + * The same ``filter[provider_type__in]`` / ``filter[provider_id__in]`` / + * ``filter[provider_groups__in]`` values currently applied via + * ``CrossProviderFilters`` (raw comma-separated strings straight from the + * URL search params). ``scanIds`` is already narrowed by these — passing + * them too is defense-in-depth for the edge case where ``scanIds`` ends up + * empty and generation would otherwise fall back to auto-selecting across + * every compatible provider instead of respecting an active filter. + */ + providerTypes?: string; + providerIds?: string; + providerGroups?: string; + /** + * A report already generated for these exact filters, resolved + * server-side alongside the page's data fetch — ``null`` means none + * exists yet (or it went stale because a contributing provider completed + * a new scan since), so the button shows "Generate" instead of + * "Download". Whenever the caller's filters change, a fresh value flows + * in here via a normal prop update (App Router re-runs the server + * component on navigation) and this component resyncs to it — state is + * derived per filter signature, so a report invalidated under the previous + * filters simply stops matching once the signature changes. + */ + latestPdfReport: LatestCrossProviderPdfReport | null; + /** Human-readable framework label (e.g. "CSA Cloud Controls Matrix v4.0") + * used to pre-fill the "name your report" dialog. */ + frameworkLabel: string; + className?: string; +} + +/** Default report name pre-filled in the dialog: framework + today's date, + * so it's descriptive and unique out of the box while still editable. */ +const buildDefaultReportName = (frameworkLabel: string) => { + const today = new Date(); + const yyyy = today.getFullYear(); + const mm = String(today.getMonth() + 1).padStart(2, "0"); + const dd = String(today.getDate()).padStart(2, "0"); + return `${frameworkLabel} - ${yyyy}-${mm}-${dd}`; +}; + +const formatGeneratedAt = (iso?: string) => { + if (!iso) return null; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return null; + return date.toLocaleString(); +}; + +/** + * "Generate PDF" / "Download PDF" button for the cross-provider compliance + * view. Three states: + * + * - No report for the current filters ("idle"): "Generate PDF". Triggers + * ad-hoc generation, however long it takes (the backend job has no fixed + * time budget). The button does NOT poll itself: it registers the task in + * ``useCrossProviderPdfStore`` and the app-wide ``CrossProviderPdfWatcher`` + * (mounted once in the layout) polls it to completion. That's what lets + * the "ready" toast still fire after the user navigates away from this + * page — the button unmounts, but the watcher and store persist. + * - Mid-generation: "Generating..." (disabled, spinner) — derived from a + * ``running`` entry in the store whose signature matches this button's. + * - A report exists for the current filters ("available", because the + * server resolved one on load or a store generation for this signature + * just completed): "Download PDF" — downloads immediately, no regen. + * + * Generation does NOT auto-download. When the watcher sees the job finish it + * fires a "ready" toast whose action links back to the page the report was + * generated from (where the button is now "Download PDF"); the user chooses + * when to download. + * + * Changing provider/scan filters is a real navigation (URL search params), + * so the parent re-fetches and this component receives a fresh + * ``latestPdfReport`` prop; state is derived per filter signature, so a + * report generated under the previous filters never lingers as "available" + * after the filters change. + */ +export const GeneratePdfButton = ({ + complianceId, + scanIds, + providerTypes, + providerIds, + providerGroups, + latestPdfReport, + frameworkLabel, + className, +}: GeneratePdfButtonProps) => { + const [isStarting, setIsStarting] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + // "Name your report" dialog state. + const [nameDialogOpen, setNameDialogOpen] = useState(false); + const [reportName, setReportName] = useState(""); + // Server-resolved report that turned out not to be servable when the user + // actually clicked Download (file expired/cleaned up). Without this, the + // stale ``latestPdfReport`` prop would keep the button on "Download PDF" + // forever, every click failing with a toast telling the user to press a + // Generate button that never appears. Tagged with the filter signature it + // was invalidated under, so it's derived away (not reset via an effect) + // the moment the filters change and a fresh ``latestPdfReport`` flows in. + const [invalidated, setInvalidated] = useState<{ + signature: string; + taskId: string; + } | null>(null); + + const generations = useCrossProviderPdfStore((state) => state.generations); + const trackGeneration = useCrossProviderPdfStore( + (state) => state.trackGeneration, + ); + const removeGeneration = useCrossProviderPdfStore( + (state) => state.removeGeneration, + ); + + // The filter signature this button was rendered for. When it changes + // (the user picked different providers, or navigated to a different + // framework), any report generated under the *previous* signature is no + // longer relevant — state is derived per signature, so it simply stops + // matching and the button falls back to whatever the server resolved for + // the new signature (``latestPdfReport``, already correct because it was + // fetched for these new filters). + const filterSignature = [ + complianceId, + scanIds.join(","), + providerTypes, + providerIds, + providerGroups, + ].join("|"); + + // The invalidation only applies to the signature it happened under; once + // the filters change it stops matching and the button falls back to the + // freshly resolved ``latestPdfReport`` — no reset effect needed. + const invalidatedTaskId = + invalidated?.signature === filterSignature ? invalidated.taskId : null; + + // Derive this button's view straight from the shared store, matched on the + // signature the generation was started under: a generation started here is + // only "mine" (and its finished report only offered for download) while the + // rendered filters still match the ones it was generated for. A ``running`` + // entry takes priority over a ``completed`` one for the same signature — + // regenerating after invalidating a stale report leaves both, and the button + // must show "Generating..." (not the old report's "Download"). + const tracked = Object.values(generations); + const runningForSignature = tracked.find( + (generation) => + generation.signature === filterSignature && + generation.status === "running", + ); + const completedForSignature = tracked.find( + (generation) => + generation.signature === filterSignature && + generation.status === "completed" && + generation.taskId !== invalidatedTaskId, + ); + const isGenerating = isStarting || Boolean(runningForSignature); + const storeReport = completedForSignature + ? { taskId: completedForSignature.taskId } + : null; + const serverReport = + latestPdfReport && latestPdfReport.taskId !== invalidatedTaskId + ? latestPdfReport + : null; + const availableReport: Pick & + Partial = storeReport ?? + serverReport ?? { taskId: "" }; + const hasAvailableReport = Boolean(storeReport ?? serverReport); + + // Read the live filter signature from a ref inside the async generate + // handler (refs are exempt from the hook dependency array and always hold + // the current-render value) so the store entry is tagged with the filters + // in effect at click time. + const currentSignatureRef = useRef(filterSignature); + currentSignatureRef.current = filterSignature; + + const handleGenerate = useCallback( + async (chosenName: string) => { + if (isGenerating) return; + setIsStarting(true); + const signature = currentSignatureRef.current; + // Snapshot the page URL (path + filters) now, so the watcher's "ready" + // toast can link back here even if the user has navigated elsewhere by + // the time generation finishes. + const reportUrl = window.location.pathname + window.location.search; + toast({ + title: "Generating PDF report", + description: + "This may take a while — the report combines every provider's latest scan for the current filters. You'll get a notification when it's ready to download; you can keep browsing in the meantime.", + }); + + const trimmedName = chosenName.trim(); + const result = await generateCrossProviderCompliancePdf({ + complianceId, + scanIds, + providerTypes, + providerIds, + providerGroups, + // Empty → server falls back to a unique timestamped default. + reportName: trimmedName.length > 0 ? trimmedName : undefined, + }); + setIsStarting(false); + + if ("error" in result) { + toast({ + variant: "destructive", + title: "Unable to start PDF generation", + description: result.error, + }); + return; + } + + // Hand off to the app-wide watcher: it polls to completion and fires the + // "ready" toast even if this button unmounts on navigation. + trackGeneration({ taskId: result.taskId, signature, reportUrl }); + }, + [ + complianceId, + scanIds, + providerTypes, + providerIds, + providerGroups, + isGenerating, + trackGeneration, + ], + ); + + // Opening the dialog seeds the input with a descriptive default the user + // can accept or overwrite. + const openNameDialog = useCallback(() => { + setReportName(buildDefaultReportName(frameworkLabel)); + setNameDialogOpen(true); + }, [frameworkLabel]); + + const confirmGenerate = useCallback(() => { + setNameDialogOpen(false); + void handleGenerate(reportName); + }, [handleGenerate, reportName]); + + const handleDownload = useCallback(async () => { + if (!hasAvailableReport || isDownloading || isGenerating) return; + setIsDownloading(true); + toast({ + title: "Download Started", + description: "Preparing the combined compliance PDF report.", + }); + try { + const result = await getCrossProviderCompliancePdf( + availableReport.taskId, + ); + if ("pending" in result && result.pending) { + // The cached report reference is no longer servable (e.g. the + // underlying file expired/was cleaned up). Invalidate BOTH sources + // feeding ``availableReport`` — dropping only the store entry would + // leave the server-resolved ``latestPdfReport`` prop keeping the + // button on "Download PDF" forever, telling the user to click a + // Generate button that never appears. + removeGeneration(availableReport.taskId); + setInvalidated({ + signature: currentSignatureRef.current, + taskId: availableReport.taskId, + }); + toast({ + variant: "destructive", + title: "Report No Longer Available", + description: + "This report isn't ready to download anymore. Click Generate PDF to create a new one.", + }); + return; + } + await downloadFile( + result, + "application/pdf", + "The combined compliance PDF report has been downloaded successfully.", + toast, + ); + } finally { + setIsDownloading(false); + } + }, [ + availableReport.taskId, + hasAvailableReport, + isDownloading, + isGenerating, + removeGeneration, + ]); + + const buttonClassName = cn( + "border-button-primary text-button-primary hover:bg-button-primary/10 h-8 px-2 text-xs", + className, + ); + + const nameDialog = ( +

+ + + Name your report + + This becomes the downloaded PDF's filename. Leave it as is or + give it a name you'll recognize later. + + + setReportName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + confirmGenerate(); + } + }} + placeholder="Report name" + aria-label="Report name" + autoFocus + /> + + + + + + + ); + + let button: ReactNode; + if (isGenerating) { + button = ( + + ); + } else if (hasAvailableReport) { + const generatedAtLabel = formatGeneratedAt(availableReport.generatedAt); + const downloadButton = ( + + ); + button = generatedAtLabel ? ( + + {downloadButton} + Generated on {generatedAtLabel} + + ) : ( + downloadButton + ); + } else { + button = ( + + ); + } + + return ( + <> + {button} + {nameDialog} + + ); +}; diff --git a/ui/components/compliance/cross-provider/provider-coverage-panel.tsx b/ui/components/compliance/cross-provider/provider-coverage-panel.tsx index 885acd36ad..3665fb1451 100644 --- a/ui/components/compliance/cross-provider/provider-coverage-panel.tsx +++ b/ui/components/compliance/cross-provider/provider-coverage-panel.tsx @@ -1,12 +1,8 @@ "use client"; -import { Check, Slash } from "lucide-react"; - +import { ProviderBadgeIcon } from "@/components/icons/providers-badge/provider-badge-icon"; import type { ProviderCoverage } from "@/lib/compliance/cross-provider-insights"; -import { - getProviderBadge, - getProviderLabel, -} from "@/lib/providers/provider-display"; +import { getProviderLabel } from "@/lib/providers/provider-display"; import { cn } from "@/lib/utils"; interface ProviderCoveragePanelProps { @@ -14,11 +10,17 @@ interface ProviderCoveragePanelProps { } /** - * 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. + * Right-of-score panel listing every SCANNED provider with its + * per-provider score. In the detail view we only show providers we + * actually have scans for — framework-compatible providers with no scan + * belong on the overview (where they're listed dimmed as "no scan yet"), + * not here. + * + * Laid out as a tile grid rather than a single-column list: universal + * frameworks can declare 15+ compatible providers, and one row per + * provider made this panel several screens taller than its siblings + * (score donut, top failing domains). The account-count detail that no + * longer fits inline moves to the tile's ``title`` tooltip. */ export const ProviderCoveragePanel = ({ coverage, @@ -28,70 +30,70 @@ export const ProviderCoveragePanel = ({

Provider Coverage

-
    + {coverage.length === 0 && ( +

    + No scanned providers for this framework yet. +

    + )} +
      {coverage.map((entry) => { - const Badge = getProviderBadge(entry.key); const label = getProviderLabel(entry.key); - const Icon = entry.contributing ? Check : Slash; const dimmed = !entry.contributing; + const tooltip = entry.contributing + ? `${label}: ${entry.scorePercent}% — ${entry.pass} pass / ${entry.fail} fail (${entry.total} total)` + + (entry.accountCount > 1 + ? ` · ${entry.accountCount} accounts` + : "") + : `${label}: scanned, no data yet`; return (
    • - {Badge ? : null} -
      -
      - - {label} - {entry.accountCount > 1 && ( - - {entry.accountCount} accounts - - )} +
      + + + {label} + + {entry.contributing ? ( + + {entry.scorePercent}% - {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 - - )} + ) : ( + + No data )}
      - + {entry.contributing && entry.total > 0 ? ( +
      + ) : null} +
      + {/* Always reserve this line — even for "no scan" tiles — + so every tile in the grid is the same height regardless + of which row it lands in. */} + + {entry.contributing ? ( + <> + {entry.pass}/{entry.total} pass + {entry.fail > 0 && ( + + {entry.fail} fail + + )} + + ) : ( + <>  )} - strokeWidth={entry.contributing ? 3 : 2} - aria-hidden="true" - /> +
    • ); })} diff --git a/ui/components/compliance/cross-provider/top-failing-domains-panel.tsx b/ui/components/compliance/cross-provider/top-failing-domains-panel.tsx index 04d6a7e053..d7f28db7a8 100644 --- a/ui/components/compliance/cross-provider/top-failing-domains-panel.tsx +++ b/ui/components/compliance/cross-provider/top-failing-domains-panel.tsx @@ -60,7 +60,7 @@ export const TopFailingDomainsPanel = ({ aria-hidden="true" />
      - + {domain.name} diff --git a/ui/components/icons/providers-badge/provider-badge-icon.test.tsx b/ui/components/icons/providers-badge/provider-badge-icon.test.tsx new file mode 100644 index 0000000000..95e7a3dc69 --- /dev/null +++ b/ui/components/icons/providers-badge/provider-badge-icon.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { ProviderBadgeIcon } from "./provider-badge-icon"; + +describe("ProviderBadgeIcon", () => { + it("renders the registered badge component for a known provider", () => { + const { container } = render( + , + ); + + // The real AWS badge is an ; the initials fallback never mounts. + expect(container.querySelector("svg")).toBeInTheDocument(); + expect(screen.queryByText("AW")).not.toBeInTheDocument(); + }); + + it("falls back to an initials chip for a provider with a label but no icon", () => { + // ``linode`` is declared by the SDK's cross-provider CIS Controls + // template but has no dedicated badge component yet — the exact gap + // ``ProviderBadgeIcon`` exists to paper over. + render(); + + expect(screen.getByText("LI")).toBeInTheDocument(); + }); + + it("hides the fallback chip from assistive tech", () => { + render(); + + expect(screen.getByText("LI")).toHaveAttribute("aria-hidden", "true"); + }); + + it("derives fallback initials from the uppercased key when the provider is entirely unknown", () => { + // Regression guard: an unrecognized key must not throw, and should + // still render *something* identifiable rather than a blank square. + render(); + + expect(screen.getByText("ZZ")).toBeInTheDocument(); + }); + + it("sizes the fallback chip from the size prop", () => { + render(); + + expect(screen.getByText("LI")).toHaveStyle({ + width: "20px", + height: "20px", + }); + }); +}); diff --git a/ui/components/icons/providers-badge/provider-badge-icon.tsx b/ui/components/icons/providers-badge/provider-badge-icon.tsx new file mode 100644 index 0000000000..8c38ce28b1 --- /dev/null +++ b/ui/components/icons/providers-badge/provider-badge-icon.tsx @@ -0,0 +1,44 @@ +import { + getProviderBadge, + getProviderLabel, +} from "@/lib/providers/provider-display"; +import { cn } from "@/lib/utils"; + +interface ProviderBadgeIconProps { + providerKey: string; + size: number; + className?: string; +} + +/** + * Resolves a provider key to its dedicated badge icon. Providers without + * one yet (e.g. newly onboarded backend-only providers that haven't had + * artwork added) fall back to an initials chip instead of rendering + * nothing, so new providers never show up as a blank square. + */ +export const ProviderBadgeIcon = ({ + providerKey, + size, + className, +}: ProviderBadgeIconProps) => { + const Badge = getProviderBadge(providerKey); + if (Badge) { + return ; + } + + const label = getProviderLabel(providerKey); + const initials = label.slice(0, 2).toUpperCase(); + + return ( + + ); +}; diff --git a/ui/lib/compliance/cis-controls.test.ts b/ui/lib/compliance/cis-controls.test.ts new file mode 100644 index 0000000000..4804a5cfb1 --- /dev/null +++ b/ui/lib/compliance/cis-controls.test.ts @@ -0,0 +1,157 @@ +import { isValidElement } from "react"; +import { describe, expect, it, vi } from "vitest"; + +// cis-controls.tsx's accordion builders transitively import the client +// accordion chain, which pulls in server-only code (next-auth) when loaded +// under vitest. Stub them the same way compliance-mapper.test.ts does — +// we're testing the mapper's data wiring, not these components' rendering. +vi.mock( + "@/components/compliance/compliance-accordion/client-accordion-content", + () => ({ ClientAccordionContent: () => null }), +); +vi.mock( + "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title", + () => ({ ComplianceAccordionRequirementTitle: () => null }), +); +vi.mock( + "@/components/compliance/compliance-accordion/compliance-accordion-title", + () => ({ ComplianceAccordionTitle: () => null }), +); + +import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance"; + +import { mapComplianceData, toAccordionItems } from "./cis-controls"; +import { crossProviderToMapperInput } from "./cross-provider-adapter"; + +const CROSS_PROVIDER_ATTRIBUTES: CrossProviderComplianceOverviewAttributes = { + compliance_id: "cis_controls_8.1", + framework: "CIS-Controls", + name: "CIS-Controls", + version: "8.1", + description: "CIS Critical Security Controls", + compatible_providers: ["azure", "gcp"], + requested_providers: ["azure", "gcp"], + providers: ["azure", "gcp"], + scan_ids: ["scan-azure-uuid", "scan-gcp-uuid"], + scan_ids_by_provider: { + azure: ["scan-azure-uuid"], + gcp: ["scan-gcp-uuid"], + }, + requirements_passed: 1, + requirements_failed: 0, + requirements_manual: 0, + total_requirements: 1, + requirements: [ + { + id: "1.1", + name: "Establish and Maintain Detailed Enterprise Asset Inventory", + description: "Establish and maintain an inventory.", + attributes: { + Section: "1. Inventory and Control of Enterprise Assets", + Function: "Identify", + AssetType: "Devices", + ImplementationGroups: ["IG1", "IG2", "IG3"], + }, + status: "PASS", + providers: { azure: "PASS", gcp: "PASS" }, + check_ids_by_provider: { + azure: ["azure_check_a"], + gcp: ["gcp_check_a"], + }, + }, + ], +}; + +describe("cis-controls mapper — cross-provider augmentation", () => { + it("propagates providers/check_ids_by_provider/scan_ids_by_provider onto the mapped requirement", () => { + // This is the exact gap that made the per-provider check grouping and + // the cross-provider findings fetch silently fall back to per-scan + // behaviour for CIS Controls 8.1: only the CSA mapper carried these + // fields until this fix. + const { attributesData, requirementsData } = crossProviderToMapperInput( + CROSS_PROVIDER_ATTRIBUTES, + ); + const frameworks = mapComplianceData(attributesData, requirementsData); + const requirement = frameworks[0].categories[0].controls[0].requirements[0]; + + expect(requirement.providers).toEqual({ azure: "PASS", gcp: "PASS" }); + expect(requirement.check_ids_by_provider).toEqual({ + azure: ["azure_check_a"], + gcp: ["gcp_check_a"], + }); + expect(requirement.scan_ids_by_provider).toEqual({ + azure: ["scan-azure-uuid"], + gcp: ["scan-gcp-uuid"], + }); + }); + + it("leaves the augmentation fields undefined for a plain per-scan requirement", () => { + const perScanAttributesData = { + data: [ + { + type: "compliance-requirements-attributes" as const, + id: "1.1", + attributes: { + framework_description: "", + name: "Establish and Maintain Detailed Enterprise Asset Inventory", + framework: "CIS-Controls", + version: "8.1", + description: "", + attributes: { + metadata: [ + { + Section: "1. Inventory and Control of Enterprise Assets", + Function: "Identify", + AssetType: "Devices", + ImplementationGroups: ["IG1"], + }, + ], + check_ids: ["azure_check_a"], + }, + }, + }, + ], + }; + const perScanRequirementsData = { + data: [ + { + type: "compliance-requirements-details" as const, + id: "1.1", + attributes: { + framework: "CIS-Controls", + version: "8.1", + description: "", + status: "PASS" as const, + }, + }, + ], + }; + + const frameworks = mapComplianceData( + perScanAttributesData as any, + + perScanRequirementsData as any, + ); + const requirement = frameworks[0].categories[0].controls[0].requirements[0]; + + expect(requirement.providers).toBeUndefined(); + expect(requirement.check_ids_by_provider).toBeUndefined(); + expect(requirement.scan_ids_by_provider).toBeUndefined(); + }); + + it("forwards the requirement's providers map into the accordion title", () => { + const { attributesData, requirementsData } = crossProviderToMapperInput( + CROSS_PROVIDER_ATTRIBUTES, + ); + const frameworks = mapComplianceData(attributesData, requirementsData); + const items = toAccordionItems(frameworks, ""); + const titleElement = items[0]?.items?.[0]?.title; + + expect(isValidElement(titleElement)).toBe(true); + if (isValidElement(titleElement)) { + expect((titleElement.props as { providers?: unknown }).providers).toEqual( + { azure: "PASS", gcp: "PASS" }, + ); + } + }); +}); diff --git a/ui/lib/compliance/cis-controls.tsx b/ui/lib/compliance/cis-controls.tsx index 885659a721..34fc7f5768 100644 --- a/ui/lib/compliance/cis-controls.tsx +++ b/ui/lib/compliance/cis-controls.tsx @@ -6,8 +6,8 @@ import { FindingStatus } from "@/components/ui/table/status-finding-badge"; import { AttributesData, CISControlsAttributesMetadata, + CrossProviderRequirement, Framework, - Requirement, REQUIREMENT_STATUS, RequirementsData, RequirementStatus, @@ -62,6 +62,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); @@ -69,7 +77,7 @@ export const mapComplianceData = ( const control = findOrCreateControl(category.controls, categoryName); const finalStatus: RequirementStatus = status as RequirementStatus; - const requirement: Requirement = { + const requirement: CrossProviderRequirement = { name: requirementName ? `${id} - ${requirementName}` : id, description, status: finalStatus, @@ -78,6 +86,9 @@ export const mapComplianceData = ( function: attrs.Function ?? undefined, asset_type: attrs.AssetType ?? undefined, implementation_groups: attrs.ImplementationGroups ?? undefined, + providers: providersForRequirement, + check_ids_by_provider: checkIdsByProvider, + scan_ids_by_provider: scanIdsByProvider, }; control.requirements.push(requirement); @@ -122,6 +133,7 @@ export const toAccordionItems = ( type="" name={requirement.name} status={requirement.status as FindingStatus} + providers={requirement.providers} /> ), content: ( diff --git a/ui/lib/compliance/cross-provider-insights.test.ts b/ui/lib/compliance/cross-provider-insights.test.ts index 8ebe754a12..5a25aa744b 100644 --- a/ui/lib/compliance/cross-provider-insights.test.ts +++ b/ui/lib/compliance/cross-provider-insights.test.ts @@ -79,7 +79,7 @@ describe("computeCrossProviderInsights", () => { expect(insights.total).toBe(5); }); - it("builds providerCoverage for every compatible provider, including non-contributing ones", () => { + it("builds providerCoverage for every scanned provider, contributing or not", () => { const insights = computeCrossProviderInsights(buildAttributes()); const byKey = Object.fromEntries( insights.providerCoverage.map((c) => [c.key, c]), @@ -99,14 +99,38 @@ describe("computeCrossProviderInsights", () => { 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. + // GCP HAS a scan (it's in scan_ids_by_provider) but the API marks it + // non-contributing (no requirement row) — it still surfaces (scanned), + // with zeroed counts so the panel can dim it. expect(byKey.gcp.contributing).toBe(false); expect(byKey.gcp.total).toBe(0); expect(byKey.gcp.scorePercent).toBe(0); }); + it("shows only scanned providers in the detail (hides compatible providers with no scan)", () => { + const noGcpScan: CrossProviderComplianceOverviewAttributes = { + ...buildAttributes(), + // gcp stays compatible but is NOT scanned (dropped from scan_ids_by_provider). + providers: ["aws", "azure"], + scan_ids_by_provider: { aws: ["scan-aws"], azure: ["scan-azure"] }, + scan_ids: ["scan-aws", "scan-azure"], + }; + const insights = computeCrossProviderInsights(noGcpScan); + + // Detail surfaces (coverage panel, heatmap columns) only include scanned. + expect(insights.scannedProviders).toEqual(["aws", "azure"]); + expect(insights.providerCoverage.map((c) => c.key)).toEqual([ + "aws", + "azure", + ]); + for (const domain of insights.domainStats) { + expect(Object.keys(domain.byProvider)).not.toContain("gcp"); + } + + // ...but the full compatible set is still reported (the overview uses it). + expect(insights.compatibleProviders).toContain("gcp"); + }); + it("aggregates per-domain stats by Section attribute, with an Other bucket fallback", () => { const insights = computeCrossProviderInsights(buildAttributes()); const byName = Object.fromEntries( @@ -183,7 +207,7 @@ describe("computeCrossProviderInsights", () => { expect(insights.scorePercent).toBe(0); expect(insights.domainStats).toEqual([]); expect(insights.domainsByFailCount).toEqual([]); - // Compatible providers still surface (empty stats) so the coverage + // Scanned 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 index 59af692d4e..860172938e 100644 --- a/ui/lib/compliance/cross-provider-insights.ts +++ b/ui/lib/compliance/cross-provider-insights.ts @@ -62,9 +62,14 @@ export interface CrossProviderInsights { 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). */ + /** Providers we actually have scans for (a subset of + * ``compatibleProviders`` — the ones present in ``scan_ids_by_provider``), + * in display order. The detail view shows only these; the overview keeps + * showing every compatible provider (scanned or not). */ + scannedProviders: string[]; + /** One coverage entry per SCANNED provider. (The overview lists every + * compatible provider and dims the unscanned ones; the detail view + * intentionally hides providers with no scan.) */ providerCoverage: ProviderCoverage[]; /** Domain stats keyed by section name, in declared order. */ domainStats: DomainStats[]; @@ -163,7 +168,16 @@ export const computeCrossProviderInsights = ( } const contributingSet = new Set(contributing); - const providerCoverage: ProviderCoverage[] = compatible.map((key) => { + + // Providers we actually have scans for. The detail view shows only these + // (framework-compatible providers with no scan belong on the overview, + // where they're listed dimmed as "no scan yet"). Kept in ``compatible`` + // display order for a stable layout; a scan can only ever exist for a + // compatible provider, so filtering ``compatible`` covers every case. + const scannedSet = new Set(Object.keys(scanIdsByProvider ?? {})); + const scannedProviders = compatible.filter((key) => scannedSet.has(key)); + + const providerCoverage: ProviderCoverage[] = scannedProviders.map((key) => { const total = providerTotal.get(key) || 0; const pass = providerPass.get(key) || 0; const scorePct = total > 0 ? Math.floor((pass / total) * 100) : 0; @@ -183,7 +197,7 @@ export const computeCrossProviderInsights = ( const domainStats: DomainStats[] = Array.from(domainAcc.entries()).map( ([name, acc]) => { const byProvider: Record = {}; - for (const providerKey of compatible) { + for (const providerKey of scannedProviders) { const statuses = acc.perProvider.get(providerKey) ?? []; byProvider[providerKey] = aggregateDomainProviderStatuses(statuses); } @@ -208,6 +222,7 @@ export const computeCrossProviderInsights = ( total, compatibleProviders: compatible, contributingProviders: contributing, + scannedProviders, providerCoverage, domainStats, domainsByFailCount, diff --git a/ui/lib/compliance/dora.test.ts b/ui/lib/compliance/dora.test.ts new file mode 100644 index 0000000000..d647481b8e --- /dev/null +++ b/ui/lib/compliance/dora.test.ts @@ -0,0 +1,154 @@ +import { isValidElement } from "react"; +import { describe, expect, it, vi } from "vitest"; + +// dora.tsx's accordion builders transitively import the client accordion +// chain, which pulls in server-only code (next-auth) when loaded under +// vitest. Stub them the same way compliance-mapper.test.ts does — we're +// testing the mapper's data wiring, not these components' rendering. +vi.mock( + "@/components/compliance/compliance-accordion/client-accordion-content", + () => ({ ClientAccordionContent: () => null }), +); +vi.mock( + "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title", + () => ({ ComplianceAccordionRequirementTitle: () => null }), +); +vi.mock( + "@/components/compliance/compliance-accordion/compliance-accordion-title", + () => ({ ComplianceAccordionTitle: () => null }), +); + +import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance"; + +import { crossProviderToMapperInput } from "./cross-provider-adapter"; +import { mapComplianceData, toAccordionItems } from "./dora"; + +const CROSS_PROVIDER_ATTRIBUTES: CrossProviderComplianceOverviewAttributes = { + compliance_id: "dora_2022_2554", + framework: "DORA", + name: "DORA", + version: "2022/2554", + description: "Digital Operational Resilience Act", + compatible_providers: ["aws", "azure"], + 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: 0, + requirements_manual: 0, + total_requirements: 1, + requirements: [ + { + id: "Art-5", + name: "ICT risk management framework", + description: "Maintain a sound ICT risk management framework.", + attributes: { + Pillar: "ICT Risk Management", + Article: "5", + ArticleTitle: "ICT risk management framework", + }, + status: "PASS", + providers: { aws: "PASS", azure: "PASS" }, + check_ids_by_provider: { + aws: ["aws_check_a"], + azure: ["azure_check_a"], + }, + }, + ], +}; + +describe("dora mapper — cross-provider augmentation", () => { + it("propagates providers/check_ids_by_provider/scan_ids_by_provider onto the mapped requirement", () => { + // Same gap fixed for CIS Controls: only the CSA mapper carried these + // fields until this fix, silently breaking per-provider check grouping + // and the cross-provider findings fetch for DORA. + const { attributesData, requirementsData } = crossProviderToMapperInput( + CROSS_PROVIDER_ATTRIBUTES, + ); + const frameworks = mapComplianceData(attributesData, requirementsData); + const requirement = frameworks[0].categories[0].controls[0].requirements[0]; + + expect(requirement.providers).toEqual({ aws: "PASS", azure: "PASS" }); + expect(requirement.check_ids_by_provider).toEqual({ + aws: ["aws_check_a"], + azure: ["azure_check_a"], + }); + expect(requirement.scan_ids_by_provider).toEqual({ + aws: ["scan-aws-uuid"], + azure: ["scan-azure-uuid"], + }); + }); + + it("leaves the augmentation fields undefined for a plain per-scan requirement", () => { + const perScanAttributesData = { + data: [ + { + type: "compliance-requirements-attributes" as const, + id: "Art-5", + attributes: { + framework_description: "", + name: "ICT risk management framework", + framework: "DORA", + version: "2022/2554", + description: "", + attributes: { + metadata: [ + { + Pillar: "ICT Risk Management", + Article: "5", + ArticleTitle: "ICT risk management framework", + }, + ], + check_ids: ["aws_check_a"], + }, + }, + }, + ], + }; + const perScanRequirementsData = { + data: [ + { + type: "compliance-requirements-details" as const, + id: "Art-5", + attributes: { + framework: "DORA", + version: "2022/2554", + description: "", + status: "PASS" as const, + }, + }, + ], + }; + + const frameworks = mapComplianceData( + perScanAttributesData as any, + + perScanRequirementsData as any, + ); + const requirement = frameworks[0].categories[0].controls[0].requirements[0]; + + expect(requirement.providers).toBeUndefined(); + expect(requirement.check_ids_by_provider).toBeUndefined(); + expect(requirement.scan_ids_by_provider).toBeUndefined(); + }); + + it("forwards the requirement's providers map into the accordion title", () => { + const { attributesData, requirementsData } = crossProviderToMapperInput( + CROSS_PROVIDER_ATTRIBUTES, + ); + const frameworks = mapComplianceData(attributesData, requirementsData); + const items = toAccordionItems(frameworks, ""); + const titleElement = items[0]?.items?.[0]?.title; + + expect(isValidElement(titleElement)).toBe(true); + if (isValidElement(titleElement)) { + expect((titleElement.props as { providers?: unknown }).providers).toEqual( + { aws: "PASS", azure: "PASS" }, + ); + } + }); +}); diff --git a/ui/lib/compliance/dora.tsx b/ui/lib/compliance/dora.tsx index b3e13af8b3..caadca2e33 100644 --- a/ui/lib/compliance/dora.tsx +++ b/ui/lib/compliance/dora.tsx @@ -5,9 +5,9 @@ import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; import { FindingStatus } from "@/components/ui/table/status-finding-badge"; import { AttributesData, + CrossProviderRequirement, DORAAttributesMetadata, Framework, - Requirement, REQUIREMENT_STATUS, RequirementsData, RequirementStatus, @@ -65,6 +65,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); @@ -72,7 +80,7 @@ export const mapComplianceData = ( const control = findOrCreateControl(category.controls, categoryName); const finalStatus: RequirementStatus = status as RequirementStatus; - const requirement: Requirement = { + const requirement: CrossProviderRequirement = { name: requirementName ? `${id} - ${requirementName}` : id, description, status: finalStatus, @@ -82,6 +90,9 @@ export const mapComplianceData = ( pillar: attrs.Pillar, article: attrs.Article, article_title: attrs.ArticleTitle, + providers: providersForRequirement, + check_ids_by_provider: checkIdsByProvider, + scan_ids_by_provider: scanIdsByProvider, }; control.requirements.push(requirement); @@ -135,6 +146,7 @@ export const toAccordionItems = ( name={requirement.name} status={requirement.status as FindingStatus} invalidConfig={requirement.invalid_config} + providers={requirement.providers} /> ), content: ( diff --git a/ui/lib/compliance/prowler-hub.test.ts b/ui/lib/compliance/prowler-hub.test.ts new file mode 100644 index 0000000000..a9ebd60f29 --- /dev/null +++ b/ui/lib/compliance/prowler-hub.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { getProwlerHubComplianceUrl } from "./prowler-hub"; + +describe("getProwlerHubComplianceUrl", () => { + it("builds the Prowler Hub compliance page URL from the framework's compliance_id", () => { + expect(getProwlerHubComplianceUrl("cis_controls_8.1")).toBe( + "https://hub.prowler.com/compliance/cis_controls_8.1", + ); + }); + + it("works for every universal framework in the catalogue", () => { + expect(getProwlerHubComplianceUrl("csa_ccm_4.0")).toBe( + "https://hub.prowler.com/compliance/csa_ccm_4.0", + ); + expect(getProwlerHubComplianceUrl("dora_2022_2554")).toBe( + "https://hub.prowler.com/compliance/dora_2022_2554", + ); + }); +}); diff --git a/ui/lib/compliance/prowler-hub.ts b/ui/lib/compliance/prowler-hub.ts new file mode 100644 index 0000000000..da8756176c --- /dev/null +++ b/ui/lib/compliance/prowler-hub.ts @@ -0,0 +1,10 @@ +const PROWLER_HUB_BASE_URL = "https://hub.prowler.com/compliance"; + +/** + * Prowler Hub page for a universal compliance framework, keyed by its + * ``compliance_id`` (e.g. ``cis_controls_8.1``) — same id the cross-provider + * overview endpoint and ``UNIVERSAL_FRAMEWORKS`` catalogue already use, so no + * separate mapping is needed per framework. + */ +export const getProwlerHubComplianceUrl = (complianceId: string): string => + `${PROWLER_HUB_BASE_URL}/${complianceId}`; diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index 9674ea0d73..5d65bec7d1 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -188,9 +188,15 @@ export const downloadScanZip = async ( }; /** - * Generic function to download a file from base64 data + * Generic function to download a file from base64 data. + * + * Exported (not just used internally by `downloadCompliancePdf`/`downloadComplianceCsv` + * /`downloadComplianceOcsf`) so other binary-download flows built on the + * same `ScanBinaryResult`-shaped server action result — e.g. the ad-hoc + * cross-provider compliance PDF — can reuse the exact same base64→Blob→click + * download mechanics instead of re-implementing them. */ -const downloadFile = async ( +export const downloadFile = async ( result: ScanBinaryResult, outputType: string, successMessage: string, diff --git a/ui/lib/providers/provider-display.test.ts b/ui/lib/providers/provider-display.test.ts new file mode 100644 index 0000000000..9d419bc439 --- /dev/null +++ b/ui/lib/providers/provider-display.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { PROVIDER_DISPLAY_NAMES } from "@/types/providers"; + +import { getProviderBadge, getProviderLabel } from "./provider-display"; + +describe("getProviderBadge", () => { + it("resolves a badge for every provider added to close the CIS Controls 8.1 icon gap", () => { + // github/googleworkspace/okta/cloudflare/mongodbatlas/openstack/vercel + // were previously missing from PROVIDER_BADGE_BY_KEY, rendering blank + // squares wherever a badge was expected. + const withIcons = [ + "github", + "googleworkspace", + "okta", + "cloudflare", + "mongodbatlas", + "openstack", + "vercel", + ]; + for (const key of withIcons) { + expect(getProviderBadge(key), key).toBeDefined(); + } + }); + + it("returns undefined for providers that only have a fallback label", () => { + // linode/stackit/nhn/scaleway are declared by the SDK's universal + // compliance templates but have no dedicated icon component yet — + // ProviderBadgeIcon depends on this returning undefined to know when + // to render its initials fallback instead. + for (const key of ["linode", "stackit", "nhn", "scaleway"]) { + expect(getProviderBadge(key), key).toBeUndefined(); + } + }); + + it("returns undefined for an entirely unknown provider key", () => { + expect(getProviderBadge("made-up-provider")).toBeUndefined(); + }); +}); + +describe("getProviderLabel", () => { + it("reuses the shared PROVIDER_DISPLAY_NAMES map instead of a divergent local copy", () => { + // Regression guard: an earlier version of this module hand-copied + // labels and drifted from types/providers.ts (e.g. "GCP" vs "Google + // Cloud", "Oracle Cloud" vs "Oracle Cloud Infrastructure"), producing a + // different label for the same provider on different parts of the same + // page. Every known ProviderType must match the shared map exactly. + for (const [key, label] of Object.entries(PROVIDER_DISPLAY_NAMES)) { + expect(getProviderLabel(key), key).toBe(label); + } + }); + + it("labels backend-only providers not yet onboarded as a ProviderType", () => { + expect(getProviderLabel("linode")).toBe("Linode"); + expect(getProviderLabel("stackit")).toBe("STACKIT"); + expect(getProviderLabel("nhn")).toBe("NHN Cloud"); + expect(getProviderLabel("scaleway")).toBe("Scaleway"); + }); + + it("falls back to the uppercased key for an entirely unknown provider", () => { + expect(getProviderLabel("made-up-provider")).toBe("MADE-UP-PROVIDER"); + }); +}); diff --git a/ui/lib/providers/provider-display.ts b/ui/lib/providers/provider-display.ts index 0bc1b7dcbb..fe609891e6 100644 --- a/ui/lib/providers/provider-display.ts +++ b/ui/lib/providers/provider-display.ts @@ -4,12 +4,20 @@ import { AlibabaCloudProviderBadge, AWSProviderBadge, AzureProviderBadge, + CloudflareProviderBadge, GCPProviderBadge, + GitHubProviderBadge, + GoogleWorkspaceProviderBadge, KS8ProviderBadge, M365ProviderBadge, + MongoDBAtlasProviderBadge, + OktaProviderBadge, + OpenStackProviderBadge, OracleCloudProviderBadge, + VercelProviderBadge, } from "@/components/icons/providers-badge"; import type { IconSvgProps } from "@/types/components"; +import { PROVIDER_DISPLAY_NAMES, type ProviderType } from "@/types/providers"; /** * Single source of truth for provider display metadata. Kept in @@ -29,21 +37,35 @@ export const PROVIDER_BADGE_BY_KEY: Record> = { oraclecloud: OracleCloudProviderBadge, kubernetes: KS8ProviderBadge, m365: M365ProviderBadge, + github: GitHubProviderBadge, + googleworkspace: GoogleWorkspaceProviderBadge, + okta: OktaProviderBadge, + cloudflare: CloudflareProviderBadge, + mongodbatlas: MongoDBAtlasProviderBadge, + openstack: OpenStackProviderBadge, + vercel: VercelProviderBadge, }; -export const PROVIDER_LABEL_BY_KEY: Record = { - aws: "AWS", - azure: "Azure", - gcp: "GCP", - alibabacloud: "Alibaba Cloud", - oraclecloud: "Oracle Cloud", - kubernetes: "Kubernetes", - m365: "Microsoft 365", +/** + * Labels for providers the SDK's universal compliance templates can declare + * (e.g. cross-provider CIS Controls) that aren't onboarded as a + * ``ProviderType`` yet — no icon, account selector, or scan config exists + * for them, so they only ever reach ``getProviderLabel``'s fallback badge. + * Real providers must NOT be duplicated here: ``PROVIDER_DISPLAY_NAMES`` in + * ``@/types/providers`` is the single source of truth for those. + */ +const FALLBACK_ONLY_PROVIDER_LABELS: Record = { + linode: "Linode", + stackit: "STACKIT", + nhn: "NHN Cloud", + scaleway: "Scaleway", }; /** Resolve a provider label, falling back to an uppercased key. */ export const getProviderLabel = (providerKey: string): string => - PROVIDER_LABEL_BY_KEY[providerKey] ?? providerKey.toUpperCase(); + PROVIDER_DISPLAY_NAMES[providerKey as ProviderType] ?? + FALLBACK_ONLY_PROVIDER_LABELS[providerKey] ?? + providerKey.toUpperCase(); /** Resolve the badge component, returning ``undefined`` when no icon * is registered for the given key (the consumer renders a fallback). */ diff --git a/ui/store/cross-provider-pdf/store.ts b/ui/store/cross-provider-pdf/store.ts new file mode 100644 index 0000000000..1393d9eba6 --- /dev/null +++ b/ui/store/cross-provider-pdf/store.ts @@ -0,0 +1,85 @@ +import { create } from "zustand"; + +/** A single in-flight (or just-finished) cross-provider PDF generation. */ +export interface CrossProviderPdfGeneration { + /** The generation task id returned by the server action. */ + taskId: string; + /** + * The filter signature the generation was started under (framework + + * scan ids + provider filters). A ``GeneratePdfButton`` only offers a + * finished report for download when its own current signature matches + * this — a report generated under the previous filters must never be + * served under new ones. + */ + signature: string; + /** + * Absolute path + search of the page the generation was started from, so + * the "ready" toast can send the user back to exactly where they + * generated it — even if they navigated away while it was rendering. + */ + reportUrl: string; + status: "running" | "completed" | "failed"; +} + +interface CrossProviderPdfState { + /** Tracked generations keyed by task id. */ + generations: Record; + /** Register a newly started generation as ``running``. */ + trackGeneration: (entry: Omit) => void; + markCompleted: (taskId: string) => void; + markFailed: (taskId: string) => void; + /** Forget a generation entirely (e.g. its file expired on download). */ + removeGeneration: (taskId: string) => void; +} + +const setStatus = ( + generations: Record, + taskId: string, + status: CrossProviderPdfGeneration["status"], +): Record => { + const entry = generations[taskId]; + if (!entry || entry.status === status) return generations; + return { ...generations, [taskId]: { ...entry, status } }; +}; + +/** + * Global registry of in-flight cross-provider PDF generations. + * + * The generation job has no fixed time budget, so completion is detected by + * client-side polling. That polling lives in a single ``CrossProviderPdfWatcher`` + * mounted once in the app layout (NOT in the ``GeneratePdfButton``) so the + * "ready" notification still fires after the user navigates away from the + * page they generated the report on — the button unmounts, but the watcher + * and this store persist across route changes. + * + * In-memory only (like the other UI stores): a full browser reload mid- + * generation drops tracking, which is acceptable — the reported gap is SPA + * navigation within the app, where the store stays alive. + */ +export const useCrossProviderPdfStore = create( + (set) => ({ + generations: {}, + trackGeneration: (entry) => + set((state) => ({ + generations: { + ...state.generations, + [entry.taskId]: { ...entry, status: "running" }, + }, + })), + markCompleted: (taskId) => + set((state) => ({ + generations: setStatus(state.generations, taskId, "completed"), + })), + markFailed: (taskId) => + set((state) => ({ + generations: setStatus(state.generations, taskId, "failed"), + })), + removeGeneration: (taskId) => + set((state) => { + if (!state.generations[taskId]) return state; + const next = { ...state.generations }; + delete next[taskId]; + return { generations: next }; + }), + }), +); diff --git a/ui/store/index.ts b/ui/store/index.ts index d02b02187b..b4cffe02ae 100644 --- a/ui/store/index.ts +++ b/ui/store/index.ts @@ -1,3 +1,4 @@ +export * from "./cross-provider-pdf/store"; export * from "./organizations/store"; export * from "./provider-wizard/store"; export * from "./scans/store";