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
- 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)
- 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)

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

View File

@@ -85,12 +85,15 @@ export const WatchlistCard = ({
const isEmpty = items.length === 0;
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">
<CardTitle>{title}</CardTitle>
{headerAction}
</div>
<CardContent className="flex flex-col">
<CardContent className="flex min-w-0 flex-1 flex-col overflow-hidden">
{isEmpty ? (
<div className="flex flex-1 flex-col items-center justify-center gap-12 py-6">
{/* Icon and message */}
@@ -102,19 +105,15 @@ export const WatchlistCard = ({
</div>
{/* Description with link */}
<p className="text-text-neutral-tertiary w-full text-sm leading-6">
{emptyState?.description && ctaHref && (
<>
Visit the{" "}
<Button variant="link" size="link-sm" asChild>
<Link href={ctaHref}>
{emptyState.linkText || ctaLabel}
</Link>
</Button>{" "}
{emptyState.description}
</>
)}
</p>
{emptyState?.description && ctaHref && (
<p className="text-text-neutral-tertiary w-full text-sm leading-6">
Visit the{" "}
<Button variant="link" size="link-sm" asChild>
<Link href={ctaHref}>{emptyState.linkText || ctaLabel}</Link>
</Button>{" "}
{emptyState.description}
</p>
)}
</div>
) : (
<>
@@ -149,7 +148,7 @@ export const WatchlistCard = ({
}
}}
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",
isClickable &&
"hover:bg-bg-neutral-tertiary cursor-pointer",
@@ -161,10 +160,10 @@ export const WatchlistCard = ({
</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}
</p>
<div className="flex items-center gap-1.5">
<div className="flex shrink-0 items-center gap-1.5">
<p
className={cn(
"text-sm leading-6 font-bold",

View File

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

View File

@@ -24,6 +24,7 @@ import {
import { StatusChartSkeleton } from "./_overview/status-chart";
import { ThreatScoreSkeleton, ThreatScoreSSR } from "./_overview/threat-score";
import {
ComplianceWatchlistSSR,
ServiceWatchlistSSR,
WatchlistCardSkeleton,
} from "./_overview/watchlist";
@@ -57,19 +58,30 @@ export default async function Home({
</Suspense>
</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">
<Suspense fallback={<WatchlistCardSkeleton />}>
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<FindingSeverityOverTimeSkeleton />}>
<FindingSeverityOverTimeSSR searchParams={resolvedSearchParams} />
</Suspense>
{/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */}
<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">
<div className="min-w-0 sm:flex-1 xl:flex-auto [&>*]:h-full">
<Suspense fallback={<WatchlistCardSkeleton />}>
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
</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 className="mt-6">

View File

@@ -3,8 +3,15 @@
* Add entries here for edge cases that heuristics can't handle.
*/
const SPECIAL_CASES: Record<string, string> = {
// Add special cases here if needed, e.g.:
// "someweirdcase": "SomeWeirdCase",
// Compliance framework acronyms (4+ chars, not caught by length heuristic)
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"
* - "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
.split("-")
.split(delimiter)
.map((word) => formatWord(word))
.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();
// 1. Check special cases dictionary