mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-10-14 03:30:50 +02:00
feat: graph memory in the test loop, wire-bound evidence, authz matrix, report redaction
Harness - graph_memory: typed attack graph (web + internal network) with Neo4j sync over the HTTP API. Graph checkpoints run after recon, validation, each chaining round and at the end. They direct agent selection and feed the live chains, next moves and dependency todolist into every prompt. - wire: every reported exchange is checked against recorded flows and re-sent through the scope-guarded replay engine. A contradiction means the finding cannot stand as confirmed. - traces: each agent's transcript is shared with the others as a fenced, structured digest (endpoints, params, error signatures, versions). - authz_matrix: role x route authorization matrix validator, with identity tiers in creds. - deliverables: evidence custody, exploit-as-test (honest oracles only), detection content and personas, with no credential leakage. - redact: credentials quoted by agents become $SECRET_n in every human-facing report (md/html/json/sarif/typ, findings.md). findings.json and the vault keep raw values for re-testing. - compliance: GDPR replaces LGPD. - New agents: authorization_matrix, session_lifecycle, api_token_structure, exposed_datastore, postgres_superuser_rce, meta/graph_reasoning. Web console - New tabs, mounted in both the detail and live views: Attack graph (replaces Generative Attack Path Chaining), Chains, Dependencies, Traffic, Traces, Deliverables. - Wire/Status columns, KPIs, CSV export. - Graph recompute / Neo4j push / Re-test actions, all through the CLI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015v24fuCWxkanS31HDmaDB6
This commit is contained in:
1 parent
f279817cfe
commit
79685cee49
29 files changed
+8052
-483
No files matched your search
+15
@@ -285,3 +285,18 @@ node web/server.js # http://localhost:4173
|
||||
`PORT` (or `NEUROSPLOIT_WEB_PORT`) overrides the port. The server auto-locates the compiled binary
|
||||
under `neurosploit-rs/target/{release,debug}/neurosploit` relative to the repo root; if neither
|
||||
exists, `/api/exploit` and `/api/repl` return a 500 with a build hint.
|
||||
|
||||
## Attack graph, traffic, traces, re-test
|
||||
|
||||
Everything below reads files the CLI wrote into the run directory, or shells out
|
||||
to the CLI — the console never re-implements harness logic.
|
||||
|
||||
| Method | Path | What |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/runs/:id/graph` | `{ chains, todo, graph }` — `graph/{live-chains,chains}.json`, `graph/todo.json`, `graph/{live,graph}.json` (live files win while the run is in progress) |
|
||||
| `POST` | `/api/runs/:id/graph` | Recompute with `neurosploit graph <run>`; body `{ "push": true }` adds `--push` (Neo4j credentials from the server's `NEO4J_URI` / `NEO4J_USER` / `NEO4J_PASSWORD`). Returns the CLI output and the refreshed graph. |
|
||||
| `GET` | `/api/runs/:id/traffic?host=&method=&q=&offset=&limit=` | The recording interceptor's `flows.jsonl`, filtered, newest first; `hosts` lists every host seen. |
|
||||
| `GET` | `/api/runs/:id/traces?q=` | The shared agent traces (`traces/*.md`) and, with `q`, line hits across all of them. |
|
||||
| `POST` | `/api/runs/:id/retest` | `neurosploit poc <run>` — re-runs every finding's recorded proof and reports what still reproduces (report only; nothing is written back). |
|
||||
|
||||
`GET /api/runs/:id` now also returns `extras`: `{ graph, todo, traces, flows, deliverables[] }`.
|
||||
+4
-3
@@ -14,9 +14,10 @@ CLI binary, never a reimplementation of harness logic.
|
||||
- **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.
|
||||
- **Attack graph** — the run's graph memory (web + internal network): host → service → finding →
|
||||
credential → impact, every edge marked confirmed / needs validation / hypothesis, findings ordered
|
||||
by kill-chain stage. Refreshed live at each graph checkpoint; click a finding to open it. Chains
|
||||
and Dependencies list the same paths as text and as the step-by-step todolist.
|
||||
- **Terminal dock (xterm.js)** — `❭_` in the sidebar, the topbar button, or `Ctrl+\`` opens a
|
||||
docked terminal running a real `neurosploit` REPL session. Its stdout is streamed **unstripped**,
|
||||
so the harness's own colour and box-drawn panels render as they do in a local shell. Line
|
||||
|
||||
+622
-405
File diff suppressed because it is too large.
Load diff
@@ -346,7 +346,6 @@
|
||||
</div>
|
||||
<nav class="run-tabs">
|
||||
<button class="run-tab active" data-tab="findings">Findings <span id="liveFindingsCount">0</span></button>
|
||||
<button class="run-tab" data-tab="attackpath">Generative Attack Path Chaining</button>
|
||||
<button class="run-tab" data-tab="log">Activity log</button>
|
||||
</nav>
|
||||
<div class="run-body">
|
||||
@@ -361,13 +360,12 @@
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="liveFindingsTable">
|
||||
<thead><tr><th data-sort="severity">Severity</th><th data-sort="title">Title</th><th data-sort="endpoint">Endpoint</th><th data-sort="cwe">CWE</th><th data-sort="agent">Agent</th><th data-sort="confidence" class="col-conf">Conf.</th></tr></thead>
|
||||
<thead><tr><th data-sort="severity">Severity</th><th data-sort="title">Title</th><th data-sort="endpoint">Endpoint</th><th data-sort="cwe">CWE</th><th data-sort="review_status">Status</th><th data-sort="wire">Wire</th><th data-sort="agent">Agent</th><th data-sort="confidence" class="col-conf">Conf.</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="empty-state" id="liveFindingsEmpty">No validated findings yet.</div>
|
||||
</div>
|
||||
<div class="run-tab-panel" data-tabpanel="attackpath" hidden><div id="liveAttackPath"></div></div>
|
||||
<div class="run-tab-panel log-tab-panel" data-tabpanel="log" hidden>
|
||||
<div class="log-panel" id="logList"></div>
|
||||
<div class="send-prompt-row" id="sendPromptRow" hidden>
|
||||
@@ -393,6 +391,9 @@
|
||||
<a class="btn" id="detailOpenReport" target="_blank" hidden>Open report</a>
|
||||
<a class="btn" id="detailOpenPdf" target="_blank" hidden>⤓ PDF</a>
|
||||
<button class="btn" id="btnBuildReport" title="Regenerate this run's report from its findings">Generate report</button>
|
||||
<button class="btn" id="btnGraphRecompute" title="Recompute the attack graph with the CLI (neurosploit graph)">↻ Graph</button>
|
||||
<button class="btn" id="btnGraphPush" title="Push the attack graph to Neo4j (neurosploit graph --push; needs NEO4J_URI / NEO4J_USER / NEO4J_PASSWORD on the server)">⇪ Neo4j</button>
|
||||
<button class="btn" id="btnRetest" title="Re-run every finding's recorded proof (neurosploit poc) and see what still reproduces">⟳ Re-test</button>
|
||||
<a class="btn" id="detailOpenAudit" target="_blank" hidden title="Every action this run took, hash-chained">Audit trail</a>
|
||||
<button class="btn btn-danger" id="btnDeleteRun" title="Permanently delete this session and its report">🗑 Delete</button>
|
||||
<button class="btn" id="btnDetailBack">← New engagement</button>
|
||||
@@ -400,7 +401,6 @@
|
||||
</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">
|
||||
@@ -411,16 +411,17 @@
|
||||
<span class="search-icon" aria-hidden="true">⌕</span>
|
||||
<input id="detailFindingSearch" type="text" placeholder="Filter findings" />
|
||||
</div>
|
||||
<button class="btn btn-sm" id="btnExportCsv" title="Export the filtered findings as CSV">⤓ CSV</button>
|
||||
</div>
|
||||
<div class="kpi-row" id="detailKpis"></div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="detailFindingsTable">
|
||||
<thead><tr><th data-sort="severity">Severity</th><th data-sort="title">Title</th><th data-sort="endpoint">Endpoint</th><th data-sort="cwe">CWE</th><th data-sort="agent">Agent</th><th data-sort="confidence" class="col-conf">Conf.</th></tr></thead>
|
||||
<thead><tr><th data-sort="severity">Severity</th><th data-sort="title">Title</th><th data-sort="endpoint">Endpoint</th><th data-sort="cwe">CWE</th><th data-sort="review_status">Status</th><th data-sort="wire">Wire</th><th data-sort="agent">Agent</th><th data-sort="confidence" class="col-conf">Conf.</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
|
||||
+167
-48
@@ -490,53 +490,6 @@ textarea { resize: vertical; min-height: 72px; }
|
||||
.run-body { flex: 1; overflow-y: auto; padding: var(--sp-5); }
|
||||
.run-tab-panel[hidden] { display: none; }
|
||||
|
||||
/* Generative Attack Path Chaining */
|
||||
.attackpath-empty { font-size: var(--fs-sm); color: var(--text-faint); padding: var(--sp-5); text-align: center; border: 1px dashed var(--border-strong); border-radius: var(--radius-sm); }
|
||||
|
||||
.ap-toolbar { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: var(--sp-2); }
|
||||
.ap-stats { font-size: var(--fs-sm); color: var(--text-dim); }
|
||||
.ap-stats b { color: var(--text); font-family: var(--mono); }
|
||||
.ap-inferred-note { color: var(--text-faint); }
|
||||
.ap-check { display: flex; align-items: center; gap: 6px; font-size: var(--fs-sm); color: var(--text-dim); }
|
||||
.ap-sev { width: auto; padding: 5px 8px; font-size: var(--fs-sm); }
|
||||
.ap-zoom { display: flex; gap: 4px; }
|
||||
.ap-zoom .btn { min-width: 32px; justify-content: center; }
|
||||
.ap-provenance { font-size: var(--fs-xs); color: var(--text-faint); margin-bottom: var(--sp-2); }
|
||||
|
||||
/* The canvas is a fixed viewport that the graph pans inside — letting the box
|
||||
grow to the graph's height (1187px on a 27-finding run) meant scrolling the
|
||||
page blind, with no way to see the shape of the path. */
|
||||
.ap-canvas-wrap { position: relative; height: min(60dvh, 560px); min-height: 240px; border-radius: var(--radius-md); overflow: hidden; background: var(--surface-2); border: 1px solid var(--border); cursor: grab; touch-action: none; }
|
||||
.ap-canvas-wrap.dragging { cursor: grabbing; }
|
||||
.ap-canvas { display: block; width: 100%; height: 100%; }
|
||||
.ap-canvas text { font-family: var(--sans); }
|
||||
.ap-hint { position: absolute; right: 8px; bottom: 6px; font-size: var(--fs-2xs); color: var(--text-faint); pointer-events: none; }
|
||||
|
||||
.ap-col-line { stroke: var(--border); stroke-width: 1; }
|
||||
.ap-col-name { font-size: var(--fs-2xs); fill: var(--text-faint); text-transform: uppercase; letter-spacing: .06em; font-weight: 600; }
|
||||
.ap-col-count { font-size: var(--fs-2xs); fill: var(--text-faint); font-family: var(--mono); }
|
||||
|
||||
.ap-node { fill: var(--surface); stroke-width: 1.6; }
|
||||
.ap-root .ap-node { stroke: var(--accent); }
|
||||
.ap-icon { font-size: var(--fs-base); }
|
||||
.ap-title { font-size: var(--fs-xs); fill: var(--text); font-weight: 600; }
|
||||
.ap-meta, .ap-conf { font-size: var(--fs-2xs); fill: var(--text-faint); font-family: var(--mono); }
|
||||
.ap-node-g { cursor: pointer; }
|
||||
.ap-node-g:hover .ap-node, .ap-node-g:focus-visible .ap-node { filter: brightness(1.04); stroke-width: 2.4; }
|
||||
.ap-node-g:focus { outline: none; }
|
||||
|
||||
.ap-edge { stroke: var(--border-strong); stroke-width: 1.6; fill: none; }
|
||||
.ap-edge marker, #apArrow path { fill: var(--border-strong); }
|
||||
/* An inferred edge is a hypothesis about the path, not evidence of it. */
|
||||
.ap-edge.inferred { stroke-dasharray: 5 4; opacity: .55; }
|
||||
.ap-edge.root-edge { opacity: .35; }
|
||||
.has-focus .ap-node-g:not(.focus) { opacity: .22; }
|
||||
.has-focus .ap-edge:not(.focus) { opacity: .1; }
|
||||
.ap-edge.focus { stroke: var(--accent); stroke-width: 2.2; opacity: 1; }
|
||||
|
||||
.ap-legend { display: flex; gap: var(--sp-4); flex-wrap: wrap; align-items: center; margin-top: var(--sp-2); font-size: var(--fs-xs); color: var(--text-faint); }
|
||||
.ap-key { display: flex; align-items: center; gap: 5px; }
|
||||
.ap-key i { width: 9px; height: 9px; border-radius: var(--radius-xs); display: inline-block; }
|
||||
|
||||
/* findings table */
|
||||
/* Wide tables scroll inside their own box; without this the whole page
|
||||
@@ -769,7 +722,6 @@ body.resizing-ns { user-select: none; cursor: ns-resize; }
|
||||
@media (max-height: 500px) and (orientation: landscape) {
|
||||
.topbar, .run-head { padding-top: var(--sp-2); padding-bottom: var(--sp-2); min-height: 0; }
|
||||
.term-dock { height: clamp(120px, 46dvh, 220px); }
|
||||
.ap-canvas-wrap { height: 58dvh; min-height: 160px; }
|
||||
.term-hints { display: none; }
|
||||
}
|
||||
|
||||
@@ -823,3 +775,170 @@ body.resizing-ns { user-select: none; cursor: ns-resize; }
|
||||
.agent-chip .spin { width: 7px; height: 7px; border: 1.5px solid currentColor; border-right-color: transparent; border-radius: 50%; display: inline-block; animation: aspin .7s linear infinite; }
|
||||
@keyframes aspin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .agent-chip .spin { animation: none; } }
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Run detail — attack graph, dependencies, traffic, traces, deliverables.
|
||||
Ideas from autonomous-pentest consoles (KPI cards, typed node graph, traffic
|
||||
table), built on this console's own tokens so both themes work.
|
||||
------------------------------------------------------------------------- */
|
||||
.muted { color: var(--text-faint); font-size: var(--fs-xs); }
|
||||
.panel-toolbar { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: var(--sp-3); }
|
||||
.panel-help { color: var(--text-dim); font-size: var(--fs-sm); margin: 0 0 var(--sp-3); max-width: 900px; }
|
||||
.chip-row { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
|
||||
.input-sm { padding: 6px 10px; font-size: var(--fs-sm); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--text); }
|
||||
.toggle { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-sm); color: var(--text-dim); }
|
||||
|
||||
/* KPI cards above the findings table */
|
||||
.kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: var(--sp-3); margin: var(--sp-3) 0; }
|
||||
.kpi { border: 1px solid var(--border); background: var(--surface); border-radius: var(--radius-md); padding: var(--sp-3) var(--sp-4); }
|
||||
.kpi-k { font-size: var(--fs-2xs); text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); font-weight: 600; }
|
||||
.kpi-v { font-size: var(--fs-2xl); font-weight: 700; font-family: var(--mono); margin: 2px 0; }
|
||||
.kpi-sub { font-size: var(--fs-xs); color: var(--text-dim); }
|
||||
.kpi-ok .kpi-v { color: var(--sev-low-fg); }
|
||||
.kpi-bad .kpi-v { color: var(--sev-critical-fg); }
|
||||
|
||||
/* status + wire badges in the findings table */
|
||||
.status-badge, .wire-badge, .chain-status, .gm-st { display: inline-block; font-size: var(--fs-2xs); font-weight: 600; padding: 2px 7px; border-radius: var(--radius-pill); border: 1px solid transparent; white-space: nowrap; }
|
||||
.st-confirmed { background: var(--sev-low-bg); color: var(--sev-low-fg); }
|
||||
.st-needs-review, .st-needs_review { background: var(--sev-info-bg); color: var(--sev-info-fg); }
|
||||
.st-needs_validation { background: var(--sev-medium-bg); color: var(--sev-medium-fg); }
|
||||
.st-hypothesis, .st-candidate { background: var(--surface-3); color: var(--text-dim); }
|
||||
.st-rejected { background: var(--sev-critical-bg); color: var(--sev-critical-fg); }
|
||||
.wire-verified { background: var(--sev-low-bg); color: var(--sev-low-fg); }
|
||||
.wire-contradicted { background: var(--sev-critical-bg); color: var(--sev-critical-fg); }
|
||||
.wire-unverified, .wire-pending { background: var(--surface-3); color: var(--text-dim); }
|
||||
.wire-none { background: transparent; color: var(--text-faint); border-color: var(--border); }
|
||||
|
||||
/* chains */
|
||||
.moves { border: 1px solid var(--border); border-left: 3px solid var(--accent); background: var(--surface); border-radius: var(--radius-sm); padding: var(--sp-3) var(--sp-4); margin-bottom: var(--sp-3); }
|
||||
.moves-h { font-size: var(--fs-2xs); text-transform: uppercase; letter-spacing: .05em; color: var(--accent); font-weight: 700; margin-bottom: 4px; }
|
||||
.moves ul { margin: 0; padding-left: 18px; font-size: var(--fs-sm); color: var(--text-dim); }
|
||||
.chain-list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.chain-card { border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); }
|
||||
.chain-card.st-confirmed { border-left: 3px solid var(--sev-low-fg); }
|
||||
.chain-card.st-needs_validation { border-left: 3px solid var(--sev-medium-fg); }
|
||||
.chain-card.st-hypothesis { border-left: 3px dashed var(--border-strong); }
|
||||
.chain-card summary { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); cursor: pointer; flex-wrap: wrap; list-style: none; }
|
||||
.chain-card summary::-webkit-details-marker { display: none; }
|
||||
.chain-title { font-weight: 600; font-size: var(--fs-base); flex: 1; min-width: 260px; }
|
||||
.chain-pot { font-size: var(--fs-xs); color: var(--sev-medium-fg); font-weight: 600; }
|
||||
.chain-meta { font-size: var(--fs-xs); color: var(--text-faint); font-family: var(--mono); }
|
||||
.chain-body { padding: 0 var(--sp-4) var(--sp-4); border-top: 1px solid var(--border); }
|
||||
.chain-impact { margin: var(--sp-3) 0; font-size: var(--fs-sm); }
|
||||
.chain-steps { margin: 0 0 var(--sp-3); padding-left: 20px; font-family: var(--mono); font-size: var(--fs-xs); }
|
||||
.chain-steps .step { padding: 3px 0; color: var(--text-dim); }
|
||||
.chain-steps .step.st-confirmed { color: var(--sev-low-fg); background: none; }
|
||||
.chain-steps .step.st-needs_validation { color: var(--sev-medium-fg); background: none; }
|
||||
.chain-steps .step.st-hypothesis { color: var(--text-faint); background: none; }
|
||||
.chain-block { margin-top: var(--sp-2); }
|
||||
.chain-block-h { font-size: var(--fs-2xs); text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); font-weight: 700; }
|
||||
.chain-block ul { margin: 4px 0 0; padding-left: 18px; font-size: var(--fs-sm); }
|
||||
|
||||
/* dependency todolist */
|
||||
.todo-chain { border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); margin-bottom: var(--sp-3); }
|
||||
.todo-chain header { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
||||
.todo-title { font-weight: 600; flex: 1; min-width: 240px; }
|
||||
.todo-progress { width: 110px; height: 6px; background: var(--surface-3); border-radius: var(--radius-pill); overflow: hidden; }
|
||||
.todo-progress span { display: block; height: 100%; background: var(--sev-low-fg); }
|
||||
.todo-steps { list-style: none; margin: 0; padding: var(--sp-2) var(--sp-4) var(--sp-3); counter-reset: ts; }
|
||||
.todo-step { display: flex; gap: var(--sp-3); padding: var(--sp-2) 0; border-bottom: 1px dashed var(--border); align-items: flex-start; }
|
||||
.todo-step:last-child { border-bottom: none; }
|
||||
.ts-badge { flex: 0 0 120px; font-size: var(--fs-2xs); font-weight: 700; padding: 3px 8px; border-radius: var(--radius-pill); text-align: center; }
|
||||
.ts-done .ts-badge { background: var(--sev-low-bg); color: var(--sev-low-fg); }
|
||||
.ts-ready .ts-badge { background: var(--accent-soft); color: var(--accent); }
|
||||
.ts-blocked .ts-badge { background: var(--surface-3); color: var(--text-faint); }
|
||||
.ts-blocked .ts-step { color: var(--text-faint); }
|
||||
.ts-step { font-family: var(--mono); font-size: var(--fs-xs); }
|
||||
.ts-action { font-size: var(--fs-sm); color: var(--text-dim); margin-top: 2px; }
|
||||
|
||||
/* graph (web + internal network) */
|
||||
.gm-wrap { position: relative; height: 640px; border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; cursor: grab;
|
||||
background-color: var(--surface-2); background-image: radial-gradient(var(--border) 1px, transparent 1px); background-size: 18px 18px; }
|
||||
.gm-wrap.dragging { cursor: grabbing; }
|
||||
.gm-wrap[hidden] { display: none; }
|
||||
.gm-stage { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
|
||||
.gm-svg { position: absolute; left: 0; top: 0; overflow: visible; pointer-events: none; }
|
||||
.gm-e { fill: none; stroke-width: 1.4; opacity: .55; }
|
||||
.gm-e.gm-trav { stroke-width: 2; opacity: .85; }
|
||||
.gm-e.es-confirmed { stroke: var(--sev-low-fg); }
|
||||
.gm-e.es-needs_validation { stroke: var(--sev-medium-fg); stroke-dasharray: 6 4; }
|
||||
.gm-e.es-hypothesis { stroke: var(--text-faint); stroke-dasharray: 2 4; }
|
||||
.gm-e.es-needs_review { stroke: var(--sev-info-fg); stroke-dasharray: 4 3; }
|
||||
.gm-stage.focusing .gm-e { opacity: .12; }
|
||||
.gm-stage.focusing .gm-e.hl { opacity: 1; stroke-width: 2.6; }
|
||||
.gm-node { position: absolute; background: var(--surface); border: 1px solid var(--border-strong); border-left: 4px solid var(--border-strong); border-radius: var(--radius-sm); padding: 6px 8px; cursor: pointer; overflow: hidden; box-shadow: 0 1px 0 var(--border); }
|
||||
.gm-node:hover { border-color: var(--text-dim); }
|
||||
.gm-node.sel { outline: 2px solid var(--focus-ring); }
|
||||
.gm-node-h { display: flex; align-items: center; gap: 5px; }
|
||||
.gm-lbl { font-size: var(--fs-2xs); text-transform: uppercase; letter-spacing: .04em; font-weight: 700; color: var(--text-faint); }
|
||||
.gm-node .sev { font-size: var(--fs-2xs); padding: 0 5px; }
|
||||
.gm-crit { color: var(--sev-critical-fg); font-size: var(--fs-xs); }
|
||||
.gm-node-t { font-size: var(--fs-xs); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; }
|
||||
.gk-asset { border-left-color: var(--sev-info-fg); }
|
||||
.gk-surface { border-left-color: var(--text-faint); }
|
||||
.gk-weak { border-left-color: var(--sev-high-fg); }
|
||||
.gk-step { border-left-color: var(--accent); }
|
||||
.gk-cred { border-left-color: var(--sev-medium-fg); }
|
||||
.gk-impact { border-left-color: var(--sev-critical-fg); background: var(--sev-critical-bg); }
|
||||
.gk-meta { border-left-color: var(--border); opacity: .8; }
|
||||
.gm-leg { font-size: var(--fs-xs); padding: 3px 8px 3px 6px; border: 1px solid var(--border); border-left-width: 4px; border-radius: var(--radius-xs); background: var(--surface); color: var(--text-dim); }
|
||||
.gm-leg-sep { width: 1px; height: 18px; background: var(--border); }
|
||||
.gm-edge-leg { font-size: var(--fs-xs); color: var(--text-dim); display: inline-flex; align-items: center; gap: 6px; }
|
||||
.gm-edge-leg::before { content: ""; width: 22px; border-top: 2px solid; }
|
||||
.gm-edge-leg.es-confirmed::before { border-color: var(--sev-low-fg); }
|
||||
.gm-edge-leg.es-needs_validation::before { border-color: var(--sev-medium-fg); border-top-style: dashed; }
|
||||
.gm-edge-leg.es-hypothesis::before { border-color: var(--text-faint); border-top-style: dotted; }
|
||||
.gm-zoom { position: absolute; left: 10px; bottom: 10px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.gm-detail { margin-top: var(--sp-3); border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); padding: var(--sp-3) var(--sp-4); }
|
||||
.gm-detail header { display: flex; align-items: center; gap: var(--sp-2); justify-content: space-between; margin-bottom: var(--sp-2); }
|
||||
.kv { border-collapse: collapse; font-size: var(--fs-xs); width: 100%; }
|
||||
.kv th { text-align: left; color: var(--text-faint); font-weight: 600; padding: 2px var(--sp-3) 2px 0; width: 160px; vertical-align: top; }
|
||||
.kv td { font-family: var(--mono); padding: 2px 0; word-break: break-all; }
|
||||
|
||||
/* traffic */
|
||||
.method { font-family: var(--mono); font-size: var(--fs-2xs); font-weight: 700; border: 1px solid var(--border-strong); border-radius: var(--radius-xs); padding: 1px 6px; }
|
||||
.tf { font-family: var(--mono); font-weight: 700; }
|
||||
.tf-2 { color: var(--sev-low-fg); }
|
||||
.tf-3 { color: var(--sev-info-fg); }
|
||||
.tf-4 { color: var(--sev-medium-fg); }
|
||||
.tf-5 { color: var(--sev-critical-fg); }
|
||||
.tf-0 { color: var(--text-faint); }
|
||||
|
||||
/* traces */
|
||||
.trace-layout { display: grid; grid-template-columns: 240px 1fr; gap: var(--sp-4); }
|
||||
.trace-files { list-style: none; margin: 0; padding: 0; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); max-height: 620px; overflow: auto; }
|
||||
.trace-files li { display: flex; justify-content: space-between; gap: var(--sp-2); padding: 6px var(--sp-3); border-bottom: 1px solid var(--border); font-size: var(--fs-sm); }
|
||||
.trace-files a { color: var(--text); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.trace-files a:hover { color: var(--accent); }
|
||||
.trace-hits { display: flex; flex-direction: column; gap: 6px; font-size: var(--fs-sm); }
|
||||
.trace-hit { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); padding: 6px var(--sp-3); }
|
||||
.trace-hit a { font-weight: 600; color: var(--text); }
|
||||
.trace-hit code { display: block; font-family: var(--mono); font-size: var(--fs-xs); color: var(--text-dim); margin-top: 3px; white-space: pre-wrap; word-break: break-all; }
|
||||
.trace-hit mark { background: var(--accent-soft); color: var(--accent); }
|
||||
@media (max-width: 760px) { .trace-layout { grid-template-columns: 1fr; } .gm-wrap { height: 460px; } }
|
||||
|
||||
/* deliverables */
|
||||
.deliv-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: var(--sp-3); }
|
||||
.deliv-card { display: flex; flex-direction: column; gap: 4px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); padding: var(--sp-3) var(--sp-4); text-decoration: none; color: var(--text); }
|
||||
a.deliv-card:hover { border-color: var(--accent); }
|
||||
.deliv-card span { font-size: var(--fs-sm); color: var(--text-dim); }
|
||||
.deliv-card code { font-family: var(--mono); font-size: var(--fs-2xs); color: var(--text-faint); }
|
||||
.deliv-group ul { margin: 4px 0 0; padding-left: 16px; font-size: var(--fs-xs); max-height: 160px; overflow: auto; }
|
||||
|
||||
/* CLI output dialog */
|
||||
.out-pre { margin: 0; padding: var(--sp-4) var(--sp-5); font-family: var(--mono); font-size: var(--fs-xs); white-space: pre-wrap; word-break: break-word; overflow: auto; max-height: 60vh; background: var(--surface-2); }
|
||||
|
||||
.moves li { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin: 2px 0; }
|
||||
.moves details summary { cursor: pointer; font-size: var(--fs-xs); color: var(--accent); margin-top: 4px; }
|
||||
|
||||
.gm-leg.gk-asset { border-left-color: var(--sev-info-fg); }
|
||||
.gm-leg.gk-surface { border-left-color: var(--text-faint); }
|
||||
.gm-leg.gk-weak { border-left-color: var(--sev-high-fg); }
|
||||
.gm-leg.gk-step { border-left-color: var(--accent); }
|
||||
.gm-leg.gk-cred { border-left-color: var(--sev-medium-fg); }
|
||||
.gm-leg.gk-impact { border-left-color: var(--sev-critical-fg); background: var(--surface); }
|
||||
.panel-toolbar .input-sm { width: 200px; flex: 0 0 auto; }
|
||||
.panel-toolbar select.input-sm { width: 150px; }
|
||||
.panel-toolbar .search-wrap { flex: 1 1 260px; max-width: 420px; }
|
||||
.gm-detail header { justify-content: flex-start; }
|
||||
.gm-detail header [data-close] { margin-left: auto; }
|
||||
+135
-2
@@ -556,7 +556,101 @@ async function runDetail(id) {
|
||||
const assets = ['report.html', 'report.pdf', 'report.md', 'recon.md', 'exploitation.md', 'audit.jsonl', 'graph.json']
|
||||
.filter((f) => fs.existsSync(path.join(dir, f)));
|
||||
const pocs = await fsp.readdir(path.join(dir, 'pocs')).catch(() => []);
|
||||
return { id, name: engagementNames.get(id) || '', meta, status, findings, assets, pocs };
|
||||
// What the harness produced beyond the report: the attack graph, the shared
|
||||
// traces, the recorded traffic, the deliverables. Presence only — the tabs
|
||||
// fetch the content on demand.
|
||||
const exists = (f) => fs.existsSync(path.join(dir, f));
|
||||
const traces = await fsp.readdir(path.join(dir, 'traces')).catch(() => []);
|
||||
const extras = {
|
||||
graph: exists('graph/chains.json') || exists('graph/live-chains.json') || exists('graph/graph.json') || exists('graph/live.json'),
|
||||
todo: exists('graph/todo.json'),
|
||||
traces: traces.filter((f) => f.endsWith('.md')).length,
|
||||
flows: exists('flows.jsonl'),
|
||||
deliverables: await listTree(path.join(dir, 'deliverables'), 'deliverables'),
|
||||
};
|
||||
return { id, name: engagementNames.get(id) || '', meta, status, findings, assets, pocs, extras };
|
||||
}
|
||||
|
||||
/// Relative file list under a directory (one level of nesting is enough for
|
||||
/// `deliverables/`: tests/, nuclei/, detection/).
|
||||
async function listTree(abs, rel, depth = 0) {
|
||||
const out = [];
|
||||
const ents = await fsp.readdir(abs, { withFileTypes: true }).catch(() => []);
|
||||
for (const e of ents) {
|
||||
if (e.isDirectory() && depth < 2) out.push(...await listTree(path.join(abs, e.name), `${rel}/${e.name}`, depth + 1));
|
||||
else if (e.isFile()) out.push(`${rel}/${e.name}`);
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
/// Run the real CLI against a run directory and capture its output. The web
|
||||
/// console never re-implements harness logic — it shells out, like `rebuild`.
|
||||
function runCli(args) {
|
||||
return new Promise((resolve) => {
|
||||
if (!BIN) return resolve({ code: -1, buf: 'neurosploit binary not found — run `cargo build --release` in neurosploit-rs/' });
|
||||
const child = spawn(BIN, args, { cwd: ROOT, env: { ...process.env, ...envOverrides() } });
|
||||
let buf = '';
|
||||
child.stdout.on('data', (c) => { buf += c.toString('utf8'); });
|
||||
child.stderr.on('data', (c) => { buf += c.toString('utf8'); });
|
||||
child.on('close', (code) => resolve({ code, buf: stripAnsi(buf) }));
|
||||
child.on('error', (e) => resolve({ code: -1, buf: e.message }));
|
||||
});
|
||||
}
|
||||
|
||||
/// The attack graph a run produced: chains, the dependency todolist and the
|
||||
/// graph itself. Live files (written during the run) win while it runs.
|
||||
async function runGraph(dir) {
|
||||
const pick = async (...names) => {
|
||||
for (const n of names) {
|
||||
const v = await readJsonSafe(path.join(dir, n), null);
|
||||
if (v) return v;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
chains: await pick('graph/live-chains.json', 'graph/chains.json'),
|
||||
todo: await pick('graph/todo.json'),
|
||||
graph: await pick('graph/live.json', 'graph/graph.json'),
|
||||
};
|
||||
}
|
||||
|
||||
/// The recording interceptor's log, filtered and paged.
|
||||
async function runTraffic(dir, q) {
|
||||
const text = await fsp.readFile(path.join(dir, 'flows.jsonl'), 'utf8').catch(() => '');
|
||||
let rows = text.split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
||||
const hostOf = (u) => { try { return new URL(u).host; } catch { return String(u || '').split('/')[0]; } };
|
||||
const hosts = [...new Set(rows.map((r) => hostOf(r.url)))].sort();
|
||||
const host = (q.get('host') || '').trim().toLowerCase();
|
||||
const method = (q.get('method') || '').trim().toUpperCase();
|
||||
const needle = (q.get('q') || '').trim().toLowerCase();
|
||||
if (host) rows = rows.filter((r) => hostOf(r.url).toLowerCase().includes(host));
|
||||
if (method) rows = rows.filter((r) => (r.method || '').toUpperCase() === method);
|
||||
if (needle) rows = rows.filter((r) => `${r.method} ${r.url} ${r.status} ${r.content_type}`.toLowerCase().includes(needle));
|
||||
rows.reverse(); // newest first
|
||||
const offset = Math.max(0, parseInt(q.get('offset') || '0', 10) || 0);
|
||||
const limit = Math.min(500, Math.max(1, parseInt(q.get('limit') || '100', 10) || 100));
|
||||
return { total: rows.length, hosts, rows: rows.slice(offset, offset + limit), offset, limit };
|
||||
}
|
||||
|
||||
/// Search every agent's trace (the shared-trace store) for a keyword.
|
||||
async function searchTraces(dir, needle) {
|
||||
const tdir = path.join(dir, 'traces');
|
||||
const files = (await fsp.readdir(tdir).catch(() => [])).filter((f) => f.endsWith('.md')).sort();
|
||||
const list = [];
|
||||
const hits = [];
|
||||
const n = (needle || '').trim().toLowerCase();
|
||||
for (const f of files) {
|
||||
const text = await fsp.readFile(path.join(tdir, f), 'utf8').catch(() => '');
|
||||
list.push({ agent: f.replace(/\.md$/, ''), file: `traces/${f}`, bytes: Buffer.byteLength(text) });
|
||||
if (!n || hits.length >= 200) continue;
|
||||
const lines = text.split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
if (hits.length < 200 && line.toLowerCase().includes(n)) {
|
||||
hits.push({ agent: f.replace(/\.md$/, ''), file: `traces/${f}`, line: i + 1, snippet: stripAnsi(line).slice(0, 300) });
|
||||
}
|
||||
});
|
||||
}
|
||||
return { traces: list, hits, query: needle || '' };
|
||||
}
|
||||
|
||||
function safeRunDir(id) {
|
||||
@@ -1190,7 +1284,8 @@ async function serveRunAsset(req, res, id, rest) {
|
||||
const dir = safeRunDir(id);
|
||||
if (!dir) { res.writeHead(400); res.end(); return; }
|
||||
const full = path.join(dir, rest);
|
||||
if (!full.startsWith(dir)) { res.writeHead(403); res.end(); return; }
|
||||
// Separator-aware: `ns-1` must not reach into `ns-10`.
|
||||
if (full !== dir && !full.startsWith(dir + path.sep)) { res.writeHead(403); res.end(); return; }
|
||||
try {
|
||||
const data = await fsp.readFile(full);
|
||||
const ext = path.extname(full);
|
||||
@@ -1449,6 +1544,44 @@ const server = http.createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- attack graph / traffic / traces (files the CLI wrote) ----
|
||||
m = p.match(/^\/api\/runs\/([^/]+)\/graph$/);
|
||||
if (m) {
|
||||
const dir = safeRunDir(decodeURIComponent(m[1]));
|
||||
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
|
||||
if (req.method === 'GET') return sendJson(res, 200, await runGraph(dir));
|
||||
if (req.method === 'POST') {
|
||||
// Recompute (and optionally push to Neo4j) through the CLI itself.
|
||||
const body = await readBody(req);
|
||||
const args = ['graph', dir, '--top', '0'];
|
||||
if (body.push) args.push('--push');
|
||||
const out = await runCli(args);
|
||||
if (out.code !== 0) return sendJson(res, 502, { error: out.buf.trim() || 'graph failed' });
|
||||
return sendJson(res, 200, { ok: true, output: out.buf.trim(), ...(await runGraph(dir)) });
|
||||
}
|
||||
}
|
||||
m = p.match(/^\/api\/runs\/([^/]+)\/traffic$/);
|
||||
if (req.method === 'GET' && m) {
|
||||
const dir = safeRunDir(decodeURIComponent(m[1]));
|
||||
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
|
||||
return sendJson(res, 200, await runTraffic(dir, u.searchParams));
|
||||
}
|
||||
m = p.match(/^\/api\/runs\/([^/]+)\/traces$/);
|
||||
if (req.method === 'GET' && m) {
|
||||
const dir = safeRunDir(decodeURIComponent(m[1]));
|
||||
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
|
||||
return sendJson(res, 200, await searchTraces(dir, u.searchParams.get('q')));
|
||||
}
|
||||
// ---- PoC re-test (the CLI re-runs each finding's recorded proof) ----
|
||||
m = p.match(/^\/api\/runs\/([^/]+)\/retest$/);
|
||||
if (req.method === 'POST' && m) {
|
||||
const dir = safeRunDir(decodeURIComponent(m[1]));
|
||||
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
|
||||
const out = await runCli(['poc', dir]);
|
||||
if (out.code !== 0) return sendJson(res, 502, { error: out.buf.trim() || 're-test failed' });
|
||||
return sendJson(res, 200, { ok: true, output: out.buf.trim() });
|
||||
}
|
||||
|
||||
// ---- report rebuild (generate/refresh the PDF for a finished run) ----
|
||||
m = p.match(/^\/api\/runs\/([^/]+)\/report$/);
|
||||
if (req.method === 'POST' && m) {
|
||||
|
||||
Reference in new issue
Block a user