feat: implement compliance watchlist (#9786)

This commit is contained in:
Alejandro Bailo
2026-01-15 12:37:16 +01:00
committed by GitHub
parent 76cda6d777
commit c8bc0576ea
10 changed files with 229 additions and 55 deletions

View File

@@ -13,6 +13,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🔄 Changed ### 🔄 Changed
- Integrate Compliance Watchlist with new `/overviews/compliance-watchlist` endpoint [(#9786)](https://github.com/prowler-cloud/prowler/pull/9786)
- Refactor ScatterPlot as reusable generic component with TypeScript generics [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664) - Refactor ScatterPlot as reusable generic component with TypeScript generics [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664)
- Swap Risk Plot axes: X = Fail Findings, Y = Prowler ThreatScore [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664) - Swap Risk Plot axes: X = Fail Findings, Y = Prowler ThreatScore [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664)
- Remove duplicate scan_id filter badge from Findings page [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664) - Remove duplicate scan_id filter badge from Findings page [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664)

View File

@@ -0,0 +1,72 @@
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { formatLabel } from "@/lib/categories";
import { ComplianceWatchlistResponse } from "./compliance-watchlist.types";
export interface EnrichedComplianceWatchlistItem {
id: string;
complianceId: string;
label: string;
icon: ReturnType<typeof getComplianceIcon>;
score: number;
requirementsPassed: number;
requirementsFailed: number;
requirementsManual: number;
totalRequirements: number;
}
/**
* Formats compliance_id into a human-readable label
* e.g., "aws_account_security_onboarding_aws" → "AWS Account Security Onboarding"
*
* Uses the shared formatLabel utility from lib/categories.ts which handles:
* - Acronyms (≤3 chars like AWS, CIS, ISO, PCI, SOC, etc.)
* - Special cases (4+ char acronyms like GDPR, HIPAA, NIST, etc.)
* - Version patterns (e.g., "v1", "v2")
*/
function formatComplianceLabel(complianceId: string): string {
// Remove trailing provider suffix (e.g., "_aws", "_gcp", "_azure")
const withoutProvider = complianceId
.replace(/_aws$/i, "")
.replace(/_gcp$/i, "")
.replace(/_azure$/i, "")
.replace(/_kubernetes$/i, "");
return formatLabel(withoutProvider, "_");
}
export function adaptComplianceWatchlistResponse(
response: ComplianceWatchlistResponse | undefined,
): EnrichedComplianceWatchlistItem[] {
if (!response?.data) {
return [];
}
return response.data.map((item) => {
const {
compliance_id,
requirements_passed,
requirements_failed,
requirements_manual,
total_requirements,
} = item.attributes;
// Defensive conversion: API types are number but JSON parsing edge cases may return strings
const totalReqs = Number(total_requirements) || 0;
const passedReqs = Number(requirements_passed) || 0;
const score =
totalReqs > 0 ? Math.round((passedReqs / totalReqs) * 100) : 0;
return {
id: item.id,
complianceId: compliance_id,
label: formatComplianceLabel(compliance_id),
icon: getComplianceIcon(compliance_id),
score,
requirementsPassed: requirements_passed,
requirementsFailed: requirements_failed,
requirementsManual: requirements_manual,
totalRequirements: total_requirements,
};
});
}

View File

@@ -0,0 +1,31 @@
"use server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { ComplianceWatchlistResponse } from "./compliance-watchlist.types";
export const getComplianceWatchlist = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<ComplianceWatchlistResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/compliance-watchlist`);
// Append filter parameters (provider_id, provider_type, etc.)
// Exclude filter[search] as this endpoint doesn't support text search
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]" && value !== undefined) {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), { headers });
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching compliance watchlist:", error);
return undefined;
}
};

View File

@@ -0,0 +1,24 @@
const COMPLIANCE_WATCHLIST_OVERVIEW_TYPE = {
WATCHLIST_OVERVIEW: "compliance-watchlist-overviews",
} as const;
type ComplianceWatchlistOverviewType =
(typeof COMPLIANCE_WATCHLIST_OVERVIEW_TYPE)[keyof typeof COMPLIANCE_WATCHLIST_OVERVIEW_TYPE];
export interface ComplianceWatchlistOverviewAttributes {
compliance_id: string;
requirements_passed: number;
requirements_failed: number;
requirements_manual: number;
total_requirements: number;
}
export interface ComplianceWatchlistOverview {
type: ComplianceWatchlistOverviewType;
id: string;
attributes: ComplianceWatchlistOverviewAttributes;
}
export interface ComplianceWatchlistResponse {
data: ComplianceWatchlistOverview[];
}

View File

@@ -0,0 +1,9 @@
export { getComplianceWatchlist } from "./compliance-watchlist";
export {
adaptComplianceWatchlistResponse,
type EnrichedComplianceWatchlistItem,
} from "./compliance-watchlist.adapter";
export type {
ComplianceWatchlistOverview,
ComplianceWatchlistResponse,
} from "./compliance-watchlist.types";

View File

@@ -14,11 +14,16 @@ export interface ComplianceData {
score: number; score: number;
} }
// Display 7 items to match the card's min-height (405px) without scrolling
const ITEMS_TO_DISPLAY = 7;
export const ComplianceWatchlist = ({ items }: { items: ComplianceData[] }) => { export const ComplianceWatchlist = ({ items }: { items: ComplianceData[] }) => {
const [isAsc, setIsAsc] = useState(true); const [isAsc, setIsAsc] = useState(true);
// Sort all items and take top 7 based on current sort order
const sortedItems = [...items] const sortedItems = [...items]
.sort((a, b) => (isAsc ? a.score - b.score : b.score - a.score)) .sort((a, b) => (isAsc ? a.score - b.score : b.score - a.score))
.slice(0, ITEMS_TO_DISPLAY)
.map((item) => ({ .map((item) => ({
key: item.id, key: item.id,
icon: item.icon ? ( icon: item.icon ? (
@@ -41,7 +46,7 @@ export const ComplianceWatchlist = ({ items }: { items: ComplianceData[] }) => {
<WatchlistCard <WatchlistCard
title="Compliance Watchlist" title="Compliance Watchlist"
items={sortedItems} items={sortedItems}
ctaLabel="Compliance Dashboard" ctaLabel="Explore Compliance for Each Scan"
ctaHref="/compliance" ctaHref="/compliance"
headerAction={ headerAction={
<SortToggleButton <SortToggleButton
@@ -51,10 +56,10 @@ export const ComplianceWatchlist = ({ items }: { items: ComplianceData[] }) => {
descendingLabel="Sort by lowest score" descendingLabel="Sort by lowest score"
/> />
} }
// TODO: Enable full emptyState with description once API endpoint is implemented
// Full emptyState: { message: "...", description: "to add compliance frameworks to your watchlist.", linkText: "Compliance Dashboard" }
emptyState={{ emptyState={{
message: "This space is looking empty.", message: "No compliance data available.",
description: "to add compliance frameworks to your watchlist.",
linkText: "Compliance Dashboard",
}} }}
/> />
); );

View File

@@ -85,12 +85,15 @@ export const WatchlistCard = ({
const isEmpty = items.length === 0; const isEmpty = items.length === 0;
return ( return (
<Card variant="base" className="flex min-h-[405px] min-w-[312px] flex-col"> <Card
variant="base"
className="flex min-h-[405px] w-full flex-col overflow-hidden"
>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle>{title}</CardTitle> <CardTitle>{title}</CardTitle>
{headerAction} {headerAction}
</div> </div>
<CardContent className="flex flex-col"> <CardContent className="flex min-w-0 flex-1 flex-col overflow-hidden">
{isEmpty ? ( {isEmpty ? (
<div className="flex flex-1 flex-col items-center justify-center gap-12 py-6"> <div className="flex flex-1 flex-col items-center justify-center gap-12 py-6">
{/* Icon and message */} {/* Icon and message */}
@@ -102,19 +105,15 @@ export const WatchlistCard = ({
</div> </div>
{/* Description with link */} {/* Description with link */}
<p className="text-text-neutral-tertiary w-full text-sm leading-6"> {emptyState?.description && ctaHref && (
{emptyState?.description && ctaHref && ( <p className="text-text-neutral-tertiary w-full text-sm leading-6">
<> Visit the{" "}
Visit the{" "} <Button variant="link" size="link-sm" asChild>
<Button variant="link" size="link-sm" asChild> <Link href={ctaHref}>{emptyState.linkText || ctaLabel}</Link>
<Link href={ctaHref}> </Button>{" "}
{emptyState.linkText || ctaLabel} {emptyState.description}
</Link> </p>
</Button>{" "} )}
{emptyState.description}
</>
)}
</p>
</div> </div>
) : ( ) : (
<> <>
@@ -149,7 +148,7 @@ export const WatchlistCard = ({
} }
}} }}
className={cn( className={cn(
"flex h-[54px] items-center justify-between gap-2 px-3 py-[11px]", "flex h-[54px] min-w-0 items-center justify-between gap-2 px-3 py-[11px]",
!isLast && "border-border-neutral-tertiary border-b", !isLast && "border-border-neutral-tertiary border-b",
isClickable && isClickable &&
"hover:bg-bg-neutral-tertiary cursor-pointer", "hover:bg-bg-neutral-tertiary cursor-pointer",
@@ -161,10 +160,10 @@ export const WatchlistCard = ({
</div> </div>
)} )}
<p className="text-text-neutral-secondary flex-1 truncate text-sm leading-6"> <p className="text-text-neutral-secondary w-0 flex-1 truncate text-sm leading-6">
{item.label} {item.label}
</p> </p>
<div className="flex items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
<p <p
className={cn( className={cn(
"text-sm leading-6 font-bold", "text-sm leading-6 font-bold",

View File

@@ -1,7 +1,7 @@
import { import {
adaptComplianceOverviewsResponse, adaptComplianceWatchlistResponse,
getCompliancesOverview, getComplianceWatchlist,
} from "@/actions/compliances"; } from "@/actions/overview/compliance-watchlist";
import { pickFilterParams } from "../_lib/filter-params"; import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types"; import { SSRComponentProps } from "../_types";
@@ -11,20 +11,19 @@ export const ComplianceWatchlistSSR = async ({
searchParams, searchParams,
}: SSRComponentProps) => { }: SSRComponentProps) => {
const filters = pickFilterParams(searchParams); const filters = pickFilterParams(searchParams);
const response = await getComplianceWatchlist({ filters });
const enrichedData = adaptComplianceWatchlistResponse(response);
const response = await getCompliancesOverview({ filters }); // Filter out ProwlerThreatScore and pass all items to client
const { data } = adaptComplianceOverviewsResponse(response); // Client handles sorting and limiting to display count
const items = enrichedData
// Filter out ProwlerThreatScore and limit to 5 items .filter((item) => !item.complianceId.toLowerCase().includes("threatscore"))
const items = data .map((item) => ({
.filter((item) => item.framework !== "ProwlerThreatScore") id: item.id,
.slice(0, 5) framework: item.complianceId,
.map((compliance) => ({ label: item.label,
id: compliance.id, icon: item.icon,
framework: compliance.framework, score: item.score,
label: compliance.label,
icon: compliance.icon,
score: compliance.score,
})); }));
return <ComplianceWatchlist items={items} />; return <ComplianceWatchlist items={items} />;

View File

@@ -24,6 +24,7 @@ import {
import { StatusChartSkeleton } from "./_overview/status-chart"; import { StatusChartSkeleton } from "./_overview/status-chart";
import { ThreatScoreSkeleton, ThreatScoreSSR } from "./_overview/threat-score"; import { ThreatScoreSkeleton, ThreatScoreSSR } from "./_overview/threat-score";
import { import {
ComplianceWatchlistSSR,
ServiceWatchlistSSR, ServiceWatchlistSSR,
WatchlistCardSkeleton, WatchlistCardSkeleton,
} from "./_overview/watchlist"; } from "./_overview/watchlist";
@@ -57,19 +58,30 @@ export default async function Home({
</Suspense> </Suspense>
</div> </div>
<div className="mt-6">
<Suspense fallback={<AttackSurfaceSkeleton />}>
<AttackSurfaceSSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="mt-6 flex flex-col gap-6 xl:flex-row"> <div className="mt-6 flex flex-col gap-6 xl:flex-row">
<Suspense fallback={<WatchlistCardSkeleton />}> {/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */}
<ServiceWatchlistSSR searchParams={resolvedSearchParams} /> <div className="flex min-w-0 flex-col gap-6 overflow-hidden sm:flex-row sm:flex-wrap sm:items-stretch xl:w-[312px] xl:shrink-0 xl:flex-col">
</Suspense> <div className="min-w-0 sm:flex-1 xl:flex-auto [&>*]:h-full">
<Suspense fallback={<FindingSeverityOverTimeSkeleton />}> <Suspense fallback={<WatchlistCardSkeleton />}>
<FindingSeverityOverTimeSSR searchParams={resolvedSearchParams} /> <ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense> </Suspense>
</div>
<div className="min-w-0 sm:flex-1 xl:flex-auto [&>*]:h-full">
<Suspense fallback={<WatchlistCardSkeleton />}>
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
</div>
{/* Charts column: Attack Surface on top, Findings Over Time below */}
<div className="flex flex-1 flex-col gap-6">
<Suspense fallback={<AttackSurfaceSkeleton />}>
<AttackSurfaceSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<FindingSeverityOverTimeSkeleton />}>
<FindingSeverityOverTimeSSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
</div> </div>
<div className="mt-6"> <div className="mt-6">

View File

@@ -3,8 +3,15 @@
* Add entries here for edge cases that heuristics can't handle. * Add entries here for edge cases that heuristics can't handle.
*/ */
const SPECIAL_CASES: Record<string, string> = { const SPECIAL_CASES: Record<string, string> = {
// Add special cases here if needed, e.g.: // Compliance framework acronyms (4+ chars, not caught by length heuristic)
// "someweirdcase": "SomeWeirdCase", gdpr: "GDPR",
hipaa: "HIPAA",
nist: "NIST",
mitre: "MITRE",
fedramp: "FedRAMP",
ffiec: "FFIEC",
kisa: "KISA",
cisa: "CISA",
}; };
/** /**
@@ -22,14 +29,29 @@ const SPECIAL_CASES: Record<string, string> = {
* - "ec2-imdsv1" -> "EC2 IMDSv1" * - "ec2-imdsv1" -> "EC2 IMDSv1"
* - "forensics-ready" -> "Forensics Ready" * - "forensics-ready" -> "Forensics Ready"
*/ */
export function getCategoryLabel(id: string): string { /**
* Generic label formatter that works with any delimiter.
* Use this for formatting IDs into human-readable labels.
*
* @param id - The ID to format
* @param delimiter - The delimiter to split on (default: "-")
*/
export function formatLabel(id: string, delimiter = "-"): string {
return id return id
.split("-") .split(delimiter)
.map((word) => formatWord(word)) .map((word) => formatWord(word))
.join(" "); .join(" ");
} }
function formatWord(word: string): string { /**
* Converts a category ID to a human-readable label.
* Convenience wrapper for formatLabel with "-" delimiter.
*/
export function getCategoryLabel(id: string): string {
return formatLabel(id, "-");
}
export function formatWord(word: string): string {
const lowerWord = word.toLowerCase(); const lowerWord = word.toLowerCase();
// 1. Check special cases dictionary // 1. Check special cases dictionary