mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-25 10:52:31 +02:00
feat(web): bulk select/clear-all leads; custom lead generates a real agent
- Select all / Clear all buttons in the Leads step toolbar - respects the
current search filter, so filtering to "sql" then Select all only pins
those, not all 412 leads. The per-category master switch (already
select/deselect-all for that category, indeterminate when partial) was
the only bulk control before; this adds the "everything" case.
- '+ Custom lead' now generates an ACTUAL specialist-agent markdown file
(agents_md/vulns/custom_<slug>.md, same format every other agent uses)
via the claude CLI on the operator's Anthropic subscription
(claude-opus-4-8 by default - matches the harness's own default model),
instead of folding free text into --focus. The new lead is immediately
selectable and pinnable via --only like any other agent; verified the
Rust harness's own agent loader picks it up (agent count went 435 -> 436,
neurosploit agents confirmed it).
Two things found and fixed while wiring this up:
- the skip-permissions flag gave the model file/bash tool access, which
made it try to write the file itself and narrate doing so instead of
just returning text. Dropped the flag (pure text completion needs no
tools) and told it explicitly not to use any.
- Even so, defensively strip anything before the first '# ' heading
before saving, in case a model still prepends commentary.
Falls back to the old free-text-focus behavior if generation fails
(claude not installed/logged in, malformed output, timeout) so the
operator's intent isn't lost.
- New "Custom Leads" category, shown first, so generated leads have a
visible home instead of landing in the catch-all "Other" bucket.
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
8047c66e8f
commit
e253b8b291
+37
-3
@@ -250,9 +250,43 @@ function renderCustomLeads() {
|
|||||||
renderCustomLeads();
|
renderCustomLeads();
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
$('#btnCustomLead').addEventListener('click', () => {
|
$('#btnCustomLead').addEventListener('click', async () => {
|
||||||
const text = prompt('Describe the custom lead (free text — becomes agent focus context):');
|
const text = prompt('Describe the custom lead — Claude (Opus, subscription) generates a real specialist agent for it, ready to pin:');
|
||||||
if (text && text.trim()) { state.customLeads.push(text.trim()); renderCustomLeads(); }
|
if (!text || !text.trim()) return;
|
||||||
|
const btn = $('#btnCustomLead');
|
||||||
|
const original = btn.textContent;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Generating…';
|
||||||
|
try {
|
||||||
|
const { agent } = await api('/api/leads/generate', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ description: text.trim() }),
|
||||||
|
});
|
||||||
|
await loadAgents(); // re-read agents_md/ so the new file appears in its category
|
||||||
|
state.selected.add(agent.id);
|
||||||
|
renderBoard();
|
||||||
|
alert(`Generated and pinned: ${agent.title}`);
|
||||||
|
} catch (e) {
|
||||||
|
// Fall back to the old behavior — fold the raw text into --focus context
|
||||||
|
// — so a missing/logged-out Claude CLI doesn't lose the operator's intent.
|
||||||
|
state.customLeads.push(text.trim());
|
||||||
|
renderCustomLeads();
|
||||||
|
alert(`Couldn't generate a skill (${e.message}) — added as a focus hint instead.`);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$('#btnSelectAll').addEventListener('click', () => {
|
||||||
|
// Respects the current search/filter — selects only what's visible, so a
|
||||||
|
// filtered view ("sql") + Select all pins just those leads, not all 412.
|
||||||
|
const visible = state.search.trim() ? allAgents().filter((a) => (a.title + ' ' + a.name).toLowerCase().includes(state.search.trim().toLowerCase())) : allAgents();
|
||||||
|
visible.forEach((a) => state.selected.add(a.id));
|
||||||
|
renderBoard();
|
||||||
|
});
|
||||||
|
$('#btnClearAll').addEventListener('click', () => {
|
||||||
|
const visible = state.search.trim() ? allAgents().filter((a) => (a.title + ' ' + a.name).toLowerCase().includes(state.search.trim().toLowerCase())) : allAgents();
|
||||||
|
visible.forEach((a) => state.selected.delete(a.id));
|
||||||
|
renderBoard();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -127,8 +127,11 @@
|
|||||||
<button class="chip" data-filter="excluded">Excluded <span id="chipExcluded">0</span></button>
|
<button class="chip" data-filter="excluded">Excluded <span id="chipExcluded">0</span></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-spacer"></div>
|
<div class="topbar-spacer"></div>
|
||||||
|
<button class="btn btn-sm" id="btnSelectAll">Select all</button>
|
||||||
|
<button class="btn btn-sm" id="btnClearAll">Clear all</button>
|
||||||
<button class="btn btn-sm" id="btnCustomLead">+ Custom lead</button>
|
<button class="btn btn-sm" id="btnCustomLead">+ Custom lead</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field-help" style="margin: -8px 0 4px;">Tip: click a category's switch to select/deselect every lead in it at once.</div>
|
||||||
<div class="custom-leads" id="customLeadsList"></div>
|
<div class="custom-leads" id="customLeadsList"></div>
|
||||||
<div class="categories" id="categories"><!-- populated --></div>
|
<div class="categories" id="categories"><!-- populated --></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+111
@@ -188,6 +188,7 @@ const CATEGORY_RULES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function classify(name, kind) {
|
function classify(name, kind) {
|
||||||
|
if (name.startsWith('custom_')) return 'Custom Leads'; // generated via /api/leads/generate
|
||||||
if (kind === 'chain') return 'Attack Chains';
|
if (kind === 'chain') return 'Attack Chains';
|
||||||
if (kind === 'recon') return 'Recon';
|
if (kind === 'recon') return 'Recon';
|
||||||
if (kind === 'code') return 'Code Review';
|
if (kind === 'code') return 'Code Review';
|
||||||
@@ -248,6 +249,7 @@ async function loadAgents() {
|
|||||||
// Selectable leads only (exclude meta/orchestration from the pentest board —
|
// Selectable leads only (exclude meta/orchestration from the pentest board —
|
||||||
// they're internal doctrine agents, not testable "leads").
|
// they're internal doctrine agents, not testable "leads").
|
||||||
const LEAD_ORDER = [
|
const LEAD_ORDER = [
|
||||||
|
'Custom Leads',
|
||||||
'Business Logic', 'Broken Access Control', 'Injection', 'Cross-Site Scripting',
|
'Business Logic', 'Broken Access Control', 'Injection', 'Cross-Site Scripting',
|
||||||
'LLM Application', 'Auth & Session', 'SSRF & Network', 'API & GraphQL',
|
'LLM Application', 'Auth & Session', 'SSRF & Network', 'API & GraphQL',
|
||||||
'Cloud & Infra', 'Client-Side', 'Cryptography', 'Rate Limiting & DoS',
|
'Cloud & Infra', 'Client-Side', 'Cryptography', 'Rate Limiting & DoS',
|
||||||
@@ -263,6 +265,86 @@ async function loadAgents() {
|
|||||||
return agentCache;
|
return agentCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom leads — "+ Custom lead" generates a REAL specialist-agent markdown
|
||||||
|
// file (same format agents_md/vulns/*.md uses) via the `claude` CLI on the
|
||||||
|
// operator's Anthropic subscription, so a custom lead is an actual pinnable
|
||||||
|
// agent, not just free text folded into --focus. Mirrors the exact one-shot
|
||||||
|
// invocation harness::models::cli_login_status() uses for the same CLI.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const GEN_MODEL = 'claude-opus-4-8'; // matches Session::default() in app/src/repl.rs
|
||||||
|
const GEN_TIMEOUT_MS = 90_000;
|
||||||
|
|
||||||
|
function slugify(s) {
|
||||||
|
return (s || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40) || 'lead';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSkillGenPrompt(description) {
|
||||||
|
return `Write ONE new security-testing specialist-agent file for this custom lead, in EXACTLY this markdown shape and nothing else — no code fences around the whole thing, no preamble, no explanation, just the file content starting at the first line:
|
||||||
|
|
||||||
|
# <Short Title> Agent
|
||||||
|
## User Prompt
|
||||||
|
You are testing **{target}** for <the specific vulnerability class this lead targets>.
|
||||||
|
**Recon Context:**
|
||||||
|
{recon_json}
|
||||||
|
**METHODOLOGY:**
|
||||||
|
### 1. <step name>
|
||||||
|
- <concrete technique>
|
||||||
|
### 2. <step name>
|
||||||
|
- <concrete technique>
|
||||||
|
(as many numbered steps as the vuln class actually needs — terse, technical, no filler)
|
||||||
|
### Report
|
||||||
|
\`\`\`
|
||||||
|
FINDING:
|
||||||
|
- Title: ...
|
||||||
|
- Severity: ...
|
||||||
|
- CWE: CWE-<pick the single most fitting CWE number>
|
||||||
|
- Endpoint: [URL]
|
||||||
|
- Evidence: ...
|
||||||
|
- Impact: ...
|
||||||
|
- Remediation: ...
|
||||||
|
\`\`\`
|
||||||
|
## System Prompt
|
||||||
|
<one paragraph: the agent's persona plus the ONE calibration rule that stops it from reporting a finding without real proof>
|
||||||
|
|
||||||
|
The operator's custom lead request, verbatim: "${description}"
|
||||||
|
|
||||||
|
Match the doctrine style of NeuroSploit's other agents_md/vulns/*.md files: terse, technical, no marketing language, one CWE, a real report template.
|
||||||
|
|
||||||
|
Do not use any tools (no file writes, no bash, no search) — this is a pure text-completion task. Respond with ONLY the markdown file content above, nothing before it and nothing after it.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCustomLead(description) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!binaryOnPath('claude')) {
|
||||||
|
return reject(new Error("claude CLI not found on PATH — install Claude Code and run `claude` to log in first"));
|
||||||
|
}
|
||||||
|
// No --dangerously-skip-permissions here: this is a pure text-completion
|
||||||
|
// call (no bash/file tools needed), and granting tool access made claude
|
||||||
|
// try to write the file itself and narrate doing so instead of just
|
||||||
|
// returning text — see buildSkillGenPrompt()'s explicit "no tools" line.
|
||||||
|
const child = spawn('claude', ['-p', '--model', GEN_MODEL, '--output-format', 'text'], { env: process.env });
|
||||||
|
let out = '', err = '';
|
||||||
|
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('generation timed out')); }, GEN_TIMEOUT_MS);
|
||||||
|
child.stdout.on('data', (c) => { out += c; });
|
||||||
|
child.stderr.on('data', (c) => { err += c; });
|
||||||
|
child.on('error', (e) => { clearTimeout(timer); reject(new Error(`claude CLI failed to start: ${e.message}`)); });
|
||||||
|
child.on('close', (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!out.trim()) return reject(new Error(err.trim() || `claude exited ${code} with no output — is it logged in? run \`claude\` once to check.`));
|
||||||
|
resolve(out);
|
||||||
|
});
|
||||||
|
child.stdin.write(buildSkillGenPrompt(description));
|
||||||
|
child.stdin.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function binaryOnPath(bin) {
|
||||||
|
const dirs = (process.env.PATH || '').split(path.delimiter);
|
||||||
|
return dirs.some((d) => { try { return fs.existsSync(path.join(d, bin)); } catch { return false; } });
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Runs — read runs/<id>/{meta,status,findings}.json
|
// Runs — read runs/<id>/{meta,status,findings}.json
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -625,6 +707,35 @@ const server = http.createServer(async (req, res) => {
|
|||||||
if (req.method === 'GET' && p === '/api/agents') {
|
if (req.method === 'GET' && p === '/api/agents') {
|
||||||
return sendJson(res, 200, await loadAgents());
|
return sendJson(res, 200, await loadAgents());
|
||||||
}
|
}
|
||||||
|
if (req.method === 'POST' && p === '/api/leads/generate') {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const description = (body.description || '').trim();
|
||||||
|
if (!description) return sendJson(res, 400, { error: 'description is required' });
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = await generateCustomLead(description);
|
||||||
|
} catch (e) {
|
||||||
|
return sendJson(res, 502, { error: e.message });
|
||||||
|
}
|
||||||
|
// Defensive: discard any wrapper text before the first '# ' heading —
|
||||||
|
// a model with tool access sometimes narrates ("I'll write the file
|
||||||
|
// now...") before the actual content despite being told not to.
|
||||||
|
const titleIdx = raw.search(/^#\s+/m);
|
||||||
|
if (titleIdx === -1) {
|
||||||
|
return sendJson(res, 502, { error: 'generation did not return a well-formed agent file', raw: raw.slice(0, 800) });
|
||||||
|
}
|
||||||
|
const clean = raw.slice(titleIdx).trim();
|
||||||
|
const titleMatch = clean.match(/^#\s+(.+?)\s*$/m);
|
||||||
|
if (!titleMatch || !/##\s*User Prompt/i.test(clean) || !/##\s*System Prompt/i.test(clean)) {
|
||||||
|
return sendJson(res, 502, { error: 'generation did not return a well-formed agent file', raw: raw.slice(0, 800) });
|
||||||
|
}
|
||||||
|
const slug = `custom_${slugify(titleMatch[1])}`;
|
||||||
|
await fsp.writeFile(path.join(AGENTS_DIR, 'vulns', `${slug}.md`), clean + '\n');
|
||||||
|
agentCache = null; // force a fresh read so the new lead shows up immediately
|
||||||
|
const { agents } = await loadAgents();
|
||||||
|
const created = agents.find((a) => a.id === slug);
|
||||||
|
return sendJson(res, 200, { agent: created, raw: clean });
|
||||||
|
}
|
||||||
|
|
||||||
// ---- runs ----
|
// ---- runs ----
|
||||||
if (req.method === 'GET' && p === '/api/runs') {
|
if (req.method === 'GET' && p === '/api/runs') {
|
||||||
|
|||||||
Reference in New Issue
Block a user