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
This commit is contained in:
Daniel Barranquero
2026-07-16 09:58:47 +02:00
parent ff45f46047
commit a381ac982d
4 changed files with 140 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
+24 -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,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,
@@ -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
+10
View File
@@ -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 {