feat(ui): filter empty Attack Paths queries from the selector in Cloud (#12010)

This commit is contained in:
Daniel Barranquero
2026-07-23 10:36:33 +02:00
committed by GitHub
parent 9f5ef80e69
commit fb75146e34
4 changed files with 144 additions and 3 deletions
+105 -2
View File
@@ -1,12 +1,26 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DOCS_URLS } from "@/lib/external-urls";
import { isCloud } from "@/lib/shared/env";
import {
ATTACK_PATH_QUERY_IDS,
type AttackPathQuery,
type AttackPathQueryParameter,
type AttackPathQueryResultSummary,
} from "@/types/attack-paths";
import { buildAttackPathQueries } from "./queries.adapter";
import {
adaptAttackPathQueriesResponse,
buildAttackPathQueries,
} from "./queries.adapter";
vi.mock("@/lib/shared/env", () => ({ isCloud: vi.fn() }));
// Empty-query filtering only applies in Prowler Cloud; default the flag on for
// the filtering suite and cover the self-hosted (flag off) case explicitly.
beforeEach(() => {
vi.mocked(isCloud).mockReturnValue(true);
});
const presetQuery: AttackPathQuery = {
type: "attack-paths-scans",
@@ -21,6 +35,95 @@ const presetQuery: AttackPathQuery = {
},
};
const makeQuery = (
id: string,
overrides: {
result_summary?: AttackPathQueryResultSummary | null;
parameters?: AttackPathQueryParameter[];
} = {},
): AttackPathQuery => ({
type: "attack-paths-scans",
id,
attributes: {
name: id,
short_description: "",
description: "",
provider: "aws",
attribution: null,
parameters: overrides.parameters ?? [],
result_summary: overrides.result_summary,
},
});
describe("adaptAttackPathQueriesResponse filtering", () => {
it("keeps only queries with data, plus everything without a definite empty verdict", () => {
const response = {
data: [
makeQuery("has-data", {
result_summary: { status: "ok", has_data: true },
}),
makeQuery("empty", {
result_summary: { status: "ok", has_data: false },
}),
makeQuery("errored", {
result_summary: { status: "error", has_data: null },
}),
makeQuery("parameterized", {
parameters: [
{
name: "ip",
label: "IP",
data_type: "string",
} as AttackPathQueryParameter,
],
result_summary: null,
}),
makeQuery("no-summary"), // scan predating the precompute step
],
};
const ids = adaptAttackPathQueriesResponse(response).data.map((q) => q.id);
// the only one hidden is the parameterless query known to be empty
expect(ids).toEqual(["has-data", "errored", "parameterized", "no-summary"]);
expect(ids).not.toContain("empty");
});
it("returns an empty list for a missing response", () => {
expect(adaptAttackPathQueriesResponse(undefined).data).toEqual([]);
});
it("updates the pagination count to the filtered length", () => {
const response = {
data: [
makeQuery("a", { result_summary: { status: "ok", has_data: true } }),
makeQuery("b", { result_summary: { status: "ok", has_data: false } }),
],
};
const { metadata } = adaptAttackPathQueriesResponse(response);
expect(metadata?.pagination.count).toBe(1);
});
it("shows every query when not running in Cloud (feature flag off)", () => {
vi.mocked(isCloud).mockReturnValue(false);
const response = {
data: [
makeQuery("has-data", {
result_summary: { status: "ok", has_data: true },
}),
makeQuery("empty", {
result_summary: { status: "ok", has_data: false },
}),
],
};
const ids = adaptAttackPathQueriesResponse(response).data.map((q) => q.id);
// self-hosted keeps current behaviour: no filtering, even the empty one
expect(ids).toEqual(["has-data", "empty"]);
});
});
describe("buildAttackPathQueries", () => {
it("prepends a custom query that links to the Prowler documentation", () => {
// When
+23 -1
View File
@@ -1,4 +1,5 @@
import { DOCS_URLS } from "@/lib/external-urls";
import { isCloud } from "@/lib/shared/env";
import { MetaDataProps } from "@/types";
import {
ATTACK_PATH_QUERY_IDS,
@@ -19,6 +20,27 @@ import {
* - Maintains separation of concerns between API layer and business logic
*/
/**
* Decide whether a query should appear in the selector.
*
* Empty-query filtering is a Prowler Cloud feature: outside Cloud
* (`isCloud()` is false) the queries API does not precompute result summaries,
* so every query is shown, matching current self-hosted behaviour.
*
* In Cloud, hide only queries the scan precomputed and found empty
* (`has_data === false`). Everything else stays visible: parameterized queries
* (no summary, run live on demand), queries that errored during precompute
* (`has_data` null), and scans that predate the precompute step (no summary at
* all). A missing/null summary is therefore never treated as "empty".
*/
function shouldShowQuery(query: AttackPathQuery): boolean {
if (!isCloud()) {
return true;
}
const summary = query.attributes.result_summary;
return !summary || summary.has_data !== false;
}
/**
* Adapt attack path queries response with enriched data
*
@@ -36,7 +58,7 @@ export function adaptAttackPathQueriesResponse(
}
// Enrich query data with computed properties
const enrichedData = response.data.map((query) => ({
const enrichedData = response.data.filter(shouldShowQuery).map((query) => ({
...query,
// Can add computed properties here, e.g.:
// parameterCount: query.attributes.parameters.length,
@@ -0,0 +1 @@
In Prowler Cloud, the Attack Paths query selector now lists only queries that returned data for the selected scan, hiding empty ones
+15
View File
@@ -129,6 +129,20 @@ export interface AttackPathQueryDocumentationLink {
link: string;
}
export const ATTACK_PATH_QUERY_STATUSES = {
OK: "ok",
ERROR: "error",
} as const;
export type AttackPathQueryStatus =
(typeof ATTACK_PATH_QUERY_STATUSES)[keyof typeof ATTACK_PATH_QUERY_STATUSES];
// Cloud-only precomputed query summary. Missing/null means no definitive empty verdict.
export interface AttackPathQueryResultSummary {
status: AttackPathQueryStatus;
has_data: boolean | null;
}
export interface AttackPathQueryAttributes {
name: string;
short_description: string;
@@ -137,6 +151,7 @@ export interface AttackPathQueryAttributes {
parameters: AttackPathQueryParameter[];
attribution: AttackPathQueryAttribution | null;
documentation_link?: AttackPathQueryDocumentationLink | null;
result_summary?: AttackPathQueryResultSummary | null;
}
export interface AttackPathQuery {