mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-25 10:52:31 +02:00
feat(web): render the attack path as a real node graph, not flat cards
The 'Generative Attack Path Chaining' tab previously showed kill-chain stages as stacked cards in columns — with 1 finding (the common case early in a run) it looked like an empty list, nothing like an attack graph. Rewritten as an inline SVG node/edge graph on a fixed-dark canvas (matches attack-graph tools like NodeZero regardless of the app's own light/dark theme — bright severity colors read better against near-black): - Root node = the target, always present. - One node per confirmed finding, positioned in its kill-chain-stage column (falls back to a single flat column when no finding has a stage yet). - Edges: from the finding's chains_from parent when the harness set one, else fanned directly from root — never invents a specific relationship that doesn't exist in the data. - Per-node icon inferred from title/evidence/cwe/stage (key/shield/ person/host/db/impact), severity-colored border + corner tick. - Nodes are clickable — opens the same finding detail modal as the findings table (PoC included). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0129WdYHccPsH27k5GGuwijd
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d42e9ff8e8
commit
7f365b4e88
+100
-23
@@ -498,7 +498,7 @@ function addFinding(f) {
|
||||
$('#liveFindingsTable tbody').insertAdjacentHTML('beforeend', findingRow(f, idx));
|
||||
$('#liveFindingsCount').textContent = state.currentJob.findings.length;
|
||||
show($('#liveFindingsEmpty'), false);
|
||||
renderAttackPath($('#liveAttackPath'), state.currentJob.findings);
|
||||
renderAttackPath($('#liveAttackPath'), state.currentJob.findings, state.currentJob.target);
|
||||
}
|
||||
|
||||
function applySnapshot(snap) {
|
||||
@@ -595,12 +595,33 @@ $('#findingModal').addEventListener('click', (e) => { if (e.target.id === 'findi
|
||||
|
||||
const KILL_CHAIN_STAGES = ['recon', 'initial-access', 'execution', 'privesc', 'lateral', 'exfil', 'impact'];
|
||||
|
||||
function renderAttackPath(container, findings) {
|
||||
// Bright, saturated palette for the dark graph canvas — the severity chip
|
||||
// colors elsewhere are tuned for text-on-light-background legibility and
|
||||
// read as muddy on a dark node graph.
|
||||
const CANVAS_SEV_COLOR = { critical: '#ff6b5b', high: '#ffab52', medium: '#f0cf5c', low: '#7fd99a', info: '#8fa3ef' };
|
||||
function canvasColor(sev) { return CANVAS_SEV_COLOR[['critical', 'high', 'medium', 'low', 'info'][sevRank(sev)]]; }
|
||||
|
||||
function nodeIcon(f) {
|
||||
const t = `${f.title} ${f.evidence} ${f.cwe} ${f.stage}`.toLowerCase();
|
||||
if (/credential|password|secret|token|api[ _]?key|jwt/.test(t)) return '🔑';
|
||||
if (/admin|privile|domain admin|root/.test(t)) return '🛡';
|
||||
if (/account|user|identity/.test(t)) return '👤';
|
||||
if (/host|server|ip |port|service/.test(t)) return '🖥';
|
||||
if (/database|sql/.test(t)) return '🗄';
|
||||
if (t.includes('impact') || t.includes('exfil')) return '💥';
|
||||
return '⚠';
|
||||
}
|
||||
|
||||
// Generative Attack Path Chaining — a real node graph (root = target, one
|
||||
// node per confirmed finding, edges from chains_from when the harness set
|
||||
// it, else fanned from root) instead of flat cards, so a single finding
|
||||
// still reads as a graph and not an empty list.
|
||||
function renderAttackPath(container, findings, target) {
|
||||
if (!findings.length) {
|
||||
container.innerHTML = '<div class="attackpath-empty">The attack path builds automatically as findings chain together — nothing confirmed yet.</div>';
|
||||
return;
|
||||
}
|
||||
const byId = new Map(findings.map((f) => [f.id, f]));
|
||||
const byId = new Map(findings.filter((f) => f.id).map((f) => [f.id, f]));
|
||||
const hasStages = findings.some((f) => f.stage);
|
||||
let groups;
|
||||
if (hasStages) {
|
||||
@@ -610,29 +631,85 @@ function renderAttackPath(container, findings) {
|
||||
const other = findings.filter((f) => !f.stage);
|
||||
if (other.length) groups.push({ label: 'unstaged', items: other });
|
||||
} else {
|
||||
const order = ['critical', 'high', 'medium', 'low', 'info'];
|
||||
groups = order
|
||||
.map((sev) => ({ label: sev, items: findings.filter((f) => (f.severity || '').toLowerCase().includes(sev)) }))
|
||||
.filter((g) => g.items.length);
|
||||
groups = [{ label: 'confirmed findings', items: findings }];
|
||||
}
|
||||
|
||||
// Layout: root at column 0; each kill-chain stage is its own column.
|
||||
const COL_W = 210, ROW_H = 78, NODE_W = 176, NODE_H = 54, PAD = 40;
|
||||
const rootX = PAD, rootY = PAD + (Math.max(...groups.map((g) => g.items.length)) * ROW_H) / 2;
|
||||
const nodes = [{ id: '__root', x: rootX, y: rootY, root: true, label: target || 'target' }];
|
||||
const nodeById = new Map(); // finding.id -> node (for chains_from edges)
|
||||
groups.forEach((g, ci) => {
|
||||
const colX = PAD + NODE_W / 2 + (ci + 1) * COL_W;
|
||||
const colH = g.items.length * ROW_H;
|
||||
const offsetY = rootY - colH / 2 + ROW_H / 2;
|
||||
g.items.forEach((f, ri) => {
|
||||
const node = { id: f.id || `${ci}-${ri}`, x: colX, y: offsetY + ri * ROW_H, finding: f, stageLabel: g.label };
|
||||
nodes.push(node);
|
||||
if (f.id) nodeById.set(f.id, node);
|
||||
});
|
||||
});
|
||||
|
||||
const edges = [];
|
||||
for (const n of nodes) {
|
||||
if (n.root) continue;
|
||||
const parents = (n.finding.chains_from || []).map((cid) => nodeById.get(cid)).filter(Boolean);
|
||||
if (parents.length) parents.forEach((p) => edges.push([p, n]));
|
||||
else edges.push([nodes[0], n]);
|
||||
}
|
||||
|
||||
const width = PAD * 2 + NODE_W + (groups.length) * COL_W;
|
||||
const height = Math.max(...nodes.map((n) => n.y)) + NODE_H + PAD;
|
||||
|
||||
const edgePath = (a, b) => {
|
||||
const x1 = a.root ? a.x + 14 : a.x + NODE_W / 2, y1 = a.y;
|
||||
const x2 = b.x - NODE_W / 2, y2 = b.y;
|
||||
const midX = (x1 + x2) / 2;
|
||||
return `M ${x1},${y1} C ${midX},${y1} ${midX},${y2} ${x2},${y2}`;
|
||||
};
|
||||
|
||||
const nodeSvg = (n) => {
|
||||
if (n.root) {
|
||||
return `<g>
|
||||
<circle cx="${n.x}" cy="${n.y}" r="15" fill="#c0392b" stroke="#ff6b5b" stroke-width="2"/>
|
||||
<text x="${n.x}" y="${n.y + 4}" text-anchor="middle" font-size="13" fill="#fff">🎯</text>
|
||||
<text x="${n.x}" y="${n.y + 30}" text-anchor="middle" font-size="10.5" fill="#c9c6bf" font-family="var(--mono)">${esc(trimMid(n.label, 26))}</text>
|
||||
</g>`;
|
||||
}
|
||||
const f = n.finding;
|
||||
const color = canvasColor(f.severity);
|
||||
const x = n.x - NODE_W / 2, y = n.y - NODE_H / 2;
|
||||
return `<g class="ap-node-g" data-idx="${esc(findings.indexOf(f))}" style="cursor:pointer;">
|
||||
<rect x="${x}" y="${y}" width="${NODE_W}" height="${NODE_H}" rx="8" fill="#181a20" stroke="${color}" stroke-width="1.6"/>
|
||||
<text x="${x + 12}" y="${y + 20}" font-size="13">${nodeIcon(f)}</text>
|
||||
<text x="${x + 32}" y="${y + 19}" font-size="11.5" fill="#e8e6e0" font-weight="600">${esc(trimMid(f.title, 22))}</text>
|
||||
<text x="${x + 32}" y="${y + 36}" font-size="10" fill="#8b8880" font-family="var(--mono)">${esc((f.mitre || f.owasp || f.cwe || n.stageLabel || '').slice(0, 26))}</text>
|
||||
<rect x="${x + NODE_W - 9}" y="${y + 6}" width="6" height="6" rx="1.5" fill="${color}"/>
|
||||
</g>`;
|
||||
};
|
||||
|
||||
container.innerHTML = `
|
||||
${!hasStages ? '<div class="field-help" style="margin-bottom:8px;">No kill-chain stage data yet — grouped by severity.</div>' : ''}
|
||||
<div class="attackpath">
|
||||
${groups.map((g, i) => `
|
||||
${i > 0 ? '<div class="ap-arrow">→</div>' : ''}
|
||||
<div class="ap-stage">
|
||||
<div class="ap-stage-head">${esc(g.label)} (${g.items.length})</div>
|
||||
${g.items.map((f) => `
|
||||
<div class="ap-node ${sevClass(f.severity)}">
|
||||
<div class="t">${esc(f.title)}</div>
|
||||
<div class="m">${esc(f.mitre || f.owasp || f.cwe || '')}</div>
|
||||
${(f.chains_from || []).length ? `<div class="chain-from">⤷ chains from ${(f.chains_from).map((cid) => esc(byId.get(cid)?.title || cid)).join(', ')}</div>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`).join('')}
|
||||
${!hasStages ? '<div class="field-help" style="margin-bottom:8px;">No kill-chain stage data yet — shown as a flat graph from the target.</div>' : ''}
|
||||
<div class="ap-canvas-wrap">
|
||||
<svg class="ap-canvas" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
|
||||
${groups.map((g, ci) => `<line x1="${PAD + NODE_W / 2 + (ci + 1) * COL_W - COL_W / 2}" y1="0" x2="${PAD + NODE_W / 2 + (ci + 1) * COL_W - COL_W / 2}" y2="${height}" stroke="#26282f" stroke-width="1"/>`).join('')}
|
||||
${edges.map(([a, b]) => `<path d="${edgePath(a, b)}" fill="none" stroke="#3a3d47" stroke-width="1.5"/>`).join('')}
|
||||
${nodes.map(nodeSvg).join('')}
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
container.querySelectorAll('.ap-node-g').forEach((g) => g.addEventListener('click', () => {
|
||||
const f = findings[Number(g.dataset.idx)];
|
||||
const isLive = container.id === 'liveAttackPath';
|
||||
const pocs = isLive ? (state.currentJob?.pocs || []) : (state.detailPocs || []);
|
||||
const runId = isLive ? state.currentJob?.runId : state.currentDetailId;
|
||||
if (f) openFindingModal(f, pocs, runId);
|
||||
}));
|
||||
}
|
||||
|
||||
function trimMid(s, n) {
|
||||
s = String(s || '');
|
||||
return s.length > n ? s.slice(0, n - 1) + '…' : s;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -712,7 +789,7 @@ async function loadDetail(id) {
|
||||
const tbody = $('#detailFindingsTable tbody');
|
||||
tbody.innerHTML = detail.findings.map((f, i) => findingRow(f, i)).join('');
|
||||
show($('#detailFindingsEmpty'), detail.findings.length === 0);
|
||||
renderAttackPath($('#detailAttackPath'), detail.findings);
|
||||
renderAttackPath($('#detailAttackPath'), detail.findings, target);
|
||||
const reportLink = $('#detailOpenReport');
|
||||
if (detail.assets.includes('report.html')) {
|
||||
reportLink.href = `/api/runs/${encodeURIComponent(id)}/asset/report.html`;
|
||||
|
||||
+8
-13
@@ -311,19 +311,14 @@ textarea { resize: vertical; min-height: 72px; }
|
||||
|
||||
/* Generative Attack Path Chaining */
|
||||
.attackpath-empty { font-size: 12.5px; color: var(--text-faint); padding: var(--sp-5); text-align: center; border: 1px dashed var(--border-strong); border-radius: var(--radius-sm); }
|
||||
.attackpath { display: flex; gap: var(--sp-4); overflow-x: auto; padding-bottom: var(--sp-3); }
|
||||
.ap-stage { flex: none; width: 220px; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.ap-stage-head { font-size: 10.5px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-faint); padding-bottom: var(--sp-2); border-bottom: 1px solid var(--border); }
|
||||
.ap-node { border: 1px solid var(--border); border-left: 3px solid var(--text-faint); border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3); background: var(--surface); font-size: 12px; }
|
||||
.ap-node.sev-critical { border-left-color: var(--sev-critical-fg); }
|
||||
.ap-node.sev-high { border-left-color: var(--sev-high-fg); }
|
||||
.ap-node.sev-medium { border-left-color: var(--sev-medium-fg); }
|
||||
.ap-node.sev-low { border-left-color: var(--sev-low-fg); }
|
||||
.ap-node.sev-info { border-left-color: var(--sev-info-fg); }
|
||||
.ap-node .t { font-weight: 600; }
|
||||
.ap-node .m { font-size: 10.5px; color: var(--text-faint); margin-top: 3px; font-family: var(--mono); }
|
||||
.ap-node .chain-from { font-size: 10.5px; color: var(--accent); margin-top: 3px; }
|
||||
.ap-arrow { flex: none; display: flex; align-items: center; color: var(--text-faint); font-size: 16px; }
|
||||
/* The graph canvas is intentionally fixed-dark regardless of the app theme —
|
||||
a node/edge map reads better with bright severity colors against a near-
|
||||
black surface, the way NodeZero/attack-graph tools render it, and it stays
|
||||
legible whether the rest of the console is in light or dark mode. */
|
||||
.ap-canvas-wrap { border-radius: var(--radius-md); overflow: auto; background: #0f1115; border: 1px solid #24262d; }
|
||||
.ap-canvas { display: block; min-width: 100%; }
|
||||
.ap-canvas text { font-family: var(--sans); }
|
||||
.ap-node-g:hover rect:first-child { filter: brightness(1.35); }
|
||||
|
||||
/* findings table */
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||
|
||||
Reference in New Issue
Block a user