Files
prowler/ui/app/(prowler)/compliance/page.tsx
T

469 lines
15 KiB
TypeScript

import { Info } from "lucide-react";
import { Suspense } from "react";
import {
COMPLIANCE_OVERVIEW_RESOURCE_TYPE,
getComplianceOverviewMetadataInfo,
getCompliancesOverview,
} from "@/actions/compliances";
import { getThreatScore } from "@/actions/overview";
import { getScan, getScans, getScansByState } from "@/actions/scans";
import {
ComplianceSkeletonGrid,
NoScansAvailable,
ThreatScoreBadge,
} from "@/components/compliance";
import { ComplianceFilters } from "@/components/compliance/compliance-header/compliance-filters";
import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overview-grid";
import { WatchlistControls } from "@/components/compliance/watchlist/watchlist-controls";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Card, CardContent } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
import { isReportDownloadLocked } from "@/lib/report-download-access";
import { isCloud } from "@/lib/shared/env";
import {
ExpandedScanData,
ScanEntity,
ScanProps,
SearchParamsProps,
} from "@/types";
import { COMPLIANCE_TAB, ComplianceOverviewData } from "@/types/compliance";
import { CompliancePageTabs } from "./_components/compliance-page-tabs";
import { getComplianceTab } from "./_components/compliance-page-tabs.shared";
import { CrossAccountOverviewSection } from "./_components/cross-account-overview-section";
import { CrossProviderOverview } from "./_components/cross-provider-overview";
import {
CrossAccountOverviewSkeleton,
CrossProviderOverviewSkeleton,
} from "./_components/multiple-scans-skeleton";
import type { ComplianceWatchlistContext } from "./_lib/watchlist-context";
import { loadComplianceWatchlistContext } from "./_lib/watchlist-context";
/**
* A scan id from the URL is trusted when it is listed, or when a single lookup
* returns a scan that is not partial (older full scans keep working). A partial
* scan, reached through a stale link, or an id the API cannot find, has no
* compliance to show, so the caller falls back to the first eligible scan.
*/
async function resolveUrlScanId(
scanIdFromUrl: string | undefined,
eligibleScans: ExpandedScanData[],
): Promise<string | undefined> {
if (!scanIdFromUrl) return undefined;
if (eligibleScans.some((scan) => scan.id === scanIdFromUrl)) {
return scanIdFromUrl;
}
const urlScan = (await getScan(scanIdFromUrl)) as
| { data?: { attributes?: { is_partial?: boolean } } }
| undefined;
const scan = urlScan?.data;
if (!scan) return undefined;
// OSS scans carry no is_partial at all, so only an explicit true excludes.
return scan.attributes?.is_partial === true ? undefined : scanIdFromUrl;
}
export default async function Compliance({
searchParams,
}: {
searchParams: Promise<SearchParamsProps>;
}) {
const resolvedSearchParams = await searchParams;
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
// Cross-Provider is Prowler Cloud-only (the OSS API has no
// cross-provider-compliance-overviews endpoint). It is the landing tab in
// Cloud; in OSS its trigger only carries the upsell badge, so Per Scan
// stays active regardless of `?tab=`.
const crossProviderEnabled = isCloud();
const activeTab = crossProviderEnabled
? getComplianceTab(resolvedSearchParams.tab, resolvedSearchParams.scanId)
: COMPLIANCE_TAB.PER_SCAN;
const watchlistPromise = loadComplianceWatchlistContext();
const watchlistControls = (
<Suspense fallback={null}>
<ComplianceWatchlistControls watchlistPromise={watchlistPromise} />
</Suspense>
);
// Only the active tab's payload is built: switching tabs is a real
// navigation, so pre-building the inactive tab buys nothing.
if (activeTab === COMPLIANCE_TAB.CROSS_PROVIDER) {
// The tour's anchors (search, framework cards) only exist on Single Scan,
// so replaying it from here navigates there — and with no scan to render
// those anchors never mount. Fall back to the scan flow instead, matching
// the Per Scan branch below. Fail-open: a failed fetch assumes scans exist.
const scansByState = await getScansByState();
const hasCompletedScan = Array.isArray(scansByState?.data)
? scansByState.data.length > 0
: true;
return (
<ContentLayout
title="Compliance"
icon="lucide:shield-check"
onboardingAction={
hasCompletedScan
? { flowId: "view-compliance" }
: {
flowId: "view-compliance",
fallbackFlowId: "view-first-scan",
useFallback: true,
}
}
>
<CompliancePageTabs
activeTab={activeTab}
crossProviderEnabled={crossProviderEnabled}
watchlistControls={watchlistControls}
perScanContent={null}
crossProviderContent={
// gap-6 = the app-wide 24px below a filter row (Findings and the
// Single Scan tab both use mb-6), so filters→"Across provider
// types" and cards→"Across providers" read as one rhythm.
<div className="flex flex-col gap-6">
<Suspense
key={`cross-provider-${searchParamsKey}`}
fallback={<CrossProviderOverviewSkeleton />}
>
<CrossProviderOverview searchParams={resolvedSearchParams} />
</Suspense>
{/* Regular per-provider frameworks viewable across accounts.
Its fallback mirrors the provider groups while this island
loads independently from the universal frameworks above. */}
<Suspense
key={`cross-account-${searchParamsKey}`}
fallback={<CrossAccountOverviewSkeleton />}
>
<CrossAccountOverviewSection
searchParams={resolvedSearchParams}
/>
</Suspense>
</div>
}
/>
</ContentLayout>
);
}
const [subscriptionOnly, scansData] = await Promise.all([
isReportDownloadLocked(),
getScans({
filters: {
"filter[state]": "completed",
// Partial scans compute no compliance. Exclude them at the API so the
// page below never fills up with them; the filter is Cloud-only.
...(isCloud() ? { "filter[is_partial]": "false" } : {}),
},
pageSize: 50,
fields: {
// is_partial is Cloud-only; the OSS API ignores unknown sparse fields.
scans: "name,completed_at,provider,is_partial",
},
include: "provider",
}),
]);
if (!scansData?.data) {
return (
<ContentLayout
title="Compliance"
icon="lucide:shield-check"
onboardingAction={{
flowId: "view-compliance",
fallbackFlowId: "view-first-scan",
useFallback: true,
}}
>
<CompliancePageTabs
activeTab={activeTab}
crossProviderEnabled={crossProviderEnabled}
watchlistControls={watchlistControls}
perScanContent={<NoScansAvailable />}
crossProviderContent={null}
/>
</ContentLayout>
);
}
// Belt and braces for an API without the filter: partial scans never
// compute compliance, so they have nothing to show or download here.
const expandedScansData: ExpandedScanData[] = scansData.data
.filter((scan: ScanProps) => !scan.attributes?.is_partial)
.filter((scan: ScanProps) => scan.relationships?.provider?.data?.id)
.map((scan: ScanProps) => {
const providerId = scan.relationships!.provider!.data!.id;
const providerData = scansData.included?.find(
(item: { type: string; id: string }) =>
item.type === "providers" && item.id === providerId,
);
if (!providerData) {
return null;
}
return {
...scan,
providerInfo: {
provider: providerData.attributes.provider,
uid: providerData.attributes.uid,
alias: providerData.attributes.alias,
},
};
})
.filter(Boolean) as ExpandedScanData[];
const scanIdParam = resolvedSearchParams.scanId;
const scanIdFromUrl = Array.isArray(scanIdParam)
? scanIdParam[0]
: scanIdParam;
const selectedScanId: string | null =
(await resolveUrlScanId(scanIdFromUrl, expandedScansData)) ||
expandedScansData[0]?.id ||
null;
const onboardingAction = selectedScanId
? { flowId: "view-compliance" }
: {
flowId: "view-compliance",
fallbackFlowId: "view-first-scan",
useFallback: true,
};
const selectedScan = expandedScansData.find(
(scan) => scan.id === selectedScanId,
);
const selectedScanData: ScanEntity | undefined = selectedScan?.providerInfo
? {
id: selectedScan.id,
providerInfo: selectedScan.providerInfo,
attributes: {
name: selectedScan.attributes.name,
completed_at: selectedScan.attributes.completed_at,
},
}
: undefined;
const metadataInfoData = selectedScanId
? await getComplianceOverviewMetadataInfo({
filters: {
"filter[scan_id]": selectedScanId,
},
})
: { data: { attributes: { regions: [] } } };
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
let threatScoreData = null;
if (selectedScanId && typeof selectedScanId === "string") {
const threatScoreResponse = await getThreatScore({
filters: { "filter[scan_id]": selectedScanId },
});
if (threatScoreResponse?.data && threatScoreResponse.data.length > 0) {
const snapshot = threatScoreResponse.data[0];
threatScoreData = {
score: parseFloat(snapshot.attributes.overall_score),
sectionScores: snapshot.attributes.section_scores,
};
}
}
const perScanContent = selectedScanId ? (
<>
<div className="mb-6">
<ComplianceFilters
scans={expandedScansData}
uniqueRegions={uniqueRegions}
selectedScanId={selectedScanId}
/>
</div>
{threatScoreData &&
typeof selectedScanId === "string" &&
selectedScan && (
<div className="mb-6">
<ThreatScoreBadge
score={threatScoreData.score}
scanId={selectedScanId}
provider={selectedScan.providerInfo.provider}
selectedScan={selectedScanData}
sectionScores={threatScoreData.sectionScores}
subscriptionOnly={subscriptionOnly}
/>
</div>
)}
<Suspense
key={searchParamsKey}
fallback={
<ComplianceOverviewPanel>
<ComplianceSkeletonGrid />
</ComplianceOverviewPanel>
}
>
<SSRComplianceGrid
searchParams={resolvedSearchParams}
scanId={selectedScanId}
selectedScan={selectedScanData}
watchlistPromise={watchlistPromise}
subscriptionOnly={subscriptionOnly}
/>
</Suspense>
</>
) : (
<NoScansAvailable />
);
return (
<ContentLayout
title="Compliance"
icon="lucide:shield-check"
onboardingAction={onboardingAction}
>
<CompliancePageTabs
activeTab={activeTab}
crossProviderEnabled={crossProviderEnabled}
watchlistControls={watchlistControls}
perScanContent={perScanContent}
crossProviderContent={null}
/>
</ContentLayout>
);
}
const SSRComplianceGrid = async ({
searchParams,
scanId,
selectedScan,
watchlistPromise,
subscriptionOnly,
}: {
searchParams: SearchParamsProps;
scanId: string | null;
selectedScan?: ScanEntity;
watchlistPromise: Promise<ComplianceWatchlistContext>;
subscriptionOnly: boolean;
}) => {
const regionFilter = searchParams["filter[region__in]"]?.toString() || "";
const compliancesData =
scanId && scanId.trim() !== ""
? await getCompliancesOverview({
scanId,
region: regionFilter,
})
: { data: [], errors: [] };
const complianceData = compliancesData?.data;
if (
compliancesData &&
"errors" in compliancesData &&
compliancesData.errors &&
compliancesData.errors.length > 0
) {
return (
<Alert variant="info">
<Info className="size-4" />
<AlertDescription>Provide a valid scan ID.</AlertDescription>
</Alert>
);
}
if (
!Array.isArray(complianceData) &&
complianceData?.type === COMPLIANCE_OVERVIEW_RESOURCE_TYPE.TASK
) {
return (
<Alert variant="info">
<Info className="size-4" />
<AlertDescription>
Compliance data is still being generated. Please try again shortly.
</AlertDescription>
</Alert>
);
}
if (!Array.isArray(complianceData) || complianceData.length === 0) {
return (
<Alert variant="info">
<Info className="size-4" />
<AlertDescription>
This scan has no compliance data available yet, please select a
different one.
</AlertDescription>
</Alert>
);
}
const frameworks = complianceData
.filter((compliance: ComplianceOverviewData) => {
return compliance.attributes.framework !== "ProwlerThreatScore";
})
.sort((a: ComplianceOverviewData, b: ComplianceOverviewData) =>
a.attributes.framework.localeCompare(b.attributes.framework),
);
// Backend only generates CIS PDFs for the latest version per provider.
const latestCisIds = pickLatestCisPerProvider(
complianceData.map((compliance: ComplianceOverviewData) => compliance.id),
);
// The watchlist is keyed by `(compliance_id, provider_type)`, and on this
// surface the provider type is fixed by the selected scan.
const providerType = selectedScan?.providerInfo.provider;
const watchlist = await watchlistPromise;
return (
<ComplianceOverviewPanel>
<ComplianceOverviewGrid
frameworks={frameworks}
scanId={scanId ?? ""}
selectedScan={selectedScan}
latestCisIds={latestCisIds}
catalogEntries={watchlist.entries}
providerType={providerType}
canManageWatchlist={watchlist.canManage}
subscriptionOnly={subscriptionOnly}
/>
</ComplianceOverviewPanel>
);
};
const ComplianceWatchlistControls = async ({
watchlistPromise,
}: {
watchlistPromise: Promise<ComplianceWatchlistContext>;
}) => {
const watchlist = await watchlistPromise;
return (
<WatchlistControls
entries={watchlist.entries}
canManageWatchlist={watchlist.canManage}
/>
);
};
const ComplianceOverviewPanel = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<Card
variant="base"
padding="none"
className="minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm"
>
<CardContent className="flex flex-col gap-4 p-4">{children}</CardContent>
</Card>
);
};