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
|
### 🔄 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)
|
||||||
|
|||||||
@@ -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;
|
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",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user