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

525 lines
18 KiB
TypeScript

import { ChevronDownIcon } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { Suspense } from "react";
import {
COMPLIANCE_OVERVIEW_RESOURCE_TYPE,
getComplianceAttributes,
getComplianceOverviewMetadataInfo,
getComplianceRequirements,
getCompliancesOverview,
} from "@/actions/compliances";
import { getThreatScore } from "@/actions/overview";
import { getScan } from "@/actions/scans";
import {
ClientAccordionWrapper,
ComplianceDownloadContainer,
ComplianceHeader,
ComplianceWarming,
RequirementsStatusCard,
RequirementsStatusCardSkeleton,
// SectionsFailureRateCard,
// SectionsFailureRateCardSkeleton,
SkeletonAccordion,
ThreatScoreBreakdownCard,
ThreatScoreBreakdownCardSkeleton,
TopFailedSectionsCard,
TopFailedSectionsCardSkeleton,
} from "@/components/compliance";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor";
import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
import {
getReportTypeForCompliance,
pickLatestCisPerProvider,
} from "@/lib/compliance/compliance-report-types";
import { LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE } from "@/lib/lighthouse/context/constants";
import { buildComplianceContext } from "@/lib/lighthouse/context/contributions";
import { isReportDownloadLocked } from "@/lib/report-download-access";
import { isCloud } from "@/lib/shared/env";
import { cn } from "@/lib/utils";
import type { SearchParamsProps } from "@/types";
import {
AttributesData,
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 { buildSearchParamsKey } from "../_lib/search-params-key";
const getSingleSearchParam = (
value: string | string[] | undefined,
): string | undefined =>
typeof value === "string" && value ? value : undefined;
export default async function ComplianceDetail({
params,
searchParams,
}: {
params: Promise<{ compliancetitle: string }>;
searchParams: Promise<SearchParamsProps>;
}) {
const { compliancetitle } = await params;
const resolvedSearchParams = await searchParams;
const complianceId = getSingleSearchParam(resolvedSearchParams.complianceId);
const version = getSingleSearchParam(resolvedSearchParams.version);
const scanId = getSingleSearchParam(resolvedSearchParams.scanId);
const section = getSingleSearchParam(resolvedSearchParams.section);
const mode = getSingleSearchParam(resolvedSearchParams.mode);
if (!complianceId) {
notFound();
}
const subscriptionOnlyPromise = isReportDownloadLocked();
// Cross-provider mode replaces the per-scan pipeline with the universal
// roll-up view. Prowler Cloud-only: the OSS API has no such endpoint, so
// the route is blocked in OSS the same way the compliance tab is.
if (mode === LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE.CROSS_PROVIDER) {
if (!isCloud()) {
redirect("/compliance");
}
const subscriptionOnly = await subscriptionOnlyPromise;
return (
<Suspense
key={buildSearchParamsKey(resolvedSearchParams)}
fallback={
<ContentLayout title="Compliance">
<div className="flex flex-col gap-8">
<div className="grid grid-cols-1 gap-6 md:grid-cols-[minmax(280px,400px)_1fr]">
<RequirementsStatusCardSkeleton />
<TopFailedSectionsCardSkeleton />
</div>
<SkeletonAccordion />
</div>
</ContentLayout>
}
>
<CrossProviderDetail
compliancetitle={compliancetitle}
complianceId={complianceId}
searchParams={resolvedSearchParams}
targetSection={section}
subscriptionOnly={subscriptionOnly}
/>
</Suspense>
);
}
// Cross-account mode: one regular framework aggregated across every
// account of one provider type. Cloud-only, like cross-provider.
if (mode === LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE.CROSS_ACCOUNT) {
if (!isCloud()) {
redirect("/compliance");
}
const providerType = getSingleSearchParam(
resolvedSearchParams.providerType,
);
if (!providerType || !isKnownProviderType(providerType)) {
notFound();
}
const crossAccountTitle = compliancetitle.split("-").join(" ");
const subscriptionOnly = await subscriptionOnlyPromise;
return (
<ContentLayout
title={
version ? `${crossAccountTitle} - ${version}` : crossAccountTitle
}
>
<Suspense
key={buildSearchParamsKey(resolvedSearchParams)}
fallback={
<div className="flex flex-col gap-8">
<div className="grid grid-cols-1 gap-6 md:grid-cols-[minmax(280px,400px)_1fr]">
<RequirementsStatusCardSkeleton />
<TopFailedSectionsCardSkeleton />
</div>
<SkeletonAccordion />
</div>
}
>
<CrossAccountDetail
compliancetitle={compliancetitle}
complianceId={complianceId}
providerType={providerType}
searchParams={resolvedSearchParams}
targetSection={section}
subscriptionOnly={subscriptionOnly}
/>
</Suspense>
</ContentLayout>
);
}
const regionFilter = getSingleSearchParam(
resolvedSearchParams["filter[region__in]"],
);
const cisProfileFilter = getSingleSearchParam(
resolvedSearchParams["filter[cis_profile_level]"],
);
const logoPath = getComplianceIcon(compliancetitle);
const searchParamsKey = buildSearchParamsKey(resolvedSearchParams);
const formattedTitle = compliancetitle.split("-").join(" ");
const pageTitle = version
? `${formattedTitle} - ${version}`
: `${formattedTitle}`;
let selectedScan: ScanEntity | null = null;
const selectedScanId = scanId || null;
const [
metadataInfoData,
attributesData,
selectedScanResponse,
subscriptionOnly,
] = await Promise.all([
getComplianceOverviewMetadataInfo({
filters: {
"filter[scan_id]": selectedScanId ?? undefined,
},
}),
getComplianceAttributes(complianceId, selectedScanId ?? undefined),
selectedScanId
? getScan(selectedScanId, { include: "provider" })
: Promise.resolve(null),
subscriptionOnlyPromise,
]);
// The compliance catalog is still warming after a deploy/restart. Show the
// "still loading" state with a Try Again instead of rendering an empty page.
if (attributesData?.warming) {
return (
<ContentLayout title={pageTitle}>
<ComplianceWarming />
</ContentLayout>
);
}
if (selectedScanResponse?.data) {
const scan = selectedScanResponse.data;
const providerId = scan.relationships?.provider?.data?.id;
const providerData = providerId
? selectedScanResponse.included?.find(
(item: { type: string; id: string }) =>
item.type === "providers" && item.id === providerId,
)
: undefined;
if (providerData) {
selectedScan = {
id: scan.id,
providerInfo: {
provider: providerData.attributes.provider,
alias: providerData.attributes.alias,
uid: providerData.attributes.uid,
},
attributes: {
name: scan.attributes.name,
completed_at: scan.attributes.completed_at,
},
};
}
}
// Only CIS variants need the "is this the latest version per provider?"
// check to gate the PDF download button. Every other framework either
// always has a PDF (ENS/NIS2/CSA/ThreatScore) or none at all, so we skip
// the extra compliance-overview roundtrip for non-CIS detail pages.
const needsCisLatestCheck =
typeof complianceId === "string" && complianceId.startsWith("cis_");
let latestCisIds: Set<string> = new Set<string>();
if (needsCisLatestCheck && selectedScanId) {
const scanCompliancesData = await getCompliancesOverview({
scanId: selectedScanId,
});
const scanComplianceIds: string[] = Array.isArray(scanCompliancesData?.data)
? scanCompliancesData.data
.map((c: { id?: string }) => c?.id)
.filter(
(id: string | undefined): id is string => typeof id === "string",
)
: [];
latestCisIds = pickLatestCisPerProvider(scanComplianceIds);
}
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
// Detect if this is a ThreatScore compliance view
const isThreatScore = complianceId?.includes("prowler_threatscore");
// Fetch ThreatScore data if applicable
let threatScoreData = null;
if (isThreatScore && selectedScanId) {
const threatScoreResponse = await getThreatScore({
filters: { "filter[scan_id]": selectedScanId },
});
if (threatScoreResponse?.data && threatScoreResponse.data.length > 0) {
const snapshot = threatScoreResponse.data[0];
threatScoreData = {
overallScore: parseFloat(snapshot.attributes.overall_score),
// The multi-provider aggregation branch serializes section scores as
// decimal strings.
sectionScores: Object.fromEntries(
Object.entries(snapshot.attributes.section_scores).map(
([name, value]) => [name, Number(value)],
),
),
};
}
}
// Use compliance_name from attributes if available, otherwise fallback to formatted title
const complianceName = attributesData?.data?.[0]?.attributes?.compliance_name;
const finalPageTitle = complianceName ? `${complianceName}` : pageTitle;
return (
<ContentLayout title={finalPageTitle}>
{/* Header card — same surface as the cross-provider detail: scan info
and filters on the left, report actions and framework logo on the
right (lighthouse-settings card pattern). */}
<Card variant="base" className="mb-6 w-full gap-4 p-4 md:p-5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<ComplianceHeader
scans={[]}
uniqueRegions={uniqueRegions}
showSearch={false}
framework={compliancetitle}
showProviders={false}
logoPath={logoPath}
complianceTitle={compliancetitle}
selectedScan={selectedScan}
/>
</div>
{selectedScanId && (
<div className="mb-4 flex-shrink-0 self-end sm:mb-0 sm:self-start sm:pt-1">
<ComplianceDownloadContainer
scanId={selectedScanId}
complianceId={complianceId}
presentation="dropdown"
dropdownTrigger={
<Button variant="outline">
Report
<ChevronDownIcon />
</Button>
}
reportType={getReportTypeForCompliance(
attributesData?.data?.[0]?.attributes?.framework,
complianceId,
latestCisIds.has(complianceId),
)}
subscriptionOnly={subscriptionOnly}
/>
</div>
)}
</div>
</Card>
<Suspense
key={searchParamsKey}
fallback={
<div className="flex flex-col gap-8">
{/* Mobile: each card on own row | Tablet: ThreatScore full row, others share row | Desktop: all 3 in one row */}
<div
className={cn(
"grid grid-cols-1 gap-6 md:grid-cols-[minmax(280px,400px)_1fr]",
isThreatScore &&
"xl:grid-cols-[minmax(280px,320px)_minmax(280px,400px)_1fr]",
)}
>
{isThreatScore && (
<div className="md:col-span-2 xl:col-span-1">
<ThreatScoreBreakdownCardSkeleton />
</div>
)}
<RequirementsStatusCardSkeleton />
<TopFailedSectionsCardSkeleton />
{/* <SectionsFailureRateCardSkeleton /> */}
</div>
<SkeletonAccordion />
</div>
}
>
<SSRComplianceContent
complianceId={complianceId}
pathname={`/compliance/${compliancetitle}`}
scanId={selectedScanId || ""}
providerUid={selectedScan?.providerInfo.uid}
mode={LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE.PER_SCAN}
region={regionFilter}
filter={cisProfileFilter}
attributesData={attributesData}
threatScoreData={threatScoreData}
targetSection={section}
/>
</Suspense>
</ContentLayout>
);
}
const SSRComplianceContent = async ({
complianceId,
pathname,
scanId,
providerUid,
mode,
region,
filter,
attributesData,
threatScoreData,
targetSection,
}: {
complianceId: string;
pathname: string;
scanId: string;
providerUid?: string;
mode: typeof LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE.PER_SCAN;
region?: string;
filter?: string;
attributesData: AttributesData;
threatScoreData: {
overallScore: number;
sectionScores: Record<string, number>;
} | null;
targetSection?: string;
}) => {
const requirementsData = await getComplianceRequirements({
complianceId,
scanId,
region,
});
const requirements = requirementsData?.data;
const type = Array.isArray(requirements) ? undefined : requirements?.type;
if (
!scanId ||
type === COMPLIANCE_OVERVIEW_RESOURCE_TYPE.TASK ||
!Array.isArray(requirements)
) {
return (
<div className="flex flex-col gap-8">
<div className="grid grid-cols-1 gap-6 md:grid-cols-[minmax(280px,400px)_1fr]">
<RequirementsStatusCard pass={0} fail={0} manual={0} />
<TopFailedSectionsCard sections={[]} />
{/* <SectionsFailureRateCard categories={[]} /> */}
</div>
<ClientAccordionWrapper items={[]} defaultExpandedKeys={[]} />
</div>
);
}
const framework = attributesData?.data?.[0]?.attributes?.framework;
const mapper = getComplianceMapper(framework);
const data = mapper.mapComplianceData(
attributesData,
{ data: requirements },
filter,
);
// const categoryHeatmapData = mapper.calculateCategoryHeatmapData(data);
const totalRequirements: 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 = mapper.toAccordionItems(data, scanId);
const topFailedResult = mapper.getTopFailedSections(data);
const frameworkAttributes = attributesData?.data?.[0]?.attributes;
// Resolve which accordion key matches the requested ?section= so we can
// auto-expand it on first render. Each mapper builds keys as
// `${framework.name}-${category.name}`; rebuild the exact candidates here
// to avoid suffix collisions across frameworks or category names.
const initialExpandedKeys: string[] = [];
if (targetSection) {
const candidates = new Set(
data.map((f: Framework) => `${f.name}-${targetSection}`),
);
const match = accordionItems.find((item) => candidates.has(item.key));
if (match) {
initialExpandedKeys.push(match.key);
}
}
return (
<div className="flex flex-col gap-8">
<LighthouseContextContributor
key={`compliance-detail-${complianceId}-${totalRequirements.pass}-${totalRequirements.fail}`}
contributorId="compliance-detail"
item={buildComplianceContext({
pathname,
id: complianceId,
framework:
frameworkAttributes?.name ||
frameworkAttributes?.framework ||
complianceId,
version: frameworkAttributes?.version,
scanId,
providerUid,
mode,
section: targetSection,
region,
score: threatScoreData?.overallScore,
passed: totalRequirements.pass,
failed: totalRequirements.fail,
total:
totalRequirements.pass +
totalRequirements.fail +
totalRequirements.manual,
})}
/>
{/* Charts section */}
{/* Mobile: each card on own row | Tablet: ThreatScore full row, others share row | Desktop: all 3 in one row */}
<div
className={cn(
"grid grid-cols-1 gap-6 md:grid-cols-[minmax(280px,400px)_1fr]",
threatScoreData &&
"xl:grid-cols-[minmax(280px,320px)_minmax(280px,400px)_1fr]",
)}
>
{threatScoreData && (
<div className="md:col-span-2 xl:col-span-1">
<ThreatScoreBreakdownCard
overallScore={threatScoreData.overallScore}
sectionScores={threatScoreData.sectionScores}
/>
</div>
)}
<RequirementsStatusCard
pass={totalRequirements.pass}
fail={totalRequirements.fail}
manual={totalRequirements.manual}
/>
<TopFailedSectionsCard
sections={topFailedResult.items}
dataType={topFailedResult.type}
prepopulated={topFailedResult.prepopulated}
/>
{/* <SectionsFailureRateCard categories={categoryHeatmapData} /> */}
</div>
<ClientAccordionWrapper
hideExpandButton={complianceId.includes("mitre_attack")}
items={accordionItems}
defaultExpandedKeys={initialExpandedKeys}
scrollToKey={initialExpandedKeys[0]}
/>
</div>
);
};