mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 07:14:20 +00:00
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:
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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user