feat(ui): add attack surface overview component (#9412)

This commit is contained in:
Alan Buscaglia
2025-12-02 13:57:07 +01:00
committed by GitHub
parent 175d7f95f5
commit 5e033321e8
12 changed files with 275 additions and 1 deletions

View File

@@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added
- Attack Surface component to Overview page [(#9412)](https://github.com/prowler-cloud/prowler/pull/9412)
- Compliance Watchlist component to Overview page [(#9199)](https://github.com/prowler-cloud/prowler/pull/9199)
- Service Watchlist component to Overview page [(#9316)](https://github.com/prowler-cloud/prowler/pull/9316)
- Risk Pipeline component with Sankey chart to Overview page [(#9317)](https://github.com/prowler-cloud/prowler/pull/9317)

View File

@@ -0,0 +1,84 @@
import {
AttackSurfaceOverview,
AttackSurfaceOverviewResponse,
} from "./types/attack-surface";
const ATTACK_SURFACE_IDS = {
INTERNET_EXPOSED: "internet-exposed",
SECRETS: "secrets",
PRIVILEGE_ESCALATION: "privilege-escalation",
EC2_IMDSV1: "ec2-imdsv1",
} as const;
export type AttackSurfaceId =
(typeof ATTACK_SURFACE_IDS)[keyof typeof ATTACK_SURFACE_IDS];
export interface AttackSurfaceItem {
id: AttackSurfaceId;
label: string;
failedFindings: number;
totalFindings: number;
}
const ATTACK_SURFACE_LABELS: Record<AttackSurfaceId, string> = {
[ATTACK_SURFACE_IDS.INTERNET_EXPOSED]: "Internet Exposed Resources",
[ATTACK_SURFACE_IDS.SECRETS]: "Exposed Secrets",
[ATTACK_SURFACE_IDS.PRIVILEGE_ESCALATION]: "IAM Policy Privilege Escalation",
[ATTACK_SURFACE_IDS.EC2_IMDSV1]: "EC2 with IMDSv1 Enabled",
};
const ATTACK_SURFACE_ORDER: AttackSurfaceId[] = [
ATTACK_SURFACE_IDS.INTERNET_EXPOSED,
ATTACK_SURFACE_IDS.SECRETS,
ATTACK_SURFACE_IDS.PRIVILEGE_ESCALATION,
ATTACK_SURFACE_IDS.EC2_IMDSV1,
];
function mapAttackSurfaceItem(item: AttackSurfaceOverview): AttackSurfaceItem {
const id = item.id as AttackSurfaceId;
return {
id,
label: ATTACK_SURFACE_LABELS[id] || item.id,
failedFindings: item.attributes.failed_findings,
totalFindings: item.attributes.total_findings,
};
}
/**
* Adapts the attack surface overview API response to a format suitable for the UI.
* Returns the items in a consistent order as defined by ATTACK_SURFACE_ORDER.
*
* @param response - The attack surface overview API response
* @returns An array of AttackSurfaceItem objects sorted by the predefined order
*/
export function adaptAttackSurfaceOverview(
response: AttackSurfaceOverviewResponse | undefined,
): AttackSurfaceItem[] {
if (!response?.data || response.data.length === 0) {
return [];
}
// Create a map for quick lookup
const itemsMap = new Map<string, AttackSurfaceOverview>();
for (const item of response.data) {
itemsMap.set(item.id, item);
}
// Return items in the predefined order
const sortedItems: AttackSurfaceItem[] = [];
for (const id of ATTACK_SURFACE_ORDER) {
const item = itemsMap.get(id);
if (item) {
sortedItems.push(mapAttackSurfaceItem(item));
}
}
// Include any items that might be in the response but not in our predefined order
for (const item of response.data) {
if (!ATTACK_SURFACE_ORDER.includes(item.id as AttackSurfaceId)) {
sortedItems.push(mapAttackSurfaceItem(item));
}
}
return sortedItems;
}

View File

@@ -1,3 +1,4 @@
export * from "./attack-surface.adapter";
export * from "./overview";
export * from "./sankey.adapter";
export * from "./threat-map.adapter";

View File

@@ -5,6 +5,7 @@ import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
import {
AttackSurfaceOverviewResponse,
FindingsSeverityOverviewResponse,
ProvidersOverviewResponse,
RegionsOverviewResponse,
@@ -84,7 +85,12 @@ export const getFindingsByStatus = async ({
query = "",
sort = "",
filters = {},
}) => {
}: {
page?: number;
query?: string;
sort?: string;
filters?: Record<string, string | string[] | undefined>;
} = {}) => {
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1) redirect("/");
@@ -207,3 +213,31 @@ export const getRegionsOverview = async ({
return undefined;
}
};
export const getAttackSurfaceOverview = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<AttackSurfaceOverviewResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/attack-surfaces`);
// Handle multiple filters
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 attack surface overview:", error);
return undefined;
}
};

View File

@@ -0,0 +1,22 @@
// Attack Surface Overview Types
// Corresponds to the /overviews/attack-surfaces endpoint
import { OverviewResponseMeta } from "./common";
export interface AttackSurfaceOverviewAttributes {
total_findings: number;
failed_findings: number;
muted_failed_findings: number;
check_ids: string[];
}
export interface AttackSurfaceOverview {
type: "attack-surface-overviews";
id: string;
attributes: AttackSurfaceOverviewAttributes;
}
export interface AttackSurfaceOverviewResponse {
data: AttackSurfaceOverview[];
meta: OverviewResponseMeta;
}

View File

@@ -1,3 +1,4 @@
export * from "./attack-surface";
export * from "./common";
export * from "./findings-severity";
export * from "./providers";

View File

@@ -0,0 +1,29 @@
import { AttackSurfaceItem } from "@/actions/overview";
import { Card, CardContent } from "@/components/shadcn";
interface AttackSurfaceCardItemProps {
item: AttackSurfaceItem;
}
export function AttackSurfaceCardItem({ item }: AttackSurfaceCardItemProps) {
return (
<Card
variant="inner"
padding="md"
className="flex min-h-[120px] min-w-[200px] flex-1 flex-col justify-between"
aria-label={`${item.label}: ${item.failedFindings} failed findings`}
>
<CardContent className="flex flex-col gap-2 p-0">
<span
className="text-5xl leading-none font-light tracking-tight"
aria-hidden="true"
>
{item.failedFindings}
</span>
<span className="text-text-neutral-tertiary text-sm leading-6">
{item.label}
</span>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,30 @@
import { Card, CardContent, Skeleton } from "@/components/shadcn";
export function AttackSurfaceSkeleton() {
return (
<Card
variant="base"
className="flex w-full flex-col"
role="status"
aria-label="Loading attack surface data"
>
<Skeleton className="h-7 w-32 rounded-xl" />
<CardContent className="mt-4 flex flex-wrap gap-4">
{Array.from({ length: 4 }).map((_, index) => (
<Card
key={index}
variant="inner"
padding="md"
className="flex min-h-[120px] min-w-[200px] flex-1 flex-col justify-between"
aria-hidden="true"
>
<div className="flex flex-col gap-2">
<Skeleton className="h-12 w-20 rounded-xl" />
<Skeleton className="h-5 w-40 rounded-xl" />
</div>
</Card>
))}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,22 @@
import {
adaptAttackSurfaceOverview,
getAttackSurfaceOverview,
} from "@/actions/overview";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../lib/filter-params";
import { AttackSurface } from "./attack-surface";
export const AttackSurfaceSSR = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const response = await getAttackSurfaceOverview({ filters });
const items = adaptAttackSurfaceOverview(response);
return <AttackSurface items={items} />;
};

View File

@@ -0,0 +1,34 @@
import { AttackSurfaceItem } from "@/actions/overview/attack-surface.adapter";
import { Card, CardContent, CardTitle } from "@/components/shadcn";
import { AttackSurfaceCardItem } from "./attack-surface-card-item";
interface AttackSurfaceProps {
items: AttackSurfaceItem[];
}
export function AttackSurface({ items }: AttackSurfaceProps) {
const isEmpty = items.length === 0;
return (
<Card variant="base" className="flex w-full flex-col">
<CardTitle>Attack Surface</CardTitle>
<CardContent className="mt-4 flex flex-wrap gap-4">
{isEmpty ? (
<div
className="flex w-full items-center justify-center py-8"
role="status"
>
<p className="text-text-neutral-tertiary text-sm">
No attack surface data available.
</p>
</div>
) : (
items.map((item) => (
<AttackSurfaceCardItem key={item.id} item={item} />
))
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,4 @@
export { AttackSurface } from "./attack-surface";
export { AttackSurfaceSSR } from "./attack-surface.ssr";
export { AttackSurfaceCardItem } from "./attack-surface-card-item";
export { AttackSurfaceSkeleton } from "./attack-surface-skeleton";

View File

@@ -5,6 +5,10 @@ import { ContentLayout } from "@/components/ui";
import { SearchParamsProps } from "@/types";
import { AccountsSelector } from "./_new-overview/components/accounts-selector";
import {
AttackSurfaceSkeleton,
AttackSurfaceSSR,
} from "./_new-overview/components/attack-surface";
import { CheckFindingsSSR } from "./_new-overview/components/check-findings";
import { GraphsTabsWrapper } from "./_new-overview/components/graphs-tabs/graphs-tabs-wrapper";
import { RiskPipelineViewSkeleton } from "./_new-overview/components/graphs-tabs/risk-pipeline-view";
@@ -50,7 +54,15 @@ export default async function Home({
<Suspense fallback={<RiskSeverityChartSkeleton />}>
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="mt-6">
<Suspense fallback={<AttackSurfaceSkeleton />}>
<AttackSurfaceSSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="mt-6 flex flex-col gap-6 md:flex-row">
<Suspense fallback={<WatchlistCardSkeleton />}>
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>