mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-26 11:22:37 +02:00
feat(web): engagement wizard, model/auth picker, Auth & Keys menu, attack-path graph
Full frontend rewrite following a deliberate visual direction (dense security-operations console — borders over shadows, two radii, one accent, no gradients/glassmorphism) and fixing real bugs found in review: - EventSource on the exploit stream never called es.close() on 'done', so the browser silently reconnected and re-streamed the whole job (duplicate log lines/findings). Fixed. - Sidebar 'running' step indicator and openRun() matched ANY running run instead of the one belonging to the current job (by runId). Fixed. New: - 5-step engagement wizard (Asset -> Scope & Auth -> Leads -> Model & Run -> Review) replacing the single flat board — inspired by the Discovery/Plan/Exploit/Remediate stage model both a.security and terra.security use publicly. - Model is now a real dropdown sourced from /api/providers (mirrors harness::models::providers()), with an API-key vs. subscription toggle that disables subscription for API-only providers. - One Auth & Keys menu: target auth header + named roles (IDOR/BOLA/BFLA multi-identity testing) materialize into an ephemeral creds.yaml passed via --creds; per-provider API keys live in server memory only (never on disk) and are merged into every spawned child's env. - Generative Attack Path Chaining: findings rendered as kill-chain columns (recon -> initial-access -> ... -> impact) with chains_from resolved to parent titles, live in the run view and static in run detail. - Findings are now a proper table (severity/title/endpoint/CWE/agent/ confidence) instead of stacked cards. - Explicit light/dark theme toggle persisted in localStorage, defaulting to light (previously light only won when the OS wasn't in dark mode). - All UI strings in English. Backend additions: GET /api/providers, GET/POST/DELETE /api/keys, ephemeral creds.yaml generation for auth/roles, env override merged into every exploit-job and REPL child spawn. 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
d1d1c71e24
commit
bb659412fc
+45
-1
@@ -52,6 +52,40 @@ An agent's `id`/`name` is exactly what the CLI's `--only <name>` flag expects (s
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Providers / models / API keys
|
||||||
|
|
||||||
|
### `GET /api/providers`
|
||||||
|
|
||||||
|
Static mirror of `crates/harness/src/models.rs` `providers()` — every provider the harness
|
||||||
|
supports, its models, and whether it's usable via a local CLI subscription login (`kind: "cli"`)
|
||||||
|
or API key only (`kind: "api"`).
|
||||||
|
|
||||||
|
```json
|
||||||
|
[ { "key": "anthropic", "label": "Anthropic Claude", "kind": "cli", "models": ["claude-opus-5", "..."] } ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/keys`
|
||||||
|
|
||||||
|
Which providers currently have an API key set **in this server process's memory** (booleans only
|
||||||
|
— never the value):
|
||||||
|
|
||||||
|
```json
|
||||||
|
[ { "provider": "anthropic", "set": true }, { "provider": "openai", "set": false } ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/keys`
|
||||||
|
|
||||||
|
Body `{ "provider": "anthropic", "key": "sk-..." }`. Stores the key in an in-memory `Map` —
|
||||||
|
**never written to disk**, lost on server restart. Every subsequent `/api/exploit` and `/api/repl`
|
||||||
|
child process is spawned with `<provider>.envKey` set from this store (merged over `process.env`).
|
||||||
|
Omitting `key` (or passing an empty string) clears it. 400 on an unknown provider.
|
||||||
|
|
||||||
|
### `DELETE /api/keys/:provider`
|
||||||
|
|
||||||
|
Clears one provider's key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Runs (history)
|
## Runs (history)
|
||||||
|
|
||||||
### `GET /api/runs`
|
### `GET /api/runs`
|
||||||
@@ -106,10 +140,20 @@ Body:
|
|||||||
"focus": "injection and business logic", // --focus
|
"focus": "injection and business logic", // --focus
|
||||||
"objective": "pre-launch review of checkout", // --objective
|
"objective": "pre-launch review of checkout", // --objective
|
||||||
"outOfScope": "staging.example.com", // --out-of-scope
|
"outOfScope": "staging.example.com", // --out-of-scope
|
||||||
"agents": ["sqli_error", "idor"] // --only <name>, repeated — the lead-board selection
|
"agents": ["sqli_error", "idor"], // --only <name>, repeated — the lead-board selection
|
||||||
|
"auth": "Authorization: Bearer <token>", // target auth header — see Target auth below
|
||||||
|
"roles": [{ "name": "admin", "header": "Authorization: Bearer ..." }], // multi-identity access-control testing
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Target auth (`auth` / `roles`)
|
||||||
|
|
||||||
|
If `creds` is omitted and either `auth` or `roles` is set, the server writes a minimal
|
||||||
|
`creds.yaml`-compatible file (matching `neurosploit-rs/creds.example.yaml`'s schema) to
|
||||||
|
`os.tmpdir()/neurosploit-web/<job-id>.creds.yaml` and passes it via `--creds`. An explicit `creds`
|
||||||
|
path always wins over `auth`/`roles`. These ephemeral files are not cleaned up automatically —
|
||||||
|
they live in the OS temp dir, never in the repo.
|
||||||
|
|
||||||
Response: `{ "id": "<job-uuid>" }`. This `id` is the **web job id**, not the run id — the CLI's own
|
Response: `{ "id": "<job-uuid>" }`. This `id` is the **web job id**, not the run id — the CLI's own
|
||||||
`ns-<timestamp>-<target>` run id is discovered from its own log line and exposed as `runId` in the
|
`ns-<timestamp>-<target>` run id is discovered from its own log line and exposed as `runId` in the
|
||||||
job snapshot once the engagement starts writing to `runs/`.
|
job snapshot once the engagement starts writing to `runs/`.
|
||||||
|
|||||||
+17
-3
@@ -1,8 +1,22 @@
|
|||||||
# NeuroSploit v4.0.0 — web console
|
# NeuroSploit v4.0.0 — web console
|
||||||
|
|
||||||
A browser UI for the `neurosploit` CLI harness: a lead board (categorized agent picker + custom
|
A browser UI for the `neurosploit` CLI harness: a 5-step engagement wizard (Asset → Scope & Auth
|
||||||
leads → `Start Exploitation`), a live structured findings view, run history, and a real REPL —
|
→ Leads → Model & Run → Review), a live structured findings view with a generative attack-path
|
||||||
all driven by spawning the actual CLI binary, never a reimplementation of harness logic.
|
graph, run history, an Auth & Keys menu, and a real REPL — all driven by spawning the actual CLI
|
||||||
|
binary, never a reimplementation of harness logic.
|
||||||
|
|
||||||
|
- **Asset** — pick black/white/grey-box, host/infra, or AI/LLM, set the target or repo.
|
||||||
|
- **Scope & Auth** — objective, focus, out-of-scope, and a link into the Auth & Keys menu.
|
||||||
|
- **Leads** — the categorized agent picker (435 agents auto-classified) + custom leads.
|
||||||
|
- **Model & Run** — pick a provider/model from the live catalog, API-key vs. subscription auth
|
||||||
|
mode, votes/chain-depth/recon intensity.
|
||||||
|
- **Review** — confirm the plan, then `Start Exploitation` spawns the real CLI.
|
||||||
|
- **Auth & Keys** (one menu, 🔑 in the sidebar) — target auth header + named roles for
|
||||||
|
IDOR/BOLA/BFLA testing, per-provider API keys (kept in server memory only, never on disk), and
|
||||||
|
an explicit `creds.yaml` path override.
|
||||||
|
- **Generative Attack Path Chaining** — findings are grouped into kill-chain columns
|
||||||
|
(recon → initial-access → execution → privesc → lateral → exfil → impact) with chained findings
|
||||||
|
linked back to their parent, built live as findings stream in.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd neurosploit-rs && cargo build --release # build the CLI once
|
cd neurosploit-rs && cargo build --release # build the CLI once
|
||||||
|
|||||||
+408
-147
@@ -1,20 +1,36 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
/* NeuroSploit v4.0.0 — web console frontend. Vanilla JS, no build step. */
|
/* NeuroSploit v4.0.0 — web console frontend. Vanilla JS, no build step. */
|
||||||
|
|
||||||
const $ = (sel) => document.querySelector(sel);
|
const $ = (sel, root = document) => root.querySelector(sel);
|
||||||
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||||||
|
|
||||||
|
const MODE_LABELS = {
|
||||||
|
run: { target: 'Target URL', help: 'The application to test.', showRepo: false, placeholder: 'https://target.example.com' },
|
||||||
|
whitebox: { target: 'Source repo / path', help: "A GitHub URL, owner/repo shorthand, or a local path — cloned automatically if it's remote.", showRepo: false, placeholder: 'owner/repo' },
|
||||||
|
greybox: { target: 'Target URL', help: 'The running application to exploit, alongside the source repo below.', showRepo: true, placeholder: 'https://target.example.com' },
|
||||||
|
host: { target: 'Target host / IP', help: 'Runs Linux / Windows / Active Directory agents.', showRepo: false, placeholder: '10.0.0.10' },
|
||||||
|
aitest: { target: 'AI endpoint URL', help: 'A live AI agent, LLM chat, or MCP endpoint (OWASP LLM Top 10).', showRepo: false, placeholder: 'https://target.example.com/chat' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEP_COUNT = 5;
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
categories: [], // from /api/agents
|
theme: localStorage.getItem('ns-theme') || 'light',
|
||||||
selected: new Set(), // agent ids toggled on
|
step: 0,
|
||||||
customLeads: [], // free-text custom leads (folded into --focus)
|
mode: 'run',
|
||||||
filter: 'all', // all | selected | excluded
|
categories: [],
|
||||||
|
selected: new Set(),
|
||||||
|
customLeads: [],
|
||||||
|
filter: 'all',
|
||||||
search: '',
|
search: '',
|
||||||
runs: [], // from /api/runs
|
providers: [],
|
||||||
currentJob: null, // {id, es} for the live view
|
auth: { header: '', roles: [] },
|
||||||
currentDetailId: null, // run id shown in detail view
|
credsPath: '',
|
||||||
|
keys: [],
|
||||||
|
runs: [],
|
||||||
|
currentJob: null,
|
||||||
|
currentDetailId: null,
|
||||||
detailPoll: null,
|
detailPoll: null,
|
||||||
askFocus: '', askObjective: '', askOutOfScope: '',
|
|
||||||
replId: null, replEs: null,
|
replId: null, replEs: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,21 +43,96 @@ function esc(s) {
|
|||||||
}
|
}
|
||||||
async function api(path, opts) {
|
async function api(path, opts) {
|
||||||
const res = await fetch(path, opts);
|
const res = await fetch(path, opts);
|
||||||
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `${path} → ${res.status}`);
|
||||||
|
}
|
||||||
return res.headers.get('content-type')?.includes('json') ? res.json() : res.text();
|
return res.headers.get('content-type')?.includes('json') ? res.json() : res.text();
|
||||||
}
|
}
|
||||||
function sevClass(sev) {
|
function sevRank(sev) {
|
||||||
const s = (sev || '').toLowerCase();
|
const s = (sev || '').toLowerCase();
|
||||||
if (s.includes('crit')) return 'sev-critical';
|
if (s.includes('crit')) return 0;
|
||||||
if (s.includes('high')) return 'sev-high';
|
if (s.includes('high')) return 1;
|
||||||
if (s.includes('med')) return 'sev-medium';
|
if (s.includes('med')) return 2;
|
||||||
if (s.includes('low')) return 'sev-low';
|
if (s.includes('low')) return 3;
|
||||||
return 'sev-info';
|
return 4;
|
||||||
}
|
}
|
||||||
function show(el, on) { el.hidden = !on; }
|
function sevClass(sev) {
|
||||||
|
return ['sev-critical', 'sev-high', 'sev-medium', 'sev-low', 'sev-info'][sevRank(sev)];
|
||||||
|
}
|
||||||
|
function show(el, on) { if (el) el.hidden = !on; }
|
||||||
|
function toast(msg) { console.log('[ns]', msg); }
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// agents / lead board
|
// theme
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function applyTheme() {
|
||||||
|
document.documentElement.setAttribute('data-theme', state.theme);
|
||||||
|
$('#btnThemeToggle').textContent = state.theme === 'dark' ? '☀' : '☾';
|
||||||
|
$('#btnThemeToggle').title = state.theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme';
|
||||||
|
}
|
||||||
|
$('#btnThemeToggle').addEventListener('click', () => {
|
||||||
|
state.theme = state.theme === 'dark' ? 'light' : 'dark';
|
||||||
|
localStorage.setItem('ns-theme', state.theme);
|
||||||
|
applyTheme();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// wizard — step navigation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function goToStep(n) {
|
||||||
|
state.step = Math.max(0, Math.min(STEP_COUNT - 1, n));
|
||||||
|
$$('.step-tab').forEach((tab, i) => {
|
||||||
|
tab.classList.toggle('active', i === state.step);
|
||||||
|
tab.classList.toggle('done', i < state.step);
|
||||||
|
});
|
||||||
|
$$('.wizard-panel').forEach((panel) => show(panel, Number(panel.dataset.panel) === state.step));
|
||||||
|
show($('#btnStepBack'), state.step > 0);
|
||||||
|
show($('#btnStepNext'), state.step < STEP_COUNT - 1);
|
||||||
|
show($('#btnLaunch'), state.step === STEP_COUNT - 1);
|
||||||
|
if (state.step === STEP_COUNT - 1) renderReview();
|
||||||
|
updateWizardSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateStep(n) {
|
||||||
|
if (n === 0) {
|
||||||
|
const target = $('#fieldTarget').value.trim();
|
||||||
|
if (!target) { alert(`${MODE_LABELS[state.mode].target} is required.`); return false; }
|
||||||
|
if (state.mode === 'greybox' && !$('#fieldRepo').value.trim()) { alert('Source repo is required for grey-box.'); return false; }
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#btnStepNext').addEventListener('click', () => { if (validateStep(state.step)) goToStep(state.step + 1); });
|
||||||
|
$('#btnStepBack').addEventListener('click', () => goToStep(state.step - 1));
|
||||||
|
$$('.step-tab').forEach((tab) => tab.addEventListener('click', () => {
|
||||||
|
const n = Number(tab.dataset.step);
|
||||||
|
if (n <= state.step || validateStep(state.step)) goToStep(n);
|
||||||
|
}));
|
||||||
|
|
||||||
|
function updateWizardSummary() {
|
||||||
|
const target = $('#fieldTarget').value.trim() || '(not set)';
|
||||||
|
$('#wizardSummary').innerHTML = `Step ${state.step + 1} of ${STEP_COUNT} · <b>${esc(state.mode)}</b> · <b>${esc(target)}</b>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// mode tiles
|
||||||
|
function selectMode(mode) {
|
||||||
|
state.mode = mode;
|
||||||
|
$$('.mode-tile').forEach((t) => t.classList.toggle('selected', t.dataset.mode === mode));
|
||||||
|
const cfg = MODE_LABELS[mode];
|
||||||
|
$('#targetLabel').textContent = cfg.target;
|
||||||
|
$('#targetHelp').textContent = cfg.help;
|
||||||
|
$('#fieldTarget').placeholder = cfg.placeholder;
|
||||||
|
show($('#fieldRepoGroup'), cfg.showRepo);
|
||||||
|
updateWizardSummary();
|
||||||
|
}
|
||||||
|
$$('.mode-tile').forEach((tile) => tile.addEventListener('click', () => selectMode(tile.dataset.mode)));
|
||||||
|
$('#fieldTarget').addEventListener('input', updateWizardSummary);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// agents / lead board (step 3)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function loadAgents() {
|
async function loadAgents() {
|
||||||
@@ -57,7 +148,6 @@ function renderBoard() {
|
|||||||
const selCount = group.agents.filter((a) => state.selected.has(a.id)).length;
|
const selCount = group.agents.filter((a) => state.selected.has(a.id)).length;
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'cat-card';
|
card.className = 'cat-card';
|
||||||
card.dataset.category = group.category;
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="cat-head">
|
<div class="cat-head">
|
||||||
<label class="switch">
|
<label class="switch">
|
||||||
@@ -92,19 +182,14 @@ function renderBoard() {
|
|||||||
});
|
});
|
||||||
card.querySelector('.cat-toggle').addEventListener('change', (e) => {
|
card.querySelector('.cat-toggle').addEventListener('change', (e) => {
|
||||||
const on = e.target.checked;
|
const on = e.target.checked;
|
||||||
for (const a of group.agents) {
|
for (const a of group.agents) { if (on) state.selected.add(a.id); else state.selected.delete(a.id); }
|
||||||
if (on) state.selected.add(a.id); else state.selected.delete(a.id);
|
|
||||||
}
|
|
||||||
renderBoard();
|
renderBoard();
|
||||||
updateChips();
|
|
||||||
});
|
});
|
||||||
rows.querySelectorAll('.agent-toggle').forEach((input) => {
|
rows.querySelectorAll('.agent-toggle').forEach((input) => {
|
||||||
input.addEventListener('change', (e) => {
|
input.addEventListener('change', (e) => {
|
||||||
const id = e.target.dataset.id;
|
const id = e.target.dataset.id;
|
||||||
if (e.target.checked) state.selected.add(id); else state.selected.delete(id);
|
if (e.target.checked) state.selected.add(id); else state.selected.delete(id);
|
||||||
renderBoard();
|
renderBoard();
|
||||||
updateChips();
|
|
||||||
applyFilters();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
root.appendChild(card);
|
root.appendChild(card);
|
||||||
@@ -113,9 +198,7 @@ function renderBoard() {
|
|||||||
applyFilters();
|
applyFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
function allAgents() {
|
function allAgents() { return state.categories.flatMap((g) => g.agents); }
|
||||||
return state.categories.flatMap((g) => g.agents);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateChips() {
|
function updateChips() {
|
||||||
const total = allAgents().length;
|
const total = allAgents().length;
|
||||||
@@ -127,8 +210,7 @@ function updateChips() {
|
|||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
const q = state.search.trim().toLowerCase();
|
const q = state.search.trim().toLowerCase();
|
||||||
$$('.agent-row').forEach((row) => {
|
$$('.agent-row').forEach((row) => {
|
||||||
const id = row.dataset.id;
|
const isSel = state.selected.has(row.dataset.id);
|
||||||
const isSel = state.selected.has(id);
|
|
||||||
let visible = true;
|
let visible = true;
|
||||||
if (state.filter === 'selected') visible = isSel;
|
if (state.filter === 'selected') visible = isSel;
|
||||||
if (state.filter === 'excluded') visible = !isSel;
|
if (state.filter === 'excluded') visible = !isSel;
|
||||||
@@ -136,108 +218,170 @@ function applyFilters() {
|
|||||||
row.classList.toggle('hidden-by-search', !visible);
|
row.classList.toggle('hidden-by-search', !visible);
|
||||||
});
|
});
|
||||||
$$('.cat-card').forEach((card) => {
|
$$('.cat-card').forEach((card) => {
|
||||||
const anyVisible = [...card.querySelectorAll('.agent-row')].some((r) => !r.classList.contains('hidden-by-search'));
|
const anyVisible = $$('.agent-row', card).some((r) => !r.classList.contains('hidden-by-search'));
|
||||||
card.style.display = anyVisible ? '' : 'none';
|
card.style.display = anyVisible ? '' : 'none';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
$$('.chip').forEach((chip) => chip.addEventListener('click', () => {
|
||||||
// engagement bar / ask panel
|
$$('.chip').forEach((c) => c.classList.remove('chip-active'));
|
||||||
// ---------------------------------------------------------------------------
|
chip.classList.add('chip-active');
|
||||||
|
state.filter = chip.dataset.filter;
|
||||||
function currentMode() { return $('#fieldMode').value; }
|
applyFilters();
|
||||||
|
}));
|
||||||
$('#fieldMode').addEventListener('change', () => {
|
|
||||||
show($('.eb-repo'), currentMode() === 'greybox');
|
|
||||||
$('.eb-target label').textContent = currentMode() === 'whitebox' ? 'Repo / path' : 'Target';
|
|
||||||
});
|
|
||||||
|
|
||||||
$('#btnAskSend').addEventListener('click', () => {
|
|
||||||
const kind = $('#askKind').value;
|
|
||||||
const text = $('#askInput').value.trim();
|
|
||||||
if (!text) return;
|
|
||||||
if (kind === 'focus') state.askFocus = text;
|
|
||||||
if (kind === 'objective') state.askObjective = text;
|
|
||||||
if (kind === 'scope-out') state.askOutOfScope = text;
|
|
||||||
$('#askHint').textContent = `✓ ${kind} definido — aplicado no próximo "Start Exploitation"`;
|
|
||||||
$('#askInput').value = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
$('#btnCustomLead').addEventListener('click', () => {
|
|
||||||
const text = prompt('Descreva a lead customizada (linguagem livre — vira contexto de foco para os agentes):');
|
|
||||||
if (text && text.trim()) {
|
|
||||||
state.customLeads.push(text.trim());
|
|
||||||
$('#askHint').textContent = `✓ custom lead adicionada (${state.customLeads.length} total)`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$$('.chip').forEach((chip) => {
|
|
||||||
chip.addEventListener('click', () => {
|
|
||||||
$$('.chip').forEach((c) => c.classList.remove('chip-active'));
|
|
||||||
chip.classList.add('chip-active');
|
|
||||||
state.filter = chip.dataset.filter;
|
|
||||||
applyFilters();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$('#leadSearch').addEventListener('input', (e) => { state.search = e.target.value; applyFilters(); });
|
$('#leadSearch').addEventListener('input', (e) => { state.search = e.target.value; applyFilters(); });
|
||||||
|
|
||||||
|
function renderCustomLeads() {
|
||||||
|
const root = $('#customLeadsList');
|
||||||
|
root.innerHTML = state.customLeads.map((text, i) => `
|
||||||
|
<div class="custom-lead-chip"><span>${esc(text)}</span><span class="x" data-i="${i}">✕</span></div>
|
||||||
|
`).join('');
|
||||||
|
$$('.custom-lead-chip .x', root).forEach((x) => x.addEventListener('click', () => {
|
||||||
|
state.customLeads.splice(Number(x.dataset.i), 1);
|
||||||
|
renderCustomLeads();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
$('#btnCustomLead').addEventListener('click', () => {
|
||||||
|
const text = prompt('Describe the custom lead (free text — becomes agent focus context):');
|
||||||
|
if (text && text.trim()) { state.customLeads.push(text.trim()); renderCustomLeads(); }
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// start exploitation → live run view
|
// providers / model (step 4)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
$('#btnStartExploitation').addEventListener('click', startExploitation);
|
async function loadProviders() {
|
||||||
|
state.providers = await api('/api/providers');
|
||||||
|
const sel = $('#fieldProvider');
|
||||||
|
sel.innerHTML = state.providers.map((p) => `<option value="${esc(p.key)}">${esc(p.label)} (${p.kind === 'cli' ? 'API or subscription' : 'API key only'})</option>`).join('');
|
||||||
|
sel.addEventListener('change', onProviderChange);
|
||||||
|
onProviderChange();
|
||||||
|
}
|
||||||
|
function onProviderChange() {
|
||||||
|
const p = state.providers.find((x) => x.key === $('#fieldProvider').value) || state.providers[0];
|
||||||
|
const modelSel = $('#fieldModelSelect');
|
||||||
|
modelSel.innerHTML = (p?.models || []).map((m) => `<option value="${esc(m)}">${esc(m)}</option>`).join('');
|
||||||
|
const subBtn = $('#authModeToggle button[data-mode="subscription"]');
|
||||||
|
const supportsSub = p?.kind === 'cli';
|
||||||
|
subBtn.disabled = !supportsSub;
|
||||||
|
subBtn.title = supportsSub ? '' : `${p?.label} has no local CLI subscription mode — API key only.`;
|
||||||
|
if (!supportsSub) setAuthMode('api');
|
||||||
|
updateAuthModeHelp();
|
||||||
|
}
|
||||||
|
function setAuthMode(mode) {
|
||||||
|
$$('#authModeToggle button').forEach((b) => b.classList.toggle('selected', b.dataset.mode === mode));
|
||||||
|
state.authMode = mode;
|
||||||
|
updateAuthModeHelp();
|
||||||
|
}
|
||||||
|
function updateAuthModeHelp() {
|
||||||
|
const p = state.providers.find((x) => x.key === $('#fieldProvider').value);
|
||||||
|
$('#authModeHelp').textContent = state.authMode === 'subscription'
|
||||||
|
? `Uses the locally logged-in ${p?.label || ''} CLI on this machine — no API key needed.`
|
||||||
|
: `Uses the API key set for ${p?.label || 'this provider'} in Auth & Keys.`;
|
||||||
|
}
|
||||||
|
$$('#authModeToggle button').forEach((b) => b.addEventListener('click', () => { if (!b.disabled) setAuthMode(b.dataset.mode); }));
|
||||||
|
state.authMode = 'api';
|
||||||
|
|
||||||
async function startExploitation() {
|
// ---------------------------------------------------------------------------
|
||||||
const mode = currentMode();
|
// review (step 5)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderReview() {
|
||||||
const target = $('#fieldTarget').value.trim();
|
const target = $('#fieldTarget').value.trim();
|
||||||
const repo = $('#fieldRepo').value.trim();
|
const repo = $('#fieldRepo').value.trim();
|
||||||
if (mode !== 'whitebox' && !target) { alert('Defina o target.'); return; }
|
const provider = $('#fieldProvider').value;
|
||||||
if (mode === 'whitebox' && !target && !repo) { alert('Defina o repo/path.'); return; }
|
const model = $('#fieldModelSelect').value;
|
||||||
if (mode === 'greybox' && !repo) { alert('Grey-box precisa de repo + target.'); return; }
|
const items = [
|
||||||
|
{ k: 'Mode', v: state.mode },
|
||||||
|
{ k: MODE_LABELS[state.mode].target, v: target || '(not set)', mono: true },
|
||||||
|
...(MODE_LABELS[state.mode].showRepo ? [{ k: 'Source repo', v: repo || '(not set)', mono: true }] : []),
|
||||||
|
{ k: 'Model', v: `${provider}:${model}` },
|
||||||
|
{ k: 'Auth mode', v: state.authMode === 'subscription' ? 'Subscription (local CLI)' : 'API key' },
|
||||||
|
{ k: 'Leads selected', v: `${state.selected.size} of ${allAgents().length}${state.selected.size === 0 ? ' — auto (recon-driven)' : ''}` },
|
||||||
|
{ k: 'Custom leads', v: String(state.customLeads.length) },
|
||||||
|
{ k: 'Votes / chain / recon', v: `${$('#fieldVotes').value} / ${$('#fieldChain').value} / ${$('#fieldRecon').value}` },
|
||||||
|
{ 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) => `
|
||||||
|
<div class="review-item"><div class="k">${esc(it.k)}</div><div class="v${it.mono ? ' mono' : ''}">${esc(it.v)}</div></div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
const modelField = $('#fieldModel').value.trim();
|
// ---------------------------------------------------------------------------
|
||||||
const focusParts = [state.askFocus, ...state.customLeads].filter(Boolean);
|
// launch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
$('#btnLaunch').addEventListener('click', startExploitation);
|
||||||
|
|
||||||
|
async function startExploitation() {
|
||||||
|
const mode = state.mode;
|
||||||
|
const target = $('#fieldTarget').value.trim();
|
||||||
|
const repo = $('#fieldRepo').value.trim();
|
||||||
|
const provider = $('#fieldProvider').value;
|
||||||
|
const model = $('#fieldModelSelect').value;
|
||||||
|
const focusParts = [$('#fieldFocus').value.trim(), ...state.customLeads].filter(Boolean);
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
mode,
|
mode,
|
||||||
target: mode === 'whitebox' ? undefined : target,
|
target: mode === 'whitebox' ? undefined : target,
|
||||||
repo: mode === 'whitebox' ? (target || repo) : (repo || undefined),
|
repo: mode === 'whitebox' ? target : (repo || undefined),
|
||||||
models: modelField ? [modelField] : [],
|
models: provider && model ? [`${provider}:${model}`] : [],
|
||||||
votes: Number($('#fieldVotes').value) || 3,
|
votes: Number($('#fieldVotes').value) || 3,
|
||||||
chainDepth: Number($('#fieldChain').value),
|
chainDepth: Number($('#fieldChain').value),
|
||||||
recon: Number($('#fieldRecon').value),
|
recon: Number($('#fieldRecon').value),
|
||||||
subscription: $('#fieldSubscription').checked,
|
subscription: state.authMode === 'subscription',
|
||||||
mcp: $('#fieldMcp').checked,
|
mcp: $('#fieldMcp').checked,
|
||||||
agents: [...state.selected],
|
agents: [...state.selected],
|
||||||
focus: focusParts.join('; ') || undefined,
|
focus: focusParts.join('; ') || undefined,
|
||||||
objective: state.askObjective || undefined,
|
objective: $('#fieldObjective').value.trim() || undefined,
|
||||||
outOfScope: state.askOutOfScope || undefined,
|
outOfScope: $('#fieldOutOfScope').value.trim() || undefined,
|
||||||
|
auth: state.auth.header || undefined,
|
||||||
|
roles: state.auth.roles.length ? state.auth.roles : undefined,
|
||||||
|
creds: state.credsPath || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
$('#btnStartExploitation').disabled = true;
|
$('#btnLaunch').disabled = true;
|
||||||
|
$('#btnLaunch').textContent = 'Starting…';
|
||||||
try {
|
try {
|
||||||
const { id } = await api('/api/exploit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
const { id } = await api('/api/exploit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
attachLiveJob(id, body.target || body.repo);
|
attachLiveJob(id, body.target || body.repo);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Falha ao iniciar: ' + e.message);
|
alert('Failed to start: ' + e.message);
|
||||||
} finally {
|
} finally {
|
||||||
$('#btnStartExploitation').disabled = false;
|
$('#btnLaunch').disabled = false;
|
||||||
|
$('#btnLaunch').textContent = 'Start Exploitation →';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachLiveJob(id, target) {
|
// ---------------------------------------------------------------------------
|
||||||
if (state.currentJob) state.currentJob.es.close();
|
// live run view
|
||||||
state.currentJob = { id, es: null, findings: [], target, phase: 'starting', agents: 0, agentsDone: 0, reportUrl: null };
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
show($('#boardView'), false);
|
function bindRunTabs(scopeEl) {
|
||||||
|
$$('.run-tab', scopeEl).forEach((tab) => tab.addEventListener('click', () => {
|
||||||
|
$$('.run-tab', scopeEl).forEach((t) => t.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
$$('.run-tab-panel', scopeEl).forEach((p) => show(p, p.dataset.tabpanel === tab.dataset.tab));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
bindRunTabs($('#liveView'));
|
||||||
|
bindRunTabs($('#detailView'));
|
||||||
|
|
||||||
|
function attachLiveJob(id, target) {
|
||||||
|
if (state.currentJob?.es) state.currentJob.es.close();
|
||||||
|
state.currentJob = { id, es: null, findings: [], target, phase: 'starting', agents: 0, agentsDone: 0, reportUrl: null, runId: null };
|
||||||
|
|
||||||
|
show($('#wizardView'), false);
|
||||||
show($('#detailView'), false);
|
show($('#detailView'), false);
|
||||||
show($('#liveView'), true);
|
show($('#liveView'), true);
|
||||||
$('#liveTarget').textContent = target || '—';
|
$('#liveTarget').textContent = target || '—';
|
||||||
$('#livePhase').textContent = 'starting';
|
$('#livePhase').textContent = 'starting';
|
||||||
$('#findingsList').innerHTML = '';
|
$('#phaseDot').style.background = '';
|
||||||
|
$('#liveFindingsTable tbody').innerHTML = '';
|
||||||
|
$('#liveAttackPath').innerHTML = '';
|
||||||
$('#logList').innerHTML = '';
|
$('#logList').innerHTML = '';
|
||||||
$('#findingsCount').textContent = '0';
|
$('#liveFindingsCount').textContent = '0';
|
||||||
|
show($('#liveFindingsEmpty'), true);
|
||||||
$('#progressFill').style.width = '0%';
|
$('#progressFill').style.width = '0%';
|
||||||
$('#progressLabel').textContent = '0 / 0 agents';
|
$('#progressLabel').textContent = '0 / 0 agents';
|
||||||
show($('#btnOpenReport'), false);
|
show($('#btnOpenReport'), false);
|
||||||
@@ -247,8 +391,8 @@ function attachLiveJob(id, target) {
|
|||||||
es.addEventListener('log', (e) => appendLog(JSON.parse(e.data).line));
|
es.addEventListener('log', (e) => appendLog(JSON.parse(e.data).line));
|
||||||
es.addEventListener('finding', (e) => addFinding(JSON.parse(e.data).finding));
|
es.addEventListener('finding', (e) => addFinding(JSON.parse(e.data).finding));
|
||||||
es.addEventListener('snapshot', (e) => applySnapshot(JSON.parse(e.data)));
|
es.addEventListener('snapshot', (e) => applySnapshot(JSON.parse(e.data)));
|
||||||
es.addEventListener('done', (e) => { applySnapshot(JSON.parse(e.data)); refreshRuns(); });
|
es.addEventListener('done', (e) => { applySnapshot(JSON.parse(e.data)); es.close(); refreshRuns(); });
|
||||||
es.onerror = () => { /* browser auto-retries; fine for a long-running engagement */ };
|
es.onerror = () => { /* EventSource auto-retries; the server replays its buffer on reconnect */ };
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendLog(line) {
|
function appendLog(line) {
|
||||||
@@ -260,52 +404,106 @@ function appendLog(line) {
|
|||||||
list.scrollTop = list.scrollHeight;
|
list.scrollTop = list.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findingRow(f) {
|
||||||
|
return `<tr>
|
||||||
|
<td><span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span></td>
|
||||||
|
<td>${esc(f.title)}</td>
|
||||||
|
<td class="col-endpoint" title="${esc(f.endpoint)}">${esc(f.endpoint)}</td>
|
||||||
|
<td>${esc(f.cwe)}</td>
|
||||||
|
<td>${esc(f.agent)}</td>
|
||||||
|
<td class="col-conf">${f.confidence ? f.confidence.toFixed(2) : '—'}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
function addFinding(f) {
|
function addFinding(f) {
|
||||||
state.currentJob.findings.push(f);
|
state.currentJob.findings.push(f);
|
||||||
const card = document.createElement('div');
|
$('#liveFindingsTable tbody').insertAdjacentHTML('beforeend', findingRow(f));
|
||||||
card.className = 'finding-card';
|
$('#liveFindingsCount').textContent = state.currentJob.findings.length;
|
||||||
card.innerHTML = `
|
show($('#liveFindingsEmpty'), false);
|
||||||
<div class="f-top"><span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span><span class="f-title">${esc(f.title)}</span></div>
|
renderAttackPath($('#liveAttackPath'), state.currentJob.findings);
|
||||||
<div class="f-meta">${esc(f.cwe || '')} ${f.endpoint ? '· ' + esc(f.endpoint) : ''} ${f.agent ? '· ' + esc(f.agent) : ''}</div>
|
|
||||||
`;
|
|
||||||
$('#findingsList').appendChild(card);
|
|
||||||
$('#findingsCount').textContent = state.currentJob.findings.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySnapshot(snap) {
|
function applySnapshot(snap) {
|
||||||
$('#livePhase').textContent = snap.phase;
|
$('#livePhase').textContent = snap.phase;
|
||||||
|
state.currentJob.runId = snap.runId;
|
||||||
$('#progressLabel').textContent = `${snap.agentsDone} / ${snap.agents || '?'} agents`;
|
$('#progressLabel').textContent = `${snap.agentsDone} / ${snap.agents || '?'} agents`;
|
||||||
if (snap.agents) $('#progressFill').style.width = `${Math.min(100, (snap.agentsDone / snap.agents) * 100)}%`;
|
if (snap.agents) $('#progressFill').style.width = `${Math.min(100, (snap.agentsDone / snap.agents) * 100)}%`;
|
||||||
if (snap.reportUrl) {
|
if (snap.reportUrl && snap.runId) {
|
||||||
const link = $('#btnOpenReport');
|
$('#btnOpenReport').href = `/api/runs/${snap.runId}/asset/report.html`;
|
||||||
link.href = `/api/runs/${snap.runId}/asset/report.html`;
|
show($('#btnOpenReport'), true);
|
||||||
show(link, !!snap.runId);
|
|
||||||
}
|
}
|
||||||
if (snap.done) $('#phaseDot').style.background = 'var(--green)';
|
if (snap.done) $('#phaseDot').classList.add('static');
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#btnStopRun').addEventListener('click', async () => {
|
$('#btnStopRun').addEventListener('click', async () => {
|
||||||
if (!state.currentJob) return;
|
if (!state.currentJob) return;
|
||||||
await api(`/api/exploit/${state.currentJob.id}/stop`, { method: 'POST' });
|
await api(`/api/exploit/${state.currentJob.id}/stop`, { method: 'POST' });
|
||||||
});
|
});
|
||||||
$('#btnBackToBoard').addEventListener('click', () => { show($('#liveView'), false); show($('#boardView'), true); });
|
$('#btnBackToBoard').addEventListener('click', () => { show($('#liveView'), false); show($('#wizardView'), true); });
|
||||||
|
$('#btnDetailBack').addEventListener('click', () => { clearInterval(state.detailPoll); show($('#detailView'), false); show($('#wizardView'), true); });
|
||||||
|
$('#btnNewEngagement').addEventListener('click', () => { clearInterval(state.detailPoll); show($('#detailView'), false); show($('#liveView'), false); show($('#wizardView'), true); });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Generative Attack Path Chaining
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const KILL_CHAIN_STAGES = ['recon', 'initial-access', 'execution', 'privesc', 'lateral', 'exfil', 'impact'];
|
||||||
|
|
||||||
|
function renderAttackPath(container, findings) {
|
||||||
|
if (!findings.length) {
|
||||||
|
container.innerHTML = '<div class="attackpath-empty">The attack path builds automatically as findings chain together — nothing confirmed yet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const byId = new Map(findings.map((f) => [f.id, f]));
|
||||||
|
const hasStages = findings.some((f) => f.stage);
|
||||||
|
let groups;
|
||||||
|
if (hasStages) {
|
||||||
|
groups = KILL_CHAIN_STAGES
|
||||||
|
.map((stage) => ({ label: stage.replace('-', ' '), items: findings.filter((f) => (f.stage || '') === stage) }))
|
||||||
|
.filter((g) => g.items.length);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
container.innerHTML = `
|
||||||
|
${!hasStages ? '<div class="field-help" style="margin-bottom:8px;">No kill-chain stage data yet — grouped by severity.</div>' : ''}
|
||||||
|
<div class="attackpath">
|
||||||
|
${groups.map((g, i) => `
|
||||||
|
${i > 0 ? '<div class="ap-arrow">→</div>' : ''}
|
||||||
|
<div class="ap-stage">
|
||||||
|
<div class="ap-stage-head">${esc(g.label)} (${g.items.length})</div>
|
||||||
|
${g.items.map((f) => `
|
||||||
|
<div class="ap-node ${sevClass(f.severity)}">
|
||||||
|
<div class="t">${esc(f.title)}</div>
|
||||||
|
<div class="m">${esc(f.mitre || f.owasp || f.cwe || '')}</div>
|
||||||
|
${(f.chains_from || []).length ? `<div class="chain-from">⤷ chains from ${(f.chains_from).map((cid) => esc(byId.get(cid)?.title || cid)).join(', ')}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// sidebar — runs history
|
// sidebar — runs history
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function refreshRuns() {
|
async function refreshRuns() {
|
||||||
try {
|
try { state.runs = await api('/api/runs'); } catch { state.runs = []; }
|
||||||
state.runs = await api('/api/runs');
|
|
||||||
} catch { state.runs = []; }
|
|
||||||
renderSidebar();
|
renderSidebar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PHASE_ORDER = { starting: 0, recon: 0, planning: 1, exploiting: 2, validating: 2, chaining: 2, complete: 3 };
|
||||||
function stepClassFor(phase, step) {
|
function stepClassFor(phase, step) {
|
||||||
const order = ['recon', 'planning', 'exploiting', 'remediation'];
|
const order = ['recon', 'planning', 'exploiting', 'remediation'];
|
||||||
const idx = { recon: 0, starting: 0, planning: 1, exploiting: 2, validating: 2, chaining: 2, complete: 3 }[phase] ?? 0;
|
if (step === 'remediation') return 'pending'; // not automated yet
|
||||||
|
const idx = PHASE_ORDER[phase] ?? 0;
|
||||||
const stepIdx = order.indexOf(step);
|
const stepIdx = order.indexOf(step);
|
||||||
if (step === 'remediation') return 'pending'; // not automated yet — shown for roadmap parity only
|
|
||||||
if (stepIdx < idx) return 'done';
|
if (stepIdx < idx) return 'done';
|
||||||
if (stepIdx === idx) return 'active';
|
if (stepIdx === idx) return 'active';
|
||||||
return 'pending';
|
return 'pending';
|
||||||
@@ -314,14 +512,9 @@ function stepClassFor(phase, step) {
|
|||||||
function renderSidebar() {
|
function renderSidebar() {
|
||||||
const root = $('#sbGroups');
|
const root = $('#sbGroups');
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
|
|
||||||
const running = state.runs.filter((r) => r.state === 'running');
|
const running = state.runs.filter((r) => r.state === 'running');
|
||||||
const completed = state.runs.filter((r) => r.state !== 'running');
|
const completed = state.runs.filter((r) => r.state !== 'running');
|
||||||
|
const groups = [{ label: 'Running', items: running }, { label: 'Completed', items: completed }];
|
||||||
const groups = [
|
|
||||||
{ label: 'Running', items: running, open: true },
|
|
||||||
{ label: 'Completed', items: completed, open: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const g of groups) {
|
for (const g of groups) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
@@ -332,15 +525,15 @@ function renderSidebar() {
|
|||||||
for (const r of g.items) {
|
for (const r of g.items) {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'sb-run' + (state.currentDetailId === r.id ? ' active' : '');
|
btn.className = 'sb-run' + (state.currentDetailId === r.id ? ' active' : '');
|
||||||
btn.innerHTML = `${esc(r.target)}<span class="sub">${esc(r.id)} · ${r.findings} finding(s)</span>`;
|
btn.innerHTML = `<span class="name">${esc(r.target)}</span><span class="sub">${esc(r.id)} · ${r.findings} finding(s)</span>`;
|
||||||
btn.addEventListener('click', () => openRun(r));
|
btn.addEventListener('click', () => openRun(r));
|
||||||
items.appendChild(btn);
|
items.appendChild(btn);
|
||||||
if (r.state === 'running' && state.currentJob) {
|
const isThisJob = r.state === 'running' && state.currentJob && r.id === state.currentJob.runId;
|
||||||
const phase = state.currentJob.target === r.target ? $('#livePhase').textContent : null;
|
if (isThisJob) {
|
||||||
const steps = document.createElement('div');
|
const steps = document.createElement('div');
|
||||||
steps.className = 'sb-steps';
|
steps.className = 'sb-steps';
|
||||||
steps.innerHTML = ['recon', 'planning', 'exploiting', 'remediation'].map((s) =>
|
steps.innerHTML = ['recon', 'planning', 'exploiting', 'remediation'].map((s) =>
|
||||||
`<div class="sb-step ${stepClassFor(phase || 'recon', s)}">${s[0].toUpperCase() + s.slice(1)}</div>`).join('');
|
`<div class="sb-step ${stepClassFor($('#livePhase').textContent, s)}">${s[0].toUpperCase() + s.slice(1)}</div>`).join('');
|
||||||
items.appendChild(steps);
|
items.appendChild(steps);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -350,11 +543,12 @@ function renderSidebar() {
|
|||||||
|
|
||||||
function openRun(run) {
|
function openRun(run) {
|
||||||
state.currentDetailId = run.id;
|
state.currentDetailId = run.id;
|
||||||
if (run.state === 'running' && state.currentJob) {
|
if (run.state === 'running' && state.currentJob && run.id === state.currentJob.runId) {
|
||||||
show($('#boardView'), false); show($('#detailView'), false); show($('#liveView'), true);
|
show($('#wizardView'), false); show($('#detailView'), false); show($('#liveView'), true);
|
||||||
|
renderSidebar();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
show($('#boardView'), false); show($('#liveView'), false); show($('#detailView'), true);
|
show($('#wizardView'), false); show($('#liveView'), false); show($('#detailView'), true);
|
||||||
loadDetail(run.id);
|
loadDetail(run.id);
|
||||||
renderSidebar();
|
renderSidebar();
|
||||||
}
|
}
|
||||||
@@ -364,27 +558,87 @@ async function loadDetail(id) {
|
|||||||
const detail = await api(`/api/runs/${encodeURIComponent(id)}`);
|
const detail = await api(`/api/runs/${encodeURIComponent(id)}`);
|
||||||
$('#detailTarget').textContent = detail.status?.target || detail.meta?.target || id;
|
$('#detailTarget').textContent = detail.status?.target || detail.meta?.target || id;
|
||||||
$('#detailState').textContent = detail.status?.state || 'unknown';
|
$('#detailState').textContent = detail.status?.state || 'unknown';
|
||||||
const list = $('#detailFindings');
|
$('#detailFindingsCount').textContent = detail.findings.length;
|
||||||
list.innerHTML = detail.findings.length
|
const tbody = $('#detailFindingsTable tbody');
|
||||||
? detail.findings.map((f) => `
|
tbody.innerHTML = detail.findings.map(findingRow).join('');
|
||||||
<div class="finding-card">
|
show($('#detailFindingsEmpty'), detail.findings.length === 0);
|
||||||
<div class="f-top"><span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span><span class="f-title">${esc(f.title)}</span></div>
|
renderAttackPath($('#detailAttackPath'), detail.findings);
|
||||||
<div class="f-meta">${esc(f.cwe || '')} ${f.endpoint ? '· ' + esc(f.endpoint) : ''} ${f.agent ? '· ' + esc(f.agent) : ''}</div>
|
|
||||||
</div>`).join('')
|
|
||||||
: '<div class="f-meta">nenhum finding validado.</div>';
|
|
||||||
const reportLink = $('#detailOpenReport');
|
const reportLink = $('#detailOpenReport');
|
||||||
if (detail.assets.includes('report.html')) {
|
if (detail.assets.includes('report.html')) {
|
||||||
reportLink.href = `/api/runs/${encodeURIComponent(id)}/asset/report.html`;
|
reportLink.href = `/api/runs/${encodeURIComponent(id)}/asset/report.html`;
|
||||||
show(reportLink, true);
|
show(reportLink, true);
|
||||||
} else show(reportLink, false);
|
} else show(reportLink, false);
|
||||||
|
if (detail.status?.state === 'running') state.detailPoll = setInterval(() => loadDetail(id), 4000);
|
||||||
if (detail.status?.state === 'running') {
|
|
||||||
state.detailPoll = setInterval(() => loadDetail(id), 4000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#btnDetailBack').addEventListener('click', () => { clearInterval(state.detailPoll); show($('#detailView'), false); show($('#boardView'), true); });
|
// ---------------------------------------------------------------------------
|
||||||
$('#btnNewEngagement').addEventListener('click', () => { show($('#detailView'), false); show($('#liveView'), false); show($('#boardView'), true); });
|
// Auth & Keys modal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function openAuthModal() {
|
||||||
|
show($('#authModal'), true);
|
||||||
|
renderRoleList();
|
||||||
|
$('#credsPath').value = state.credsPath;
|
||||||
|
refreshKeyStatus();
|
||||||
|
}
|
||||||
|
['#btnOpenAuth', '#btnOpenAuth2', '#btnOpenAuth3'].forEach((sel) => $(sel)?.addEventListener('click', openAuthModal));
|
||||||
|
$('#btnCloseAuth').addEventListener('click', () => show($('#authModal'), false));
|
||||||
|
$('#authModal').addEventListener('click', (e) => { if (e.target.id === 'authModal') show($('#authModal'), false); });
|
||||||
|
|
||||||
|
$$('.modal-tab').forEach((tab) => tab.addEventListener('click', () => {
|
||||||
|
$$('.modal-tab').forEach((t) => t.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
$$('.modal-panel').forEach((p) => show(p, p.dataset.mpanel === tab.dataset.mtab));
|
||||||
|
}));
|
||||||
|
|
||||||
|
$('#authHeader').addEventListener('input', (e) => { state.auth.header = e.target.value.trim(); });
|
||||||
|
$('#authHeader').value = state.auth.header;
|
||||||
|
$('#credsPath').addEventListener('input', (e) => { state.credsPath = e.target.value.trim(); });
|
||||||
|
|
||||||
|
function renderRoleList() {
|
||||||
|
const root = $('#roleList');
|
||||||
|
root.innerHTML = state.auth.roles.map((r, i) => `
|
||||||
|
<div class="role-row">
|
||||||
|
<input class="role-name" data-i="${i}" data-f="name" placeholder="role name" value="${esc(r.name)}" />
|
||||||
|
<input data-i="${i}" data-f="header" placeholder="Authorization: Bearer ..." value="${esc(r.header)}" />
|
||||||
|
<button class="icon-btn" data-i="${i}" data-remove>✕</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
$$('input[data-f]', root).forEach((inp) => inp.addEventListener('input', (e) => {
|
||||||
|
state.auth.roles[Number(e.target.dataset.i)][e.target.dataset.f] = e.target.value;
|
||||||
|
}));
|
||||||
|
$$('[data-remove]', root).forEach((btn) => btn.addEventListener('click', () => {
|
||||||
|
state.auth.roles.splice(Number(btn.dataset.i), 1);
|
||||||
|
renderRoleList();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
$('#btnAddRole').addEventListener('click', () => { state.auth.roles.push({ name: '', header: '' }); renderRoleList(); });
|
||||||
|
|
||||||
|
async function refreshKeyStatus() {
|
||||||
|
try { state.keys = await api('/api/keys'); } catch { state.keys = []; }
|
||||||
|
const root = $('#providerKeyList');
|
||||||
|
root.innerHTML = state.providers.map((p) => {
|
||||||
|
const set = state.keys.find((k) => k.provider === p.key)?.set;
|
||||||
|
return `
|
||||||
|
<div class="provider-row">
|
||||||
|
<span class="dot ${set ? 'set' : ''}"></span>
|
||||||
|
<span class="p-name">${esc(p.label)}</span>
|
||||||
|
<span class="p-kind">${p.kind === 'cli' ? 'cli+api' : 'api only'}</span>
|
||||||
|
<input type="password" data-provider="${esc(p.key)}" placeholder="${set ? '•••••••• (set — enter to replace)' : 'paste API key'}" />
|
||||||
|
<button class="btn btn-sm" data-save="${esc(p.key)}">Save</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
$$('[data-save]', root).forEach((btn) => btn.addEventListener('click', async () => {
|
||||||
|
const provider = btn.dataset.save;
|
||||||
|
const input = root.querySelector(`input[data-provider="${provider}"]`);
|
||||||
|
const key = input.value.trim();
|
||||||
|
if (!key) return;
|
||||||
|
await api('/api/keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider, key }) });
|
||||||
|
input.value = '';
|
||||||
|
refreshKeyStatus();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// REPL drawer — real CLI harness session
|
// REPL drawer — real CLI harness session
|
||||||
@@ -401,10 +655,10 @@ async function startRepl() {
|
|||||||
es.addEventListener('data', (e) => {
|
es.addEventListener('data', (e) => {
|
||||||
const { chunk } = JSON.parse(e.data);
|
const { chunk } = JSON.parse(e.data);
|
||||||
const out = $('#replOutput');
|
const out = $('#replOutput');
|
||||||
out.textContent += chunk;
|
out.appendChild(document.createTextNode(chunk));
|
||||||
out.scrollTop = out.scrollHeight;
|
out.scrollTop = out.scrollHeight;
|
||||||
});
|
});
|
||||||
es.addEventListener('close', () => { es.close(); });
|
es.addEventListener('close', () => es.close());
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#fabRepl').addEventListener('click', openReplDrawer);
|
$('#fabRepl').addEventListener('click', openReplDrawer);
|
||||||
@@ -421,7 +675,10 @@ $('#replInput').addEventListener('keydown', async (e) => {
|
|||||||
const line = e.target.value;
|
const line = e.target.value;
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
const out = $('#replOutput');
|
const out = $('#replOutput');
|
||||||
out.textContent += `❭ ${line}\n`;
|
const echo = document.createElement('span');
|
||||||
|
echo.className = 'repl-echo';
|
||||||
|
echo.textContent = `❭ ${line}\n`;
|
||||||
|
out.appendChild(echo);
|
||||||
out.scrollTop = out.scrollHeight;
|
out.scrollTop = out.scrollHeight;
|
||||||
if (!state.replId) await startRepl();
|
if (!state.replId) await startRepl();
|
||||||
await api(`/api/repl/${state.replId}/input`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ line }) });
|
await api(`/api/repl/${state.replId}/input`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ line }) });
|
||||||
@@ -432,9 +689,13 @@ $('#replInput').addEventListener('keydown', async (e) => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
|
applyTheme();
|
||||||
|
selectMode('run');
|
||||||
|
goToStep(0);
|
||||||
|
renderCustomLeads();
|
||||||
const meta = await api('/api/meta').catch(() => ({}));
|
const meta = await api('/api/meta').catch(() => ({}));
|
||||||
$('#sbMeta').textContent = `v${meta.version || '4.0.0'}`;
|
$('#sbVersion').textContent = `v${meta.version || '4.0.0'}`;
|
||||||
await loadAgents();
|
await Promise.all([loadAgents(), loadProviders()]);
|
||||||
await refreshRuns();
|
await refreshRuns();
|
||||||
setInterval(refreshRuns, 6000);
|
setInterval(refreshRuns, 6000);
|
||||||
}
|
}
|
||||||
|
|||||||
+251
-132
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="pt-BR">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
@@ -12,179 +12,298 @@
|
|||||||
<div class="app">
|
<div class="app">
|
||||||
|
|
||||||
<!-- ============ SIDEBAR ============ -->
|
<!-- ============ SIDEBAR ============ -->
|
||||||
<aside class="sidebar">
|
<aside class="sidebar" id="sidebar">
|
||||||
<div class="sb-top">
|
<div class="sb-top">
|
||||||
<div class="brand">🧠</div>
|
<div class="brand"><span class="mark">NS</span> NeuroSploit</div>
|
||||||
<div class="sb-icons">
|
<button class="icon-btn" id="btnThemeToggle" title="Toggle light / dark theme">☾</button>
|
||||||
<button class="icon-btn" id="btnSearchRuns" title="Buscar engagement">⌕</button>
|
</div>
|
||||||
<button class="icon-btn" id="btnCollapseSidebar" title="Recolher">⟨⟩</button>
|
|
||||||
|
<button class="sb-new" id="btnNewEngagement">+ New engagement</button>
|
||||||
|
|
||||||
|
<div class="sb-groups" id="sbGroups"><!-- populated by app.js --></div>
|
||||||
|
|
||||||
|
<div class="sb-bottom">
|
||||||
|
<span class="sb-version" id="sbVersion">v4.0.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 REPL">❭_</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="new-engagement" id="btnNewEngagement">+ Novo engagement</button>
|
|
||||||
|
|
||||||
<div class="sb-groups" id="sbGroups">
|
|
||||||
<!-- populated by app.js -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sb-footer">
|
|
||||||
<div class="sb-meta" id="sbMeta">v4.0.0</div>
|
|
||||||
<button class="icon-btn" id="btnOpenRepl" title="Abrir REPL">⌘_</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- ============ MAIN ============ -->
|
<!-- ============ MAIN ============ -->
|
||||||
<main class="main">
|
<main class="main">
|
||||||
|
|
||||||
<!-- top bar -->
|
<!-- ============ WIZARD (new engagement) ============ -->
|
||||||
<header class="topbar">
|
<section class="wizard" id="wizardView">
|
||||||
<div class="search-wrap">
|
<header class="topbar">
|
||||||
<span class="search-icon">⌕</span>
|
|
||||||
<input id="leadSearch" type="text" placeholder="Search lead" />
|
|
||||||
</div>
|
|
||||||
<div class="chips">
|
|
||||||
<button class="chip chip-active" data-filter="all">All <span id="chipAll">0</span></button>
|
|
||||||
<button class="chip" data-filter="selected">Selected <span id="chipSelected">0</span></button>
|
|
||||||
<button class="chip" data-filter="excluded">Excluded <span id="chipExcluded">0</span></button>
|
|
||||||
</div>
|
|
||||||
<div class="topbar-spacer"></div>
|
|
||||||
<button class="btn btn-ghost" id="btnCustomLead">+ Custom lead</button>
|
|
||||||
<button class="btn btn-primary" id="btnStartExploitation">Start Exploitation →</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- engagement setup strip -->
|
|
||||||
<section class="engagement-bar" id="engagementBar">
|
|
||||||
<div class="eb-field eb-target">
|
|
||||||
<label>Target</label>
|
|
||||||
<input id="fieldTarget" type="text" placeholder="https://alvo.com ou owner/repo" />
|
|
||||||
</div>
|
|
||||||
<div class="eb-field">
|
|
||||||
<label>Modo</label>
|
|
||||||
<select id="fieldMode">
|
|
||||||
<option value="run">Black-box (run)</option>
|
|
||||||
<option value="whitebox">White-box</option>
|
|
||||||
<option value="greybox">Grey-box</option>
|
|
||||||
<option value="host">Host/Infra</option>
|
|
||||||
<option value="aitest">AI/LLM</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="eb-field eb-repo" hidden>
|
|
||||||
<label>Repo (greybox)</label>
|
|
||||||
<input id="fieldRepo" type="text" placeholder="owner/repo ou path local" />
|
|
||||||
</div>
|
|
||||||
<div class="eb-field eb-narrow">
|
|
||||||
<label>Modelo</label>
|
|
||||||
<input id="fieldModel" type="text" placeholder="anthropic:claude-opus-4-8" />
|
|
||||||
</div>
|
|
||||||
<div class="eb-field eb-narrow">
|
|
||||||
<label>Votes</label>
|
|
||||||
<input id="fieldVotes" type="number" min="1" max="9" value="3" />
|
|
||||||
</div>
|
|
||||||
<div class="eb-field eb-narrow">
|
|
||||||
<label>Recon</label>
|
|
||||||
<select id="fieldRecon">
|
|
||||||
<option value="1">1 · quick</option>
|
|
||||||
<option value="2">2 · standard</option>
|
|
||||||
<option value="3" selected>3 · deep</option>
|
|
||||||
<option value="4">4 · exhaustive</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="eb-field eb-narrow">
|
|
||||||
<label>Chain</label>
|
|
||||||
<input id="fieldChain" type="number" min="0" max="5" value="2" />
|
|
||||||
</div>
|
|
||||||
<label class="eb-check"><input type="checkbox" id="fieldSubscription" /> subscription</label>
|
|
||||||
<label class="eb-check"><input type="checkbox" id="fieldMcp" /> MCP</label>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ============ BOARD VIEW (lead picker) ============ -->
|
|
||||||
<section class="board" id="boardView">
|
|
||||||
<div class="board-scroll" id="boardScroll">
|
|
||||||
<h2 class="board-title">Set and modify the action plan</h2>
|
|
||||||
<div class="categories" id="categories"><!-- populated --></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside class="ask-panel">
|
|
||||||
<div class="ask-title">Ask anything</div>
|
|
||||||
<textarea id="askInput" placeholder="e.g. Prioritize the paths most likely to cause data leak"></textarea>
|
|
||||||
<div class="ask-row">
|
|
||||||
<select id="askKind">
|
|
||||||
<option value="focus">Focus</option>
|
|
||||||
<option value="objective">Objective</option>
|
|
||||||
<option value="scope-out">Out of scope</option>
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-icon" id="btnAskSend" title="Aplicar">➤</button>
|
|
||||||
</div>
|
|
||||||
<div class="ask-hint" id="askHint"></div>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ============ LIVE RUN VIEW ============ -->
|
|
||||||
<section class="liverun" id="liveView" hidden>
|
|
||||||
<div class="liverun-head">
|
|
||||||
<div>
|
<div>
|
||||||
<div class="liverun-target" id="liveTarget">—</div>
|
<div class="topbar-title">New engagement</div>
|
||||||
<div class="liverun-phase"><span class="phase-dot" id="phaseDot"></span><span id="livePhase">starting</span></div>
|
<div class="topbar-sub">Asset → Scope & Auth → Leads → Model & Run → Review</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="liverun-actions">
|
<div class="topbar-spacer"></div>
|
||||||
<a class="btn btn-ghost" id="btnOpenReport" target="_blank" hidden>Abrir report</a>
|
<button class="btn" id="btnOpenAuth2">🔑 Auth & Keys</button>
|
||||||
<button class="btn btn-danger" id="btnStopRun">Stop</button>
|
</header>
|
||||||
<button class="btn btn-ghost" id="btnBackToBoard">← Board</button>
|
|
||||||
|
<nav class="stepper" id="stepper">
|
||||||
|
<button class="step-tab active" data-step="0"><span class="n">1</span> Asset</button>
|
||||||
|
<button class="step-tab" data-step="1"><span class="n">2</span> Scope & Auth</button>
|
||||||
|
<button class="step-tab" data-step="2"><span class="n">3</span> Leads</button>
|
||||||
|
<button class="step-tab" data-step="3"><span class="n">4</span> Model & Run</button>
|
||||||
|
<button class="step-tab" data-step="4"><span class="n">5</span> Review</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="wizard-body">
|
||||||
|
|
||||||
|
<!-- Step 1 — Asset -->
|
||||||
|
<div class="wizard-panel" data-panel="0">
|
||||||
|
<div>
|
||||||
|
<div class="section-title">What are you testing?</div>
|
||||||
|
<div class="section-desc">Pick the engagement type — this decides which CLI subcommand runs underneath.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mode-tiles" id="modeTiles">
|
||||||
|
<button class="mode-tile" data-mode="run"><span class="t">Black-box</span><span class="d">URL only — recon-driven</span></button>
|
||||||
|
<button class="mode-tile" data-mode="whitebox"><span class="t">White-box</span><span class="d">Source repo — SAST agents</span></button>
|
||||||
|
<button class="mode-tile" data-mode="greybox"><span class="t">Grey-box</span><span class="d">URL + source together</span></button>
|
||||||
|
<button class="mode-tile" data-mode="host"><span class="t">Host / Infra</span><span class="d">IP — Linux/Windows/AD</span></button>
|
||||||
|
<button class="mode-tile" data-mode="aitest"><span class="t">AI / LLM</span><span class="d">Live AI agent or MCP endpoint</span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" id="fieldTargetGroup">
|
||||||
|
<label class="field-label" id="targetLabel">Target URL</label>
|
||||||
|
<input id="fieldTarget" type="text" placeholder="https://target.example.com" />
|
||||||
|
<div class="field-help" id="targetHelp">The application, host, or endpoint to test.</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-group" id="fieldRepoGroup" hidden>
|
||||||
|
<label class="field-label">Source repo</label>
|
||||||
|
<input id="fieldRepo" type="text" placeholder="owner/repo, a GitHub URL, or a local path" />
|
||||||
|
<div class="field-help">Cloned automatically if it's a GitHub URL or owner/repo shorthand.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2 — Scope & Auth -->
|
||||||
|
<div class="wizard-panel" data-panel="1" hidden>
|
||||||
|
<div>
|
||||||
|
<div class="section-title">Objective & focus</div>
|
||||||
|
<div class="section-desc">Steers what the agents prioritise and what counts as impact.</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field-label">Objective</label>
|
||||||
|
<textarea id="fieldObjective" placeholder="e.g. Pre-launch review of the checkout flow — prove any path to unauthorized order access."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field-label">Focus</label>
|
||||||
|
<textarea id="fieldFocus" placeholder="e.g. Prioritize the paths most likely to cause data leakage."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field-label">Out of scope</label>
|
||||||
|
<textarea id="fieldOutOfScope" placeholder="Hosts, paths, or techniques the agents must not touch."></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="section-title">Authentication</div>
|
||||||
|
<div class="section-desc">Test as a logged-in user. Configured in the <button class="btn btn-sm" id="btnOpenAuth3" style="display:inline">🔑 Auth & Keys</button> menu.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3 — Leads -->
|
||||||
|
<div class="wizard-panel" data-panel="2" hidden style="max-width: none;">
|
||||||
|
<div>
|
||||||
|
<div class="section-title">Set the action plan</div>
|
||||||
|
<div class="section-desc">Toggle specific leads to test, or leave everything off to let recon-driven auto-selection choose.</div>
|
||||||
|
</div>
|
||||||
|
<div class="lead-toolbar">
|
||||||
|
<div class="search-wrap">
|
||||||
|
<span class="search-icon">⌕</span>
|
||||||
|
<input id="leadSearch" type="text" placeholder="Search lead" />
|
||||||
|
</div>
|
||||||
|
<div class="chips">
|
||||||
|
<button class="chip chip-active" data-filter="all">All <span id="chipAll">0</span></button>
|
||||||
|
<button class="chip" data-filter="selected">Selected <span id="chipSelected">0</span></button>
|
||||||
|
<button class="chip" data-filter="excluded">Excluded <span id="chipExcluded">0</span></button>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-spacer"></div>
|
||||||
|
<button class="btn btn-sm" id="btnCustomLead">+ Custom lead</button>
|
||||||
|
</div>
|
||||||
|
<div class="custom-leads" id="customLeadsList"></div>
|
||||||
|
<div class="categories" id="categories"><!-- populated --></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 4 — Model & Run -->
|
||||||
|
<div class="wizard-panel" data-panel="3" hidden>
|
||||||
|
<div>
|
||||||
|
<div class="section-title">Model</div>
|
||||||
|
<div class="section-desc">Pick a provider and model from the harness's live catalog.</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field-label">Provider</label>
|
||||||
|
<select id="fieldProvider"></select>
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field-label">Model</label>
|
||||||
|
<select id="fieldModelSelect"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field-label">Auth mode</label>
|
||||||
|
<div class="auth-mode-toggle" id="authModeToggle">
|
||||||
|
<button data-mode="api" class="selected">API key</button>
|
||||||
|
<button data-mode="subscription">Subscription (local CLI login)</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-help" id="authModeHelp">Uses the API key set in Auth & Keys for this provider.</div>
|
||||||
|
</div>
|
||||||
|
<div class="check-row"><input type="checkbox" id="fieldMcp" /> <label for="fieldMcp">Playwright MCP (browser tool access, subscription backends only)</label></div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="section-title">Run settings</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field-group"><label class="field-label">Votes</label><input class="narrow" id="fieldVotes" type="number" min="1" max="9" value="3" /></div>
|
||||||
|
<div class="field-group"><label class="field-label">Chain depth</label><input class="narrow" id="fieldChain" type="number" min="0" max="5" value="2" /></div>
|
||||||
|
<div class="field-group"><label class="field-label">Recon intensity</label>
|
||||||
|
<select class="narrow" id="fieldRecon">
|
||||||
|
<option value="1">1 · quick</option>
|
||||||
|
<option value="2">2 · standard</option>
|
||||||
|
<option value="3" selected>3 · deep</option>
|
||||||
|
<option value="4">4 · exhaustive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 5 — Review -->
|
||||||
|
<div class="wizard-panel" data-panel="4" hidden>
|
||||||
|
<div>
|
||||||
|
<div class="section-title">Review</div>
|
||||||
|
<div class="section-desc">Confirm before launching — this spawns the real CLI harness.</div>
|
||||||
|
</div>
|
||||||
|
<div class="review-grid" id="reviewGrid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<footer class="wizard-footer">
|
||||||
|
<div class="summary-line" id="wizardSummary"></div>
|
||||||
|
<div style="display:flex; gap:8px;">
|
||||||
|
<button class="btn" id="btnStepBack">← Back</button>
|
||||||
|
<button class="btn btn-primary" id="btnStepNext">Next →</button>
|
||||||
|
<button class="btn btn-primary" id="btnLaunch" hidden>Start Exploitation →</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ LIVE RUN ============ -->
|
||||||
|
<section class="runpage" id="liveView" hidden>
|
||||||
|
<header class="run-head">
|
||||||
|
<div>
|
||||||
|
<div class="run-target" id="liveTarget">—</div>
|
||||||
|
<div class="run-meta"><span class="phase-dot" id="phaseDot"></span><span id="livePhase">starting</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="run-actions">
|
||||||
|
<a class="btn" id="btnOpenReport" target="_blank" hidden>Open report</a>
|
||||||
|
<button class="btn btn-danger" id="btnStopRun">Stop</button>
|
||||||
|
<button class="btn" id="btnBackToBoard">← New engagement</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
<div class="progress-wrap">
|
<div class="progress-wrap">
|
||||||
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
|
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
|
||||||
<div class="progress-label" id="progressLabel">0 / 0 agents</div>
|
<div class="progress-label" id="progressLabel">0 / 0 agents</div>
|
||||||
</div>
|
</div>
|
||||||
|
<nav class="run-tabs">
|
||||||
<div class="liverun-body">
|
<button class="run-tab active" data-tab="findings">Findings <span id="liveFindingsCount">0</span></button>
|
||||||
<div class="findings-col">
|
<button class="run-tab" data-tab="attackpath">Generative Attack Path Chaining</button>
|
||||||
<div class="col-head">Findings <span id="findingsCount">0</span></div>
|
<button class="run-tab" data-tab="log">Activity log</button>
|
||||||
<div class="findings-list" id="findingsList"></div>
|
</nav>
|
||||||
</div>
|
<div class="run-body">
|
||||||
<div class="log-col">
|
<div class="run-tab-panel" data-tabpanel="findings"><table class="data-table" id="liveFindingsTable"><thead><tr><th>Severity</th><th>Title</th><th>Endpoint</th><th>CWE</th><th>Agent</th><th>Conf.</th></tr></thead><tbody></tbody></table><div class="empty-state" id="liveFindingsEmpty">No validated findings yet.</div></div>
|
||||||
<div class="col-head">Activity feed</div>
|
<div class="run-tab-panel" data-tabpanel="attackpath" hidden><div id="liveAttackPath"></div></div>
|
||||||
<div class="log-list" id="logList"></div>
|
<div class="run-tab-panel" data-tabpanel="log" hidden><div class="log-panel" id="logList" style="height: 100%;"></div></div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ============ RUN DETAIL VIEW (past run) ============ -->
|
<!-- ============ RUN DETAIL (past run) ============ -->
|
||||||
<section class="rundetail" id="detailView" hidden>
|
<section class="runpage" id="detailView" hidden>
|
||||||
<div class="liverun-head">
|
<header class="run-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="liverun-target" id="detailTarget">—</div>
|
<div class="run-target" id="detailTarget">—</div>
|
||||||
<div class="liverun-phase" id="detailState">—</div>
|
<div class="run-meta"><span class="phase-dot static" id="detailDot"></span><span id="detailState">—</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="liverun-actions">
|
<div class="run-actions">
|
||||||
<a class="btn btn-ghost" id="detailOpenReport" target="_blank" hidden>Abrir report</a>
|
<a class="btn" id="detailOpenReport" target="_blank" hidden>Open report</a>
|
||||||
<button class="btn btn-ghost" id="btnDetailBack">← Board</button>
|
<button class="btn" id="btnDetailBack">← New engagement</button>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
<nav class="run-tabs">
|
||||||
|
<button class="run-tab active" data-tab="findings">Findings <span id="detailFindingsCount">0</span></button>
|
||||||
|
<button class="run-tab" data-tab="attackpath">Generative Attack Path Chaining</button>
|
||||||
|
</nav>
|
||||||
|
<div class="run-body">
|
||||||
|
<div class="run-tab-panel" data-tabpanel="findings"><table class="data-table" id="detailFindingsTable"><thead><tr><th>Severity</th><th>Title</th><th>Endpoint</th><th>CWE</th><th>Agent</th><th>Conf.</th></tr></thead><tbody></tbody></table><div class="empty-state" id="detailFindingsEmpty">No validated findings.</div></div>
|
||||||
|
<div class="run-tab-panel" data-tabpanel="attackpath" hidden><div id="detailAttackPath"></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="findings-list" id="detailFindings"></div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ AUTH & KEYS MODAL ============ -->
|
||||||
|
<div class="modal-overlay" id="authModal" hidden>
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div class="title">Auth & Keys</div>
|
||||||
|
<button class="icon-btn" id="btnCloseAuth">✕</button>
|
||||||
|
</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="creds">Creds file</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="modal-panel" data-mpanel="target">
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field-label">Auth header</label>
|
||||||
|
<input id="authHeader" type="text" placeholder="Authorization: Bearer <token> or Cookie: session=..." />
|
||||||
|
<div class="field-help">Used so agents test as a logged-in user. Kept only for this session, sent to the CLI as an ephemeral creds file.</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field-label">Named roles (multi-identity access-control testing)</label>
|
||||||
|
<div class="role-list" id="roleList"></div>
|
||||||
|
<button class="btn btn-sm" id="btnAddRole" style="align-self:flex-start;">+ Add role</button>
|
||||||
|
<div class="field-help">Two or more roles enable IDOR/BOLA/BFLA cross-role testing.</div>
|
||||||
|
</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 id="providerKeyList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-panel" data-mpanel="creds" hidden>
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field-label">creds.yaml path (overrides target auth above)</label>
|
||||||
|
<input id="credsPath" type="text" placeholder="creds.yaml" />
|
||||||
|
<div class="field-help">An explicit file on disk — see neurosploit-rs/creds.example.yaml for the schema (jwt/header/cookie/login/roles/ssh/windows/cloud).</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ============ REPL DRAWER ============ -->
|
<!-- ============ REPL DRAWER ============ -->
|
||||||
<div class="repl-drawer" id="replDrawer" hidden>
|
<div class="repl-drawer" id="replDrawer" hidden>
|
||||||
<div class="repl-head">
|
<div class="repl-head">
|
||||||
<span>NeuroSploit CLI harness — REPL</span>
|
<span>NeuroSploit CLI harness — REPL</span>
|
||||||
<div>
|
<div>
|
||||||
<button class="icon-btn" id="btnReplRestart" title="Reiniciar sessão">⟲</button>
|
<button class="icon-btn" id="btnReplRestart" title="Restart session">⟲</button>
|
||||||
<button class="icon-btn" id="btnReplClose" title="Fechar">✕</button>
|
<button class="icon-btn" id="btnReplClose" title="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="repl-output" id="replOutput"></div>
|
<div class="repl-output" id="replOutput"></div>
|
||||||
<div class="repl-input-row">
|
<div class="repl-input-row">
|
||||||
<span class="repl-prompt">❭</span>
|
<span class="repl-prompt">❭</span>
|
||||||
<input id="replInput" type="text" autocomplete="off" spellcheck="false" placeholder="/help · /run · /status · ou descreva em linguagem natural" />
|
<input id="replInput" type="text" autocomplete="off" spellcheck="false" placeholder="/help · /run · /status · or describe it in plain language" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="fab" id="fabRepl" title="Abrir REPL">❭_</button>
|
<button class="fab" id="fabRepl" title="Open REPL">❭_</button>
|
||||||
|
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+338
-185
@@ -1,242 +1,395 @@
|
|||||||
|
/* NeuroSploit v4.0.0 — web console.
|
||||||
|
Visual direction: dense security-operations console (not a marketing SaaS
|
||||||
|
page). Borders over shadows, typography over color, two radii, one accent.
|
||||||
|
*/
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #f3efe9;
|
/* spacing scale — 4/8/12/16/24/32/48/64 */
|
||||||
--panel: #ffffff;
|
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
|
||||||
--panel-2: #faf8f5;
|
--sp-5: 24px; --sp-6: 32px; --sp-7: 48px; --sp-8: 64px;
|
||||||
--border: #e6e1d8;
|
|
||||||
--text: #1c1a17;
|
--radius-sm: 6px;
|
||||||
--text-dim: #7a746a;
|
--radius-md: 10px;
|
||||||
--text-faint: #a39d92;
|
|
||||||
--accent: #1e2a5e;
|
|
||||||
--accent-2: #2f3f8f;
|
|
||||||
--accent-soft: #eef0fa;
|
|
||||||
--gold: #e8b93f;
|
|
||||||
--gold-soft: #fdf3d9;
|
|
||||||
--green: #2e8b57;
|
|
||||||
--red: #c0392b;
|
|
||||||
--orange: #d97a1f;
|
|
||||||
--radius: 12px;
|
|
||||||
--radius-sm: 8px;
|
|
||||||
--mono: "SF Mono", "Cascadia Code", "JetBrains Mono", Consolas, monospace;
|
|
||||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
|
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
|
||||||
--shadow: 0 1px 2px rgba(20, 16, 8, 0.04), 0 8px 24px rgba(20, 16, 8, 0.06);
|
--mono: "SF Mono", "Cascadia Code", "JetBrains Mono", Consolas, monospace;
|
||||||
|
|
||||||
|
/* light (default, explicit) */
|
||||||
|
--bg: #f5f4f1;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #faf9f6;
|
||||||
|
--surface-3: #efeeea;
|
||||||
|
--border: #e1dfd8;
|
||||||
|
--border-strong: #cfccc3;
|
||||||
|
--text: #17161a;
|
||||||
|
--text-dim: #6b6862;
|
||||||
|
--text-faint: #9c988f;
|
||||||
|
--accent: #b5590a;
|
||||||
|
--accent-hover: #9a4b07;
|
||||||
|
--accent-contrast: #fff8f0;
|
||||||
|
--accent-soft: #fbe9d8;
|
||||||
|
--focus-ring: #b5590a;
|
||||||
|
|
||||||
|
--sev-critical-bg: #fbe0dc; --sev-critical-fg: #8a271a;
|
||||||
|
--sev-high-bg: #fbe6cf; --sev-high-fg: #8a4a0a;
|
||||||
|
--sev-medium-bg: #f8edc9; --sev-medium-fg: #715600;
|
||||||
|
--sev-low-bg: #dcecdd; --sev-low-fg: #235c34;
|
||||||
|
--sev-info-bg: #e2e6f0; --sev-info-fg: #333e5c;
|
||||||
|
|
||||||
|
--shadow-float: 0 12px 32px rgba(20, 16, 8, 0.16), 0 2px 6px rgba(20, 16, 8, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
:root[data-theme="dark"] {
|
||||||
:root:not([data-theme="light"]) {
|
--bg: #121113;
|
||||||
--bg: #16151a;
|
--surface: #191819;
|
||||||
--panel: #1d1c22;
|
--surface-2: #1f1e20;
|
||||||
--panel-2: #23222a;
|
--surface-3: #262427;
|
||||||
--border: #302f38;
|
--border: #2d2b2e;
|
||||||
--text: #ecebf0;
|
--border-strong: #3c3a3d;
|
||||||
--text-dim: #9a97a6;
|
--text: #ece9e4;
|
||||||
--text-faint: #6d6a78;
|
--text-dim: #a19d95;
|
||||||
--accent: #6d7fe0;
|
--text-faint: #6f6b64;
|
||||||
--accent-2: #8b9af0;
|
--accent: #e08a3e;
|
||||||
--accent-soft: #262c4a;
|
--accent-hover: #ec9c57;
|
||||||
--gold: #e8b93f;
|
--accent-contrast: #1a1208;
|
||||||
--gold-soft: #3a3320;
|
--accent-soft: #3a2a16;
|
||||||
--green: #4fbf82;
|
--focus-ring: #e08a3e;
|
||||||
--red: #e0665a;
|
|
||||||
--orange: #e69a4b;
|
--sev-critical-bg: #3a1c17; --sev-critical-fg: #f0968a;
|
||||||
--shadow: 0 1px 2px rgba(0,0,0,0.3), 0 8px 24px rgba(0,0,0,0.35);
|
--sev-high-bg: #3a2914; --sev-high-fg: #eeae6a;
|
||||||
}
|
--sev-medium-bg: #362c10; --sev-medium-fg: #e2c568;
|
||||||
|
--sev-low-bg: #17301f; --sev-low-fg: #78c896;
|
||||||
|
--sev-info-bg: #232840; --sev-info-fg: #9aa6d8;
|
||||||
|
|
||||||
|
--shadow-float: 0 16px 40px rgba(0, 0, 0, 0.5), 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--text); font-family: var(--sans); }
|
html, body { margin: 0; padding: 0; height: 100%; }
|
||||||
|
body {
|
||||||
|
background: var(--bg); color: var(--text); font-family: var(--sans);
|
||||||
|
font-size: 13px; line-height: 1.5; -webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
button { font-family: inherit; cursor: pointer; }
|
button { font-family: inherit; cursor: pointer; }
|
||||||
input, select, textarea { font-family: inherit; color: inherit; }
|
input, select, textarea { font-family: inherit; color: inherit; font-size: 13px; }
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
|
||||||
|
|
||||||
|
/* ============================================================ AppShell */
|
||||||
|
|
||||||
.app { display: flex; height: 100vh; overflow: hidden; }
|
.app { display: flex; height: 100vh; overflow: hidden; }
|
||||||
|
|
||||||
/* ---------------- sidebar ---------------- */
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 260px; flex: none; background: var(--panel); border-right: 1px solid var(--border);
|
width: 248px; flex: none; background: var(--surface); border-right: 1px solid var(--border);
|
||||||
display: flex; flex-direction: column; padding: 14px 12px;
|
display: flex; flex-direction: column;
|
||||||
}
|
}
|
||||||
.sb-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
.sb-top {
|
||||||
.brand { width: 32px; height: 32px; border-radius: 9px; background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 16px; }
|
display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-4) var(--sp-4) var(--sp-3);
|
||||||
.sb-icons { display: flex; gap: 4px; }
|
|
||||||
.icon-btn { background: transparent; border: 1px solid transparent; color: var(--text-dim); width: 28px; height: 28px; border-radius: var(--radius-sm); font-size: 13px; }
|
|
||||||
.icon-btn:hover { background: var(--panel-2); border-color: var(--border); }
|
|
||||||
|
|
||||||
.new-engagement {
|
|
||||||
border: 1px solid var(--border); background: var(--panel-2); color: var(--text);
|
|
||||||
border-radius: var(--radius-sm); padding: 9px 10px; font-size: 13px; text-align: left; margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
.new-engagement:hover { border-color: var(--accent); color: var(--accent); }
|
.brand { display: flex; align-items: center; gap: var(--sp-2); font-weight: 600; font-size: 13px; 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: 12px; font-weight: 700; font-family: var(--mono); }
|
||||||
|
|
||||||
.sb-groups { flex: 1; overflow-y: auto; }
|
.icon-btn {
|
||||||
.sb-group { margin-bottom: 6px; }
|
background: transparent; border: 1px solid transparent; color: var(--text-dim);
|
||||||
|
width: 26px; height: 26px; border-radius: var(--radius-sm); font-size: 13px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.icon-btn:hover { background: var(--surface-3); border-color: var(--border); color: var(--text); }
|
||||||
|
|
||||||
|
.sb-new {
|
||||||
|
margin: 0 var(--sp-4) var(--sp-4); padding: var(--sp-3) var(--sp-3);
|
||||||
|
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface); color: var(--text); font-size: 12.5px; font-weight: 600; text-align: left;
|
||||||
|
}
|
||||||
|
.sb-new:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
.sb-groups { flex: 1; overflow-y: auto; padding: 0 var(--sp-2) var(--sp-4); }
|
||||||
.sb-group-head {
|
.sb-group-head {
|
||||||
display: flex; align-items: center; gap: 6px; padding: 6px 6px; font-size: 12px; color: var(--text-dim);
|
display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-2);
|
||||||
text-transform: none; cursor: pointer; user-select: none;
|
font-size: 11px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--text-faint);
|
||||||
|
cursor: pointer; user-select: none;
|
||||||
}
|
}
|
||||||
.sb-group-head .count { margin-left: auto; opacity: .7; }
|
.sb-group-head .count { margin-left: auto; font-weight: 400; }
|
||||||
.sb-group-head .caret { font-size: 10px; transition: transform .15s; }
|
.sb-group-head .caret { font-size: 9px; transition: transform .15s; }
|
||||||
.sb-group.collapsed .caret { transform: rotate(-90deg); }
|
.sb-group.collapsed .caret { transform: rotate(-90deg); }
|
||||||
.sb-group.collapsed .sb-items { display: none; }
|
.sb-group.collapsed .sb-items { display: none; }
|
||||||
.sb-items { padding-left: 4px; }
|
|
||||||
.sb-run {
|
.sb-run { display: block; width: 100%; text-align: left; background: transparent; border: none; border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-2); color: var(--text); margin-bottom: 1px; }
|
||||||
display: block; width: 100%; text-align: left; background: transparent; border: none; color: var(--text);
|
.sb-run:hover { background: var(--surface-3); }
|
||||||
padding: 7px 8px; border-radius: var(--radius-sm); font-size: 13px; margin-bottom: 2px;
|
.sb-run.active { background: var(--accent-soft); }
|
||||||
}
|
.sb-run .name { font-size: 12.5px; font-weight: 500; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.sb-run:hover { background: var(--panel-2); }
|
.sb-run .sub { display: block; font-size: 11px; color: var(--text-faint); font-family: var(--mono); margin-top: 2px; }
|
||||||
.sb-run.active { background: var(--gold-soft); color: #7a5a00; font-weight: 600; }
|
|
||||||
.sb-run .sub {
|
.sb-steps { padding: var(--sp-1) var(--sp-2) var(--sp-2) var(--sp-5); display: flex; flex-direction: column; gap: 2px; }
|
||||||
display: block; font-size: 11px; color: var(--text-faint); font-weight: 400; margin-top: 1px;
|
.sb-step { font-size: 11px; color: var(--text-faint); display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
}
|
.sb-step::before { content: "○"; font-size: 9px; width: 10px; }
|
||||||
.sb-steps { padding: 2px 8px 8px 20px; }
|
|
||||||
.sb-step { font-size: 12px; padding: 3px 0; color: var(--text-faint); display: flex; align-items: center; gap: 6px; }
|
|
||||||
.sb-step.done { color: var(--text-dim); }
|
.sb-step.done { color: var(--text-dim); }
|
||||||
.sb-step.done::before { content: "✓"; color: var(--green); }
|
.sb-step.done::before { content: "●"; color: var(--sev-low-fg); }
|
||||||
.sb-step.active { color: var(--gold); font-weight: 600; }
|
.sb-step.active { color: var(--accent); font-weight: 600; }
|
||||||
.sb-step.active::before { content: "◐"; }
|
.sb-step.active::before { content: "◐"; color: var(--accent); }
|
||||||
.sb-step.pending::before { content: "○"; opacity: .5; }
|
|
||||||
|
|
||||||
.sb-footer { display: flex; align-items: center; justify-content: space-between; padding-top: 8px; border-top: 1px solid var(--border); }
|
.sb-bottom { border-top: 1px solid var(--border); padding: var(--sp-3) var(--sp-4); display: flex; align-items: center; justify-content: space-between; }
|
||||||
.sb-meta { font-size: 11px; color: var(--text-faint); }
|
.sb-version { font-size: 11px; color: var(--text-faint); font-family: var(--mono); }
|
||||||
|
.sb-bottom-actions { display: flex; gap: var(--sp-1); }
|
||||||
|
|
||||||
|
/* ============================================================ Main / Topbar */
|
||||||
|
|
||||||
/* ---------------- main / topbar ---------------- */
|
|
||||||
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
|
||||||
.topbar { display: flex; align-items: center; gap: 14px; padding: 14px 20px; border-bottom: 1px solid var(--border); background: var(--bg); }
|
.topbar {
|
||||||
.search-wrap { position: relative; }
|
display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-3) var(--sp-5);
|
||||||
.search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-faint); font-size: 13px; }
|
border-bottom: 1px solid var(--border); background: var(--bg); min-height: 56px;
|
||||||
#leadSearch {
|
|
||||||
width: 220px; padding: 8px 10px 8px 28px; border-radius: 999px; border: 1px solid var(--border);
|
|
||||||
background: var(--panel); color: var(--text); font-size: 13px;
|
|
||||||
}
|
}
|
||||||
.chips { display: flex; gap: 8px; }
|
.topbar-title { font-size: 15px; font-weight: 600; }
|
||||||
.chip {
|
.topbar-sub { font-size: 11px; color: var(--text-faint); font-family: var(--mono); margin-top: 1px; }
|
||||||
border: 1px solid var(--border); background: var(--panel); color: var(--text-dim); border-radius: 999px;
|
|
||||||
padding: 7px 12px; font-size: 12px; display: flex; gap: 6px; align-items: center;
|
|
||||||
}
|
|
||||||
.chip span { color: var(--text-faint); }
|
|
||||||
.chip-active { background: var(--gold-soft); border-color: var(--gold); color: #7a5a00; }
|
|
||||||
.chip-active span { color: #7a5a00; }
|
|
||||||
.topbar-spacer { flex: 1; }
|
.topbar-spacer { flex: 1; }
|
||||||
|
|
||||||
.btn { border-radius: var(--radius-sm); border: 1px solid var(--border); padding: 9px 14px; font-size: 13px; background: var(--panel); color: var(--text); }
|
.btn {
|
||||||
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
|
border-radius: var(--radius-sm); border: 1px solid var(--border-strong); padding: var(--sp-2) var(--sp-4);
|
||||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
|
font-size: 12.5px; font-weight: 500; background: var(--surface); color: var(--text);
|
||||||
.btn-primary:hover { background: var(--accent-2); }
|
display: inline-flex; align-items: center; gap: var(--sp-2); white-space: nowrap;
|
||||||
.btn-danger { background: var(--red); border-color: var(--red); color: #fff; }
|
}
|
||||||
.btn-icon { padding: 8px 10px; }
|
.btn:hover { border-color: var(--text-dim); }
|
||||||
|
.btn-sm { padding: 6px var(--sp-3); font-size: 12px; }
|
||||||
|
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast); font-weight: 600; }
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||||
|
.btn-danger { background: transparent; border-color: var(--sev-critical-fg); color: var(--sev-critical-fg); }
|
||||||
|
.btn-danger:hover { background: var(--sev-critical-bg); }
|
||||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.btn:focus-visible { outline-offset: 2px; }
|
||||||
|
|
||||||
.engagement-bar {
|
/* ============================================================ Wizard */
|
||||||
display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: end; padding: 12px 20px; background: var(--panel);
|
|
||||||
border-bottom: 1px solid var(--border);
|
.wizard { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.stepper {
|
||||||
|
display: flex; align-items: center; padding: 0 var(--sp-5); border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface); overflow-x: auto;
|
||||||
}
|
}
|
||||||
.eb-field { display: flex; flex-direction: column; gap: 4px; }
|
.step-tab {
|
||||||
.eb-field label { font-size: 11px; color: var(--text-faint); }
|
display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-4) var(--sp-4) var(--sp-3);
|
||||||
.eb-field input, .eb-field select {
|
border-bottom: 2px solid transparent; color: var(--text-faint); font-size: 12.5px; font-weight: 500;
|
||||||
border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 7px 9px; font-size: 13px;
|
background: transparent; border-top: none; border-left: none; border-right: none; white-space: nowrap;
|
||||||
background: var(--panel-2); color: var(--text); min-width: 120px;
|
|
||||||
}
|
}
|
||||||
.eb-target input { min-width: 260px; }
|
.step-tab .n { font-family: var(--mono); font-size: 11px; width: 18px; height: 18px; border-radius: 50%; border: 1px solid var(--border-strong); display: flex; align-items: center; justify-content: center; }
|
||||||
.eb-narrow input, .eb-narrow select { width: 88px; min-width: 0; }
|
.step-tab.done .n { background: var(--sev-low-fg); border-color: var(--sev-low-fg); color: #fff; }
|
||||||
.eb-check { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-dim); margin-bottom: 2px; }
|
.step-tab.done .n::before { content: "✓"; }
|
||||||
|
.step-tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
||||||
|
.step-tab.active .n { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.step-tab:disabled { cursor: default; }
|
||||||
|
|
||||||
/* ---------------- board ---------------- */
|
.wizard-body { flex: 1; overflow-y: auto; padding: var(--sp-6) var(--sp-5) var(--sp-8); }
|
||||||
.board { flex: 1; display: flex; overflow: hidden; }
|
.wizard-panel { max-width: 780px; margin: 0 auto; display: flex; flex-direction: column; gap: var(--sp-6); }
|
||||||
.board-scroll { flex: 1; overflow-y: auto; padding: 18px 20px 40px; }
|
.wizard-panel[hidden] { display: none; }
|
||||||
.board-title { font-size: 15px; font-weight: 600; margin: 4px 0 16px; color: var(--text); }
|
|
||||||
|
|
||||||
.categories { display: flex; flex-direction: column; gap: 12px; max-width: 720px; }
|
.field-group { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
.cat-card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
|
.field-group + .field-group { margin-top: var(--sp-5); }
|
||||||
.cat-head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; cursor: pointer; user-select: none; }
|
.field-label { font-size: 11px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: var(--text-faint); }
|
||||||
.cat-head .swatch { width: 10px; height: 10px; border-radius: 3px; background: var(--accent); }
|
.field-help { font-size: 11.5px; color: var(--text-faint); }
|
||||||
.cat-head .cat-name { font-weight: 600; font-size: 13.5px; flex: 1; }
|
.field-row { display: flex; gap: var(--sp-4); flex-wrap: wrap; }
|
||||||
.cat-head .cat-count { font-size: 12px; color: var(--text-faint); }
|
.field-row > * { flex: 1; min-width: 160px; }
|
||||||
.cat-head .caret { font-size: 10px; color: var(--text-faint); transition: transform .15s; }
|
|
||||||
|
.section-title { font-size: 13px; font-weight: 600; }
|
||||||
|
.section-desc { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
|
||||||
|
|
||||||
|
input[type="text"], input[type="number"], input[type="password"], select, textarea {
|
||||||
|
border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 8px 10px;
|
||||||
|
background: var(--surface); color: var(--text); width: 100%;
|
||||||
|
}
|
||||||
|
input:focus-visible, select:focus-visible, textarea:focus-visible { border-color: var(--accent); }
|
||||||
|
textarea { resize: vertical; min-height: 72px; }
|
||||||
|
.narrow { max-width: 120px; }
|
||||||
|
|
||||||
|
.mode-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--sp-3); }
|
||||||
|
.mode-tile {
|
||||||
|
border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: var(--sp-3) var(--sp-3);
|
||||||
|
background: var(--surface); text-align: left; display: flex; flex-direction: column; gap: 2px;
|
||||||
|
}
|
||||||
|
.mode-tile .t { font-weight: 600; font-size: 12.5px; }
|
||||||
|
.mode-tile .d { font-size: 11px; color: var(--text-faint); }
|
||||||
|
.mode-tile:hover { border-color: var(--text-dim); }
|
||||||
|
.mode-tile.selected { border-color: var(--accent); background: var(--accent-soft); }
|
||||||
|
|
||||||
|
.auth-mode-toggle { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
|
||||||
|
.auth-mode-toggle button { border: none; background: var(--surface); padding: 8px 14px; font-size: 12.5px; color: var(--text-dim); }
|
||||||
|
.auth-mode-toggle button.selected { background: var(--accent); color: var(--accent-contrast); font-weight: 600; }
|
||||||
|
.auth-mode-toggle button:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.check-row { display: flex; align-items: center; gap: var(--sp-2); font-size: 12.5px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
.role-list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.role-row { display: flex; gap: var(--sp-2); align-items: center; }
|
||||||
|
.role-row input { flex: 1; }
|
||||||
|
.role-row input.role-name { max-width: 140px; flex: none; }
|
||||||
|
|
||||||
|
/* leads step reuses category cards */
|
||||||
|
.lead-toolbar { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }
|
||||||
|
.search-wrap { position: relative; flex: 1; min-width: 200px; max-width: 320px; }
|
||||||
|
.search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-faint); font-size: 12px; }
|
||||||
|
#leadSearch { padding-left: 28px; }
|
||||||
|
.chips { display: flex; gap: var(--sp-2); }
|
||||||
|
.chip { border: 1px solid var(--border-strong); background: var(--surface); color: var(--text-dim); border-radius: var(--radius-sm); padding: 6px 10px; font-size: 11.5px; display: flex; gap: 5px; }
|
||||||
|
.chip span { color: var(--text-faint); font-family: var(--mono); }
|
||||||
|
.chip-active { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.chip-active span { color: var(--accent); }
|
||||||
|
|
||||||
|
.categories { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.cat-card { border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
|
||||||
|
.cat-head { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) var(--sp-3); cursor: pointer; background: var(--surface); }
|
||||||
|
.cat-head .cat-name { font-weight: 600; font-size: 12.5px; flex: 1; }
|
||||||
|
.cat-head .cat-count { font-size: 11px; color: var(--text-faint); font-family: var(--mono); }
|
||||||
|
.cat-head .caret { font-size: 9px; color: var(--text-faint); transition: transform .15s; }
|
||||||
.cat-card.collapsed .caret { transform: rotate(-90deg); }
|
.cat-card.collapsed .caret { transform: rotate(-90deg); }
|
||||||
.cat-card.collapsed .agent-rows { display: none; }
|
.cat-card.collapsed .agent-rows { display: none; }
|
||||||
.agent-rows { border-top: 1px solid var(--border); }
|
.agent-rows { border-top: 1px solid var(--border); background: var(--surface-2); }
|
||||||
.agent-row { display: flex; align-items: center; gap: 10px; padding: 9px 14px; border-bottom: 1px solid var(--border); font-size: 13px; }
|
.agent-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-3); border-bottom: 1px solid var(--border); font-size: 12.5px; }
|
||||||
.agent-row:last-child { border-bottom: none; }
|
.agent-row:last-child { border-bottom: none; }
|
||||||
.agent-row.hidden-by-search { display: none; }
|
.agent-row.hidden-by-search { display: none; }
|
||||||
.agent-row .agent-title { flex: 1; }
|
.agent-row .agent-title { flex: 1; }
|
||||||
.agent-row .agent-cwe { font-size: 11px; color: var(--text-faint); }
|
.agent-row .agent-cwe { font-size: 10.5px; color: var(--text-faint); font-family: var(--mono); }
|
||||||
.agent-row .agent-frac { font-size: 11px; color: var(--text-faint); width: 46px; text-align: right; }
|
|
||||||
.agent-row .chevron { color: var(--text-faint); font-size: 12px; }
|
|
||||||
|
|
||||||
.switch { position: relative; width: 34px; height: 19px; flex: none; }
|
.switch { position: relative; width: 30px; height: 17px; flex: none; }
|
||||||
.switch input { opacity: 0; width: 0; height: 0; }
|
.switch input { opacity: 0; width: 0; height: 0; }
|
||||||
.switch .track { position: absolute; inset: 0; background: var(--border); border-radius: 999px; transition: background .15s; }
|
.switch .track { position: absolute; inset: 0; background: var(--border-strong); border-radius: 999px; transition: background .15s; }
|
||||||
.switch .thumb { position: absolute; top: 2px; left: 2px; width: 15px; height: 15px; border-radius: 50%; background: #fff; transition: transform .15s; box-shadow: 0 1px 2px rgba(0,0,0,.3); }
|
.switch .thumb { position: absolute; top: 2px; left: 2px; width: 13px; height: 13px; border-radius: 50%; background: var(--surface); transition: transform .15s; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
|
||||||
.switch input:checked + .track { background: var(--accent); }
|
.switch input:checked + .track { background: var(--accent); }
|
||||||
.switch input:checked + .track + .thumb { transform: translateX(15px); }
|
.switch input:checked + .track + .thumb { transform: translateX(13px); }
|
||||||
|
|
||||||
.ask-panel {
|
.custom-leads { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
width: 300px; flex: none; border-left: 1px solid var(--border); background: var(--panel); padding: 16px;
|
.custom-lead-chip { display: flex; align-items: center; gap: var(--sp-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px var(--sp-3); font-size: 12px; background: var(--surface-2); }
|
||||||
display: flex; flex-direction: column; gap: 10px;
|
.custom-lead-chip .x { margin-left: auto; color: var(--text-faint); }
|
||||||
|
.custom-lead-chip .x:hover { color: var(--sev-critical-fg); }
|
||||||
|
|
||||||
|
.wizard-footer {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5);
|
||||||
|
border-top: 1px solid var(--border); background: var(--surface);
|
||||||
}
|
}
|
||||||
.ask-title { font-size: 12px; color: var(--text-faint); }
|
.summary-line { font-size: 11.5px; color: var(--text-faint); }
|
||||||
#askInput {
|
.summary-line b { color: var(--text); font-weight: 600; }
|
||||||
flex: 1; min-height: 90px; resize: vertical; border: 1px solid var(--border); border-radius: var(--radius-sm);
|
|
||||||
padding: 10px; background: var(--panel-2); font-size: 13px;
|
|
||||||
}
|
|
||||||
.ask-row { display: flex; gap: 8px; }
|
|
||||||
.ask-row select { flex: 1; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--panel-2); padding: 8px; font-size: 12px; }
|
|
||||||
.ask-hint { font-size: 11px; color: var(--text-faint); min-height: 14px; }
|
|
||||||
|
|
||||||
/* ---------------- live run / detail ---------------- */
|
.review-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--sp-4); }
|
||||||
.liverun, .rundetail { flex: 1; display: flex; flex-direction: column; overflow: hidden; padding: 18px 20px; }
|
.review-item { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--sp-3); }
|
||||||
.liverun-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
.review-item .k { font-size: 10.5px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-faint); }
|
||||||
.liverun-target { font-weight: 600; font-size: 15px; }
|
.review-item .v { font-size: 13px; margin-top: 3px; font-weight: 500; }
|
||||||
.liverun-phase { font-size: 12px; color: var(--text-dim); display: flex; align-items: center; gap: 6px; margin-top: 3px; }
|
.review-item .v.mono { font-family: var(--mono); font-size: 12px; }
|
||||||
.phase-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--gold); animation: pulse 1.4s infinite; }
|
|
||||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
|
|
||||||
.liverun-actions { display: flex; gap: 8px; }
|
|
||||||
|
|
||||||
.progress-wrap { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
/* ============================================================ Live run / detail */
|
||||||
.progress-bar { flex: 1; height: 8px; border-radius: 999px; background: var(--panel-2); border: 1px solid var(--border); overflow: hidden; }
|
|
||||||
|
.runpage { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.runpage[hidden] { display: none; }
|
||||||
|
.run-head { display: flex; align-items: flex-start; justify-content: space-between; padding: var(--sp-5) var(--sp-5) var(--sp-4); border-bottom: 1px solid var(--border); }
|
||||||
|
.run-target { font-size: 15px; font-weight: 600; font-family: var(--mono); }
|
||||||
|
.run-meta { display: flex; align-items: center; gap: var(--sp-3); margin-top: var(--sp-1); font-size: 12px; color: var(--text-dim); }
|
||||||
|
.phase-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); animation: pulse 1.4s infinite; }
|
||||||
|
.phase-dot.static { animation: none; }
|
||||||
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
|
||||||
|
.run-actions { display: flex; gap: var(--sp-2); }
|
||||||
|
|
||||||
|
.progress-wrap { display: flex; align-items: center; gap: var(--sp-3); padding: 0 var(--sp-5) var(--sp-4); }
|
||||||
|
.progress-bar { flex: 1; height: 6px; border-radius: 999px; background: var(--surface-3); overflow: hidden; }
|
||||||
.progress-fill { height: 100%; width: 0%; background: var(--accent); transition: width .3s; }
|
.progress-fill { height: 100%; width: 0%; background: var(--accent); transition: width .3s; }
|
||||||
.progress-label { font-size: 12px; color: var(--text-faint); white-space: nowrap; }
|
.progress-label { font-size: 11.5px; color: var(--text-faint); font-family: var(--mono); white-space: nowrap; }
|
||||||
|
|
||||||
.liverun-body { flex: 1; display: flex; gap: 16px; overflow: hidden; }
|
.run-tabs { display: flex; gap: var(--sp-1); padding: 0 var(--sp-5); border-bottom: 1px solid var(--border); }
|
||||||
.findings-col, .log-col { flex: 1; display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
.run-tab { padding: var(--sp-3) var(--sp-3); font-size: 12px; font-weight: 500; color: var(--text-faint); border-bottom: 2px solid transparent; background: none; border-top: none; border-left: none; border-right: none; }
|
||||||
.col-head { padding: 10px 14px; font-size: 12px; font-weight: 600; color: var(--text-dim); border-bottom: 1px solid var(--border); background: var(--panel-2); }
|
.run-tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
||||||
.findings-list, .log-list { flex: 1; overflow-y: auto; padding: 8px 10px; }
|
|
||||||
|
|
||||||
.finding-card { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px 12px; margin-bottom: 8px; background: var(--panel-2); }
|
.run-body { flex: 1; overflow-y: auto; padding: var(--sp-5); }
|
||||||
.finding-card .f-top { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
.run-tab-panel[hidden] { display: none; }
|
||||||
.sev { font-size: 10px; font-weight: 700; text-transform: uppercase; padding: 2px 7px; border-radius: 999px; letter-spacing: .03em; }
|
|
||||||
.sev-critical { background: #f6d9d5; color: #8c2a1c; }
|
|
||||||
.sev-high { background: #fbe1cf; color: #8c4a10; }
|
|
||||||
.sev-medium { background: #fdf0c8; color: #7a5a00; }
|
|
||||||
.sev-low { background: #dcecdd; color: #235c34; }
|
|
||||||
.sev-info { background: #e2e6f5; color: #2c3a7a; }
|
|
||||||
.finding-card .f-title { font-size: 13px; font-weight: 600; }
|
|
||||||
.finding-card .f-meta { font-size: 11px; color: var(--text-faint); margin-top: 3px; }
|
|
||||||
|
|
||||||
.log-line { font-family: var(--mono); font-size: 11.5px; color: var(--text-dim); padding: 2px 0; white-space: pre-wrap; word-break: break-word; }
|
/* 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; }
|
||||||
|
|
||||||
|
/* findings table */
|
||||||
|
.data-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||||
|
.data-table th { text-align: left; font-size: 10.5px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-faint); font-weight: 600; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-strong); white-space: nowrap; }
|
||||||
|
.data-table td { padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||||
|
.data-table tbody tr:hover { background: var(--surface-2); }
|
||||||
|
.data-table .col-endpoint { font-family: var(--mono); font-size: 11.5px; color: var(--text-dim); max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.data-table .col-conf { font-family: var(--mono); text-align: right; }
|
||||||
|
.empty-state { padding: var(--sp-7) var(--sp-5); text-align: center; color: var(--text-faint); font-size: 12.5px; }
|
||||||
|
|
||||||
|
.sev { font-size: 10px; font-weight: 700; text-transform: uppercase; padding: 3px 8px; border-radius: var(--radius-sm); letter-spacing: .02em; white-space: nowrap; }
|
||||||
|
.sev-critical { background: var(--sev-critical-bg); color: var(--sev-critical-fg); }
|
||||||
|
.sev-high { background: var(--sev-high-bg); color: var(--sev-high-fg); }
|
||||||
|
.sev-medium { background: var(--sev-medium-bg); color: var(--sev-medium-fg); }
|
||||||
|
.sev-low { background: var(--sev-low-bg); color: var(--sev-low-fg); }
|
||||||
|
.sev-info { background: var(--sev-info-bg); color: var(--sev-info-fg); }
|
||||||
|
|
||||||
|
.log-panel { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-2); max-height: 100%; overflow-y: auto; padding: var(--sp-3); }
|
||||||
|
.log-line { font-family: var(--mono); font-size: 11px; color: var(--text-dim); padding: 1px 0; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
|
||||||
|
/* ============================================================ Modal (Auth & Keys) */
|
||||||
|
|
||||||
|
.modal-overlay { position: fixed; inset: 0; background: rgba(10,9,8,.45); display: flex; align-items: center; justify-content: center; z-index: 60; }
|
||||||
|
.modal-overlay[hidden] { display: none; }
|
||||||
|
.modal { width: 620px; max-width: calc(100vw - 40px); max-height: calc(100vh - 80px); background: var(--surface); border-radius: var(--radius-md); box-shadow: var(--shadow-float); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--border); }
|
||||||
|
.modal-head { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border); }
|
||||||
|
.modal-head .title { font-size: 14px; font-weight: 600; }
|
||||||
|
.modal-tabs { display: flex; gap: var(--sp-1); padding: 0 var(--sp-5); border-bottom: 1px solid var(--border); }
|
||||||
|
.modal-tab { padding: var(--sp-3) var(--sp-2); font-size: 12px; font-weight: 500; color: var(--text-faint); border-bottom: 2px solid transparent; background: none; border-top: none; border-left: none; border-right: none; }
|
||||||
|
.modal-tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
||||||
|
.modal-body { padding: var(--sp-5); overflow-y: auto; flex: 1; }
|
||||||
|
.modal-panel[hidden] { display: none; }
|
||||||
|
|
||||||
|
.provider-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.provider-row:last-child { border-bottom: none; }
|
||||||
|
.provider-row .p-name { flex: none; width: 150px; font-size: 12.5px; font-weight: 500; }
|
||||||
|
.provider-row .p-kind { flex: none; width: 74px; font-size: 10px; text-transform: uppercase; color: var(--text-faint); font-family: var(--mono); }
|
||||||
|
.provider-row input { flex: 1; font-family: var(--mono); font-size: 12px; }
|
||||||
|
.provider-row .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--border-strong); flex: none; }
|
||||||
|
.provider-row .dot.set { background: var(--sev-low-fg); }
|
||||||
|
|
||||||
|
/* ============================================================ REPL drawer */
|
||||||
|
|
||||||
/* ---------------- REPL drawer ---------------- */
|
|
||||||
.repl-drawer {
|
.repl-drawer {
|
||||||
position: fixed; right: 20px; bottom: 20px; width: 560px; height: 400px; background: #0e0e12; color: #dcdce0;
|
position: fixed; right: var(--sp-5); bottom: var(--sp-5); width: 560px; height: 400px;
|
||||||
border-radius: var(--radius); box-shadow: 0 20px 60px rgba(0,0,0,.45); display: flex; flex-direction: column;
|
background: #0f0e10; color: #d8d5cf; border-radius: var(--radius-md); box-shadow: var(--shadow-float);
|
||||||
overflow: hidden; z-index: 50; border: 1px solid #2a2a33;
|
display: flex; flex-direction: column; overflow: hidden; z-index: 50; border: 1px solid #2a282a;
|
||||||
}
|
}
|
||||||
.repl-head { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #17171d; font-size: 12px; color: #9a97a6; border-bottom: 1px solid #2a2a33; }
|
.repl-drawer[hidden] { display: none; }
|
||||||
.repl-head .icon-btn { color: #9a97a6; }
|
.repl-head { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-2) var(--sp-3); background: #171618; font-size: 11.5px; color: #8f8b85; border-bottom: 1px solid #2a282a; }
|
||||||
.repl-head .icon-btn:hover { background: #22222b; color: #fff; }
|
.repl-head .icon-btn { color: #8f8b85; }
|
||||||
.repl-output { flex: 1; overflow-y: auto; padding: 10px 12px; font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; word-break: break-word; }
|
.repl-head .icon-btn:hover { background: #232123; color: #fff; }
|
||||||
.repl-input-row { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-top: 1px solid #2a2a33; }
|
.repl-output { flex: 1; overflow-y: auto; padding: var(--sp-3); font-family: var(--mono); font-size: 12px; white-space: pre-wrap; word-break: break-word; }
|
||||||
.repl-prompt { color: #6d7fe0; font-family: var(--mono); }
|
.repl-echo { color: var(--accent); }
|
||||||
#replInput { flex: 1; background: transparent; border: none; color: #ecebf0; font-family: var(--mono); font-size: 13px; outline: none; }
|
.repl-input-row { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); border-top: 1px solid #2a282a; }
|
||||||
|
.repl-prompt { color: #e08a3e; font-family: var(--mono); }
|
||||||
|
#replInput { flex: 1; background: transparent; border: none; color: #ece9e4; font-family: var(--mono); font-size: 12.5px; outline: none; padding: 4px 0; }
|
||||||
|
|
||||||
.fab {
|
.fab {
|
||||||
position: fixed; right: 20px; bottom: 20px; width: 52px; height: 52px; border-radius: 50%; background: var(--accent);
|
position: fixed; right: var(--sp-5); bottom: var(--sp-5); width: 46px; height: 46px; border-radius: var(--radius-md);
|
||||||
color: #fff; border: none; font-family: var(--mono); font-size: 16px; box-shadow: 0 10px 24px rgba(30,42,94,.35);
|
background: var(--accent); color: var(--accent-contrast); border: none; font-family: var(--mono); font-size: 14px;
|
||||||
z-index: 40;
|
box-shadow: var(--shadow-float); z-index: 40;
|
||||||
}
|
}
|
||||||
.fab:hover { background: var(--accent-2); }
|
.fab:hover { background: var(--accent-hover); }
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
/* ============================================================ Responsive */
|
||||||
.ask-panel { display: none; }
|
|
||||||
.sidebar { width: 210px; }
|
@media (max-width: 1024px) {
|
||||||
.repl-drawer { width: calc(100vw - 24px); left: 12px; right: 12px; }
|
.sidebar { width: 200px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar { position: fixed; left: -220px; top: 0; bottom: 0; z-index: 55; transition: left .2s; }
|
||||||
|
.sidebar.open { left: 0; }
|
||||||
|
.field-row { flex-direction: column; }
|
||||||
|
.repl-drawer { width: calc(100vw - 24px); right: 12px; left: 12px; }
|
||||||
|
.modal { width: calc(100vw - 24px); }
|
||||||
|
.review-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.topbar { flex-wrap: wrap; gap: var(--sp-2); }
|
||||||
|
.stepper { padding: 0 var(--sp-3); }
|
||||||
}
|
}
|
||||||
|
|||||||
+113
-5
@@ -17,6 +17,7 @@
|
|||||||
const http = require('node:http');
|
const http = require('node:http');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const fsp = fs.promises;
|
const fsp = fs.promises;
|
||||||
|
const os = require('node:os');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { spawn } = require('node:child_process');
|
const { spawn } = require('node:child_process');
|
||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
@@ -46,6 +47,90 @@ const BIN = findBinary();
|
|||||||
|
|
||||||
const PORT = Number(process.env.NEUROSPLOIT_WEB_PORT || process.env.PORT || 4173);
|
const PORT = Number(process.env.NEUROSPLOIT_WEB_PORT || process.env.PORT || 4173);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Providers — mirrors crates/harness/src/models.rs `providers()`. Kept as a
|
||||||
|
// literal table (not parsed from CLI output) so `kind` ("cli" = usable via a
|
||||||
|
// locally-installed agentic CLI subscription login, "api" = key-only) and
|
||||||
|
// `envKey` (the environment variable the harness reads for that provider)
|
||||||
|
// are available without shelling out. Keep this in sync when models.rs adds
|
||||||
|
// a provider.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PROVIDERS = [
|
||||||
|
{ key: 'anthropic', label: 'Anthropic Claude', kind: 'cli', envKey: 'ANTHROPIC_API_KEY',
|
||||||
|
models: ['claude-opus-5', 'claude-sonnet-5', 'claude-opus-4-8', 'claude-sonnet-4-6', 'claude-haiku-4-5'] },
|
||||||
|
{ key: 'openai', label: 'OpenAI (ChatGPT)', kind: 'cli', envKey: 'OPENAI_API_KEY',
|
||||||
|
models: ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.2', 'gpt-5.1', 'gpt-5.1-codex', 'o4'] },
|
||||||
|
{ key: 'xai', label: 'xAI Grok', kind: 'cli', envKey: 'XAI_API_KEY',
|
||||||
|
models: ['grok-4.5', 'grok-4', 'grok-4-fast'] },
|
||||||
|
{ key: 'gemini', label: 'Google Gemini', kind: 'cli', envKey: 'GEMINI_API_KEY',
|
||||||
|
models: ['gemini-3-pro', 'gemini-2.5-pro', 'gemini-2.5-flash'] },
|
||||||
|
{ key: 'opencode', label: 'OpenCode Zen', kind: 'cli', envKey: 'OPENCODE_API_KEY',
|
||||||
|
models: ['claude-opus-5', 'claude-sonnet-5', 'gpt-5.6-sol', 'gpt-5.5', 'gemini-3-pro', 'grok-4.5', 'deepseek-v4-pro', 'qwen3.7-max', 'kimi-k3'] },
|
||||||
|
{ key: 'nous', label: 'Nous Research (Hermes)', kind: 'cli', envKey: 'NOUS_API_KEY',
|
||||||
|
models: ['Hermes-4-405B', 'Hermes-4-70B', 'DeepHermes-3-Mistral-24B-Preview'] },
|
||||||
|
{ key: 'nvidia_nim', label: 'NVIDIA NIM', kind: 'api', envKey: 'NVIDIA_NIM_API_KEY',
|
||||||
|
models: ['nvidia/llama-3.3-nemotron-super-49b-v1', 'deepseek-ai/deepseek-r1', 'qwen/qwen2.5-coder-32b-instruct'] },
|
||||||
|
{ key: 'deepseek', label: 'DeepSeek', kind: 'api', envKey: 'DEEPSEEK_API_KEY',
|
||||||
|
models: ['deepseek-reasoner', 'deepseek-chat'] },
|
||||||
|
{ key: 'mistral', label: 'Mistral', kind: 'api', envKey: 'MISTRAL_API_KEY',
|
||||||
|
models: ['mistral-large-latest', 'codestral-latest'] },
|
||||||
|
{ key: 'qwen', label: 'Qwen (DashScope)', kind: 'api', envKey: 'DASHSCOPE_API_KEY',
|
||||||
|
models: ['qwen-max', 'qwen2.5-coder-32b-instruct', 'qwq-plus'] },
|
||||||
|
{ key: 'groq', label: 'Groq', kind: 'api', envKey: 'GROQ_API_KEY',
|
||||||
|
models: ['llama-3.3-70b-versatile', 'qwen-2.5-coder-32b'] },
|
||||||
|
{ key: 'together', label: 'Together AI', kind: 'api', envKey: 'TOGETHER_API_KEY',
|
||||||
|
models: ['Qwen/Qwen2.5-Coder-32B-Instruct', 'deepseek-ai/DeepSeek-R1', 'meta-llama/Llama-3.3-70B-Instruct-Turbo'] },
|
||||||
|
{ key: 'moonshot', label: 'Moonshot AI (Kimi)', kind: 'api', envKey: 'MOONSHOT_API_KEY',
|
||||||
|
models: ['kimi-k3', 'kimi-k2', 'moonshot-v1-128k', 'moonshot-v1-32k'] },
|
||||||
|
{ key: 'litellm', label: 'LiteLLM (proxy)', kind: 'api', envKey: 'LITELLM_API_KEY',
|
||||||
|
models: ['gpt-4o', 'claude-3-7-sonnet', 'gemini/gemini-2.5-pro'] },
|
||||||
|
{ key: 'openrouter', label: 'OpenRouter', kind: 'api', envKey: 'OPENROUTER_API_KEY',
|
||||||
|
models: ['anthropic/claude-opus-4-8', 'qwen/qwen-2.5-coder-32b-instruct', 'deepseek/deepseek-r1', 'meta-llama/llama-3.3-70b-instruct'] },
|
||||||
|
{ key: 'azure', label: 'Azure OpenAI', kind: 'api', envKey: 'AZURE_OPENAI_API_KEY',
|
||||||
|
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-5.1', 'o4-mini'] },
|
||||||
|
{ key: 'ollama', label: 'Ollama (local)', kind: 'api', envKey: 'OLLAMA_API_KEY',
|
||||||
|
models: ['qwen2.5-coder:32b', 'qwq:32b', 'deepseek-r1:32b', 'llama3.3:70b'] },
|
||||||
|
{ key: 'llamacpp', label: 'llama.cpp (local)', kind: 'api', envKey: 'LLAMACPP_API_KEY',
|
||||||
|
models: ['qwen2.5-coder-32b-instruct', 'dolphin-2.9-llama3-70b', 'deepseek-r1-distill-qwen-32b', 'llama-3.3-70b-instruct'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// In-memory only — never written to disk. Cleared on server restart.
|
||||||
|
const apiKeys = new Map(); // provider key -> secret
|
||||||
|
|
||||||
|
function envOverrides() {
|
||||||
|
const env = {};
|
||||||
|
for (const [key, secret] of apiKeys) {
|
||||||
|
const p = PROVIDERS.find((x) => x.key === key);
|
||||||
|
if (p && secret) env[p.envKey] = secret;
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a minimal creds.yaml-compatible file (see neurosploit-rs/creds.example.yaml)
|
||||||
|
/// from a raw auth header and/or named roles, so the CLI's --creds flag can be
|
||||||
|
/// used to carry web-entered auth material without a real file on disk.
|
||||||
|
function buildCredsYaml({ auth, roles }) {
|
||||||
|
const lines = ['# generated by the NeuroSploit web console — ephemeral, not committed'];
|
||||||
|
if (auth) lines.push(`header: ${JSON.stringify(auth)}`);
|
||||||
|
for (const r of roles || []) {
|
||||||
|
if (!r?.name || !r?.header) continue;
|
||||||
|
const safe = String(r.name).replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
lines.push(`${safe}:`, ` header: ${JSON.stringify(r.header)}`);
|
||||||
|
}
|
||||||
|
return lines.join('\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function materializeCreds(body, jobId) {
|
||||||
|
if (body.creds) return body.creds; // explicit file path on disk wins
|
||||||
|
if (!body.auth && !(body.roles || []).length) return undefined;
|
||||||
|
const dir = path.join(os.tmpdir(), 'neurosploit-web');
|
||||||
|
await fsp.mkdir(dir, { recursive: true });
|
||||||
|
const file = path.join(dir, `${jobId}.creds.yaml`);
|
||||||
|
await fsp.writeFile(file, buildCredsYaml(body));
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Agent library — read agents_md/{vulns,ai,infra,code,chains,recon,meta}/*.md
|
// Agent library — read agents_md/{vulns,ai,infra,code,chains,recon,meta}/*.md
|
||||||
// and classify each into a lead category the UI can group + toggle.
|
// and classify each into a lead category the UI can group + toggle.
|
||||||
@@ -338,14 +423,15 @@ function buildArgs(body) {
|
|||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
function startJob(body) {
|
async function startJob(body) {
|
||||||
if (!BIN) throw new Error('neurosploit binary not found — run `cargo build --release` in neurosploit-rs/');
|
if (!BIN) throw new Error('neurosploit binary not found — run `cargo build --release` in neurosploit-rs/');
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const args = buildArgs(body);
|
const credsPath = await materializeCreds(body, id);
|
||||||
|
const args = buildArgs({ ...body, creds: credsPath });
|
||||||
const job = new Job(id, BIN, args, body.repo || body.target || '');
|
const job = new Job(id, BIN, args, body.repo || body.target || '');
|
||||||
jobs.set(id, job);
|
jobs.set(id, job);
|
||||||
|
|
||||||
const child = spawn(BIN, args, { cwd: ROOT, env: process.env });
|
const child = spawn(BIN, args, { cwd: ROOT, env: { ...process.env, ...envOverrides() } });
|
||||||
job.child = child;
|
job.child = child;
|
||||||
let buf = '';
|
let buf = '';
|
||||||
const onData = (chunk) => {
|
const onData = (chunk) => {
|
||||||
@@ -399,7 +485,7 @@ class ReplSession extends EventEmitter {
|
|||||||
function startRepl() {
|
function startRepl() {
|
||||||
if (!BIN) throw new Error('neurosploit binary not found — run `cargo build --release` in neurosploit-rs/');
|
if (!BIN) throw new Error('neurosploit binary not found — run `cargo build --release` in neurosploit-rs/');
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const child = spawn(BIN, [], { cwd: ROOT, env: process.env });
|
const child = spawn(BIN, [], { cwd: ROOT, env: { ...process.env, ...envOverrides() } });
|
||||||
const session = new ReplSession(id, child);
|
const session = new ReplSession(id, child);
|
||||||
replSessions.set(id, session);
|
replSessions.set(id, session);
|
||||||
const onData = (chunk) => session.push(stripAnsi(chunk.toString('utf8')));
|
const onData = (chunk) => session.push(stripAnsi(chunk.toString('utf8')));
|
||||||
@@ -535,7 +621,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
if (req.method === 'POST' && p === '/api/exploit') {
|
if (req.method === 'POST' && p === '/api/exploit') {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
const job = startJob(body);
|
const job = await startJob(body);
|
||||||
return sendJson(res, 200, { id: job.id });
|
return sendJson(res, 200, { id: job.id });
|
||||||
}
|
}
|
||||||
m = p.match(/^\/api\/exploit\/([^/]+)$/);
|
m = p.match(/^\/api\/exploit\/([^/]+)$/);
|
||||||
@@ -607,6 +693,28 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return sendJson(res, 200, { version: '4.0.0', binary: BIN, root: ROOT });
|
return sendJson(res, 200, { version: '4.0.0', binary: BIN, root: ROOT });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- providers / API keys (in-memory only, never persisted) ----
|
||||||
|
if (req.method === 'GET' && p === '/api/providers') {
|
||||||
|
return sendJson(res, 200, PROVIDERS.map(({ key, label, kind, models }) => ({ key, label, kind, models })));
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && p === '/api/keys') {
|
||||||
|
return sendJson(res, 200, PROVIDERS.map((pr) => ({ provider: pr.key, set: apiKeys.has(pr.key) && !!apiKeys.get(pr.key) })));
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && p === '/api/keys') {
|
||||||
|
const body = await readBody(req);
|
||||||
|
if (!body.provider || !PROVIDERS.some((pr) => pr.key === body.provider)) {
|
||||||
|
return sendJson(res, 400, { error: 'unknown provider' });
|
||||||
|
}
|
||||||
|
if (body.key) apiKeys.set(body.provider, body.key);
|
||||||
|
else apiKeys.delete(body.provider);
|
||||||
|
return sendJson(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
m = p.match(/^\/api\/keys\/([^/]+)$/);
|
||||||
|
if (req.method === 'DELETE' && m) {
|
||||||
|
apiKeys.delete(decodeURIComponent(m[1]));
|
||||||
|
return sendJson(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
sendJson(res, 404, { error: 'not found' });
|
sendJson(res, 404, { error: 'not found' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendJson(res, 500, { error: err.message });
|
sendJson(res, 500, { error: err.message });
|
||||||
|
|||||||
Reference in New Issue
Block a user