Files
prowler/ui/components/issues/issue-graph.adapter.ts
T

145 lines
4.6 KiB
TypeScript

import { AttackPathView } from "@/lib/attack-paths/group-graph";
import { GraphEdge, GraphNode } from "@/types/attack-paths";
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;
// shortcut: covers the resource kinds the issue patterns fold today; anything
// else draws with the renderer's generic icon. Extend when a pattern adds one.
const RESOURCE_LABELS: [RegExp, string][] = [
[/^arn:aws:s3:/, "S3Bucket"],
[/^arn:aws:iam::[^:]*:role\//, "AWSRole"],
[/^arn:aws:iam::[^:]*:user\//, "AWSUser"],
[/^arn:aws:iam::[^:]*:root$/, "AWSAccount"],
[/^arn:aws:ec2:.*:instance\//, "EC2Instance"],
[/^arn:aws:ec2:.*:security-group\//, "EC2SecurityGroup"],
[/^arn:aws:lambda:/, "AWSLambda"],
[/^arn:aws:rds:/, "RDSInstance"],
[/^arn:aws:secretsmanager:/, "Secret"],
[/\/virtualMachines\//i, "VirtualMachine"],
];
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;
/** Folded findings and supporting evidence associate checks with resources, not attack paths. */
export const buildIssueGraph = (
issue: IssueAttributes,
resourceUids: string[],
findings: IssueFinding[] = [],
): AttackPathView => {
const nodes: GraphNode[] = [];
const edges: GraphEdge[] = [];
const evidence = Object.values(issue.legs ?? {})
.flatMap((leg) => leg?.evidence ?? [])
.map((line) => {
const [checkId, uid] = line.split(" · ");
return { line, checkId, uid };
})
.filter(({ checkId, uid }) => checkId && uid);
const evidenceResources = new Set(evidence.map(({ uid }) => uid));
const findingResources = new Set(
findings.map(({ resourceUid }) => resourceUid),
);
const resources = [
...resourceUids.filter((uid) => findingResources.has(uid)),
...resourceUids.filter(
(uid) => !findingResources.has(uid) && evidenceResources.has(uid),
),
...resourceUids.filter(
(uid) => !findingResources.has(uid) && !evidenceResources.has(uid),
),
].slice(0, GRAPH_RESOURCE_LIMIT);
const resourceId = (uid: string) => `resource:${uid}`;
resources.forEach((uid) => {
nodes.push({
id: resourceId(uid),
labels: [resourceLabel(uid)],
properties: { id: uid, name: shortName(uid) },
});
});
const shown = new Set(resources);
const seen = new Set<string>();
const addFinding = (
uid: string,
checkId: string,
properties: GraphNode["properties"],
) => {
const line = `${checkId} · ${uid}`;
if (!shown.has(uid) || seen.has(line)) return;
seen.add(line);
const id = `finding:${line}`;
nodes.push({
id,
labels: ["ProwlerFinding"],
properties: {
id,
check_id: checkId,
resource_uid: uid,
...properties,
},
});
edges.push({
id: `${uid}->${id}`,
source: resourceId(uid),
target: id,
type: "HAS_FINDING",
});
};
findings.forEach(({ id, checkId, checkTitle, severity, resourceUid }) =>
addFinding(resourceUid, checkId, {
finding_id: id,
check_title: checkTitle,
severity,
}),
);
evidence.forEach(({ checkId, uid }) =>
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() };
};