diff --git a/docs/user-guide/compliance/tutorials/cross-provider-compliance.mdx b/docs/user-guide/compliance/tutorials/cross-provider-compliance.mdx index 545f5fb834..19fc15ded2 100644 --- a/docs/user-guide/compliance/tutorials/cross-provider-compliance.mdx +++ b/docs/user-guide/compliance/tutorials/cross-provider-compliance.mdx @@ -1,7 +1,7 @@ --- title: 'Cross-Provider Compliance' sidebarTitle: 'Cross-Provider Compliance' -description: 'Aggregate a single universal compliance framework across every connected provider in Prowler Cloud, review a consolidated roll-up and per-provider breakdown, and download a combined PDF report.' +description: 'Aggregate a universal compliance framework across every connected provider type, or a single-provider framework across every provider of one type, review consolidated roll-ups and per-provider breakdowns, and download combined PDF reports.' --- import { VersionBadge } from "/snippets/version-badge.mdx" @@ -50,12 +50,12 @@ The catalog grows as new universal frameworks ship in Prowler. Browse the full c Sign in to Prowler Cloud at [cloud.prowler.com](https://cloud.prowler.com/sign-in) and select **Compliance** from the left navigation. - - At the top of the Compliance page, select the **Cross-provider** tab. The **Per Scan** tab (the default) keeps the single-provider compliance experience unchanged. + + At the top of the Compliance page, select the **Multiple Scans** tab. The **Single Scan** tab (the default) keeps the single-provider compliance experience unchanged. -Compliance page showing the Per Scan and Cross-provider tabs, with the Cross-provider tab highlighted +Compliance page showing the Single Scan and Multiple Scans tabs, with the Multiple Scans tab highlighted Cross-Provider Compliance requires at least one completed scan for a compatible provider. If no compatible provider has finished a scan yet, the page shows a notice prompting you to launch or wait for a scan to complete. @@ -63,7 +63,12 @@ Cross-Provider Compliance requires at least one completed scan for a compatible ## Exploring the Overview -The overview presents one card per supported universal framework, each summarizing the consolidated posture across every contributing provider. +The Multiple Scans tab is organized into two sections, each labeled with the axis it aggregates across: + +* **Across provider types:** One card per supported universal framework, aggregating every compatible provider type. This is the Cross-Provider Compliance experience described in the rest of this guide. +* **Across providers:** One card per single-provider framework (for example, CIS AWS) that can be aggregated across every connected provider of the same type. See [Aggregating a Single-Provider Framework Across Providers](#aggregating-a-single-provider-framework-across-providers). + +The **Across provider types** section presents one card per supported universal framework, each summarizing the consolidated posture across every contributing provider. Cross-Provider Compliance overview showing the provider filters and the framework grid (CSA CCM, CIS Controls, DORA) with per-provider chips, scores, and failed/manual counts @@ -201,6 +206,27 @@ A report already generated for a given set of filters does not need to be genera The PDF detail section renders only **failed** requirements by default so the report stays focused as an executive/auditor document. As with every Prowler PDF, the detail section is capped at the first 100 failed findings per check; use the per-scan CSV or JSON-OCSF exports for the complete, untruncated list. See [Downloading Compliance Reports](/user-guide/compliance/tutorials/compliance#downloading-compliance-reports) for the full PDF behavior and the `DJANGO_PDF_MAX_FINDINGS_PER_CHECK` setting. +## Aggregating a Single-Provider Framework Across Providers + +Universal frameworks answer the cross-provider-type question, but most compliance frameworks target a single provider type — CIS AWS, CIS GCP, ENS for Azure. The **Across providers** section of the Multiple Scans tab answers the sibling question for those frameworks: **"How compliant are all of my AWS accounts against CIS AWS, together?"** + +For every provider type with **two or more connected providers**, the section shows a collapsible group headed by the provider type and its counts (frameworks available and providers connected). Expanding a group reveals one card per single-provider framework available for that type, keeping the section compact even when several multi-provider types are connected. Selecting a card opens a detail page with the same layout as the cross-provider detail, with the column axis swapped from provider type to provider: + +* **One column per provider:** Prowler auto-selects the latest completed scan of every provider of that type you are allowed to see, and each requirement shows a status chip per provider (labeled with its alias or account ID). +* **Account Coverage:** The coverage card ranks each provider's individual posture so the weakest account is visible at a glance. +* **Filters:** Narrow the aggregation to specific providers or provider groups. The provider type is fixed by the framework, so there is no type filter here. +* **Findings drill-down:** Expanding a requirement queries the findings of every contributing provider's scan and merges them into a single table. + +The roll-up rules are identical to the cross-provider ones (**FAIL > PASS > MANUAL**, only providers that contributed a result count), applied per provider instead of per provider type. Scan selection, RBAC scoping, and staleness behavior also match: the view always reflects each provider's most recent completed scan, scoped to your role's visibility. + + +Provider types with a single connected provider are not listed — with one provider the aggregation is identical to the standard per-scan [Compliance](/user-guide/compliance/tutorials/compliance) view. + + +### Cross-Account PDF Report + +The detail page's **Report** button produces a single combined PDF across every contributing provider of the type, with the same asynchronous generation, background notification, and **Download latest** reuse flow as the [cross-provider report](#downloading-the-combined-pdf-report). The report is tied to the exact resolved scan set, so a new scan in any contributing provider makes the previous report stale and Prowler offers to generate an up-to-date one. + ## Related Documentation * [Compliance](/user-guide/compliance/tutorials/compliance) diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index d1d734ea66..d081334503 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -42,8 +42,10 @@ import { Framework, RequirementsTotals, } from "@/types/compliance"; +import { isKnownProviderType } from "@/types/providers"; import { ScanEntity } from "@/types/scans"; +import { CrossAccountDetail } from "../_components/cross-account-detail"; import { CrossProviderDetail } from "../_components/cross-provider-detail"; import { resolveCrossProviderFramework } from "../_lib/cross-provider-frameworks"; import { buildSearchParamsKey } from "../_lib/search-params-key"; @@ -113,6 +115,51 @@ export default async function ComplianceDetail({ ); } + // Cross-account mode: one regular framework aggregated across every + // account of one provider type. Cloud-only, like cross-provider. + if (mode === "cross-account") { + if (!isCloud()) { + redirect("/compliance"); + } + + const providerType = getSingleSearchParam( + resolvedSearchParams.providerType, + ); + if (!providerType || !isKnownProviderType(providerType)) { + notFound(); + } + + const crossAccountTitle = compliancetitle.split("-").join(" "); + return ( + + +
+ + +
+ + + } + > + +
+
+ ); + } + const regionFilter = getSingleSearchParam( resolvedSearchParams["filter[region__in]"], ); diff --git a/ui/app/(prowler)/compliance/_actions/cross-account.ts b/ui/app/(prowler)/compliance/_actions/cross-account.ts new file mode 100644 index 0000000000..b9f7de4e40 --- /dev/null +++ b/ui/app/(prowler)/compliance/_actions/cross-account.ts @@ -0,0 +1,306 @@ +"use server"; + +import * as Sentry from "@sentry/nextjs"; + +import type { ScanBinaryResult } from "@/actions/scans/scans"; +import { + apiBaseUrl, + GENERIC_SERVER_ERROR_MESSAGE, + getAuthHeaders, + getErrorMessage, +} from "@/lib"; +import { hasActionError } from "@/lib/action-errors"; +import { handleApiResponse } from "@/lib/server-actions-helper"; +import { SentryErrorSource, SentryErrorType } from "@/sentry"; + +import type { + CrossAccountApiFilters, + CrossAccountOverviewResponse, + CrossAccountOverviewResult, + LatestCrossProviderPdf, +} from "../_types"; +import { + CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE, + CROSS_PROVIDER_OVERVIEW_RESULT_STATUS, +} from "../_types"; + +const CROSS_ACCOUNT_API_PATH = "/cross-account-compliance-overviews"; + +/** Error payload shapes the PDF endpoints emit (JSON:API or plain). */ +interface PdfEndpointErrorBody { + errors?: Array<{ detail?: string }>; + error?: string; + message?: string; +} + +/** Cross-account sibling of the cross-provider action module's helper: a + * user-safe message from a failed PDF endpoint response, with 5xx reported + * to Sentry. `operation` must be a STATIC route template. */ +const getPdfEndpointErrorMessage = async ( + response: Response, + fallbackMessage: string, + operation: string, +): Promise => { + const contentType = response.headers.get("content-type")?.toLowerCase() || ""; + const errorData: PdfEndpointErrorBody | null = contentType.includes( + "text/html", + ) + ? null + : await response.json().catch(() => null); + + if (response.status >= 500) { + Sentry.captureException( + new Error( + `Cross-account PDF request failed (${response.status}) at ${operation}`, + ), + { + tags: { + api_error: true, + status_code: response.status.toString(), + error_type: SentryErrorType.SERVER_ERROR, + error_source: SentryErrorSource.SERVER_ACTION, + }, + level: "error", + contexts: { + api_response: { + status: response.status, + statusText: response.statusText, + operation, + }, + }, + }, + ); + return GENERIC_SERVER_ERROR_MESSAGE; + } + + return ( + errorData?.errors?.[0]?.detail || + errorData?.error || + errorData?.message || + fallbackMessage + ); +}; + +/** Appends the required identity params + shared filters to a request URL. */ +const applyCrossAccountParams = ( + url: URL, + complianceId: string, + providerType: string, + filters?: CrossAccountApiFilters, +) => { + url.searchParams.set("filter[compliance_id]", complianceId); + url.searchParams.set("filter[provider_type]", providerType); + + if (filters?.scanIds && filters.scanIds.length > 0) { + url.searchParams.set("filter[scan__in]", filters.scanIds.join(",")); + } + const paramMap = { + "filter[provider_id__in]": filters?.providerIds, + "filter[provider_groups__in]": filters?.providerGroups, + }; + for (const [key, value] of Object.entries(paramMap)) { + if (value && value.trim().length > 0) { + url.searchParams.set(key, value); + } + } +}; + +/** + * Aggregate a regular (per-provider) compliance framework across the latest + * completed scan of every visible account of one provider type (Prowler + * Cloud only — the OSS API has no such endpoint). + * + * When `filters.scanIds` is omitted the API auto-selects the latest COMPLETED + * scan per account. Non-2xx responses are returned as structured action + * errors so callers can reuse the app-wide 402/403 handlers, mirroring + * `getCrossProviderComplianceOverview`. + */ +export const getCrossAccountComplianceOverview = async ({ + complianceId, + providerType, + filters, +}: { + complianceId: string; + providerType: string; + filters?: CrossAccountApiFilters; +}): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}`); + applyCrossAccountParams(url, complianceId, providerType, filters); + + try { + const response = await fetch(url.toString(), { headers }); + const responseData = await handleApiResponse(response); + + if (hasActionError(responseData)) { + return { + status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR, + result: responseData, + }; + } + + return { + status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS, + response: responseData as CrossAccountOverviewResponse, + }; + } catch (error) { + console.error("Error fetching cross-account compliance overview:", error); + return { + status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR, + message: CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE, + }; + } +}; + +/** + * Trigger ad-hoc generation of the combined cross-account compliance PDF. + * Mirrors `generateCrossProviderPdf`: returns the async task id so the + * caller can track it and download via {@link getCrossAccountPdfBinary}. + * Pass the exact `scanIds` currently on screen so the report matches the + * displayed data instead of racing a scan completing in between. + */ +export const generateCrossAccountPdf = async ({ + complianceId, + providerType, + filters, + reportName, +}: { + complianceId: string; + providerType: string; + filters?: CrossAccountApiFilters; + /** Optional download filename; sanitized server-side. */ + reportName?: string; +}): Promise<{ taskId: string } | { error: string }> => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}/pdf`); + applyCrossAccountParams(url, complianceId, providerType, filters); + if (reportName) url.searchParams.set("report_name", reportName); + + try { + const response = await fetch(url.toString(), { method: "POST", headers }); + + if (!response.ok) { + throw new Error( + await getPdfEndpointErrorMessage( + response, + "Unable to start PDF generation. Contact support if the issue continues.", + `POST ${CROSS_ACCOUNT_API_PATH}/pdf`, + ), + ); + } + + 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-account PDF for a task started by + * {@link generateCrossAccountPdf}. Same 202-pending / 2xx-binary / error-JSON + * protocol (and shared `ScanBinaryResult` shape) as the cross-provider + * download action. + */ +export const getCrossAccountPdfBinary = async ( + taskId: string, +): Promise => { + const safeTaskId = taskId.trim(); + if (!/^[A-Za-z0-9_-]+$/.test(safeTaskId)) { + return { error: "Invalid task identifier." }; + } + + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL( + `${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}/pdf/${encodeURIComponent(safeTaskId)}`, + ); + + 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 getPdfEndpointErrorMessage( + response, + "Unable to retrieve the compliance PDF report. Contact support if the issue continues.", + `GET ${CROSS_ACCOUNT_API_PATH}/pdf/{taskId}`, + ), + ); + } + + const contentDisposition = + response.headers.get("content-disposition") || ""; + const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/i); + const filename = filenameMatch?.[1] || "cross-account-compliance.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) }; + } +}; + +/** + * Check whether a cross-account PDF already exists for the given filters — + * same degrade-to-`null` contract as `getLatestCrossProviderPdf` (404 means + * "not generated yet"; the report goes stale the moment any account + * completes a new scan). + */ +export const getLatestCrossAccountPdf = async ({ + complianceId, + providerType, + filters, +}: { + complianceId: string; + providerType: string; + filters?: CrossAccountApiFilters; +}): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}/pdf/latest`); + applyCrossAccountParams(url, complianceId, providerType, filters); + + try { + const response = await fetch(url.toString(), { headers }); + + if (response.status === 404) return null; + + if (!response.ok) { + throw new Error( + await getPdfEndpointErrorMessage( + response, + "Unable to check for an existing PDF report.", + `GET ${CROSS_ACCOUNT_API_PATH}/pdf/latest`, + ), + ); + } + + const json = await response.json(); + const taskId = json?.data?.id; + if (!taskId) return null; + + return { + taskId, + filename: json?.data?.attributes?.result?.filename, + completedAt: json?.data?.attributes?.completed_at, + }; + } catch (error) { + console.error("Error checking for an existing cross-account PDF:", error); + return null; + } +}; diff --git a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx index 617d943030..b182f5e4df 100644 --- a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx +++ b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx @@ -50,7 +50,7 @@ describe("CompliancePageTabs", () => { />, ); - await user.click(screen.getByRole("tab", { name: /cross-provider/i })); + await user.click(screen.getByRole("tab", { name: /multiple scans/i })); expect(pushMock).toHaveBeenCalledWith("/compliance?tab=cross-provider"); rerender( @@ -62,7 +62,7 @@ describe("CompliancePageTabs", () => { />, ); - await user.click(screen.getByRole("tab", { name: /per scan/i })); + await user.click(screen.getByRole("tab", { name: /single scan/i })); expect(pushMock).toHaveBeenCalledWith("/compliance"); }); @@ -78,7 +78,7 @@ describe("CompliancePageTabs", () => { ); const crossProviderTab = screen.getByRole("tab", { - name: /cross-provider/i, + name: /multiple scans/i, }); await user.click(crossProviderTab); diff --git a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx index 79e5d8af00..109ffe968c 100644 --- a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx +++ b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx @@ -64,7 +64,7 @@ export const CompliancePageTabs = ({ className="flex flex-col gap-[18px]" > - Per Scan + Single Scan - Cross-Provider + Multiple Scans diff --git a/ui/app/(prowler)/compliance/_components/compliance-section-header.tsx b/ui/app/(prowler)/compliance/_components/compliance-section-header.tsx new file mode 100644 index 0000000000..b69655056a --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/compliance-section-header.tsx @@ -0,0 +1,17 @@ +interface ComplianceSectionHeaderProps { + title: string; + description: string; +} + +/** Shared heading for the Multiple Scans tab's framework sections + * ("Across provider types" / "Across providers"), so both explain their + * aggregation axis with one consistent look. */ +export const ComplianceSectionHeader = ({ + title, + description, +}: ComplianceSectionHeaderProps) => ( +
+

{title}

+

{description}

+
+); diff --git a/ui/app/(prowler)/compliance/_components/cross-account-detail.tsx b/ui/app/(prowler)/compliance/_components/cross-account-detail.tsx new file mode 100644 index 0000000000..ff81454c9b --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-account-detail.tsx @@ -0,0 +1,257 @@ +import { Info } from "lucide-react"; +import Image from "next/image"; + +import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; +import { getAllProviders } from "@/actions/providers"; +import { + ClientAccordionWrapper, + RequirementsStatusCard, + TopFailedSectionsCard, +} from "@/components/compliance"; +import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; +import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon"; +import { Alert, AlertDescription } from "@/components/shadcn/alert"; +import { Card } from "@/components/shadcn/card/card"; +import { getComplianceMapper } from "@/lib/compliance/compliance-mapper"; +import type { Framework, RequirementsTotals } from "@/types/compliance"; +import { + type KnownProviderType, + PROVIDER_DISPLAY_NAMES, +} from "@/types/providers"; + +import { + getCrossAccountComplianceOverview, + getLatestCrossAccountPdf, +} from "../_actions/cross-account"; +import { toCrossAccountAccordionItems } from "../_lib/cross-account-accordion"; +import { + buildAccountExtrasMap, + computeAccountBreakdown, + crossAccountToMapperInput, +} from "../_lib/cross-account-adapter"; +import { parseCrossAccountFilters } from "../_lib/cross-account-frameworks"; +import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types"; +import { CrossProviderErrorAlert } from "./cross-provider-error-alert"; +import type { + CrossProviderAccountOption, + CrossProviderGroupOption, +} from "./cross-provider-filters"; +import { CrossProviderFilters } from "./cross-provider-filters"; +import { CrossProviderPdfButton } from "./cross-provider-pdf-button"; +import type { CoverageRow } from "./provider-coverage-card"; +import { ProviderCoverageCard } from "./provider-coverage-card"; + +interface CrossAccountDetailProps { + compliancetitle: string; + complianceId: string; + providerType: KnownProviderType; + searchParams: Record; + targetSection?: string; +} + +/** + * Server island for the cross-account detail (`?mode=cross-account`): the + * account-axis sibling of `CrossProviderDetail`. Fetches the roll-up of one + * regular framework across every account of one provider type, funnels it + * through the real framework mapper via the adapter, and renders the same + * summary-charts + accordion layout with per-account augmentations. + */ +export const CrossAccountDetail = async ({ + compliancetitle, + complianceId, + providerType, + searchParams, + targetSection, +}: CrossAccountDetailProps) => { + const filters = parseCrossAccountFilters(searchParams); + + const [overviewResponse, providersData, providerGroupsData] = + await Promise.all([ + getCrossAccountComplianceOverview({ + complianceId, + providerType, + filters, + }), + getAllProviders(), + getAllProviderGroups(), + ]); + + if ( + overviewResponse.status === + CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR + ) { + return ; + } + + if ( + overviewResponse.status === CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR + ) { + return ; + } + + const overviewData = overviewResponse.response.data; + + if (!overviewData?.attributes) { + return ( + + + + No cross-account compliance data was returned for this framework. The + view aggregates the latest completed scan of every account of this + provider type — run a scan to populate it. + + + ); + } + + const attrs = overviewData.attributes; + + // Scoped to the EXACT scans the overview resolved (not the raw filters), + // so an offered "Download latest" always matches the data on screen even + // if an account finished a new scan between the two calls. + const latestPdf = await getLatestCrossAccountPdf({ + complianceId, + providerType, + filters: { ...filters, scanIds: attrs.scan_ids }, + }); + + const mapper = getComplianceMapper(attrs.framework); + const { attributesData, requirementsData } = crossAccountToMapperInput(attrs); + const data = mapper.mapComplianceData(attributesData, requirementsData); + const extras = buildAccountExtrasMap(attrs); + const coverageRows: CoverageRow[] = computeAccountBreakdown(attrs).map( + (entry) => ({ + key: entry.id, + label: entry.label, + iconType: providerType, + pass: entry.pass, + fail: entry.fail, + manual: entry.manual, + score: entry.score, + }), + ); + + const totals: RequirementsTotals = data.reduce( + (acc: RequirementsTotals, framework: Framework) => ({ + pass: acc.pass + framework.pass, + fail: acc.fail + framework.fail, + manual: acc.manual + framework.manual, + }), + { pass: 0, fail: 0, manual: 0 }, + ); + const accordionItems = toCrossAccountAccordionItems( + data, + extras, + attrs.framework, + attrs.accounts, + ); + const topFailedResult = mapper.getTopFailedSections(data); + + // Same `${framework.name}-${category.name}` key scheme as the per-scan + // detail, so ?section= deep links (e.g. from Top Failed Sections) work. + const initialExpandedKeys: string[] = []; + if (targetSection) { + const candidates = new Set( + data.map((framework: Framework) => `${framework.name}-${targetSection}`), + ); + const match = accordionItems.find((item) => candidates.has(item.key)); + if (match) { + initialExpandedKeys.push(match.key); + } + } + + const logoPath = getComplianceIcon(compliancetitle); + + const providerAccounts: CrossProviderAccountOption[] = ( + providersData?.data || [] + ) + .filter((provider) => provider.attributes.provider === providerType) + .map((provider) => ({ + id: provider.id, + label: provider.attributes.alias + ? `${provider.attributes.alias} (${provider.attributes.uid})` + : provider.attributes.uid, + type: provider.attributes.provider, + })); + + const providerGroups: CrossProviderGroupOption[] = ( + providerGroupsData?.data || [] + ).map((group) => ({ id: group.id, name: group.attributes.name })); + + return ( +
+ {/* Header card — same structure as the cross-provider detail: identity + row (logo + context), filters below. */} + +
+
+
+ {logoPath && ( +
+ {`${compliancetitle} +
+ )} +
+ + {attrs.name || compliancetitle.split("-").join(" ")} + +

+ + {PROVIDER_DISPLAY_NAMES[providerType]} ·{" "} + {attrs.accounts.length}{" "} + {attrs.accounts.length === 1 ? "account" : "accounts"}{" "} + aggregated · {attrs.scan_ids.length}{" "} + {attrs.scan_ids.length === 1 ? "scan" : "scans"} +

+
+
+
+ +
+
+ + {/* No providerTypes: the type select is meaningless here — the + provider type is fixed by the framework being viewed. */} + +
+
+ +
+ + + +
+ + +
+ ); +}; diff --git a/ui/app/(prowler)/compliance/_components/cross-account-framework-card.tsx b/ui/app/(prowler)/compliance/_components/cross-account-framework-card.tsx new file mode 100644 index 0000000000..60966a82ce --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-account-framework-card.tsx @@ -0,0 +1,95 @@ +"use client"; + +import Image from "next/image"; +import { useRouter, useSearchParams } from "next/navigation"; + +import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; +import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon"; +import { Card, CardContent } from "@/components/shadcn/card/card"; +import { PROVIDER_DISPLAY_NAMES } from "@/types/providers"; + +import { buildCrossAccountDetailHref } from "../_lib/cross-account-frameworks"; +import type { CrossAccountFrameworkEntry } from "../_types"; + +/** + * Card for a regular per-provider framework in the Cross-Provider tab's + * "across accounts" section. Deliberately lightweight — no roll-up numbers: + * the section only enumerates which frameworks can be viewed across accounts + * (computing every framework's N-account aggregation up front would be one + * heavy roll-up call per card). The detail computes the real aggregation. + */ +export const CrossAccountFrameworkCard = ({ + complianceId, + title, + version, + providerType, + accountCount, +}: CrossAccountFrameworkEntry) => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const formattedTitle = `${title.split("-").join(" ")}${version ? ` - ${version}` : ""}`; + + const navigateToDetail = () => { + router.push( + buildCrossAccountDetailHref( + { complianceId, title, version, providerType }, + Object.fromEntries(searchParams.entries()), + ), + ); + }; + + return ( + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + navigateToDetail(); + } + }} + > + +
+
+ {getComplianceIcon(title) && ( +
+ {`${title} +
+ )} +
+

+ {formattedTitle} +

+ + View across providers + +
+
+ +
+ + + {PROVIDER_DISPLAY_NAMES[providerType]} + + + {accountCount} providers + +
+
+
+
+ ); +}; diff --git a/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx b/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx new file mode 100644 index 0000000000..69b1b79b46 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx @@ -0,0 +1,167 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getCompliancesOverview } from "@/actions/compliances"; +import { getAllProviders } from "@/actions/providers"; +import { getScans } from "@/actions/scans"; + +import { CrossAccountOverviewSection } from "./cross-account-overview-section"; + +vi.mock("@/actions/providers", () => ({ + getAllProviders: vi.fn(), +})); + +vi.mock("@/actions/scans", () => ({ + getScans: vi.fn(), +})); + +vi.mock("@/actions/compliances", () => ({ + getCompliancesOverview: vi.fn(), +})); + +vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({ + ProviderTypeIcon: () =>