diff --git a/ui/app/(prowler)/_overview/exploitability/_components/top-issues.tsx b/ui/app/(prowler)/_overview/exploitability/_components/top-issues.tsx index c53bf8f799..f1adac9440 100644 --- a/ui/app/(prowler)/_overview/exploitability/_components/top-issues.tsx +++ b/ui/app/(prowler)/_overview/exploitability/_components/top-issues.tsx @@ -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; @@ -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 (
  • @@ -65,6 +73,12 @@ export const TopIssues = ({ issues, scope }: TopIssuesProps) => (

    {attributes.account}

    + {/* The reason to look is the combination, not the rank. */} + {factors && ( +

    + {factors} +

    + )}
  • diff --git a/ui/app/(prowler)/_overview/exploitability/top-issues.ssr.test.tsx b/ui/app/(prowler)/_overview/exploitability/top-issues.ssr.test.tsx index 9a19c9a21b..5bdc7955c6 100644 --- a/ui/app/(prowler)/_overview/exploitability/top-issues.ssr.test.tsx +++ b/ui/app/(prowler)/_overview/exploitability/top-issues.ssr.test.tsx @@ -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", diff --git a/ui/components/issues/issue-detail.test.tsx b/ui/components/issues/issue-detail.test.tsx index 34a505f074..d6e78b240d 100644 --- a/ui/components/issues/issue-detail.test.tsx +++ b/ui/components/issues/issue-detail.test.tsx @@ -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( + , + ); + + 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(); + + expect(screen.queryByText("Outcome")).not.toBeInTheDocument(); + }); + it("says so when the resources cannot be loaded", async () => { getIssueMock.mockResolvedValue(undefined); render(); @@ -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( + , + ); + + 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: { diff --git a/ui/components/issues/issue-detail.tsx b/ui/components/issues/issue-detail.tsx index fd9a03ff69..66182abd35 100644 --- a/ui/components/issues/issue-detail.tsx +++ b/ui/components/issues/issue-detail.tsx @@ -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 (
    + {/* The conditions that hold at once: the verdict says how bad, these + say why, and which part to argue with. */} + {factors.length > 0 && ( +
      + {factors.map((factor) => ( +
    • + + {factor.label} + +
    • + ))} +
    + )} {state.status === "loaded" && state.findings === undefined && ( )} + {issue.outcome && issue.attacker_start && ( +
    + + {ISSUE_OUTCOME_LABEL[issue.outcome]} + + {/* Inline, not an InfoTooltip: its trigger has no tab stop. */} + + {ATTACKER_START_LABEL[issue.attacker_start]} +

    + Where the attacker has to be standing. Whether they also need a + credential is the Access leg. +

    +
    +
    + )}

    Exploitability assessment diff --git a/ui/components/issues/issue-graph.adapter.test.ts b/ui/components/issues/issue-graph.adapter.test.ts index 593834b44d..de496d79e1 100644 --- a/ui/components/issues/issue-graph.adapter.test.ts +++ b/ui/components/issues/issue-graph.adapter.test.ts @@ -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) => 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(["yes", "no", "partial", "unknown"])( "does not infer per-resource Internet reachability from aggregate %s", (state) => { const view = buildIssueGraph( diff --git a/ui/components/issues/issue-graph.adapter.ts b/ui/components/issues/issue-graph.adapter.ts index 3abd73554e..489dfeadc7 100644 --- a/ui/components/issues/issue-graph.adapter.ts +++ b/ui/components/issues/issue-graph.adapter.ts @@ -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() }; }; diff --git a/ui/components/issues/issues-table.test.tsx b/ui/components/issues/issues-table.test.tsx index 5e3d1f67bc..e87e260f1e 100644 --- a/ui/components/issues/issues-table.test.tsx +++ b/ui/components/issues/issues-table.test.tsx @@ -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, diff --git a/ui/components/issues/issues.integration.test.tsx b/ui/components/issues/issues.integration.test.tsx index dd27e3a031..fb35bb2476 100644 --- a/ui/components/issues/issues.integration.test.tsx +++ b/ui/components/issues/issues.integration.test.tsx @@ -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, diff --git a/ui/lib/issues/risk-factors.test.ts b/ui/lib/issues/risk-factors.test.ts new file mode 100644 index 0000000000..8a7ad48a2c --- /dev/null +++ b/ui/lib/issues/risk-factors.test.ts @@ -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, + legs: Partial> = {}, +) => + ({ 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"]); + }); +}); diff --git a/ui/lib/issues/risk-factors.ts b/ui/lib/issues/risk-factors.ts new file mode 100644 index 0000000000..fe8d8c112e --- /dev/null +++ b/ui/lib/issues/risk-factors.ts @@ -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["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> = { + 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; +}; diff --git a/ui/types/issues.ts b/ui/types/issues.ts index 674287c02f..cfa660cfcb 100644 --- a/ui/types/issues.ts +++ b/ui/types/issues.ts @@ -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; fix: string; findings_count: number;