Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
84 changes: 84 additions & 0 deletions ui/actions/overview/attack-surface.adapter.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions ui/actions/overview/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./attack-surface.adapter";
export * from "./overview";
export * from "./sankey.adapter";
export * from "./threat-map.adapter";
Expand Down
36 changes: 35 additions & 1 deletion ui/actions/overview/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";

import {
AttackSurfaceOverviewResponse,
FindingsSeverityOverviewResponse,
ProvidersOverviewResponse,
RegionsOverviewResponse,
Expand Down Expand Up @@ -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("/");
Expand Down Expand Up @@ -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;
}
};
22 changes: 22 additions & 0 deletions ui/actions/overview/types/attack-surface.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions ui/actions/overview/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./attack-surface";
export * from "./common";
export * from "./findings-severity";
export * from "./providers";
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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} />;
};
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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";
12 changes: 12 additions & 0 deletions ui/app/(prowler)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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>
Expand Down
Loading