mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
fix(ui): load every Attack Paths scan before displaying the selector (#10864)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com> Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
This commit is contained in:
committed by
GitHub
parent
80d62f355f
commit
3554859a5c
+2
-1
@@ -18,11 +18,12 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
---
|
||||
|
||||
## [1.24.4] (Prowler UNRELEASED)
|
||||
## [1.24.4] (Prowler 5.24.4)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Provider wizard no longer advances to the Launch Scan step when rotating credentials [(#10851)](https://github.com/prowler-cloud/prowler/pull/10851)
|
||||
- Attack Paths scan selector now lists scans from every provider with working pagination, instead of capping the list at the first ten [(#10864)](https://github.com/prowler-cloud/prowler/pull/10864)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
type AttackPathScan,
|
||||
type AttackPathScansResponse,
|
||||
SCAN_STATES,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
import { adaptAttackPathScansResponse } from "./scans.adapter";
|
||||
|
||||
const makeScan = (
|
||||
id: string,
|
||||
overrides: Partial<AttackPathScan["attributes"]> = {},
|
||||
): AttackPathScan => ({
|
||||
type: "attack-paths-scans",
|
||||
id,
|
||||
attributes: {
|
||||
state: SCAN_STATES.COMPLETED,
|
||||
progress: 100,
|
||||
graph_data_ready: true,
|
||||
provider_alias: `alias-${id}`,
|
||||
provider_type: "aws",
|
||||
provider_uid: id,
|
||||
inserted_at: "2026-04-23T10:00:00Z",
|
||||
started_at: "2026-04-23T10:00:00Z",
|
||||
completed_at: "2026-04-23T10:10:00Z",
|
||||
duration: 600,
|
||||
...overrides,
|
||||
},
|
||||
relationships: {} as AttackPathScan["relationships"],
|
||||
});
|
||||
|
||||
describe("adaptAttackPathScansResponse", () => {
|
||||
it("returns an empty list when the response is undefined", () => {
|
||||
// When
|
||||
const result = adaptAttackPathScansResponse(undefined);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ data: [] });
|
||||
});
|
||||
|
||||
it("enriches each scan with durationLabel and isRecent", () => {
|
||||
// Given a scan that completed recently
|
||||
const recentCompletion = new Date(
|
||||
Date.now() - 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
const response: AttackPathScansResponse = {
|
||||
data: [makeScan("s1", { completed_at: recentCompletion, duration: 90 })],
|
||||
links: { first: "", last: "", next: null, prev: null },
|
||||
meta: { pagination: { page: 1, pages: 1, count: 1 } },
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptAttackPathScansResponse(response);
|
||||
|
||||
// Then
|
||||
expect(result.data).toHaveLength(1);
|
||||
const enriched = result.data[0]
|
||||
.attributes as (typeof result.data)[0]["attributes"] & {
|
||||
durationLabel: string | null;
|
||||
isRecent: boolean;
|
||||
};
|
||||
expect(enriched.durationLabel).toBeDefined();
|
||||
expect(enriched.isRecent).toBe(true);
|
||||
});
|
||||
|
||||
it("surfaces meta.pagination values unchanged in the adapted metadata", () => {
|
||||
// Given a paginated API response
|
||||
const response: AttackPathScansResponse = {
|
||||
data: [makeScan("s1"), makeScan("s2")],
|
||||
links: { first: "", last: "", next: null, prev: null },
|
||||
meta: { pagination: { page: 3, pages: 5, count: 42 }, version: "2.0" },
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptAttackPathScansResponse(response);
|
||||
|
||||
// Then
|
||||
expect(result.metadata).toEqual({
|
||||
pagination: {
|
||||
page: 3,
|
||||
pages: 5,
|
||||
count: 42,
|
||||
itemsPerPage: [5, 10, 25, 50, 100],
|
||||
},
|
||||
version: "2.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits metadata when the response has no pagination info", () => {
|
||||
// Given
|
||||
const response: AttackPathScansResponse = {
|
||||
data: [makeScan("s1")],
|
||||
links: { first: "", last: "", next: null, prev: null },
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptAttackPathScansResponse(response);
|
||||
|
||||
// Then
|
||||
expect(result.metadata).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -44,18 +44,15 @@ export function adaptAttackPathScansResponse(
|
||||
},
|
||||
}));
|
||||
|
||||
// Transform links to MetaDataProps format if pagination exists
|
||||
const metadata: MetaDataProps | undefined = response.links
|
||||
const metadata: MetaDataProps | undefined = response.meta?.pagination
|
||||
? {
|
||||
pagination: {
|
||||
// Links-based pagination doesn't have traditional page numbers
|
||||
// but we preserve the structure for consistency
|
||||
page: 1,
|
||||
pages: 1,
|
||||
count: enrichedData.length,
|
||||
itemsPerPage: [10, 25, 50, 100],
|
||||
page: response.meta.pagination.page,
|
||||
pages: response.meta.pagination.pages,
|
||||
count: response.meta.pagination.count,
|
||||
itemsPerPage: [5, 10, 25, 50, 100],
|
||||
},
|
||||
version: "1.0",
|
||||
version: response.meta.version ?? "1.0",
|
||||
}
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
type AttackPathScan,
|
||||
type AttackPathScansResponse,
|
||||
SCAN_STATES,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
|
||||
() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
handleApiResponseMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
import { getAttackPathScans } from "./scans";
|
||||
|
||||
const makeScan = (id: string): AttackPathScan => ({
|
||||
type: "attack-paths-scans",
|
||||
id,
|
||||
attributes: {
|
||||
state: SCAN_STATES.COMPLETED,
|
||||
progress: 100,
|
||||
graph_data_ready: true,
|
||||
provider_alias: `alias-${id}`,
|
||||
provider_type: "aws",
|
||||
provider_uid: id,
|
||||
inserted_at: "2026-04-23T10:00:00Z",
|
||||
started_at: "2026-04-23T10:00:00Z",
|
||||
completed_at: "2026-04-23T10:10:00Z",
|
||||
duration: 600,
|
||||
},
|
||||
relationships: {} as AttackPathScan["relationships"],
|
||||
});
|
||||
|
||||
const pageResponse = (
|
||||
ids: string[],
|
||||
page: number,
|
||||
pages: number,
|
||||
count: number,
|
||||
): AttackPathScansResponse => ({
|
||||
data: ids.map(makeScan),
|
||||
links: {
|
||||
first: "first",
|
||||
last: "last",
|
||||
next: page < pages ? "next" : null,
|
||||
prev: page > 1 ? "prev" : null,
|
||||
},
|
||||
meta: {
|
||||
pagination: { page, pages, count },
|
||||
},
|
||||
});
|
||||
|
||||
const getFetchedPageNumber = (call: unknown[]) => {
|
||||
const url = new URL(String(call[0]));
|
||||
return Number(url.searchParams.get("page[number]"));
|
||||
};
|
||||
|
||||
describe("getAttackPathScans", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
});
|
||||
|
||||
it("requests page[size]=100 with page[number]=1 on the first call", async () => {
|
||||
// Given
|
||||
handleApiResponseMock.mockResolvedValueOnce(pageResponse(["s1"], 1, 1, 1));
|
||||
|
||||
// When
|
||||
await getAttackPathScans();
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const call = fetchMock.mock.calls[0];
|
||||
const url = new URL(String(call[0]));
|
||||
expect(url.pathname).toBe("/api/v1/attack-paths-scans");
|
||||
expect(url.searchParams.get("page[number]")).toBe("1");
|
||||
expect(url.searchParams.get("page[size]")).toBe("100");
|
||||
});
|
||||
|
||||
it("iterates across every backend page and aggregates all scans", async () => {
|
||||
// Given three pages totalling 22 scans
|
||||
handleApiResponseMock
|
||||
.mockResolvedValueOnce(
|
||||
pageResponse(
|
||||
Array.from({ length: 10 }, (_, i) => `a${i}`),
|
||||
1,
|
||||
3,
|
||||
22,
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
pageResponse(
|
||||
Array.from({ length: 10 }, (_, i) => `b${i}`),
|
||||
2,
|
||||
3,
|
||||
22,
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(pageResponse(["c0", "c1"], 3, 3, 22));
|
||||
|
||||
// When
|
||||
const result = await getAttackPathScans();
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(fetchMock.mock.calls.map(getFetchedPageNumber)).toEqual([1, 2, 3]);
|
||||
expect(result?.data).toHaveLength(22);
|
||||
});
|
||||
|
||||
it("stops requesting when the current page equals meta.pagination.pages", async () => {
|
||||
// Given a single-page response
|
||||
handleApiResponseMock.mockResolvedValueOnce(
|
||||
pageResponse(["only"], 1, 1, 1),
|
||||
);
|
||||
|
||||
// When
|
||||
const result = await getAttackPathScans();
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(result?.data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("stops when a page returns an empty data array", async () => {
|
||||
// Given the second page is unexpectedly empty
|
||||
handleApiResponseMock
|
||||
.mockResolvedValueOnce(pageResponse(["a0"], 1, 3, 3))
|
||||
.mockResolvedValueOnce({
|
||||
data: [],
|
||||
links: { first: "", last: "", next: null, prev: null },
|
||||
meta: { pagination: { page: 2, pages: 3, count: 3 } },
|
||||
} as AttackPathScansResponse);
|
||||
|
||||
// When
|
||||
const result = await getAttackPathScans();
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(result?.data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns undefined when the first fetch throws", async () => {
|
||||
// Given
|
||||
handleApiResponseMock.mockRejectedValueOnce(new Error("network down"));
|
||||
|
||||
// When
|
||||
const result = await getAttackPathScans();
|
||||
|
||||
// Then
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves scans from earlier pages when a later fetch throws", async () => {
|
||||
// Given the first page resolves but the second page errors mid-iteration
|
||||
handleApiResponseMock
|
||||
.mockResolvedValueOnce(pageResponse(["a0", "a1"], 1, 3, 5))
|
||||
.mockRejectedValueOnce(new Error("network blip"));
|
||||
|
||||
// When
|
||||
const result = await getAttackPathScans();
|
||||
|
||||
// Then we keep the scans we already fetched instead of discarding everything
|
||||
expect(result?.data).toHaveLength(2);
|
||||
expect(result?.data.map((scan) => scan.id)).toEqual(["a0", "a1"]);
|
||||
});
|
||||
|
||||
it("returns an empty list when the first page has no data", async () => {
|
||||
// Given
|
||||
handleApiResponseMock.mockResolvedValueOnce(undefined);
|
||||
|
||||
// When
|
||||
const result = await getAttackPathScans();
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ data: [] });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -8,33 +8,79 @@ import { AttackPathScan, AttackPathScansResponse } from "@/types/attack-paths";
|
||||
|
||||
import { adaptAttackPathScansResponse } from "./scans.adapter";
|
||||
|
||||
// Validation schema for UUID - RFC 9562/4122 compliant
|
||||
const UUIDSchema = z.uuid();
|
||||
|
||||
const ATTACK_PATH_SCANS_PAGE_SIZE = 100;
|
||||
const ATTACK_PATH_SCANS_MAX_PAGES = 50;
|
||||
|
||||
/**
|
||||
* Fetch list of attack path scans (latest scan for each provider)
|
||||
* Fetch list of attack path scans (latest scan for each provider).
|
||||
*
|
||||
* Iterates through every backend page so callers receive the complete
|
||||
* dedup'd dataset along with an accurate total count. The underlying
|
||||
* endpoint is paginated server-side (default page_size=10), so fetching
|
||||
* only the first page would silently hide providers beyond that window.
|
||||
*/
|
||||
export const getAttackPathScans = async (): Promise<
|
||||
{ data: AttackPathScan[] } | undefined
|
||||
> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const allScans: AttackPathScan[] = [];
|
||||
let currentPage = 1;
|
||||
let lastResponse: AttackPathScansResponse | undefined;
|
||||
let hasMorePages = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/attack-paths-scans`, {
|
||||
headers,
|
||||
method: "GET",
|
||||
});
|
||||
while (hasMorePages && currentPage <= ATTACK_PATH_SCANS_MAX_PAGES) {
|
||||
try {
|
||||
const url = new URL(`${apiBaseUrl}/attack-paths-scans`);
|
||||
url.searchParams.append("page[number]", currentPage.toString());
|
||||
url.searchParams.append(
|
||||
"page[size]",
|
||||
ATTACK_PATH_SCANS_PAGE_SIZE.toString(),
|
||||
);
|
||||
|
||||
const apiResponse = (await handleApiResponse(
|
||||
response,
|
||||
)) as AttackPathScansResponse;
|
||||
const adaptedData = adaptAttackPathScansResponse(apiResponse);
|
||||
const response = await fetch(url.toString(), {
|
||||
headers,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
return { data: adaptedData.data };
|
||||
} catch (error) {
|
||||
console.error("Error fetching attack path scans:", error);
|
||||
return undefined;
|
||||
const data = (await handleApiResponse(response)) as
|
||||
| AttackPathScansResponse
|
||||
| undefined;
|
||||
|
||||
if (!data?.data || data.data.length === 0) {
|
||||
hasMorePages = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
allScans.push(...data.data);
|
||||
lastResponse = data;
|
||||
|
||||
const totalPages = data.meta?.pagination?.pages ?? 1;
|
||||
if (currentPage >= totalPages) {
|
||||
hasMorePages = false;
|
||||
} else {
|
||||
currentPage++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching attack path scans:", error);
|
||||
if (allScans.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastResponse) {
|
||||
return { data: [] };
|
||||
}
|
||||
|
||||
const adapted = adaptAttackPathScansResponse({
|
||||
...lastResponse,
|
||||
data: allScans,
|
||||
});
|
||||
|
||||
return { data: adapted.data };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+17
-2
@@ -3,6 +3,7 @@
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Check, Minus } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRef } from "react";
|
||||
|
||||
import {
|
||||
RadioGroup,
|
||||
@@ -28,8 +29,8 @@ interface ScanListTableProps {
|
||||
scans: AttackPathScan[];
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 5;
|
||||
const PAGE_SIZE_OPTIONS = [2, 5, 10, 15];
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
const PAGE_SIZE_OPTIONS = [10, 25];
|
||||
const parsePageParam = (value: string | null, fallback: number) => {
|
||||
if (!value) return fallback;
|
||||
|
||||
@@ -243,6 +244,15 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
|
||||
const endIndex = startIndex + pageSize;
|
||||
const paginatedScans = scans.slice(startIndex, endIndex);
|
||||
|
||||
// TODO(#10863): remove this workaround (ref + split handlers + pushWithParams)
|
||||
// once the DataTable unified-pagination-callback refactor in PR #10863 lands.
|
||||
// The underlying issue is that DataTablePagination's controlled mode fires
|
||||
// onPageSizeChange and onPageChange(1) back-to-back in the same tick, so the
|
||||
// second router.push reads a stale searchParams snapshot and silently reverts
|
||||
// the page-size change. Replace both handlers with a single
|
||||
// onPaginationChange handler after that PR merges.
|
||||
const suppressNextPageResetRef = useRef(false);
|
||||
|
||||
const pushWithParams = (nextParams: Record<string, string>) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
@@ -258,10 +268,15 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
if (suppressNextPageResetRef.current && page === 1) {
|
||||
suppressNextPageResetRef.current = false;
|
||||
return;
|
||||
}
|
||||
pushWithParams({ scanPage: page.toString() });
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (nextPageSize: number) => {
|
||||
suppressNextPageResetRef.current = true;
|
||||
pushWithParams({
|
||||
scanPage: "1",
|
||||
scanPageSize: nextPageSize.toString(),
|
||||
|
||||
@@ -67,9 +67,21 @@ export interface PaginationLinks {
|
||||
prev: string | null;
|
||||
}
|
||||
|
||||
export interface AttackPathScansResponsePagination {
|
||||
page: number;
|
||||
pages: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AttackPathScansResponseMeta {
|
||||
pagination: AttackPathScansResponsePagination;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface AttackPathScansResponse {
|
||||
data: AttackPathScan[];
|
||||
links: PaginationLinks;
|
||||
meta?: AttackPathScansResponseMeta;
|
||||
}
|
||||
|
||||
// Data type constants
|
||||
|
||||
Reference in New Issue
Block a user