From fb75146e345cd1a76f9ba0745a26d862cfa703de Mon Sep 17 00:00:00 2001 From: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:36:33 +0200 Subject: [PATCH] feat(ui): filter empty Attack Paths queries from the selector in Cloud (#12010) --- .../attack-paths/queries.adapter.test.ts | 107 +++++++++++++++++- ui/actions/attack-paths/queries.adapter.ts | 24 +++- ...attack-paths-filter-empty-queries.added.md | 1 + ui/types/attack-paths.ts | 15 +++ 4 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 ui/changelog.d/attack-paths-filter-empty-queries.added.md diff --git a/ui/actions/attack-paths/queries.adapter.test.ts b/ui/actions/attack-paths/queries.adapter.test.ts index 77298991f3..18e89d9a4d 100644 --- a/ui/actions/attack-paths/queries.adapter.test.ts +++ b/ui/actions/attack-paths/queries.adapter.test.ts @@ -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 diff --git a/ui/actions/attack-paths/queries.adapter.ts b/ui/actions/attack-paths/queries.adapter.ts index b4635ed333..38ce566740 100644 --- a/ui/actions/attack-paths/queries.adapter.ts +++ b/ui/actions/attack-paths/queries.adapter.ts @@ -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, diff --git a/ui/changelog.d/attack-paths-filter-empty-queries.added.md b/ui/changelog.d/attack-paths-filter-empty-queries.added.md new file mode 100644 index 0000000000..b9087b0ff4 --- /dev/null +++ b/ui/changelog.d/attack-paths-filter-empty-queries.added.md @@ -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 diff --git a/ui/types/attack-paths.ts b/ui/types/attack-paths.ts index e1378a1b1c..28201b2367 100644 --- a/ui/types/attack-paths.ts +++ b/ui/types/attack-paths.ts @@ -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 {