feat(ui): link every Attack Paths query to its Prowler Hub page (#12145)

This commit is contained in:
Daniel Barranquero
2026-07-29 13:31:42 +02:00
committed by GitHub
parent 4c3017e2ed
commit d4a33c0d1c
4 changed files with 84 additions and 36 deletions
@@ -1,7 +1,10 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { AttackPathQuery } from "@/types/attack-paths";
import {
ATTACK_PATH_QUERY_IDS,
type AttackPathQuery,
} from "@/types/attack-paths";
import { QueryDescription } from "./query-description";
@@ -23,7 +26,51 @@ const customQuery: AttackPathQuery = {
},
};
const builtInQuery: AttackPathQuery = {
type: "attack-paths-scans",
id: "aws-sts-privesc-assume-role",
attributes: {
name: "Role Assumption for Privilege Escalation (STS-001)",
short_description: "Detect principals who can assume other IAM roles.",
description:
"Detect principals who can assume other IAM roles via sts:AssumeRole.",
provider: "aws",
attribution: null,
parameters: [],
},
};
describe("QueryDescription", () => {
it("renders a Prowler Hub link for a built-in query", () => {
// Given
render(<QueryDescription query={builtInQuery} />);
// When
const link = screen.getByRole("link", { name: /view on prowler hub/i });
// Then
expect(link).toHaveAttribute(
"href",
"https://hub.prowler.com/attack-paths/aws-sts-privesc-assume-role",
);
});
it("does not render a Prowler Hub link for the custom query", () => {
// Given
const query: AttackPathQuery = {
...customQuery,
id: ATTACK_PATH_QUERY_IDS.CUSTOM,
};
// When
render(<QueryDescription query={query} />);
// Then
expect(
screen.queryByRole("link", { name: /view on prowler hub/i }),
).not.toBeInTheDocument();
});
it("renders the documentation link inside an info alert", () => {
// Given
render(<QueryDescription query={customQuery} />);
@@ -39,9 +86,9 @@ describe("QueryDescription", () => {
expect(link).toHaveAttribute("href", "https://example.com/docs");
});
it("does not render unsafe documentation or attribution URLs as clickable links", () => {
it("does not render an unsafe documentation URL as a clickable link", () => {
// Given
const queryWithUnsafeLinks: AttackPathQuery = {
const queryWithUnsafeLink: AttackPathQuery = {
...customQuery,
attributes: {
...customQuery.attributes,
@@ -49,15 +96,11 @@ describe("QueryDescription", () => {
text: "Learn how to write custom openCypher queries",
link: "javascript:alert('xss')",
},
attribution: {
text: "Unsafe source",
link: "javascript:alert('xss')",
},
},
};
// When
render(<QueryDescription query={queryWithUnsafeLinks} />);
render(<QueryDescription query={queryWithUnsafeLink} />);
// Then
expect(
@@ -65,12 +108,8 @@ describe("QueryDescription", () => {
name: /learn how to write custom opencypher queries/i,
}),
).not.toBeInTheDocument();
expect(
screen.queryByRole("link", { name: /unsafe source/i }),
).not.toBeInTheDocument();
expect(
screen.getByText(/learn how to write custom opencypher queries/i),
).toBeInTheDocument();
expect(screen.getByText(/unsafe source/i)).toBeInTheDocument();
});
});
@@ -1,7 +1,11 @@
import { Info } from "lucide-react";
import { Alert, AlertDescription } from "@/components/shadcn";
import type { AttackPathQuery } from "@/types/attack-paths";
import { getAttackPathHubUrl } from "@/lib/external-urls";
import {
ATTACK_PATH_QUERY_IDS,
type AttackPathQuery,
} from "@/types/attack-paths";
interface QueryDescriptionProps {
query: AttackPathQuery;
@@ -18,7 +22,12 @@ const isSafeUrl = (url: string): boolean => {
export const QueryDescription = ({ query }: QueryDescriptionProps) => {
const documentationLink = query.attributes.documentation_link;
const attribution = query.attributes.attribution;
// Every built-in query has a Prowler Hub page keyed by its id. The synthetic
// custom query has no catalog entry, so it gets no hub link.
const hubUrl =
query.id === ATTACK_PATH_QUERY_IDS.CUSTOM
? null
: getAttackPathHubUrl(query.id);
return (
<Alert variant="info">
@@ -26,6 +35,19 @@ export const QueryDescription = ({ query }: QueryDescriptionProps) => {
<AlertDescription className="w-full gap-2">
<p className="whitespace-pre-line">{query.attributes.description}</p>
{hubUrl && (
<p className="text-xs">
<a
href={hubUrl}
target="_blank"
rel="noopener noreferrer"
className="font-medium underline"
>
View on Prowler Hub
</a>
</p>
)}
{documentationLink && (
<p className="text-xs">
{isSafeUrl(documentationLink.link) ? (
@@ -42,28 +64,6 @@ export const QueryDescription = ({ query }: QueryDescriptionProps) => {
)}
</p>
)}
{attribution && (
<p className="text-xs">
{isSafeUrl(attribution.link) ? (
<>
Source:{" "}
<a
href={attribution.link}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{attribution.text}
</a>
</>
) : (
<>
Source: <span>{attribution.text}</span>
</>
)}
</p>
)}
</AlertDescription>
</Alert>
);
@@ -0,0 +1 @@
Attack Paths query info panel now links every query to its page on Prowler Hub
+8
View File
@@ -18,6 +18,14 @@ export const DOCS_URLS = {
AI_AGENTS: "https://docs.prowler.com/user-guide/ai-agents/",
} as const;
// Prowler Hub — the public catalog of Prowler artifacts (checks, compliance,
// attack paths). Every built-in Attack Paths query has a page keyed by its
// query id, e.g. https://hub.prowler.com/attack-paths/aws-sts-privesc-assume-role
export const PROWLER_HUB_URL = "https://hub.prowler.com";
export const getAttackPathHubUrl = (queryId: string): string =>
`${PROWLER_HUB_URL}/attack-paths/${encodeURIComponent(queryId)}`;
// CloudFormation template URL for the ProwlerScan role.
// Also used (URL-encoded) as the templateURL param in the quick-create links
// built by getAWSCredentialsTemplateLinks and getAWSOrgDeploymentQuickLink below.