From a094fedc5c7fda9bfe4afee8230650aa65d97946 Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Thu, 15 Oct 2026 18:20:01 -0300 Subject: [PATCH] fix(web): parse live findings/agent progress from decorated REPL output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REPL decorates every streamed line with a leading glyph (" · ", " ✗ ", " 🧪 testing "), so the server's anchored regexes for finding_json: and "exploit via … → N candidate(s)" never matched — findings and per-agent progress reached the attack graph (built from findings.json) but never the live Findings tab or the "what's being tested" panel. Normalize each line (strip the leading decoration) before matching the machine markers, and relax the anchors accordingly. Co-Authored-By: Claude Opus 4.8 --- web/server.js | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/web/server.js b/web/server.js index 6ec564e..f3d0c33 100644 --- a/web/server.js +++ b/web/server.js @@ -921,18 +921,26 @@ function ingestLine(job, rawLine) { const low = line.toLowerCase(); job.push({ type: 'log', line }); + // The REPL/CLI decorates every streamed line with a leading glyph + spaces + // (" · ", " ✗ ", " 🧪 testing ", …). Machine markers (finding_json:, + // "exploit via … → N candidate(s)") therefore don't start at column 0, + // so strip the decoration before matching — otherwise live findings and the + // per-agent progress never reach the UI even though the run is producing them. + const norm = line.replace(/^\s*[^\w/{(]*\s*/u, '').replace(/^testing\s+/i, ''); + const lown = norm.toLowerCase(); + if (low.includes('paused by operator')) job.phase = 'paused (operator)'; else if (low.includes('resumed by operator') || low.includes('▶ resumed')) job.phase = 'running'; else if (low.includes('token/quota exhausted') || low.includes('run is paused')) job.phase = 'paused (quota)'; else if (low.includes('authentication failed') || low.includes('circuit breaker')) job.phase = 'paused (auth)'; - else if (low.startsWith('recon') || low.startsWith('ai-recon') || low.includes('recon round') || low.startsWith('probe:')) job.phase = 'recon'; + else if (lown.startsWith('recon') || lown.startsWith('ai-recon') || low.includes('recon round') || lown.startsWith('probe:')) job.phase = 'recon'; else if (low.includes('selected') && low.includes('agent')) { job.phase = 'planning'; const n = line.split(/\s+/).map(Number).find((x) => Number.isFinite(x)); if (n) job.agents = n; - } else if (low.startsWith('exploit') || low.startsWith('test ') || low.includes('launching agent')) job.phase = 'exploiting'; - else if (low.startsWith('vote') || low.includes('validating')) job.phase = 'validating'; - else if (low.startsWith('chain')) job.phase = 'chaining'; + } else if (lown.startsWith('exploit') || lown.startsWith('test ') || low.includes('launching agent')) job.phase = 'exploiting'; + else if (lown.startsWith('vote') || low.includes('validating')) job.phase = 'validating'; + else if (lown.startsWith('chain')) job.phase = 'chaining'; else if (low.includes('phase complete') || low.includes('validated finding(s)')) { job.phase = 'complete'; // A REPL-backed job's child process doesn't exit when the engagement @@ -941,9 +949,9 @@ function ingestLine(job, rawLine) { if (job.repl && !job.done) { job.done = true; job.push({ type: 'done', exitCode: 0 }); } } - if (/candidate\(s\)/.test(low) && /^(exploit |test |analyze |review )/.test(low)) job.agentsDone += 1; + if (/candidate\(s\)/.test(lown) && /^(exploit|test|analyze|review)\b/.test(lown)) job.agentsDone += 1; - const fj = line.match(/^finding_json:\s*(.+)$/); + const fj = norm.match(/^finding_json:\s*(.+)$/); if (fj) { try { const finding = JSON.parse(fj[1]); @@ -975,14 +983,14 @@ function ingestLine(job, rawLine) { job.push({ type: 'agent', name: nm, status: 'running' }); } // Done: "exploit|analyze|test via → N candidate(s)" - const done = line.match(/^\s*(?:exploit|analyze|test)\s+(\S+)\s+via\s+.+?(?:→|->)\s*(\d+)\s+candidate/i); + const done = norm.match(/^(?:exploit|analyze|test|review)\s+(\S+)\s+via\s+.+?(?:→|->)\s*(\d+)\s+candidate/i); if (done) { const nm = done[1]; const n = Number(done[2]) || 0; job.agentStatus[nm] = { ...(job.agentStatus[nm] || {}), status: 'done', found: n }; job.push({ type: 'agent', name: nm, status: 'done', found: n }); } // Failed: " failed: ..." - const failed = line.match(/^\s*(?:exploit|analyze|test|chain)\s+(\S+)\s+failed/i); + const failed = norm.match(/^(?:exploit|analyze|test|chain|review)\s+(\S+)\s+failed/i); if (failed) { const nm = failed[1]; job.agentStatus[nm] = { ...(job.agentStatus[nm] || {}), status: 'failed' };