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.
' : ''}
+
+
`;
+ 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; }