mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-01-25 02:08:11 +00:00
feat: implement compliance watchlist (#9786)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
9
ui/actions/overview/compliance-watchlist/index.ts
Normal file
9
ui/actions/overview/compliance-watchlist/index.ts
Normal 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";
|
||||
@@ -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.",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user