mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-10-19 22:17:18 +02:00
v4.3.0: web console parity + report redaction + per-finding wire evidence & re-test
Harness / CLI - redact: credentials become $SECRET_n in every human-facing report (md/html/json/sarif/typ, findings.md); findings.json & vault keep raw values. - poc: `--only <id> --json` re-tests a single finding (powers the web Re-test). - version bump 4.2.4 → 4.3.0 (Cargo, CLI banner, web). Web console - Attack graph replaces "Generative Attack Path Chaining": one tab, findings ordered by kill-chain stage, click a finding to open it. Mounted in detail + live. - Finding modal: Wire evidence — the real request/response pairs behind a finding (baseline/attack, identity pair, matrix probes, oracle) with wire provenance, the wire-check summary, and recorded flows on the same endpoint. - Per-finding Re-test with dated history (reproduced/changed/gone/unverifiable). - Transcript viewer in the Traces tab (navigable blocks; search spans all agents). - Regression vs the previous run of the same target: new / recurring badges and a "fixed since" banner. - Chains tab: readable formatting (node refs as chips, relations as labels). - Custom lead: describe-it (Claude writes it) OR paste your own agent/system prompt (verbatim); remove a custom lead from the board. - GDPR added to compliance mapping; LLM API keys tab clarified; Reasoning-budget and Authorization layouts fixed; NeuroSploit logo added. Docs / config - README rewritten as a concise landing page; TUTORIAL.md updated to v4.3.0 with new web/graph/Neo4j/wire sections and first-time configuration. - .env.example: v4.3.0, Z.ai/Moonshot keys, Neo4j + graph-salt + capability env. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
79685cee49
commit
e9da74a981
13 files changed
+991
-1036
No files matched your search
+295
-19
@@ -247,9 +247,15 @@ function renderBoard() {
|
||||
</label>
|
||||
<span class="agent-title">${esc(a.title)}</span>
|
||||
${a.cwe ? `<span class="agent-cwe">${esc(a.cwe)}</span>` : ''}
|
||||
${String(a.id).startsWith('custom_') ? `<button class="agent-remove" data-remove="${esc(a.id)}" title="Remove this custom lead">✕</button>` : ''}
|
||||
`;
|
||||
rows.appendChild(row);
|
||||
}
|
||||
rows.querySelectorAll('.agent-remove').forEach((btn) => btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const a = group.agents.find((x) => x.id === btn.dataset.remove);
|
||||
removeCustomAgent(btn.dataset.remove, a?.title);
|
||||
}));
|
||||
card.querySelector('.cat-head').addEventListener('click', (e) => {
|
||||
if (e.target.closest('.switch')) return;
|
||||
const nowCollapsed = card.classList.toggle('collapsed');
|
||||
@@ -341,9 +347,18 @@ function renderCustomLeads() {
|
||||
|
||||
// `prompt()` gave a one-line box with no room to describe a lead, no way to
|
||||
// see the wizard behind it, and no place to report a generation failure.
|
||||
function setLeadMode(mode) {
|
||||
state.leadMode = mode;
|
||||
$$('#leadModeTabs .seg-tab').forEach((t) => t.classList.toggle('active', t.dataset.leadmode === mode));
|
||||
$$('[data-leadpanel]').forEach((p) => show(p, p.dataset.leadpanel === mode));
|
||||
$('#btnLeadGenerate').textContent = mode === 'own' ? 'Add lead' : 'Generate lead';
|
||||
}
|
||||
function openLeadModal() {
|
||||
$('#leadDesc').value = '';
|
||||
$('#leadName') && ($('#leadName').value = '');
|
||||
$('#leadOwn') && ($('#leadOwn').value = '');
|
||||
fieldError('#errLead', '');
|
||||
setLeadMode('describe');
|
||||
show($('#leadModal'), true);
|
||||
$('#leadDesc').focus();
|
||||
}
|
||||
@@ -352,14 +367,39 @@ $('#btnCustomLead').addEventListener('click', openLeadModal);
|
||||
$('#btnCloseLead').addEventListener('click', closeLeadModal);
|
||||
$('#btnLeadCancel').addEventListener('click', closeLeadModal);
|
||||
$('#leadModal').addEventListener('click', (e) => { if (e.target.id === 'leadModal') closeLeadModal(); });
|
||||
$$('#leadModeTabs .seg-tab').forEach((t) => t.addEventListener('click', () => setLeadMode(t.dataset.leadmode)));
|
||||
$('#leadDesc').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) $('#btnLeadGenerate').click();
|
||||
});
|
||||
$('#btnLeadGenerate').addEventListener('click', async () => {
|
||||
const text = $('#leadDesc').value.trim();
|
||||
if (!text) { fieldError('#errLead', 'Describe what the lead should test.'); return; }
|
||||
const mode = state.leadMode || 'describe';
|
||||
const btn = $('#btnLeadGenerate');
|
||||
const original = btn.textContent;
|
||||
// Mode B — the operator's own agent / system prompt, used verbatim.
|
||||
if (mode === 'own') {
|
||||
const md = $('#leadOwn').value.trim();
|
||||
const title = $('#leadName').value.trim();
|
||||
if (!md) { fieldError('#errLead', 'Paste your agent markdown or system prompt.'); return; }
|
||||
btn.disabled = true; btn.textContent = 'Adding…';
|
||||
try {
|
||||
const { agent } = await api('/api/leads/custom', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, markdown: md }),
|
||||
});
|
||||
await loadAgents();
|
||||
if (agent) state.selected.add(agent.id);
|
||||
renderBoard();
|
||||
closeLeadModal();
|
||||
toast(`Added and pinned: ${agent?.title || title || 'custom lead'}`, 'ok');
|
||||
} catch (e) {
|
||||
fieldError('#errLead', e.message);
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = original;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Mode A — Claude writes the agent from a description.
|
||||
const text = $('#leadDesc').value.trim();
|
||||
if (!text) { fieldError('#errLead', 'Describe what the lead should test.'); return; }
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Generating…';
|
||||
try {
|
||||
@@ -383,6 +423,20 @@ $('#btnLeadGenerate').addEventListener('click', async () => {
|
||||
btn.textContent = original;
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a custom lead (the operator's own custom_* agent file) from the board.
|
||||
async function removeCustomAgent(id, title) {
|
||||
if (!confirm(`Remove custom lead “${title || id}”? This deletes its agent file.`)) return;
|
||||
try {
|
||||
await api(`/api/leads/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
state.selected.delete(id);
|
||||
await loadAgents();
|
||||
renderBoard();
|
||||
toast('Custom lead removed', 'ok');
|
||||
} catch (e) {
|
||||
toast(`Could not remove: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
$('#btnExpandAll').addEventListener('click', () => {
|
||||
const anyCollapsed = $$('.cat-card').some((c) => c.classList.contains('collapsed'));
|
||||
state.expandedCats = anyCollapsed ? new Set(state.categories.map((g) => g.category)) : new Set();
|
||||
@@ -499,7 +553,7 @@ function renderReview() {
|
||||
{ k: 'Sandbox', v: $('#fieldSandbox').value ? 'Kali container' : 'host' },
|
||||
{ k: 'PoC re-validation', v: $('#fieldRevalidatePoc').checked ? 'on' : 'off' },
|
||||
{ k: 'TypeSafe', v: $('#fieldTypesafe') ? $('#fieldTypesafe').value : 'auto' },
|
||||
{ k: 'Compliance', v: (['fieldCompPci', 'fieldCompHipaa', 'fieldCompSoc2'].map((id) => $(`#${id}`).checked && $(`#${id}`).value).filter(Boolean).join(', ')) || 'none' },
|
||||
{ k: 'Compliance', v: (['fieldCompPci', 'fieldCompHipaa', 'fieldCompSoc2', 'fieldCompGdpr'].map((id) => $(`#${id}`).checked && $(`#${id}`).value).filter(Boolean).join(', ')) || 'none' },
|
||||
{ k: 'Target auth', v: state.auth.header ? 'header set' : (state.auth.roles.length ? `${state.auth.roles.length} role(s)` : 'none') },
|
||||
];
|
||||
$('#reviewGrid').innerHTML = items.map((it) => `
|
||||
@@ -547,7 +601,7 @@ async function startExploitation() {
|
||||
sandbox: $('#fieldSandbox').value || undefined,
|
||||
revalidatePoc: $('#fieldRevalidatePoc').checked,
|
||||
typesafe: $('#fieldTypesafe') ? $('#fieldTypesafe').value : undefined,
|
||||
compliance: ['fieldCompPci', 'fieldCompHipaa', 'fieldCompSoc2'].map((id) => $(`#${id}`).checked && $(`#${id}`).value).filter(Boolean),
|
||||
compliance: ['fieldCompPci', 'fieldCompHipaa', 'fieldCompSoc2', 'fieldCompGdpr'].map((id) => $(`#${id}`).checked && $(`#${id}`).value).filter(Boolean),
|
||||
tokenLimit: Number($('#fieldTokenLimit').value) || undefined,
|
||||
order: $('#fieldOrder').value,
|
||||
samplePerRoute: Number($('#fieldSampleRoute').value) || undefined,
|
||||
@@ -742,9 +796,12 @@ function reviewStatus(f) {
|
||||
function findingRow(f, idx) {
|
||||
const w = wireState(f);
|
||||
const st = reviewStatus(f);
|
||||
const reg = state.detailRegression?.status?.[f.id];
|
||||
const regBadge = reg === 'recurring' ? '<span class="reg-badge reg-recurring" title="Also present in the previous run of this target">recurring</span>'
|
||||
: reg === 'new' ? '<span class="reg-badge reg-new" title="Not in the previous run of this target">new</span>' : '';
|
||||
return `<tr data-idx="${idx}">
|
||||
<td><span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span></td>
|
||||
<td>${esc(f.title)}</td>
|
||||
<td>${esc(f.title)}${regBadge}</td>
|
||||
<td class="col-endpoint" title="${esc(f.endpoint)}">${esc(f.endpoint)}</td>
|
||||
<td class="col-cwe">${esc(f.cwe)}</td>
|
||||
<td><span class="status-badge st-${esc(st)}">${esc(st)}</span></td>
|
||||
@@ -1064,11 +1121,124 @@ function openFindingModal(f, pocs, runId) {
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
// Wire evidence — the real request/response pairs behind the finding, plus
|
||||
// its re-test history. Loaded on demand; the section hides itself when the
|
||||
// finding carries no replayable exchange and has never been re-tested.
|
||||
state.fmFinding = { f, runId };
|
||||
loadWireEvidence(f, runId);
|
||||
|
||||
show($('#findingModal'), true);
|
||||
}
|
||||
$('#btnCloseFinding').addEventListener('click', () => show($('#findingModal'), false));
|
||||
$('#findingModal').addEventListener('click', (e) => { if (e.target.id === 'findingModal') show($('#findingModal'), false); });
|
||||
|
||||
// ---- wire evidence + per-finding re-test -----------------------------------
|
||||
|
||||
const PROV_LABEL = {
|
||||
'wire:replay': 'wire · harness replay', 'wire:recorded': 'wire · interceptor',
|
||||
'wire:harness': 'wire · harness', '': 'agent-reported',
|
||||
};
|
||||
const REPRO_CLASS = { reproduced: 'ok', changed: 'warn', gone: 'bad', unverifiable: 'muted' };
|
||||
|
||||
async function loadWireEvidence(f, runId) {
|
||||
const summary = $('#fmWireSummary');
|
||||
const exRoot = $('#fmWireExchanges');
|
||||
summary.innerHTML = '';
|
||||
exRoot.innerHTML = '<div class="field-help">loading…</div>';
|
||||
let data = {};
|
||||
try { data = await api(`/api/runs/${encodeURIComponent(runId)}/findings/${encodeURIComponent(f.id)}/traffic`); } catch { data = {}; }
|
||||
// A later modal may have opened while this was in flight.
|
||||
if (state.fmFinding?.f?.id !== f.id) return;
|
||||
|
||||
const w = data.wire;
|
||||
if (w) {
|
||||
const chips = [];
|
||||
if (w.reproduced) chips.push(`<span class="wire-chip ok">${w.reproduced} reproduced by replay</span>`);
|
||||
if (w.corroborated) chips.push(`<span class="wire-chip ok">${w.corroborated} corroborated on wire</span>`);
|
||||
if (w.harness_observed) chips.push(`<span class="wire-chip ok">${w.harness_observed} harness-observed</span>`);
|
||||
if (w.unverified) chips.push(`<span class="wire-chip muted">${w.unverified} unverifiable</span>`);
|
||||
(w.contradicted || []).forEach((c) => chips.push(`<span class="wire-chip bad" title="${esc(c)}">contradicted</span>`));
|
||||
(w.absent_from_capture || []).length && chips.push(`<span class="wire-chip warn">${w.absent_from_capture.length} not seen by interceptor</span>`);
|
||||
summary.innerHTML = `<div class="wire-chips">${chips.join('')}</div>` +
|
||||
((w.contradicted || []).length ? `<ul class="wire-contra">${w.contradicted.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>` : '');
|
||||
}
|
||||
|
||||
const ex = data.exchanges || [];
|
||||
const flows = data.flows || [];
|
||||
exRoot.innerHTML =
|
||||
(ex.length ? ex.map(exchangeCard).join('') : '') +
|
||||
(flows.length ? `<div class="wire-sub">Recorded flows on this endpoint (${flows.length})</div>` + flows.map(flowRow).join('') : '') +
|
||||
(!ex.length && !flows.length ? '<div class="field-help">No replayable exchange recorded for this finding. Re-test sends its proof again if one exists.</div>' : '');
|
||||
|
||||
loadRetestHistory(f.id, runId);
|
||||
}
|
||||
|
||||
function exchangeCard(ex) {
|
||||
const prov = PROV_LABEL[ex.provenance] ?? ex.provenance ?? 'agent-reported';
|
||||
const wireCls = String(ex.provenance || '').startsWith('wire:') ? 'ok' : 'muted';
|
||||
const st = ex.status >= 500 ? 'tf-5' : ex.status >= 400 ? 'tf-4' : ex.status >= 300 ? 'tf-3' : ex.status >= 200 ? 'tf-2' : 'tf-0';
|
||||
const reqH = Object.entries(ex.request_headers || {}).map(([k, v]) => `${k}: ${v}`).join('\n');
|
||||
const resH = Object.entries(ex.headers || {}).map(([k, v]) => `${k}: ${v}`).join('\n');
|
||||
const body = (ex.body || '').slice(0, 4000);
|
||||
return `<details class="wire-ex">
|
||||
<summary>
|
||||
<span class="wire-role">${esc(ex.role || '')}</span>
|
||||
<span class="method">${esc(ex.method || 'GET')}</span>
|
||||
<span class="wire-url" title="${esc(ex.url)}">${esc(ex.url || '')}</span>
|
||||
<span class="tf ${st}">${ex.status || '—'}</span>
|
||||
${ex.identity ? `<span class="wire-id">as ${esc(ex.identity)}</span>` : ''}
|
||||
<span class="wire-prov ${wireCls}">${esc(prov)}</span>
|
||||
</summary>
|
||||
<div class="wire-ex-body">
|
||||
${reqH ? `<div class="wire-h"><b>Request headers</b><pre class="poc-pre">${esc(reqH)}</pre></div>` : ''}
|
||||
${resH ? `<div class="wire-h"><b>Response headers</b><pre class="poc-pre">${esc(resH)}</pre></div>` : ''}
|
||||
${body ? `<div class="wire-h"><b>Response body${ex.body.length > 4000 ? ' (first 4 KB)' : ''}</b><pre class="poc-pre">${esc(body)}</pre></div>` : ''}
|
||||
</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function flowRow(r) {
|
||||
const st = r.status >= 500 ? 'tf-5' : r.status >= 400 ? 'tf-4' : r.status >= 300 ? 'tf-3' : r.status >= 200 ? 'tf-2' : 'tf-0';
|
||||
return `<div class="wire-flow"><span class="method">${esc(r.method || '')}</span>
|
||||
<span class="wire-url" title="${esc(r.url)}">${esc(r.url || '')}</span>
|
||||
<span class="tf ${st}">${r.tunnelled ? 'TLS' : r.status || '—'}</span></div>`;
|
||||
}
|
||||
|
||||
async function loadRetestHistory(fid, runId) {
|
||||
const root = $('#fmRetestHistory');
|
||||
let h = [];
|
||||
try { h = (await api(`/api/runs/${encodeURIComponent(runId)}/retests/${encodeURIComponent(fid)}`)).history || []; } catch { h = []; }
|
||||
if (state.fmFinding?.f?.id !== fid) return;
|
||||
renderRetestHistory(h);
|
||||
}
|
||||
|
||||
function renderRetestHistory(h) {
|
||||
const root = $('#fmRetestHistory');
|
||||
if (!h.length) { root.innerHTML = ''; return; }
|
||||
root.innerHTML = `<div class="retest-hist"><div class="wire-sub">Re-test history</div>${h.slice().reverse().map((e) => {
|
||||
const cls = REPRO_CLASS[e.reproduction] || 'muted';
|
||||
return `<div class="retest-row"><span class="wire-chip ${cls}">${esc(e.reproduction || 'error')}</span>
|
||||
<span class="retest-when">${e.at ? new Date(e.at * 1000).toLocaleString() : ''}</span>
|
||||
<span class="retest-detail" title="${esc(e.detail || e.raw || '')}">${esc(e.detail || e.raw || '')}</span></div>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
||||
$('#btnFindingRetest').addEventListener('click', async (e) => {
|
||||
const cur = state.fmFinding;
|
||||
if (!cur?.f || !cur.runId) return;
|
||||
const btn = e.currentTarget;
|
||||
btn.disabled = true; const label = btn.textContent; btn.textContent = '⟳ re-testing…';
|
||||
try {
|
||||
const r = await api(`/api/runs/${encodeURIComponent(cur.runId)}/retest`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ finding: cur.f.id }) });
|
||||
if (state.fmFinding?.f?.id === cur.f.id) renderRetestHistory(r.history || []);
|
||||
toast(`Re-test: ${r.result?.reproduction || 'done'}`, (REPRO_CLASS[r.result?.reproduction] === 'bad' ? 'error' : 'ok'));
|
||||
} catch (err) {
|
||||
toast(`Re-test failed: ${err.message}`, 'error');
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = label;
|
||||
}
|
||||
});
|
||||
|
||||
/// Exactly where the problem is: parameter/field/flow, not just the URL.
|
||||
function locationLine(f) {
|
||||
const loc = (f.location || '').trim();
|
||||
@@ -1403,6 +1573,32 @@ function openRun(run) {
|
||||
renderSidebar();
|
||||
}
|
||||
|
||||
async function loadRegression(id, token) {
|
||||
const box = $('#detailRegression');
|
||||
let r = null;
|
||||
try { r = await api(`/api/runs/${encodeURIComponent(id)}/regression`); } catch { r = null; }
|
||||
if (token !== state.detailToken) return;
|
||||
if (!r || !r.prev) { state.detailRegression = null; show(box, false); return; }
|
||||
state.detailRegression = r;
|
||||
renderFindings('detail'); // repaint so the new/recurring badges show
|
||||
// Count over the findings actually shown, so the banner matches the table
|
||||
// even when a run's findings.json reuses an id.
|
||||
const cur = state.detailFindings || [];
|
||||
const recurring = cur.filter((f) => r.status?.[f.id] === 'recurring').length;
|
||||
const fresh = cur.filter((f) => r.status?.[f.id] === 'new').length;
|
||||
const fixed = r.fixed || [];
|
||||
const when = r.prev.when ? timeAgo(Math.floor(new Date(r.prev.when).getTime() / 1000)) : '';
|
||||
box.innerHTML = `<div class="reg-banner">
|
||||
<span class="reg-h">vs previous run</span>
|
||||
<span class="muted">${esc(when)}</span>
|
||||
<span class="reg-chip reg-new">${fresh} new</span>
|
||||
<span class="reg-chip reg-recurring">${recurring} recurring</span>
|
||||
<span class="reg-chip reg-fixed">${fixed.length} fixed</span>
|
||||
${fixed.length ? `<details class="reg-fixed-list"><summary>fixed since</summary><ul>${fixed.map((x) => `<li><span class="sev ${sevClass(x.severity)}">${esc(x.severity)}</span> ${esc(x.title)}</li>`).join('')}</ul></details>` : ''}
|
||||
</div>`;
|
||||
show(box, true);
|
||||
}
|
||||
|
||||
async function loadDetail(id) {
|
||||
clearInterval(state.detailPoll);
|
||||
// A poll for the previously opened run may still be in flight; when it
|
||||
@@ -1424,7 +1620,11 @@ async function loadDetail(id) {
|
||||
$('#detailState').textContent = detail.status?.state || 'unknown';
|
||||
state.detailFindings = detail.findings;
|
||||
state.detailPocs = detail.pocs || [];
|
||||
// Regression vs the previous run of this target — new / recurring badges and a
|
||||
// "fixed since" banner. Best-effort: it never blocks the findings table.
|
||||
state.detailRegression = null;
|
||||
renderFindings('detail');
|
||||
loadRegression(id, token);
|
||||
// A past run's identity: when it ran, how many agents, what asset the recon
|
||||
// decided it was. Previously the page showed only target + state, so two
|
||||
// runs of the same target were indistinguishable.
|
||||
@@ -2614,21 +2814,42 @@ function renderChains(x, g) {
|
||||
<span class="chain-meta">conf ${Number(c.confidence || 0).toFixed(2)} · blast ${c.blast_radius} · ${esc(c.exploitability)}</span>
|
||||
</summary>
|
||||
<div class="chain-body">
|
||||
<div class="chain-impact"><b>Impact:</b> ${esc(c.impact)}</div>
|
||||
<ol class="chain-steps">${(c.step_text || []).map((s, j) => `<li class="step st-${esc(c.steps?.[j]?.status || '')}">${esc(s)}</li>`).join('')}</ol>
|
||||
${block('Findings involved', (c.findings || []).map((f) => `${f.title} — ${f.severity} (${f.status})`))}
|
||||
${block('Evidence references', c.evidence, 'none recorded — this chain is unproven')}
|
||||
${block('Required assumptions', c.assumptions, 'none — every step is evidenced')}
|
||||
${block('Recommended validation', c.validation, 'none needed — confirmed end to end')}
|
||||
${block('Recommended remediation', c.remediation)}
|
||||
<div class="chain-impact"><b>Impact:</b> ${chainText(c.impact)}</div>
|
||||
<ol class="chain-steps">${(c.step_text || []).map((s, j) => `<li class="step st-${esc(c.steps?.[j]?.status || '')}">${chainText(s)}</li>`).join('')}</ol>
|
||||
${block('Findings involved', (c.findings || []).map((f) => `${chainText(f.title)} <span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span> <span class="chain-note">${esc(f.status)}</span>`), null, true)}
|
||||
${block('Evidence references', (c.evidence || []).map(chainText), 'none recorded — this chain is unproven', true)}
|
||||
${block('Required assumptions', (c.assumptions || []).map(chainText), 'none — every step is evidenced', true)}
|
||||
${block('Recommended validation', (c.validation || []).map(chainText), 'none needed — confirmed end to end', true)}
|
||||
${block('Recommended remediation', (c.remediation || []).map(chainText), null, true)}
|
||||
</div>
|
||||
</details>`).join('');
|
||||
}
|
||||
|
||||
function block(title, items, emptyText) {
|
||||
// Turn the graph's prose into readable markup: node names (backtick-quoted) become
|
||||
// trimmed code references, relation tokens (—[ESCALATES_TO]→) become labelled
|
||||
// chips, and the "(needs_validation, conf 0.80)" tail becomes a muted note.
|
||||
function chainText(s) {
|
||||
let e = esc(String(s || ''));
|
||||
// Node references: `…` → <code>, with mid-word ellipsis trimmed to a word edge.
|
||||
e = e.replace(/`([^`]*)`/g, (_m, inner) => {
|
||||
let t = inner;
|
||||
if (t.endsWith('…')) t = t.replace(/\s*\S*…$/, '…');
|
||||
return `<code class="chain-ref" title="${inner}">${t}</code>`;
|
||||
});
|
||||
// Generic node-type words before a reference are noise once it is a code chip.
|
||||
e = e.replace(/\b(?:Finding|BusinessImpact|Host|Service|Endpoint|Credential|Identity|AttackStep|Vulnerability)\s+(?=<code)/g, '');
|
||||
// Relation tokens, em-dash or ASCII arrow form.
|
||||
e = e.replace(/\s*(?:—|-)\[([A-Z_]+)\](?:→|->|->)\s*/g, ' <span class="chain-rel">$1</span> ');
|
||||
// Trailing status/confidence note.
|
||||
e = e.replace(/\(([a-z_]+,\s*conf\s*[0-9.]+)\)/g, '<span class="chain-note">$1</span>');
|
||||
return e;
|
||||
}
|
||||
|
||||
function block(title, items, emptyText, html) {
|
||||
const arr = items || [];
|
||||
if (!arr.length && !emptyText) return '';
|
||||
return `<div class="chain-block"><div class="chain-block-h">${esc(title)}</div><ul>${arr.length ? arr.map((v) => `<li>${esc(v)}</li>`).join('') : `<li class="muted">${esc(emptyText)}</li>`}</ul></div>`;
|
||||
const cell = (v) => html ? v : esc(v);
|
||||
return `<div class="chain-block"><div class="chain-block-h">${esc(title)}</div><ul>${arr.length ? arr.map((v) => `<li>${cell(v)}</li>`).join('') : `<li class="muted">${esc(emptyText)}</li>`}</ul></div>`;
|
||||
}
|
||||
|
||||
// ---- dependency todolist ---------------------------------------------------
|
||||
@@ -2887,14 +3108,69 @@ async function extrasTraces(x) {
|
||||
x.count('traces', (r.traces || []).length);
|
||||
show(x.q('tr-empty'), !(r.traces || []).length);
|
||||
const base = `/api/runs/${encodeURIComponent(id)}/asset/`;
|
||||
x.q('tr-files').innerHTML = (r.traces || []).map((t) => `<li><a href="${base}${encodeURI(t.file)}" target="_blank">${esc(t.agent)}</a><span class="muted">${(t.bytes / 1024).toFixed(1)} KB</span></li>`).join('');
|
||||
x.q('tr-note').textContent = qv ? `${r.hits.length}${r.hits.length >= 200 ? '+' : ''} hit(s) for “${qv}”` : `${(r.traces || []).length} agent trace(s) — search across all of them`;
|
||||
x.q('tr-files').innerHTML = (r.traces || []).map((t) =>
|
||||
`<li><button class="trace-file-btn${x.trAgent === t.agent ? ' active' : ''}" data-agent="${esc(t.agent)}" data-file="${esc(t.file)}">${esc(t.agent)}</button><span class="muted">${(t.bytes / 1024).toFixed(1)} KB</span></li>`).join('');
|
||||
$$('.trace-file-btn', x.q('tr-files')).forEach((b) => b.addEventListener('click', () => showTranscript(x, b.dataset.agent, b.dataset.file)));
|
||||
x.q('tr-note').textContent = qv ? `${r.hits.length}${r.hits.length >= 200 ? '+' : ''} hit(s) for “${qv}”` : `${(r.traces || []).length} agent trace(s) — click one to read it, or search across all`;
|
||||
const re = qv ? new RegExp(qv.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'ig') : null;
|
||||
x.q('tr-hits').innerHTML = qv
|
||||
? (r.hits.length ? r.hits.map((h) => `<div class="trace-hit"><a href="${base}${encodeURI(h.file)}" target="_blank">${esc(h.agent)}</a><span class="muted">:${h.line}</span><code>${esc(h.snippet).replace(re, (m) => `<mark>${m}</mark>`)}</code></div>`).join('') : '<div class="muted">No agent saw that.</div>')
|
||||
: '<div class="muted">Type a keyword — an error string, a parameter, a version — to see which agents already observed it.</div>';
|
||||
if (qv) {
|
||||
x.trAgent = null;
|
||||
x.q('tr-hits').innerHTML = r.hits.length
|
||||
? r.hits.map((h) => `<div class="trace-hit"><button class="trace-file-btn" data-agent="${esc(h.agent)}" data-file="${esc(h.file)}" data-line="${h.line}">${esc(h.agent)}</button><span class="muted">:${h.line}</span><code>${esc(h.snippet).replace(re, (m) => `<mark>${m}</mark>`)}</code></div>`).join('')
|
||||
: '<div class="muted">No agent saw that.</div>';
|
||||
$$('.trace-file-btn', x.q('tr-hits')).forEach((b) => b.addEventListener('click', () => showTranscript(x, b.dataset.agent, b.dataset.file, qv)));
|
||||
} else if (x.trAgent) {
|
||||
const cur = (r.traces || []).find((t) => t.agent === x.trAgent);
|
||||
if (cur) showTranscript(x, cur.agent, cur.file);
|
||||
else x.q('tr-hits').innerHTML = '<div class="muted">Click an agent on the left to read its transcript.</div>';
|
||||
} else {
|
||||
x.q('tr-hits').innerHTML = '<div class="muted">Click an agent on the left to read its transcript, or type a keyword to search across all of them.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// A transcript is mostly target output and tool noise. Render it readable:
|
||||
// group by the tool-call / HTTP-request boundaries the harness writes, and let
|
||||
// each block collapse, so a long run is navigable rather than one wall of text.
|
||||
const TR_STEP_RE = /^(?:#{1,4}\s|\$\s|>\s|(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|CONNECT)\s+\S|(?:TOOL|STEP|AGENT|ROUND|PHASE)\b|={3,}|-{3,}\s*$)/i;
|
||||
|
||||
async function showTranscript(x, agent, file, highlight) {
|
||||
x.trAgent = agent;
|
||||
$$('.trace-file-btn', x.q('tr-files')).forEach((b) => b.classList.toggle('active', b.dataset.agent === agent));
|
||||
const hits = x.q('tr-hits');
|
||||
hits.innerHTML = '<div class="field-help">loading…</div>';
|
||||
const id = x.getRunId();
|
||||
let text = '';
|
||||
try { text = await (await fetch(`/api/runs/${encodeURIComponent(id)}/asset/${encodeURI(file)}`)).text(); } catch { hits.innerHTML = '<div class="muted">Could not load trace.</div>'; return; }
|
||||
if (x.trAgent !== agent) return;
|
||||
text = stripAnsiJs(text);
|
||||
const lines = text.split('\n');
|
||||
// Split into blocks at each step/request boundary.
|
||||
const blocks = [];
|
||||
let cur = null;
|
||||
for (const line of lines) {
|
||||
if (TR_STEP_RE.test(line) || !cur) { cur = { head: line, body: [] }; blocks.push(cur); }
|
||||
else cur.body.push(line);
|
||||
}
|
||||
const re = highlight ? new RegExp(highlight.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'ig') : null;
|
||||
const mark = (s) => { const e = esc(s); return re ? e.replace(re, (m) => `<mark>${m}</mark>`) : e; };
|
||||
hits.innerHTML = `<div class="transcript">
|
||||
<div class="transcript-head"><b>${esc(agent)}</b><span class="muted">${blocks.length} block(s) · ${lines.length} line(s)</span>
|
||||
<a class="btn btn-sm" href="/api/runs/${encodeURIComponent(id)}/asset/${encodeURI(file)}" target="_blank">Open raw</a></div>
|
||||
${blocks.map((b) => {
|
||||
const body = b.body.join('\n').replace(/\s+$/, '');
|
||||
const open = re && (re.test(b.head) || re.test(body));
|
||||
re && (re.lastIndex = 0);
|
||||
return `<details class="transcript-block"${open ? ' open' : ''}>
|
||||
<summary>${mark(b.head.slice(0, 200)) || '<span class="muted">(blank)</span>'}</summary>
|
||||
${body ? `<pre class="poc-pre">${mark(body)}</pre>` : ''}
|
||||
</details>`;
|
||||
}).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/// Strip ANSI escapes client-side (traces are served raw).
|
||||
function stripAnsiJs(s) { return String(s || '').replace(/\u001b\[[0-9;]*m/g, ''); }
|
||||
|
||||
// ---- deliverables ----------------------------------------------------------
|
||||
|
||||
const DELIV_INFO = {
|
||||
|
||||
+55
-17
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>NeuroSploit v4.2.4 — Console</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🧠</text></svg>">
|
||||
<title>NeuroSploit v4.3.0 — Console</title>
|
||||
<link rel="icon" type="image/svg+xml" href="logo.svg">
|
||||
<link rel="stylesheet" href="/vendor/xterm.css" />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
</head>
|
||||
@@ -18,7 +18,20 @@
|
||||
<!-- ============ SIDEBAR ============ -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sb-top">
|
||||
<div class="brand"><span class="mark">NS</span> NeuroSploit</div>
|
||||
<div class="brand">
|
||||
<svg class="mark" viewBox="0 0 48 48" fill="none" aria-hidden="true">
|
||||
<path d="M9 39V11l20 20V11" stroke="currentColor" stroke-width="5.5" stroke-linecap="square" stroke-linejoin="miter"/>
|
||||
<path d="M39 9V37" stroke="currentColor" stroke-width="5.5" stroke-linecap="square"/>
|
||||
<g stroke="#22a8ff" stroke-width="2.4" fill="#22a8ff">
|
||||
<path d="M12 16h7l6 6h8" fill="none"/>
|
||||
<circle cx="11" cy="16" r="3.4"/><circle cx="34" cy="22" r="3.4"/>
|
||||
<circle cx="38" cy="10" r="3.4"/><circle cx="15" cy="37" r="3.4"/>
|
||||
<path d="M18 34l-3 3" fill="none"/>
|
||||
</g>
|
||||
<path d="M41 7L18 41" stroke="#22a8ff" stroke-width="2.6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
NeuroSploit
|
||||
</div>
|
||||
<button class="icon-btn" id="btnThemeToggle" title="Toggle light / dark theme">☾</button>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +46,7 @@
|
||||
<div class="sb-groups" id="sbGroups"><!-- populated by app.js --></div>
|
||||
|
||||
<div class="sb-bottom">
|
||||
<span class="sb-version" id="sbVersion">v4.2.4</span>
|
||||
<span class="sb-version" id="sbVersion">v4.3.0</span>
|
||||
<div class="sb-bottom-actions">
|
||||
<button class="icon-btn" id="btnOpenAuth" title="Auth & API keys">🔑</button>
|
||||
<button class="icon-btn" id="btnOpenRepl" title="Open terminal (Ctrl+`)">❭_</button>
|
||||
@@ -220,13 +233,13 @@
|
||||
<div class="section-title">Reasoning budget</div>
|
||||
<div class="section-desc">Optional. Left on <em>unlimited</em>, the run behaves exactly as it always has — full depth, no cap.</div>
|
||||
</div>
|
||||
<div class="check-col">
|
||||
<div class="check-row"><input type="checkbox" id="fieldResearch" /> <label for="fieldResearch"><b>🔬 Research mode</b> — hunt a novel, CVE-reportable bug (whitebox/greybox)</label></div>
|
||||
<div class="field-help check-help">Known-CVE de-dup + patch-diff variant analysis. Best with a source repo; reports only genuinely new issues (or a concrete patch bypass).</div>
|
||||
<div class="check-row check-quick"><input type="checkbox" id="fieldQuick" /> <label for="fieldQuick"><b>⚡ Quick mode</b> — short, low-cost test</label></div>
|
||||
<div class="field-help check-help">Economy preset: 1 voter, 1 chain round, light recon, ≤6 agents, eco budget. The big token saver. Wins over the settings below.</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="check-row"><input type="checkbox" id="fieldResearch" /> <label for="fieldResearch"><b>🔬 Research mode</b> — hunt a novel, CVE-reportable bug (whitebox/greybox)</label>
|
||||
<div class="field-help">Known-CVE de-dup + patch-diff variant analysis. Best with a source repo; reports only genuinely new issues (or a concrete patch bypass).</div>
|
||||
</div>
|
||||
<div class="check-row check-quick"><input type="checkbox" id="fieldQuick" /> <label for="fieldQuick"><b>⚡ Quick mode</b> — short, low-cost test</label>
|
||||
<div class="field-help">Economy preset: 1 voter, 1 chain round, light recon, ≤6 agents, eco budget. The big token saver. Wins over the settings below.</div>
|
||||
</div>
|
||||
<div class="field-group"><label class="field-label" for="fieldBudget">Budget mode</label>
|
||||
<select class="narrow" id="fieldBudget">
|
||||
<option value="unlimited" selected>unlimited · full run (default)</option>
|
||||
@@ -289,6 +302,7 @@
|
||||
<div class="check-row"><input type="checkbox" id="fieldCompPci" value="pci-dss" /> <label for="fieldCompPci">PCI-DSS v4.0</label></div>
|
||||
<div class="check-row"><input type="checkbox" id="fieldCompHipaa" value="hipaa" /> <label for="fieldCompHipaa">HIPAA Security Rule</label></div>
|
||||
<div class="check-row"><input type="checkbox" id="fieldCompSoc2" value="soc2" /> <label for="fieldCompSoc2">SOC 2 (Trust Services Criteria)</label></div>
|
||||
<div class="check-row"><input type="checkbox" id="fieldCompGdpr" value="gdpr" /> <label for="fieldCompGdpr">GDPR (data-protection articles)</label></div>
|
||||
<div class="field-help">Maps confirmed findings onto control requirements in the report. Indicates gaps for an assessor — never a compliance verdict.</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -414,6 +428,7 @@
|
||||
<button class="btn btn-sm" id="btnExportCsv" title="Export the filtered findings as CSV">⤓ CSV</button>
|
||||
</div>
|
||||
<div class="kpi-row" id="detailKpis"></div>
|
||||
<div id="detailRegression" hidden></div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="detailFindingsTable">
|
||||
<thead><tr><th data-sort="severity">Severity</th><th data-sort="title">Title</th><th data-sort="endpoint">Endpoint</th><th data-sort="cwe">CWE</th><th data-sort="review_status">Status</th><th data-sort="wire">Wire</th><th data-sort="agent">Agent</th><th data-sort="confidence" class="col-conf">Conf.</th></tr></thead>
|
||||
@@ -460,7 +475,7 @@
|
||||
</div>
|
||||
<div class="modal-tabs">
|
||||
<button class="modal-tab active" data-mtab="target">Target auth</button>
|
||||
<button class="modal-tab" data-mtab="keys">API keys</button>
|
||||
<button class="modal-tab" data-mtab="keys">LLM API keys</button>
|
||||
<button class="modal-tab" data-mtab="creds">Creds file</button>
|
||||
<button class="modal-tab" data-mtab="authz">Authorization</button>
|
||||
</div>
|
||||
@@ -479,7 +494,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-panel" data-mpanel="keys" hidden>
|
||||
<div class="field-help" style="margin-bottom:12px;">Keys are kept in this server process's memory only — never written to disk. Cleared on restart.</div>
|
||||
<div class="section-title">Model provider keys</div>
|
||||
<div class="field-help" style="margin:4px 0 12px;">The API keys for the <b>LLMs that run the engagement</b> (not the target's auth — that's the <em>Target auth</em> tab). Kept in this server process's memory only, never written to disk, cleared on restart.</div>
|
||||
<div id="providerKeyList"></div>
|
||||
</div>
|
||||
<div class="modal-panel" data-mpanel="authz" hidden>
|
||||
@@ -500,11 +516,11 @@
|
||||
<div class="field-help">Without this the engagement is authorized against the target and nothing else — discovering a host is not permission to test it.</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title" style="margin-top:20px;display:flex;align-items:center;gap:8px;">
|
||||
<div class="section-title" style="margin-top:24px;margin-bottom:8px;display:flex;align-items:center;gap:8px;">
|
||||
Scoping & Guardrails
|
||||
<span class="pill" style="font-size:10px;">enforced in code</span>
|
||||
</div>
|
||||
<div class="field-help" style="margin:-6px 0 10px;">
|
||||
<div class="field-help" style="margin:0 0 14px;">
|
||||
The <b>hard scope</b> is the boundary: a request whose host is not listed is <b>refused before it is sent</b> — not warned about. Exclusions always win. A capability token still caps all of this. Leave the hard list empty to keep the plain target + <em>Additional authorized hosts</em> behaviour.
|
||||
</div>
|
||||
<div class="field-group">
|
||||
@@ -628,16 +644,28 @@
|
||||
<button class="icon-btn" id="btnCloseLead">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field-group">
|
||||
<div class="field-help" style="margin-bottom:14px;">A <b>lead</b> is a specialist agent the harness runs. Add one two ways — let Claude write it from a description, or paste your own agent / system prompt to run verbatim. Either way it is saved as a real, pinnable agent in <code>agents_md/vulns/</code> and you can remove it later.</div>
|
||||
<div class="seg-tabs" id="leadModeTabs">
|
||||
<button class="seg-tab active" data-leadmode="describe">Describe it</button>
|
||||
<button class="seg-tab" data-leadmode="own">Paste my own</button>
|
||||
</div>
|
||||
<div class="field-group" data-leadpanel="describe">
|
||||
<label class="field-label" for="leadDesc">What should this lead test?</label>
|
||||
<textarea id="leadDesc" placeholder="e.g. Abuse the invoice PDF export to read internal files via the template engine."></textarea>
|
||||
<div class="field-help">Claude (Opus, subscription) generates a real specialist agent file for it and pins it. If generation is unavailable, the text is folded into <code>--focus</code> instead.</div>
|
||||
<div class="field-help">Claude (Opus, subscription) writes a real specialist agent for it and pins it. If generation is unavailable, the text is folded into <code>--focus</code> instead.</div>
|
||||
</div>
|
||||
<div class="field-group" data-leadpanel="own" hidden>
|
||||
<label class="field-label" for="leadName">Name</label>
|
||||
<input id="leadName" type="text" placeholder="e.g. GraphQL Introspection Abuse" />
|
||||
<label class="field-label" for="leadOwn" style="margin-top:10px;">Your agent / system prompt</label>
|
||||
<textarea id="leadOwn" rows="10" placeholder="Paste a full agent markdown (# Title / ## User Prompt / ## System Prompt), or just your system prompt — it will be wrapped into an agent. Use {target} and {recon_json} placeholders."></textarea>
|
||||
<div class="field-help">Used verbatim — no model rewrites it. {target} and {recon_json} are filled per run.</div>
|
||||
</div>
|
||||
<div class="field-error" id="errLead" hidden></div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn" id="btnLeadCancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="btnLeadGenerate">Generate lead</button>
|
||||
<button class="btn btn-primary" id="btnLeadGenerate">Add lead</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -660,6 +688,16 @@
|
||||
<div id="fmSection-remediation"></div>
|
||||
<div id="fmSection-chains"></div>
|
||||
|
||||
<div class="field-group" id="fmSection-wire">
|
||||
<div class="fm-wire-head">
|
||||
<label class="field-label">Wire evidence — the bytes behind this finding</label>
|
||||
<button class="btn btn-sm" id="btnFindingRetest">⟳ Re-test this finding</button>
|
||||
</div>
|
||||
<div id="fmWireSummary"></div>
|
||||
<div id="fmRetestHistory"></div>
|
||||
<div id="fmWireExchanges" class="wire-exchanges"></div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label">Other scripts from this run</label>
|
||||
<div id="fmPocList" class="field-help">—</div>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" color="#0b1220" role="img" aria-label="NeuroSploit">
|
||||
<!-- angular N monogram (uses currentColor so it follows the theme) -->
|
||||
<path d="M9 39V11l20 20V11" stroke="currentColor" stroke-width="5.5" stroke-linecap="square" stroke-linejoin="miter"/>
|
||||
<path d="M39 9V37" stroke="currentColor" stroke-width="5.5" stroke-linecap="square"/>
|
||||
<!-- cyan circuit trace + nodes -->
|
||||
<g stroke="#22a8ff" stroke-width="2.4" fill="#22a8ff">
|
||||
<path d="M12 16h7l6 6h8" fill="none"/>
|
||||
<circle cx="11" cy="16" r="3.4"/>
|
||||
<circle cx="34" cy="22" r="3.4"/>
|
||||
<circle cx="38" cy="10" r="3.4"/>
|
||||
<circle cx="15" cy="37" r="3.4"/>
|
||||
<path d="M18 34l-3 3" fill="none"/>
|
||||
</g>
|
||||
<!-- diagonal slash -->
|
||||
<path d="M41 7L18 41" stroke="#22a8ff" stroke-width="2.6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 863 B |
+74
-1
@@ -140,7 +140,7 @@ a { color: var(--accent); text-decoration: none; }
|
||||
display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-4) var(--sp-4) var(--sp-3);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: var(--sp-2); font-weight: 600; font-size: var(--fs-base); letter-spacing: .01em; flex: 1; }
|
||||
.brand .mark { width: 22px; height: 22px; border-radius: var(--radius-sm); background: var(--accent); color: var(--accent-contrast); display: flex; align-items: center; justify-content: center; font-size: var(--fs-sm); font-weight: 700; font-family: var(--mono); }
|
||||
.brand .mark { width: 26px; height: 26px; flex: none; color: var(--text); }
|
||||
|
||||
.icon-btn {
|
||||
background: transparent; border: 1px solid transparent; color: var(--text-dim);
|
||||
@@ -393,6 +393,18 @@ textarea { resize: vertical; min-height: 72px; }
|
||||
.auth-mode-toggle button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
|
||||
.check-row { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--fs-sm); color: var(--text-dim); }
|
||||
/* a column of full-width checkbox rows (no squeezing into flex columns) */
|
||||
.check-col { display: flex; flex-direction: column; gap: 4px; margin-bottom: var(--sp-4); }
|
||||
.check-col .check-row { align-items: flex-start; }
|
||||
.check-col .check-help { margin: 0 0 var(--sp-2) 26px; }
|
||||
/* segmented tabs (lead modal mode) */
|
||||
.seg-tabs { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; margin-bottom: 14px; }
|
||||
.seg-tab { background: var(--surface); border: none; padding: 6px 14px; font: inherit; font-size: var(--fs-sm); color: var(--text-dim); cursor: pointer; }
|
||||
.seg-tab + .seg-tab { border-left: 1px solid var(--border); }
|
||||
.seg-tab.active { background: var(--accent); color: #fff; }
|
||||
/* remove a custom lead from the board */
|
||||
.agent-remove { margin-left: auto; background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: var(--fs-sm); padding: 0 4px; border-radius: var(--radius-xs); }
|
||||
.agent-remove:hover { color: var(--sev-critical-fg); background: var(--sev-critical-bg); }
|
||||
|
||||
.role-list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.role-row { display: flex; gap: var(--sp-2); align-items: center; }
|
||||
@@ -833,6 +845,14 @@ body.resizing-ns { user-select: none; cursor: ns-resize; }
|
||||
.chain-block { margin-top: var(--sp-2); }
|
||||
.chain-block-h { font-size: var(--fs-2xs); text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); font-weight: 700; }
|
||||
.chain-block ul { margin: 4px 0 0; padding-left: 18px; font-size: var(--fs-sm); }
|
||||
.chain-block li { margin: 3px 0; line-height: 1.5; }
|
||||
/* readable chain prose: node refs as chips, relations as labels, meta muted */
|
||||
.chain-steps { font-family: var(--sans); }
|
||||
.chain-steps .step { line-height: 1.6; }
|
||||
.chain-ref { font-family: var(--mono); font-size: 0.92em; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 0 5px; color: var(--text); white-space: normal; }
|
||||
.chain-rel { font-family: var(--mono); font-size: var(--fs-2xs); font-weight: 700; letter-spacing: .03em; color: var(--accent); background: var(--accent-soft); border-radius: var(--radius-xs); padding: 0 5px; margin: 0 2px; }
|
||||
.chain-rel::before { content: ""; }
|
||||
.chain-note { color: var(--text-faint); font-size: var(--fs-xs); font-family: var(--mono); margin-left: 4px; }
|
||||
|
||||
/* dependency todolist */
|
||||
.todo-chain { border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); margin-bottom: var(--sp-3); }
|
||||
@@ -915,6 +935,59 @@ body.resizing-ns { user-select: none; cursor: ns-resize; }
|
||||
.trace-hit a { font-weight: 600; color: var(--text); }
|
||||
.trace-hit code { display: block; font-family: var(--mono); font-size: var(--fs-xs); color: var(--text-dim); margin-top: 3px; white-space: pre-wrap; word-break: break-all; }
|
||||
.trace-hit mark { background: var(--accent-soft); color: var(--accent); }
|
||||
.trace-file-btn { background: none; border: none; padding: 0; margin: 0; font: inherit; color: var(--text); cursor: pointer; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.trace-file-btn:hover { color: var(--accent); }
|
||||
.trace-file-btn.active { color: var(--accent); font-weight: 700; }
|
||||
|
||||
/* transcript viewer */
|
||||
.transcript-head { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-2); }
|
||||
.transcript-head .muted { flex: 1; font-size: var(--fs-xs); }
|
||||
.transcript-block { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); margin-bottom: 4px; }
|
||||
.transcript-block > summary { cursor: pointer; padding: 5px var(--sp-3); font-family: var(--mono); font-size: var(--fs-xs); color: var(--text); white-space: pre-wrap; word-break: break-all; }
|
||||
.transcript-block > summary mark { background: var(--accent-soft); color: var(--accent); }
|
||||
.transcript-block pre { margin: 0; border-top: 1px solid var(--border); }
|
||||
.transcript-block pre mark { background: var(--accent-soft); color: var(--accent); }
|
||||
|
||||
/* wire evidence (finding modal) */
|
||||
.fm-wire-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
||||
.wire-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: var(--sp-2) 0; }
|
||||
.wire-chip { font-size: var(--fs-2xs); font-weight: 700; padding: 2px 8px; border-radius: var(--radius-xs); border: 1px solid var(--border-strong); }
|
||||
.wire-chip.ok { background: var(--sev-low-bg); color: var(--sev-low-fg); border-color: transparent; }
|
||||
.wire-chip.warn { background: var(--sev-medium-bg); color: var(--sev-medium-fg); border-color: transparent; }
|
||||
.wire-chip.bad { background: var(--sev-critical-bg); color: var(--sev-critical-fg); border-color: transparent; }
|
||||
.wire-chip.muted { color: var(--text-faint); }
|
||||
.wire-contra { margin: 0 0 var(--sp-2) 0; padding-left: var(--sp-5); font-size: var(--fs-xs); color: var(--sev-critical-fg); }
|
||||
.wire-sub { font-size: var(--fs-xs); font-weight: 700; color: var(--text-dim); margin: var(--sp-3) 0 4px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.wire-exchanges { display: flex; flex-direction: column; gap: 4px; }
|
||||
.wire-ex { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); }
|
||||
.wire-ex > summary { cursor: pointer; display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-3); font-size: var(--fs-xs); flex-wrap: wrap; }
|
||||
.wire-role { font-weight: 700; color: var(--text-dim); min-width: 96px; }
|
||||
.wire-url { font-family: var(--mono); color: var(--text-dim); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.wire-id { font-size: var(--fs-2xs); color: var(--text-faint); }
|
||||
.wire-prov { font-size: var(--fs-2xs); font-weight: 700; padding: 1px 6px; border-radius: var(--radius-xs); }
|
||||
.wire-prov.ok { background: var(--sev-low-bg); color: var(--sev-low-fg); }
|
||||
.wire-prov.muted { color: var(--text-faint); border: 1px solid var(--border); }
|
||||
.wire-ex-body { padding: 0 var(--sp-3) var(--sp-3); }
|
||||
.wire-h { margin-top: var(--sp-2); }
|
||||
.wire-h b { font-size: var(--fs-2xs); color: var(--text-dim); text-transform: uppercase; letter-spacing: .04em; }
|
||||
.wire-flow { display: flex; align-items: center; gap: var(--sp-2); padding: 4px var(--sp-3); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); font-size: var(--fs-xs); }
|
||||
.retest-hist { margin: var(--sp-2) 0; }
|
||||
.retest-row { display: flex; align-items: center; gap: var(--sp-3); padding: 3px 0; font-size: var(--fs-xs); }
|
||||
.retest-when { color: var(--text-faint); font-family: var(--mono); font-size: var(--fs-2xs); white-space: nowrap; }
|
||||
.retest-detail { color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* regression vs previous run */
|
||||
.reg-badge { font-size: var(--fs-2xs); font-weight: 700; padding: 0 6px; border-radius: var(--radius-xs); margin-left: 6px; vertical-align: middle; }
|
||||
.reg-new { background: var(--sev-medium-bg); color: var(--sev-medium-fg); }
|
||||
.reg-recurring { background: var(--sev-info-bg); color: var(--sev-info-fg); }
|
||||
.reg-fixed { background: var(--sev-low-bg); color: var(--sev-low-fg); }
|
||||
.reg-banner { display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-3); padding: 6px var(--sp-3); margin-bottom: var(--sp-3); border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); font-size: var(--fs-sm); }
|
||||
.reg-banner .reg-h { font-weight: 700; text-transform: uppercase; letter-spacing: .04em; font-size: var(--fs-xs); color: var(--text-dim); }
|
||||
.reg-chip { font-size: var(--fs-2xs); font-weight: 700; padding: 1px 8px; border-radius: var(--radius-xs); }
|
||||
.reg-fixed-list { font-size: var(--fs-sm); }
|
||||
.reg-fixed-list summary { cursor: pointer; color: var(--accent); }
|
||||
.reg-fixed-list ul { margin: var(--sp-2) 0 0; padding-left: var(--sp-5); }
|
||||
.reg-fixed-list li { margin: 2px 0; }
|
||||
@media (max-width: 760px) { .trace-layout { grid-template-columns: 1fr; } .gm-wrap { height: 460px; } }
|
||||
|
||||
/* deliverables */
|
||||
|
||||
Reference in new issue
Block a user