feat(ui): show what each attack path buys and where it starts (#13152)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Rubén De la Torre Vicoandalejandrobailo authored and GitHub committed 2026-10-26 15:59:38 +01:00
1 parent 40758818c3
commit 1ae3856de0
11 files changed
+387 -4

No files matched your search

@@ -4,10 +4,14 @@ import { IssuesLoadError } from "@/components/issues/issues-load-error";
import { Card, CardContent, CardTitle } from "@/components/shadcn";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { riskFactors } from "@/lib/issues/risk-factors";
import { issuesHref } from "@/lib/issues/scope";
import { VERDICTS } from "@/lib/issues/verdicts";
import { IssueResource } from "@/types/issues";
/** Enough to show the combination; the rest is on the issue. */
const TOP_FACTORS = 3;
interface TopIssuesProps {
issues: IssueResource[] | null;
scope: Record<string, string>;
@@ -31,6 +35,10 @@ export const TopIssues = ({ issues, scope }: TopIssuesProps) => (
(item) => item.id === attributes.verdict,
);
const providerId = relationships?.provider?.data?.id;
const factors = riskFactors(attributes)
.slice(0, TOP_FACTORS)
.map((factor) => factor.label)
.join(" · ");
return (
<li key={id} className="min-w-0">
<Card variant="inner" padding="md" className="h-full gap-2">
@@ -65,6 +73,12 @@ export const TopIssues = ({ issues, scope }: TopIssuesProps) => (
<p className="text-text-neutral-tertiary truncate text-xs">
{attributes.account}
</p>
{/* The reason to look is the combination, not the rank. */}
{factors && (
<p className="text-text-neutral-secondary truncate text-xs">
{factors}
</p>
)}
</div>
</Card>
</li>
@@ -24,6 +24,11 @@ describe("TopIssuesSSR", () => {
title: "RDS snapshot is visible to every AWS account",
verdict: "likely",
findings_count: 1,
attacker_start: "authenticated_principal",
outcome: "data_exposure",
legs: {
target: { state: "yes", text: "A snapshot.", evidence: [] },
},
},
relationships: {
provider: { data: { type: "providers", id: "p1" } },
@@ -38,6 +43,9 @@ describe("TopIssuesSSR", () => {
expect(row.closest("li")).toHaveTextContent("Likely");
expect(row.closest("li")).toHaveTextContent("aws_testing_1");
expect(row.closest("li")).toHaveTextContent("1 finding");
expect(row.closest("li")).toHaveTextContent(
"Any signed-in identity · Data exposure",
);
expect(row).toHaveAttribute(
"href",
"/issues?filter%5Bpattern%5D=public_snapshot&filter%5Bprovider_id__in%5D=p1",
@@ -37,6 +37,8 @@ const issue = {
title: "Findings no attack path models yet (300)",
verdict: "unknown",
severity: "high",
outcome: "",
attacker_start: "",
legs: {},
fix: "Review these findings individually.",
findings_count: 300,
@@ -77,6 +79,39 @@ describe("IssueDetail", () => {
expect(screen.getByTestId("issue-graph")).toHaveTextContent("15 resources");
});
it("says what a path buys the attacker and where it starts", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: [] } },
});
render(
<IssueDetail
id="issue-1"
issue={{
...issue,
pattern: "aws_public_bucket",
outcome: "data_exposure",
attacker_start: "internet",
}}
/>,
);
expect(screen.getByText("Data exposure")).toBeInTheDocument();
expect(screen.getByText("The internet")).toBeInTheDocument();
// Shown inline so keyboard users can read it too.
expect(
screen.getByText(/Where the attacker has to be standing/),
).toBeInTheDocument();
});
it("claims no outcome for an issue that is not one path", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: [] } },
});
render(<IssueDetail id="issue-1" issue={issue} />);
expect(screen.queryByText("Outcome")).not.toBeInTheDocument();
});
it("says so when the resources cannot be loaded", async () => {
getIssueMock.mockResolvedValue(undefined);
render(<IssueDetail id="issue-1" issue={issue} />);
@@ -116,6 +151,37 @@ describe("IssueDetail", () => {
]);
});
it("names the conditions that make the path, explained by each leg", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: [] } },
});
render(
<IssueDetail
id="issue-1"
issue={{
...issue,
outcome: "data_exposure",
attacker_start: "internet",
legs: {
...issue.legs,
controls: {
state: "no",
text: "The public access block is off.",
evidence: [],
},
},
}}
/>,
);
const factors = screen.getByRole("list", { name: "Risk factors" });
expect(factors).toHaveTextContent("Internet-reachable");
expect(screen.getByText("No control blocking")).toHaveAttribute(
"title",
"The public access block is off.",
);
});
it("lists each folded finding once, with its own triage and a link to it", async () => {
getIssueMock.mockResolvedValue({
data: {
+33
View File
@@ -13,11 +13,15 @@ import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { CodeSnippet } from "@/components/shadcn/code-snippet";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { riskFactors } from "@/lib/issues/risk-factors";
import { legBadgeVariant } from "@/lib/issues/verdicts";
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
import {
ATTACKER_START_LABEL,
ISSUE_OUTCOME_LABEL,
IssueAttributes,
IssueFinding,
LegName,
@@ -120,6 +124,7 @@ export const IssueDetail = ({
setAttempt((value) => value + 1);
};
const activeScanId = state.status === "loaded" ? state.scanId : scanId;
const factors = riskFactors(issue);
const uniqueFindings =
state.status === "loaded"
? Array.from(
@@ -137,6 +142,19 @@ export const IssueDetail = ({
return (
<div className="flex min-w-0 flex-col gap-4 py-4">
{/* The conditions that hold at once: the verdict says how bad, these
say why, and which part to argue with. */}
{factors.length > 0 && (
<ul aria-label="Risk factors" className="flex flex-wrap gap-1.5">
{factors.map((factor) => (
<li key={factor.label}>
<Badge variant={factor.variant} title={factor.title}>
{factor.label}
</Badge>
</li>
))}
</ul>
)}
{state.status === "loaded" && state.findings === undefined && (
<IssuesLoadError
message="Could not load the findings for this issue."
@@ -155,6 +173,21 @@ export const IssueDetail = ({
: { providerId: undefined, scanId: undefined })}
/>
)}
{issue.outcome && issue.attacker_start && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<InfoField label="Outcome">
{ISSUE_OUTCOME_LABEL[issue.outcome]}
</InfoField>
{/* Inline, not an InfoTooltip: its trigger has no tab stop. */}
<InfoField label="Starts from">
{ATTACKER_START_LABEL[issue.attacker_start]}
<p className="text-text-neutral-tertiary text-xs">
Where the attacker has to be standing. Whether they also need a
credential is the Access leg.
</p>
</InfoField>
</div>
)}
<section aria-label="Exploitability assessment">
<h4 className="mb-3 text-sm font-semibold">
Exploitability assessment
@@ -4,9 +4,9 @@ import {
buildIssueGraph,
GRAPH_RESOURCE_LIMIT,
} from "@/components/issues/issue-graph.adapter";
import { IssueAttributes } from "@/types/issues";
import { IssueAttributes, LegState } from "@/types/issues";
const leg = (state: string, evidence: string[] = []) => ({
const leg = (state: LegState, evidence: string[] = []) => ({
state,
text: "",
evidence,
@@ -31,6 +31,55 @@ const ids = (view: ReturnType<typeof buildIssueGraph>) =>
view.nodes.map((n) => n.id);
describe("buildIssueGraph", () => {
it("draws the internet reaching the resources the reachability leg cites", () => {
// Given - a path that starts on the internet; only bucket a is cited.
const view = buildIssueGraph(issue({ attacker_start: "internet" }), [
"arn:aws:s3:::a",
"arn:aws:s3:::b",
]);
// Then - one Internet node, as Attack Paths draws it, and one edge.
expect(view.nodes).toContainEqual(
expect.objectContaining({ id: "internet", labels: ["Internet"] }),
);
expect(view.edges.filter((edge) => edge.source === "internet")).toEqual([
expect.objectContaining({
target: "resource:arn:aws:s3:::a",
type: "CAN_ACCESS",
}),
]);
});
it("does not claim the internet reaches a path that does not start there", () => {
expect(ids(buildIssueGraph(issue(), ["arn:aws:s3:::a"]))).not.toContain(
"internet",
);
// Nor one that starts there but is not shown to be reachable, nor one
// whose leg cites no drawn resource: the leg alone is aggregate.
expect(
ids(
buildIssueGraph(
issue({
attacker_start: "internet",
legs: { ...issue().legs, reachability: leg("unknown") },
}),
["arn:aws:s3:::a"],
),
),
).not.toContain("internet");
expect(
ids(
buildIssueGraph(
issue({
attacker_start: "internet",
legs: { ...issue().legs, reachability: leg("yes") },
}),
["arn:aws:s3:::a"],
),
),
).not.toContain("internet");
});
it("draws folded findings absent from leg evidence with their real severity", () => {
// Given - only the instance is cited, but both security groups also have findings.
const resources = ["instance", "security-group-ssh", "security-group-rdp"];
@@ -124,7 +173,7 @@ describe("buildIssueGraph", () => {
});
});
it.each(["yes", "no", "partial", "unknown"])(
it.each<LegState>(["yes", "no", "partial", "unknown"])(
"does not infer per-resource Internet reachability from aggregate %s",
(state) => {
const view = buildIssueGraph(
+36 -1
View File
@@ -1,6 +1,6 @@
import { AttackPathView } from "@/lib/attack-paths/group-graph";
import { GraphEdge, GraphNode } from "@/types/attack-paths";
import { IssueAttributes, IssueFinding } from "@/types/issues";
import { IssueAttributes, IssueFinding, LEG_STATE } from "@/types/issues";
/** Resources drawn before the rest are left to the list below the graph. */
export const GRAPH_RESOURCE_LIMIT = 12;
@@ -23,6 +23,10 @@ const RESOURCE_LABELS: [RegExp, string][] = [
const resourceLabel = (uid: string) =>
RESOURCE_LABELS.find(([pattern]) => pattern.test(uid))?.[1] ?? "Resource";
/** The id and edge Attack Paths gives the internet, so it draws the same node. */
const INTERNET_NODE_ID = "internet";
const REACHABLE: string[] = [LEG_STATE.YES, LEG_STATE.PARTIAL];
/** The last path or ARN segment: what an operator calls the resource. */
const shortName = (uid: string) =>
uid.split(/[/:]/).filter(Boolean).pop() ?? uid;
@@ -105,5 +109,36 @@ export const buildIssueGraph = (
addFinding(uid, checkId, { check_title: checkId }),
);
// A path that starts on the internet starts at the Internet node, drawn the
// way Attack Paths draws it. Each edge stands on a reachability check that
// failed on that resource: the leg's verdict alone is aggregate and would
// claim every resource is exposed.
const reachability = issue.legs?.reachability;
if (
issue.attacker_start === "internet" &&
reachability &&
REACHABLE.includes(reachability.state)
) {
const cited = new Set(
reachability.evidence.map((line) => line.split(" · ")[1]),
);
const reached = resources.filter((uid) => cited.has(uid));
if (reached.length > 0) {
nodes.unshift({
id: INTERNET_NODE_ID,
labels: ["Internet"],
properties: { name: "Internet" },
});
}
reached.forEach((uid) =>
edges.push({
id: `${INTERNET_NODE_ID}->${uid}`,
source: INTERNET_NODE_ID,
target: resourceId(uid),
type: "CAN_ACCESS",
}),
);
}
return { nodes, edges, groupMembers: new Map() };
};
@@ -36,6 +36,8 @@ const issue: IssueResponse["data"][number] = {
title: "Public bucket",
verdict: "likely",
severity: "high",
outcome: "data_exposure",
attacker_start: "internet",
findings_count: 3,
triage_status: "open",
notes_count: 0,
@@ -27,6 +27,8 @@ const issue: IssueResource = {
title: "Public bucket exposes customer data",
verdict: "likely",
severity: "high",
outcome: "data_exposure",
attacker_start: "internet",
findings_count: 3,
triage_status: "open",
notes_count: 0,
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { riskFactors } from "@/lib/issues/risk-factors";
import { IssueAttributes, LegAssessment, LegName } from "@/types/issues";
const leg = (state: LegAssessment["state"], text = `${state} text`) => ({
state,
text,
evidence: [],
});
const issueWith = (
overrides: Partial<IssueAttributes>,
legs: Partial<Record<LegName, LegAssessment>> = {},
) =>
({ outcome: "", attacker_start: "", ...overrides, legs }) as IssueAttributes;
describe("riskFactors", () => {
it("names every condition of an open path, explained by its leg", () => {
const factors = riskFactors(
issueWith(
{ attacker_start: "internet", outcome: "data_exposure" },
{
access: leg("yes", "No credential is needed."),
target: leg("yes"),
controls: leg("no", "The public access block is off."),
},
),
);
expect(factors.map(({ label, variant }) => [label, variant])).toEqual([
["Internet-reachable", "error"],
["Access possible", "error"],
["Data exposure", "error"],
["No control blocking", "warning"],
]);
expect(factors[1].title).toBe("No credential is needed.");
expect(factors[3].title).toBe("The public access block is off.");
});
it("reads a control in force as the good news it is", () => {
const factors = riskFactors(
issueWith({ attacker_start: "foothold" }, { controls: leg("yes") }),
);
expect(factors.map(({ label, variant }) => [label, variant])).toEqual([
["Needs a foothold", "warning"],
["Blocked by a control", "success"],
]);
});
it("says nothing it cannot back: unknown legs and no start give no chips", () => {
expect(
riskFactors(
issueWith(
{},
{
access: leg("unknown"),
target: leg("partial"),
controls: leg("unknown"),
},
),
),
).toEqual([]);
// The unmodelled issue carries no legs at all.
expect(riskFactors(issueWith({}))).toEqual([]);
});
it("falls back to a generic target label when the path has no outcome", () => {
expect(
riskFactors(issueWith({}, { target: leg("yes") })).map((f) => f.label),
).toEqual(["Worth taking"]);
});
});
+77
View File
@@ -0,0 +1,77 @@
import { ComponentProps } from "react";
import { Badge } from "@/components/shadcn/badge/badge";
import {
AttackerStart,
ISSUE_OUTCOME_LABEL,
IssueAttributes,
LEG_STATE,
} from "@/types/issues";
export interface RiskFactor {
label: string;
variant: ComponentProps<typeof Badge>["variant"];
/** Why the factor is on the issue, shown on hover. */
title: string;
}
/** Where the attacker starts, as a chip; a foothold is a precondition, not an exposure. */
const START_FACTOR: Record<AttackerStart, Omit<RiskFactor, "title">> = {
internet: { label: "Internet-reachable", variant: "error" },
authenticated_principal: {
label: "Any signed-in identity",
variant: "error",
},
credential_leak: { label: "Leaked credential", variant: "error" },
external_user: { label: "Outside user", variant: "error" },
foothold: { label: "Needs a foothold", variant: "warning" },
};
/**
* The conditions that line up to make the issue, read from the legs the engine
* already decided, so a chip can never contradict the verdict beside it. Each
* chip's hover text is the leg's own explanation.
*/
export const riskFactors = (issue: IssueAttributes): RiskFactor[] => {
const { legs } = issue;
const factors: RiskFactor[] = [];
if (issue.attacker_start) {
factors.push({
...START_FACTOR[issue.attacker_start],
title: "Where the attacker has to be standing for the path to start",
});
}
if (legs.access?.state === LEG_STATE.YES) {
factors.push({
label: "Access possible",
variant: "error",
title: legs.access.text,
});
}
if (legs.target?.state === LEG_STATE.YES) {
factors.push({
label: issue.outcome
? ISSUE_OUTCOME_LABEL[issue.outcome]
: "Worth taking",
variant: "error",
title: legs.target.text,
});
}
// Controls is the leg where "yes" is the good news: a control is in force.
if (legs.controls?.state === LEG_STATE.YES) {
factors.push({
label: "Blocked by a control",
variant: "success",
title: legs.controls.text,
});
} else if (legs.controls?.state === LEG_STATE.NO) {
factors.push({
label: "No control blocking",
variant: "warning",
title: legs.controls.text,
});
}
return factors;
};
+23
View File
@@ -34,6 +34,26 @@ export const LEG_STATE = {
export type LegState = (typeof LEG_STATE)[keyof typeof LEG_STATE];
/** What walking the path buys the attacker. */
export const ISSUE_OUTCOME_LABEL = {
data_exposure: "Data exposure",
privilege_escalation: "Privilege escalation",
code_execution: "Code execution",
} as const;
export type IssueOutcome = keyof typeof ISSUE_OUTCOME_LABEL;
/** Where the attacker has to be standing; whether they need a credential is the access leg. */
export const ATTACKER_START_LABEL = {
internet: "The internet",
authenticated_principal: "Any signed-in identity",
credential_leak: "A leaked credential",
foothold: "A foothold on a host",
external_user: "An outside user",
} as const;
export type AttackerStart = keyof typeof ATTACKER_START_LABEL;
export interface LegAssessment {
state: LegState;
text: string;
@@ -48,6 +68,9 @@ export interface IssueAttributes {
title: string;
verdict: Verdict;
severity: Severity;
/** Blank on the hygiene and unmodelled issues, which are not one path. */
outcome: IssueOutcome | "";
attacker_start: AttackerStart | "";
legs: Record<LegName, LegAssessment>;
fix: string;
findings_count: number;