diff --git a/docs/user-guide/compliance/tutorials/cross-provider-compliance.mdx b/docs/user-guide/compliance/tutorials/cross-provider-compliance.mdx index 545f5fb834..afc24e6f22 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 compatible providers with completed scans, 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" @@ -9,7 +9,7 @@ import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" -Cross-Provider Compliance consolidates a single **universal compliance framework** across all of your connected providers into one unified view. Instead of reviewing the same framework on AWS, Azure, Google Cloud, and every other provider as separate reports, Prowler takes the most recent completed scan of every compatible provider, aggregates them by requirement, and produces a single roll-up posture with a per-provider breakdown and a combined executive PDF. +Cross-Provider Compliance consolidates a single **universal compliance framework** across your compatible providers with completed scans into one unified view. Instead of reviewing the same framework on AWS, Azure, Google Cloud, and other supported providers as separate reports, Prowler takes the most recent completed scan of every compatible provider, aggregates them by requirement, and produces a single roll-up posture with a per-provider breakdown and a combined executive PDF. @@ -50,20 +50,25 @@ 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. +The **Across provider types** section requires at least one completed scan for a provider compatible with a universal framework. If none is available, that section shows a notice prompting you to launch or wait for a scan to complete. The **Across providers** section can still list single-provider frameworks when its own account and scan requirements are met. ## 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 @@ -193,7 +198,7 @@ Because the report aggregates many scans, it is generated **asynchronously**: A report already generated for a given set of filters does not need to be generated again. When you open the detail page with a filter combination that was reported before, Prowler detects the existing report and surfaces **Report → Download latest** so you can download it immediately, without launching a new job. You only need to generate a fresh report when: * You apply a filter combination that has never been reported before, or -* A contributing provider has run a new scan since the report was generated. The report is tied to the specific scans it was built from, so a newer scan makes the previous report stale; Prowler recognizes it no longer matches the current selection and offers to generate an up-to-date one. +* A contributing provider has completed a new scan since the report was generated. The report is tied to the specific scans it was built from, so a newer completed scan makes the previous report stale; Prowler recognizes it no longer matches the current selection and offers to generate an up-to-date one. "Download latest" reuses an existing report only when it matches the framework, the exact resolved scan set for the current filters, and the same report options. This guarantees the PDF you download reflects the posture you are looking at, rather than a report generated for a different filter or an older scan. @@ -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 completed 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)/_overview/watchlist/_components/compliance-watchlist.tsx b/ui/app/(prowler)/_overview/watchlist/_components/compliance-watchlist.tsx index e59ab4e915..0db318952a 100644 --- a/ui/app/(prowler)/_overview/watchlist/_components/compliance-watchlist.tsx +++ b/ui/app/(prowler)/_overview/watchlist/_components/compliance-watchlist.tsx @@ -3,6 +3,8 @@ import Image, { type StaticImageData } from "next/image"; import { useState } from "react"; +import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url"; + import { SortToggleButton } from "./sort-toggle-button"; import { WatchlistCard } from "./watchlist-card"; @@ -47,7 +49,7 @@ export const ComplianceWatchlist = ({ items }: { items: ComplianceData[] }) => { title="Compliance Watchlist" items={sortedItems} ctaLabel="Explore Compliance for Each Scan" - ctaHref="/compliance" + ctaHref={buildPerScanComplianceHref()} headerAction={ ); } + // 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.test.ts b/ui/app/(prowler)/compliance/_actions/cross-account.test.ts new file mode 100644 index 0000000000..4775166110 --- /dev/null +++ b/ui/app/(prowler)/compliance/_actions/cross-account.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + captureExceptionMock, + fetchMock, + getAuthHeadersMock, + handleApiResponseMock, +} = vi.hoisted(() => ({ + captureExceptionMock: vi.fn(), + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiResponseMock: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.test/api/v1", + GENERIC_SERVER_ERROR_MESSAGE: "Generic server error.", + getAuthHeaders: getAuthHeadersMock, + getErrorMessage: (error: unknown) => + error instanceof Error ? error.message : String(error), +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiResponse: handleApiResponseMock, +})); + +vi.mock("@sentry/nextjs", () => ({ + captureException: captureExceptionMock, +})); + +import { + generateCrossAccountPdf, + getCrossAccountComplianceOverview, + getCrossAccountPdfBinary, + getLatestCrossAccountPdf, +} from "./cross-account"; + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/vnd.api+json" }, + }); + +const lastFetchUrl = () => { + const call = fetchMock.mock.calls.at(-1); + if (!call) throw new Error("fetch was not called"); + return new URL(String(call[0])); +}; + +const fetchCallAt = (index: number) => { + const call = fetchMock.mock.calls[index]; + if (!call) throw new Error(`fetch call ${index} was not found`); + return { + init: call[1] as RequestInit, + url: new URL(String(call[0])), + }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockResolvedValue(jsonResponse({ data: null })); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer test-token" }); + handleApiResponseMock.mockResolvedValue({ data: null }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("cross-account compliance actions", () => { + it("loads the overview with its identity and account filters", async () => { + const payload = { data: { id: "cis_2.0_aws" } }; + handleApiResponseMock.mockResolvedValue(payload); + + const result = await getCrossAccountComplianceOverview({ + complianceId: "cis_2.0_aws", + providerType: "aws", + filters: { + scanIds: ["scan-1", "scan-2"], + providerIds: "provider-1,provider-2", + providerGroups: "group-1", + }, + }); + + expect(result).toEqual({ status: "success", response: payload }); + const url = lastFetchUrl(); + expect(url.pathname).toBe("/api/v1/cross-account-compliance-overviews"); + expect(url.searchParams.get("filter[compliance_id]")).toBe("cis_2.0_aws"); + expect(url.searchParams.get("filter[provider_type]")).toBe("aws"); + expect(url.searchParams.get("filter[scan__in]")).toBe("scan-1,scan-2"); + expect(url.searchParams.get("filter[provider_id__in]")).toBe( + "provider-1,provider-2", + ); + expect(url.searchParams.get("filter[provider_groups__in]")).toBe("group-1"); + }); + + it("routes PDF operations through the cross-account endpoints", async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse({ data: { type: "tasks", id: "task-1" } }, 202), + ) + .mockResolvedValueOnce( + new Response(Buffer.from("pdf-bytes"), { + headers: { + "Content-Disposition": 'attachment; filename="report.pdf"', + "Content-Type": "application/pdf", + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + data: { + id: "task-2", + attributes: { result: { filename: "latest.pdf" } }, + }, + }), + ); + + await generateCrossAccountPdf({ + complianceId: "cis_2.0_aws", + providerType: "aws", + filters: { scanIds: ["scan-1"] }, + reportName: "report.pdf", + }); + await getCrossAccountPdfBinary("task-1"); + await getLatestCrossAccountPdf({ + complianceId: "cis_2.0_aws", + providerType: "aws", + filters: { providerIds: "provider-1" }, + }); + + const generation = fetchCallAt(0); + expect(generation.init.method).toBe("POST"); + expect(generation.url.pathname).toBe( + "/api/v1/cross-account-compliance-overviews/pdf", + ); + expect(generation.url.searchParams.get("filter[compliance_id]")).toBe( + "cis_2.0_aws", + ); + expect(generation.url.searchParams.get("filter[provider_type]")).toBe( + "aws", + ); + expect(generation.url.searchParams.get("filter[scan__in]")).toBe("scan-1"); + expect(generation.url.searchParams.get("report_name")).toBe("report.pdf"); + + const binary = fetchCallAt(1); + expect(binary.url.pathname).toBe( + "/api/v1/cross-account-compliance-overviews/pdf/task-1", + ); + + const latest = fetchCallAt(2); + expect(latest.url.pathname).toBe( + "/api/v1/cross-account-compliance-overviews/pdf/latest", + ); + expect(latest.url.searchParams.get("filter[provider_id__in]")).toBe( + "provider-1", + ); + + expect([generation, binary, latest].every(({ init }) => init.signal)).toBe( + true, + ); + }); + + it("aborts a stalled request and reports the network failure", async () => { + vi.useFakeTimers(); + let requestSignal: AbortSignal | undefined; + fetchMock.mockImplementation( + (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + requestSignal = init?.signal ?? undefined; + requestSignal?.addEventListener("abort", () => { + reject(requestSignal?.reason ?? new Error("aborted")); + }); + }), + ); + + const request = getCrossAccountComplianceOverview({ + complianceId: "cis_2.0_aws", + providerType: "aws", + }); + await vi.advanceTimersByTimeAsync(30_000); + + await expect(request).resolves.toEqual({ + status: "load-error", + message: + "Could not load cross-provider compliance data. Try again later.", + }); + expect(requestSignal?.aborted).toBe(true); + expect(captureExceptionMock).toHaveBeenCalledTimes(1); + }); +}); 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..06568bba70 --- /dev/null +++ b/ui/app/(prowler)/compliance/_actions/cross-account.ts @@ -0,0 +1,126 @@ +"use server"; + +import type { ScanBinaryResult } from "@/actions/scans/scans"; +import { apiBaseUrl } from "@/lib"; + +import { + generateAggregatedCompliancePdf, + getAggregatedComplianceOverview, + getAggregatedCompliancePdfBinary, + getLatestAggregatedCompliancePdf, +} from "../_lib/aggregated-compliance-actions"; +import type { + CrossAccountApiFilters, + CrossAccountOverviewResponse, + CrossAccountOverviewResult, + LatestCrossProviderPdf, +} from "../_types"; + +const CROSS_ACCOUNT_API_PATH = "/cross-account-compliance-overviews"; + +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?.length) { + url.searchParams.set("filter[scan__in]", filters.scanIds.join(",")); + } + + const params = { + "filter[provider_id__in]": filters?.providerIds, + "filter[provider_groups__in]": filters?.providerGroups, + }; + for (const [key, value] of Object.entries(params)) { + if (value?.trim()) url.searchParams.set(key, value); + } +}; + +const buildCrossAccountUrl = ( + suffix: string, + complianceId: string, + providerType: string, + filters?: CrossAccountApiFilters, +) => { + const url = new URL(`${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}${suffix}`); + applyCrossAccountParams(url, complianceId, providerType, filters); + return url; +}; + +export const getCrossAccountComplianceOverview = async ({ + complianceId, + providerType, + filters, +}: { + complianceId: string; + providerType: string; + filters?: CrossAccountApiFilters; +}): Promise => { + const url = buildCrossAccountUrl("", complianceId, providerType, filters); + return getAggregatedComplianceOverview( + url, + `GET ${CROSS_ACCOUNT_API_PATH}`, + ); +}; + +export const generateCrossAccountPdf = async ({ + complianceId, + providerType, + filters, + reportName, +}: { + complianceId: string; + providerType: string; + filters?: CrossAccountApiFilters; + reportName?: string; +}): Promise<{ taskId: string } | { error: string }> => { + const url = buildCrossAccountUrl("/pdf", complianceId, providerType, filters); + if (reportName) url.searchParams.set("report_name", reportName); + return generateAggregatedCompliancePdf( + url, + `POST ${CROSS_ACCOUNT_API_PATH}/pdf`, + ); +}; + +export const getCrossAccountPdfBinary = async ( + taskId: string, +): Promise => { + const safeTaskId = taskId.trim(); + if (!/^[A-Za-z0-9_-]+$/.test(safeTaskId)) { + return { error: "Invalid task identifier." }; + } + + const url = new URL( + `${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}/pdf/${encodeURIComponent(safeTaskId)}`, + ); + return getAggregatedCompliancePdfBinary({ + url, + operation: `GET ${CROSS_ACCOUNT_API_PATH}/pdf/{taskId}`, + defaultFilename: "cross-account-compliance.pdf", + }); +}; + +export const getLatestCrossAccountPdf = async ({ + complianceId, + providerType, + filters, +}: { + complianceId: string; + providerType: string; + filters?: CrossAccountApiFilters; +}): Promise => { + const url = buildCrossAccountUrl( + "/pdf/latest", + complianceId, + providerType, + filters, + ); + return getLatestAggregatedCompliancePdf( + url, + `GET ${CROSS_ACCOUNT_API_PATH}/pdf/latest`, + ); +}; diff --git a/ui/app/(prowler)/compliance/_actions/cross-provider.ts b/ui/app/(prowler)/compliance/_actions/cross-provider.ts index eacdc29fde..1cd9f77fac 100644 --- a/ui/app/(prowler)/compliance/_actions/cross-provider.ts +++ b/ui/app/(prowler)/compliance/_actions/cross-provider.ts @@ -1,119 +1,54 @@ "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 { apiBaseUrl } from "@/lib"; +import { + generateAggregatedCompliancePdf, + getAggregatedComplianceOverview, + getAggregatedCompliancePdfBinary, + getLatestAggregatedCompliancePdf, +} from "../_lib/aggregated-compliance-actions"; import type { CrossProviderApiFilters, CrossProviderOverviewResponse, CrossProviderOverviewResult, LatestCrossProviderPdf, } from "../_types"; -import { - CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE, - CROSS_PROVIDER_OVERVIEW_RESULT_STATUS, -} from "../_types"; const CROSS_PROVIDER_API_PATH = "/cross-provider-compliance-overviews"; -/** Error payload shapes the PDF endpoints emit (JSON:API or plain). */ -interface PdfEndpointErrorBody { - errors?: Array<{ detail?: string }>; - error?: string; - message?: string; -} +const applyCrossProviderParams = ( + url: URL, + complianceId: string, + filters?: CrossProviderApiFilters, +) => { + url.searchParams.set("filter[compliance_id]", complianceId); -/** - * Extracts a user-safe message from a failed PDF endpoint response and, for - * unexpected failures, reports it to Sentry. `operation` must be a STATIC - * route template (e.g. `GET .../pdf/{taskId}`) — never the - * resolved URL, which would carry the task id or a user-typed report name. - */ -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); - - // These endpoints bypass handleApiResponse (binary/task protocol), so - // server failures would otherwise go unmonitored. - if (response.status >= 500) { - Sentry.captureException( - new Error( - `Cross-provider 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 shared cross-provider filter params to a request URL. */ -const applyFilters = (url: URL, filters?: CrossProviderApiFilters) => { - if (!filters) return; - - if (filters.scanIds && filters.scanIds.length > 0) { + if (filters?.scanIds?.length) { url.searchParams.set("filter[scan__in]", filters.scanIds.join(",")); } - const paramMap = { - "filter[provider_type__in]": filters.providerTypes, - "filter[provider_id__in]": filters.providerIds, - "filter[provider_groups__in]": filters.providerGroups, + const params = { + "filter[provider_type__in]": filters?.providerTypes, + "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); - } + for (const [key, value] of Object.entries(params)) { + if (value?.trim()) url.searchParams.set(key, value); } }; -/** - * Aggregate a universal compliance framework across one scan per compatible - * provider (Prowler Cloud only — the OSS API has no such endpoint). - * - * When `filters.scanIds` is omitted the API auto-selects the latest COMPLETED - * scan per compatible provider. Non-2xx responses are returned as structured - * action errors so callers can reuse the app-wide 402/403 handlers. - */ +const buildCrossProviderUrl = ( + suffix: string, + complianceId: string, + filters?: CrossProviderApiFilters, +) => { + const url = new URL(`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}${suffix}`); + applyCrossProviderParams(url, complianceId, filters); + return url; +}; + export const getCrossProviderComplianceOverview = async ({ complianceId, filters, @@ -121,45 +56,13 @@ export const getCrossProviderComplianceOverview = async ({ complianceId: string; filters?: CrossProviderApiFilters; }): Promise => { - const headers = await getAuthHeaders({ contentType: false }); - const url = new URL(`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}`); - url.searchParams.set("filter[compliance_id]", complianceId); - applyFilters(url, 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 CrossProviderOverviewResponse, - }; - } catch (error) { - console.error("Error fetching cross-provider compliance overview:", error); - return { - status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR, - message: CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE, - }; - } + const url = buildCrossProviderUrl("", complianceId, filters); + return getAggregatedComplianceOverview( + url, + `GET ${CROSS_PROVIDER_API_PATH}`, + ); }; -/** - * Trigger ad-hoc generation of the combined cross-provider compliance PDF. - * - * The PDF is built asynchronously by a backend task: this returns the task id - * so the caller can poll it and then download via - * {@link getCrossProviderPdfBinary}. Pass the exact `scanIds` currently on - * screen (`attributes.scan_ids`) so the report matches the displayed data - * instead of re-resolving "latest scan per provider", which could race a scan - * completing in between. - */ export const generateCrossProviderPdf = async ({ complianceId, filters, @@ -167,109 +70,34 @@ export const generateCrossProviderPdf = async ({ }: { complianceId: string; filters?: CrossProviderApiFilters; - /** 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_PROVIDER_API_PATH}/pdf`); - url.searchParams.set("filter[compliance_id]", complianceId); - applyFilters(url, filters); + const url = buildCrossProviderUrl("/pdf", complianceId, 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_PROVIDER_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) }; - } + return generateAggregatedCompliancePdf( + url, + `POST ${CROSS_PROVIDER_API_PATH}/pdf`, + ); }; -/** - * Fetch the finished cross-provider PDF for a task started by - * {@link generateCrossProviderPdf}. Speaks the same 202-pending / - * 2xx-binary / error-JSON protocol as the per-scan report endpoints, so it - * returns the shared {@link ScanBinaryResult} shape and callers can reuse the - * existing download plumbing unchanged. - */ export const getCrossProviderPdfBinary = async ( taskId: string, ): Promise => { - // The task id reaches the URL path: constrain it to the task-id charset - // (UUIDs) so a crafted value cannot smuggle `/`, `..` or a host. 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_PROVIDER_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_PROVIDER_API_PATH}/pdf/{taskId}`, - ), - ); - } - - const contentDisposition = - response.headers.get("content-disposition") || ""; - const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/i); - const filename = filenameMatch?.[1] || "cross-provider-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) }; - } + return getAggregatedCompliancePdfBinary({ + url, + operation: `GET ${CROSS_PROVIDER_API_PATH}/pdf/{taskId}`, + defaultFilename: "cross-provider-compliance.pdf", + }); }; -/** - * Check whether a cross-provider PDF already exists for the given filters so - * the UI can offer "Download" immediately instead of forcing a re-generate. - * - * 404 means "not generated yet" — a normal state, returned as `null` rather - * than an error. The backend only matches reports built from the exact scan - * set the filters resolve to, so a report goes stale (→ `null`) as soon as a - * contributing provider completes a new scan. Failures also degrade to - * `null`: this is an optional availability check and the caller's fallback - * (show "Generate") is always safe. - */ export const getLatestCrossProviderPdf = async ({ complianceId, filters, @@ -277,39 +105,9 @@ export const getLatestCrossProviderPdf = async ({ complianceId: string; filters?: CrossProviderApiFilters; }): Promise => { - const headers = await getAuthHeaders({ contentType: false }); - const url = new URL(`${apiBaseUrl}${CROSS_PROVIDER_API_PATH}/pdf/latest`); - url.searchParams.set("filter[compliance_id]", complianceId); - applyFilters(url, 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_PROVIDER_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) { - // Degraded on purpose, but logged: without this a systematically failing - // endpoint would be indistinguishable from "never generated". - console.error("Error checking for an existing cross-provider PDF:", error); - return null; - } + const url = buildCrossProviderUrl("/pdf/latest", complianceId, filters); + return getLatestAggregatedCompliancePdf( + url, + `GET ${CROSS_PROVIDER_API_PATH}/pdf/latest`, + ); }; diff --git a/ui/app/(prowler)/compliance/_components/aggregated-compliance-detail.test.tsx b/ui/app/(prowler)/compliance/_components/aggregated-compliance-detail.test.tsx new file mode 100644 index 0000000000..08643b3937 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/aggregated-compliance-detail.test.tsx @@ -0,0 +1,141 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { AggregatedComplianceDetail } from "./aggregated-compliance-detail"; + +vi.mock("@/components/compliance", () => ({ + ClientAccordionWrapper: () =>
, + RequirementsStatusCard: () =>
, + TopFailedSectionsCard: () =>
, +})); + +describe("AggregatedComplianceDetail", () => { + it("stacks actions on mobile and keeps the link after the title on desktop", () => { + // Given / When + const { container } = render( + CSA Cloud Controls Matrix} + description={

5 of 5 compatible providers scanned

} + headerLink={View on Prowler Hub} + reportAction={} + filters={
Filters
} + totals={{ pass: 1, fail: 2, manual: 3 }} + coverage={
Coverage
} + topFailed={{ sections: [], dataType: "sections" }} + accordionItems={[]} + initialExpandedKeys={[]} + />, + ); + + // Then + const logo = screen.getByAltText("CSA-CCM logo"); + const header = container.querySelector( + '[data-slot="aggregated-compliance-header"]', + ); + const heading = container.querySelector( + '[data-slot="aggregated-compliance-heading"]', + ); + const title = container.querySelector( + '[data-slot="aggregated-compliance-title"]', + ); + const description = container.querySelector( + '[data-slot="aggregated-compliance-description"]', + ); + const headerLink = container.querySelector( + '[data-slot="aggregated-compliance-header-link"]', + ); + const reportAction = container.querySelector( + '[data-slot="aggregated-compliance-report-action"]', + ); + if ( + !header || + !heading || + !title || + !description || + !headerLink || + !reportAction + ) { + throw new Error("Expected every aggregated compliance header region"); + } + + expect(header).toHaveClass("sm:grid-cols-[auto_minmax(0,1fr)_auto]"); + expect(heading).toHaveClass( + "contents", + "sm:grid", + "sm:grid-cols-[minmax(0,max-content)_auto]", + "sm:items-center", + "sm:justify-start", + "sm:gap-x-4", + ); + expect(heading).toContainElement(title); + expect(heading).toContainElement(description); + expect(heading).toContainElement(headerLink); + expect(title).toHaveClass( + "col-start-2", + "row-start-1", + "min-w-0", + "truncate", + ); + expect(description).toHaveClass("col-span-2", "row-start-2"); + expect(headerLink).toHaveClass("col-span-2", "row-start-3"); + expect(reportAction).toHaveClass( + "col-span-2", + "row-start-4", + "sm:col-start-3", + ); + + const orderedElements = [ + logo, + title, + description, + headerLink, + reportAction, + ]; + orderedElements.slice(0, -1).forEach((element, index) => { + expect( + element.compareDocumentPosition(orderedElements[index + 1]!) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + }); + + it("places the report directly after information when no header link exists", () => { + // Given / When + const { container } = render( + Custom Framework} + description={

2 accounts aggregated

} + reportAction={} + filters={
Filters
} + totals={{ pass: 1, fail: 2, manual: 3 }} + coverage={
Coverage
} + topFailed={{ sections: [], dataType: "sections" }} + accordionItems={[]} + initialExpandedKeys={[]} + />, + ); + + // Then + expect( + container.querySelector('[data-slot="aggregated-compliance-title"]'), + ).toHaveClass("col-start-1", "row-start-1"); + expect( + container.querySelector( + '[data-slot="aggregated-compliance-description"]', + ), + ).toHaveClass("col-start-1", "row-start-2"); + expect( + container.querySelector( + '[data-slot="aggregated-compliance-report-action"]', + ), + ).toHaveClass("col-start-1", "row-start-3"); + expect( + container.querySelector( + '[data-slot="aggregated-compliance-header-link"]', + ), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/app/(prowler)/compliance/_components/aggregated-compliance-detail.tsx b/ui/app/(prowler)/compliance/_components/aggregated-compliance-detail.tsx new file mode 100644 index 0000000000..1a6380cee5 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/aggregated-compliance-detail.tsx @@ -0,0 +1,160 @@ +import Image from "next/image"; +import type { ComponentProps, ReactNode } from "react"; + +import { + ClientAccordionWrapper, + RequirementsStatusCard, + TopFailedSectionsCard, +} from "@/components/compliance"; +import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; +import { Card } from "@/components/shadcn/card/card"; +import { cn } from "@/lib/utils"; +import type { RequirementsTotals } from "@/types/compliance"; + +interface AggregatedComplianceDetailProps { + compliancetitle: string; + logoPath?: ComponentProps["src"]; + title: ReactNode; + description: ReactNode; + headerLink?: ReactNode; + reportAction: ReactNode; + filters: ReactNode; + totals: RequirementsTotals; + coverage: ReactNode; + topFailed: ComponentProps; + accordionItems: AccordionItemProps[]; + initialExpandedKeys: string[]; +} + +export const AggregatedComplianceDetail = ({ + compliancetitle, + logoPath, + title, + description, + headerLink, + reportAction, + filters, + totals, + coverage, + topFailed, + accordionItems, + initialExpandedKeys, +}: AggregatedComplianceDetailProps) => ( +
+ +
+
+ {logoPath && ( +
+ {`${compliancetitle} +
+ )} + {headerLink ? ( +
+
+ {title} +
+
+ {description} +
+
+ {headerLink} +
+
+ ) : ( + <> +
+ {title} +
+
+ {description} +
+ + )} +
+ {reportAction} +
+
+ {filters} +
+
+ +
+ + {coverage} + +
+ + +
+); diff --git a/ui/app/(prowler)/compliance/_components/aggregated-framework-card.tsx b/ui/app/(prowler)/compliance/_components/aggregated-framework-card.tsx new file mode 100644 index 0000000000..6c71db3822 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/aggregated-framework-card.tsx @@ -0,0 +1,85 @@ +import Image from "next/image"; +import type { KeyboardEventHandler, ReactNode } from "react"; + +import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; +import { Card, CardContent } from "@/components/shadcn/card/card"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; + +interface AggregatedFrameworkCardProps { + frameworkTitle: string; + formattedTitle: string; + ariaLabel: string; + onActivate: () => void; + subtitle: ReactNode; + tooltip?: string; + children: ReactNode; +} + +export const AggregatedFrameworkCard = ({ + frameworkTitle, + formattedTitle, + ariaLabel, + onActivate, + subtitle, + tooltip, + children, +}: AggregatedFrameworkCardProps) => { + const handleKeyDown: KeyboardEventHandler = (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onActivate(); + } + }; + const logo = getComplianceIcon(frameworkTitle); + const title = ( +

{formattedTitle}

+ ); + + return ( + + +
+
+ {logo && ( +
+ {`${frameworkTitle} +
+ )} +
+ {tooltip ? ( + + {title} + {tooltip} + + ) : ( + title + )} + {subtitle} +
+
+ {children} +
+
+
+ ); +}; diff --git a/ui/app/(prowler)/compliance/_components/aggregated-requirement-content.tsx b/ui/app/(prowler)/compliance/_components/aggregated-requirement-content.tsx new file mode 100644 index 0000000000..e3975b36d7 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/aggregated-requirement-content.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; +import type { CheckProviderTypesMap, Requirement } from "@/types/compliance"; + +interface AggregatedRequirementContentProps { + requirement: Requirement; + framework: string; + scanIds: string[]; + emptyMessage: string; + checkProviders?: CheckProviderTypesMap; +} + +export const AggregatedRequirementContent = ({ + requirement, + framework, + scanIds, + emptyMessage, + checkProviders, +}: AggregatedRequirementContentProps) => { + if (scanIds.length === 0) return

{emptyMessage}

; + + return ( + + ); +}; diff --git a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.shared.ts b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.shared.ts index 7cb6b8cb39..7f38e2b9d3 100644 --- a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.shared.ts +++ b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.shared.ts @@ -1,17 +1,26 @@ -import { COMPLIANCE_TAB, type ComplianceTab } from "../_types"; +import { COMPLIANCE_TAB, type ComplianceTab } from "@/types/compliance"; function isComplianceTab(value: string): value is ComplianceTab { return Object.values(COMPLIANCE_TAB).includes(value as ComplianceTab); } -/** Resolves `?tab=` into a valid tab, defaulting to Per Scan so existing - * bookmarks (no query param) keep working. */ -function getComplianceTab(value: string | string[] | undefined): ComplianceTab { - if (typeof value !== "string") { - return COMPLIANCE_TAB.PER_SCAN; +/** Resolves `?tab=` into a valid tab, defaulting to Cross Provider — the + * Multiple Scans tab, which owns the bare `/compliance` route. + * + * A `scanId` with no explicit tab means the link predates the tab split (or + * was shared from Single Scan), so it keeps resolving to Per Scan instead of + * landing on the aggregated view, which ignores the scan entirely. */ +function getComplianceTab( + value: string | string[] | undefined, + scanId?: string | string[] | undefined, +): ComplianceTab { + if (typeof value === "string" && isComplianceTab(value)) { + return value; } - return isComplianceTab(value) ? value : COMPLIANCE_TAB.PER_SCAN; + return typeof scanId === "string" && scanId + ? COMPLIANCE_TAB.PER_SCAN + : COMPLIANCE_TAB.CROSS_PROVIDER; } export { getComplianceTab }; 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 531cfbe79d..cedd113870 100644 --- a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx +++ b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx @@ -4,8 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useCloudUpgradeStore } from "@/store"; import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; - -import { COMPLIANCE_TAB } from "../_types"; +import { COMPLIANCE_TAB } from "@/types/compliance"; import { CompliancePageTabs } from "./compliance-page-tabs"; import { getComplianceTab } from "./compliance-page-tabs.shared"; @@ -21,14 +20,29 @@ vi.mock("next/navigation", () => ({ })); describe("getComplianceTab", () => { - it("falls back to per-scan for missing or invalid values", () => { - expect(getComplianceTab(undefined)).toBe(COMPLIANCE_TAB.PER_SCAN); - expect(getComplianceTab(["cross-provider"])).toBe(COMPLIANCE_TAB.PER_SCAN); - expect(getComplianceTab("bogus")).toBe(COMPLIANCE_TAB.PER_SCAN); + it("falls back to cross-provider for missing or invalid values", () => { + expect(getComplianceTab(undefined)).toBe(COMPLIANCE_TAB.CROSS_PROVIDER); + expect(getComplianceTab(["per-scan"])).toBe(COMPLIANCE_TAB.CROSS_PROVIDER); + expect(getComplianceTab("bogus")).toBe(COMPLIANCE_TAB.CROSS_PROVIDER); + expect(getComplianceTab("per-scan")).toBe(COMPLIANCE_TAB.PER_SCAN); expect(getComplianceTab("cross-provider")).toBe( COMPLIANCE_TAB.CROSS_PROVIDER, ); }); + + it("keeps pre-split links alive: a bare scanId still opens Single Scan", () => { + expect(getComplianceTab(undefined, "scan-1")).toBe(COMPLIANCE_TAB.PER_SCAN); + expect(getComplianceTab("bogus", "scan-1")).toBe(COMPLIANCE_TAB.PER_SCAN); + // An explicit tab always wins over the inferred one. + expect(getComplianceTab("cross-provider", "scan-1")).toBe( + COMPLIANCE_TAB.CROSS_PROVIDER, + ); + // Empty or repeated scanId carries no selection to honour. + expect(getComplianceTab(undefined, "")).toBe(COMPLIANCE_TAB.CROSS_PROVIDER); + expect(getComplianceTab(undefined, ["scan-1"])).toBe( + COMPLIANCE_TAB.CROSS_PROVIDER, + ); + }); }); describe("CompliancePageTabs", () => { @@ -40,21 +54,8 @@ describe("CompliancePageTabs", () => { useCloudUpgradeStore.getState().closeCloudUpgrade(); }); - it("navigates with ?tab=cross-provider and back to the bare route", async () => { - const user = userEvent.setup(); - const { rerender } = render( - Per scan content
} - crossProviderContent={
Cross provider content
} - />, - ); - - await user.click(screen.getByRole("tab", { name: /cross-provider/i })); - expect(pushMock).toHaveBeenCalledWith("/compliance?tab=cross-provider"); - - rerender( + it("renders Multiple Scans as the first tab", () => { + render( { />, ); - await user.click(screen.getByRole("tab", { name: /per scan/i })); + expect( + screen.getAllByRole("tab").map((tab) => tab.textContent), + ).toStrictEqual(["Multiple Scans", "Single Scan"]); + }); + + it("navigates with ?tab=per-scan and back to the bare route", async () => { + const user = userEvent.setup(); + const { rerender } = render( + Per scan content
} + crossProviderContent={
Cross provider content
} + />, + ); + + await user.click(screen.getByRole("tab", { name: /single scan/i })); + expect(pushMock).toHaveBeenCalledWith("/compliance?tab=per-scan"); + + rerender( + Per scan content
} + crossProviderContent={
Cross provider content
} + />, + ); + + await user.click(screen.getByRole("tab", { name: /multiple scans/i })); expect(pushMock).toHaveBeenCalledWith("/compliance"); }); @@ -79,7 +108,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..6fc7112bce 100644 --- a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx +++ b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx @@ -10,10 +10,10 @@ import { TabsList, TabsTrigger, } from "@/components/shadcn"; +import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url"; import { useCloudUpgradeStore } from "@/store"; import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; - -import { COMPLIANCE_TAB, type ComplianceTab } from "../_types"; +import { COMPLIANCE_TAB, type ComplianceTab } from "@/types/compliance"; interface CompliancePageTabsProps { activeTab: ComplianceTab; @@ -47,42 +47,43 @@ export const CompliancePageTabs = ({ return; } - // Per Scan renders without the query param so existing bookmarks and - // shared links keep resolving to the default view. - if (typedTab === COMPLIANCE_TAB.PER_SCAN) { - router.push("/compliance"); - } else { - router.push(`/compliance?tab=${typedTab}`); - } + // Multiple Scans is the landing view, so it owns the bare route; Single + // Scan pins `?tab=` to stay linkable. + router.push( + typedTab === COMPLIANCE_TAB.CROSS_PROVIDER + ? "/compliance" + : buildPerScanComplianceHref(), + ); }; return ( - // Same layout spacing as the scans view tabs (scans-page-shell.tsx). - - - Per Scan - Cloud - ) : undefined - } - > - Cross-Provider - - + +
+
+ + Cloud + ) : undefined + } + > + Multiple Scans + + + Single Scan + + +
- - {perScanContent} - - - {crossProviderContent} - + + {crossProviderContent} + + + {perScanContent} + +
); }; 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..1696fab333 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-account-detail.tsx @@ -0,0 +1,214 @@ +import { Info } from "lucide-react"; + +import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; +import { getAllProviders } from "@/actions/providers"; +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 { getComplianceMapper } from "@/lib/compliance/compliance-mapper"; +import { + type KnownProviderType, + PROVIDER_DISPLAY_NAMES, +} from "@/types/providers"; + +import { + getCrossAccountComplianceOverview, + getLatestCrossAccountPdf, +} from "../_actions/cross-account"; +import { + getAggregatedInitialExpandedKeys, + getAggregatedRequirementsTotals, +} from "../_lib/aggregated-compliance-detail"; +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 { AggregatedComplianceDetail } from "./aggregated-compliance-detail"; +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 = getAggregatedRequirementsTotals(data); + const accordionItems = toCrossAccountAccordionItems( + data, + extras, + attrs.framework, + attrs.accounts, + ); + const topFailedResult = mapper.getTopFailedSections(data); + + const initialExpandedKeys = getAggregatedInitialExpandedKeys( + data, + accordionItems, + targetSection, + ); + + 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 ( + + {attrs.name || compliancetitle.split("-").join(" ")} + + } + description={ +

+ + {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"} +

+ } + reportAction={ + + } + filters={ + + } + totals={totals} + coverage={ + + } + topFailed={{ + sections: topFailedResult.items, + dataType: topFailedResult.type, + prepopulated: topFailedResult.prepopulated, + }} + accordionItems={accordionItems} + initialExpandedKeys={initialExpandedKeys} + /> + ); +}; 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..7760f14e88 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-account-framework-card.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; + +import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon"; +import { PROVIDER_DISPLAY_NAMES } from "@/types/providers"; + +import { buildCrossAccountDetailHref } from "../_lib/cross-account-frameworks"; +import type { CrossAccountFrameworkEntry } from "../_types"; + +import { AggregatedFrameworkCard } from "./aggregated-framework-card"; + +/** + * 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 ( + + 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..a3f9ce0732 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx @@ -0,0 +1,259 @@ +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: () =>