From 7f365b4e88ec0cd8635a2453aa5a2f22d9cb0c5a Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Sun, 23 Aug 2026 15:21:41 -0300 Subject: [PATCH] feat(web): render the attack path as a real node graph, not flat cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_0129WdYHccPsH27k5GGuwijd --- web/public/app.js | 123 +++++++++++++++++++++++++++++++++++-------- web/public/style.css | 21 +++----- 2 files changed, 108 insertions(+), 36 deletions(-) diff --git a/web/public/app.js b/web/public/app.js index f748c3f..252a8bf 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -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 = '
The attack path builds automatically as findings chain together — nothing confirmed yet.
'; 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 ` + + 🎯 + ${esc(trimMid(n.label, 26))} + `; + } + const f = n.finding; + const color = canvasColor(f.severity); + const x = n.x - NODE_W / 2, y = n.y - NODE_H / 2; + return ` + + ${nodeIcon(f)} + ${esc(trimMid(f.title, 22))} + ${esc((f.mitre || f.owasp || f.cwe || n.stageLabel || '').slice(0, 26))} + + `; + }; + container.innerHTML = ` - ${!hasStages ? '
No kill-chain stage data yet — grouped by severity.
' : ''} -
- ${groups.map((g, i) => ` - ${i > 0 ? '
' : ''} -
-
${esc(g.label)} (${g.items.length})
- ${g.items.map((f) => ` -
-
${esc(f.title)}
-
${esc(f.mitre || f.owasp || f.cwe || '')}
- ${(f.chains_from || []).length ? `
⤷ chains from ${(f.chains_from).map((cid) => esc(byId.get(cid)?.title || cid)).join(', ')}
` : ''} -
- `).join('')} -
- `).join('')} + ${!hasStages ? '
No kill-chain stage data yet — shown as a flat graph from the target.
' : ''} +
+ + ${groups.map((g, ci) => ``).join('')} + ${edges.map(([a, b]) => ``).join('')} + ${nodes.map(nodeSvg).join('')} +
`; + 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`; diff --git a/web/public/style.css b/web/public/style.css index 2bc83fc..1122838 100644 --- a/web/public/style.css +++ b/web/public/style.css @@ -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; }