From a381ac982d398475e419b869f529335a0fb2bcb0 Mon Sep 17 00:00:00 2001 From: Daniel Barranquero Date: Thu, 16 Jul 2026 09:58:47 +0200 Subject: [PATCH] feat(ui): filter empty Attack Paths queries from the selector in Cloud In Prowler Cloud, the Attack Paths query selector now lists only parameterless queries that returned data for the selected scan, using the precomputed per-query result summary from the queries API. Parameterized and custom queries are always shown (they run live on demand), and so are queries that errored during precompute or a scan predating the precompute step -- a query is hidden only when the scan computed it and found it empty. Gated behind NEXT_PUBLIC_IS_CLOUD_ENV: self-hosted deployments, whose API does not precompute result summaries, keep showing every query as before. Refs PROWLER-2195 --- .../attack-paths/queries.adapter.test.ts | 107 +++++++++++++++++- ui/actions/attack-paths/queries.adapter.ts | 25 +++- ...attack-paths-filter-empty-queries.added.md | 1 + ui/types/attack-paths.ts | 10 ++ 4 files changed, 140 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..3142c3f5fd 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,28 @@ 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 + * (`NEXT_PUBLIC_IS_CLOUD_ENV !== "true"`) 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". + */ +export 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 +59,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..ec38f41c64 100644 --- a/ui/types/attack-paths.ts +++ b/ui/types/attack-paths.ts @@ -129,6 +129,15 @@ export interface AttackPathQueryDocumentationLink { link: string; } +// Precomputed per-query result summary from the scan (Prowler Cloud only). +// `has_data` is null when the query errored; the whole object is null/absent +// when the query was never precomputed (parameterized, a scan predating the +// precompute step, or one the bounded pass did not reach). +export interface AttackPathQueryResultSummary { + status: "ok" | "error"; + has_data: boolean | null; +} + export interface AttackPathQueryAttributes { name: string; short_description: string; @@ -137,6 +146,7 @@ export interface AttackPathQueryAttributes { parameters: AttackPathQueryParameter[]; attribution: AttackPathQueryAttribution | null; documentation_link?: AttackPathQueryDocumentationLink | null; + result_summary?: AttackPathQueryResultSummary | null; } export interface AttackPathQuery {