Compare commits

..
3 Commits
Author SHA1 Message Date
CyberSecurityUP 53c07b9a9c v3.6.3: resumable interrupted runs + crash-proof mid-run browsing
- /continue (and /resume) now relaunch a recovered interrupted run on the same
  target, carrying its findings forward and steering agents to widen coverage /
  chain from them instead of re-reporting. Offer shown at launch; a fresh /run
  supersedes it. Findings merge (dedup by title+endpoint) across both runs.
- Opening /results, /finding or /report while a run streams no longer corrupts
  the terminal: live background output is paused for the picker (still captured
  in /logs) and restored on exit, so Ctrl-C in a picker can't take the process
  down mid-run.
2026-07-10 21:44:22 -03:00
CyberSecurityUP 865611d552 recon: time-box tool installs and skip on failure — never stall on a download
A missing or un-downloadable recon tool must never block the run. Both the recon
intensity directive and the general tool doctrine now instruct agents to:
- wrap every install in `timeout 90 <install> || echo skip` and run non-interactively
- try each tool install at most once; on failure/no-package/no-network/hang, skip
  immediately and fall back to an installed alternative or curl/nc/dig/python3
- never wait on, retry, or block the whole recon for a single tool download
2026-07-10 17:35:11 -03:00
CyberSecurityUP ce31478068 v3.6.2: stream Codex tool-by-tool + capture agent commands in /logs & /status
- Drive `codex exec --json` and parse its JSONL event stream into the same
  categorized live feed as Claude Code (exec/edit/tool/net/tokens), so recon and
  exploitation are visible as each command runs instead of a silent black box.
- Fix the activity feed to keep per-agent tool events (commands, network, files,
  findings) and only filter model reasoning + token telemetry, so /logs shows the
  real command trail and /status 'last:' is a true sign-of-life.
- Surface failed internal commands as 'exec: (exit N)'; keep Codex auth/rate
  detection from stderr.
2026-07-10 17:28:40 -03:00
16 changed files with 327 additions and 60 deletions
+12 -10
View File
@@ -1,4 +1,4 @@
<h1 align="center">🧠 NeuroSploit v3.6.1</h1> <h1 align="center">🧠 NeuroSploit v3.6.3</h1>
<p align="center"> <p align="center">
<a href="https://trendshift.io/repositories/22624?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-22624" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/22624/daily?language=Python" alt="JoasASantos%2FNeuroSploit | Trendshift" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/22624?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-22624" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/22624/daily?language=Python" alt="JoasASantos%2FNeuroSploit | Trendshift" width="250" height="55"/></a>
@@ -12,7 +12,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/Version-3.6.1-blue?style=flat-square"> <img src="https://img.shields.io/badge/Version-3.6.3-blue?style=flat-square">
<img src="https://img.shields.io/badge/Harness-Rust%20%7C%20tokio-e6b673?style=flat-square"> <img src="https://img.shields.io/badge/Harness-Rust%20%7C%20tokio-e6b673?style=flat-square">
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square"> <img src="https://img.shields.io/badge/License-MIT-green?style=flat-square">
<img src="https://img.shields.io/badge/MD%20Agents-417-red?style=flat-square"> <img src="https://img.shields.io/badge/MD%20Agents-417-red?style=flat-square">
@@ -28,14 +28,16 @@
> >
> 📖 **New here? Read the [full Tutorial & User Guide →](TUTORIAL.md)** — every mode, flag, config and example explained. > 📖 **New here? Read the [full Tutorial & User Guide →](TUTORIAL.md)** — every mode, flag, config and example explained.
> 🆕 **New in v3.6.1Cloud testing + REPL navigation + deeper recon:** > 🆕 **New in v3.6.3resumable interrupted runs + crash-proof browsing:**
> **AWS/GCP/Azure** agents (+17 → **375** total) with credentials wired through > If a run is interrupted (terminal closed, Ctrl-C, crash), **`/continue` now
> `creds.yaml`; a more navigable **REPL** — **`/timeout`** idle guardrail, > relaunches it** on the same target and **carries the recovered findings
> **multi-target** `/target a,b,c` (sequential), an interactive **`/results`** > forward** — the offer appears right at launch. And opening **`/results`**,
> browser (target → vuln → detail, Esc to go back) and **`/report`** picker; and > **`/finding`** or **`/report`** while a run streams no longer corrupts the
> **deeper recon** that downloads & analyzes JavaScript (endpoints, secrets, > terminal: live output is paused for the picker (still captured in `/logs`) and
> source maps) and does request/response differential analysis. Interactive > restored on exit. *(v3.6.2 added live Codex tool-by-tool streaming + the full
> line-editing prompt bug fixed. > activity feed in `/logs`/`/status`; v3.6.1 added GPT-5.6 sol/terra/luna and
> the Codex exit-1 fix; v3.6.0 added AI-agent/LLM/MCP/Skills/n8n testing +
> onboarding wizard + Cloud scope.)*
> *(v3.5.4 added robust attack chaining + false-positive reduction; v3.5.3 > *(v3.5.4 added robust attack chaining + false-positive reduction; v3.5.3
> GitHub/GitLab/Jira **[integrations](TUTORIAL-INTEGRATION.md)**; v3.5.2 the DEPTH > GitHub/GitLab/Jira **[integrations](TUTORIAL-INTEGRATION.md)**; v3.5.2 the DEPTH
> doctrine + report-hygiene — see [RELEASE.md](RELEASE.md).)* > doctrine + report-hygiene — see [RELEASE.md](RELEASE.md).)*
+40 -2
View File
@@ -1,7 +1,7 @@
# NeuroSploit v3.6.1 — Release Notes # NeuroSploit v3.6.3 — Release Notes
**Release Date:** July 2026 **Release Date:** July 2026
**Codename:** GPT-5.6 models **Codename:** Resume & Recover
**License:** MIT **License:** MIT
**Credits:** Joas A Santos & Red Team Leaders **Credits:** Joas A Santos & Red Team Leaders
@@ -9,6 +9,44 @@
## Highlights ## Highlights
- **Interrupted runs are resumable.** When a run is cut off (terminal closed,
Ctrl-C, crash, SSH drop), its findings were already checkpointed live and
recovered as a run on the next launch. Now `/continue` (or `/resume`) also
**relaunches the engagement** on the same target and **carries those findings
forward** — steering agents to widen coverage and chain from what was already
found instead of re-reporting it. The offer is shown at launch right under the
recovery line. A fresh `/run` supersedes the pending resume.
- **Browsing no longer kills a live run.** Opening `/results`, `/finding` or
`/report` while a run streams used to let the background printer and the
full-screen picker fight over the terminal — pressing Ctrl-C to escape could
take the whole process down. Live output is now paused while any picker is
open (still captured in `/logs`) and restored when you exit, so browsing
findings mid-run is safe.
- Findings merge (dedup by title + endpoint) across the interrupted and
continued runs, and the merged report is rewritten to include everything.
---
## Previously in v3.6.2
- **Codex now streams live, tool-by-tool.** `codex exec` is driven with `--json`
and its JSONL event stream is parsed into the same categorized activity feed
as Claude Code: every shell command it runs (`exec:`), file edit (`edit:`),
MCP tool call (`tool:`), web search (`net:`) and token count appears the moment
it happens. A long, intense recon (subfinder → httpx → katana → nmap …) is no
longer a silent black box — you watch each tool execute.
- **`/logs` and `/status` now capture what each agent actually runs.** The
activity feed previously dropped the per-agent tool events; it now keeps the
actionable ones (commands, network, files, findings) and only filters long
model reasoning and token telemetry. `/logs` shows the real command trail;
`/status` `last:` shows a true sign-of-life.
- Failed internal commands surface as `exec: (exit N) <cmd>` instead of
silently vanishing, and Codex auth/rate errors are still detected from stderr.
---
## Previously in v3.6.1
- **Added the GPT-5.6 model line** (OpenAI / ChatGPT): `openai:gpt-5.6-sol` - **Added the GPT-5.6 model line** (OpenAI / ChatGPT): `openai:gpt-5.6-sol`
(frontier / default), `openai:gpt-5.6-terra` (balanced), and (frontier / default), `openai:gpt-5.6-terra` (balanced), and
`openai:gpt-5.6-luna` (fast & affordable) — alongside the existing GPT-5.x, `openai:gpt-5.6-luna` (fast & affordable) — alongside the existing GPT-5.x,
+2 -2
View File
@@ -1,4 +1,4 @@
# NeuroSploit — Tutorial & User Guide (v3.6.1) # NeuroSploit — Tutorial & User Guide (v3.6.3)
A complete, hands-on guide to installing, configuring and running NeuroSploit — A complete, hands-on guide to installing, configuring and running NeuroSploit —
the autonomous, multi-model penetration-testing harness. the autonomous, multi-model penetration-testing harness.
@@ -98,7 +98,7 @@ Agents **degrade gracefully**: if `rustscan` is absent they use `nmap`; if neith
### Verify ### Verify
```bash ```bash
neurosploit --version # neurosploit 3.6.1 neurosploit --version # neurosploit 3.6.3
neurosploit agents # {"vulns":196,...,"chains":12,"total":417} neurosploit agents # {"vulns":196,...,"chains":12,"total":417}
neurosploit models # all providers & models neurosploit models # all providers & models
``` ```
+2 -2
View File
@@ -871,7 +871,7 @@ dependencies = [
[[package]] [[package]]
name = "neurosploit" name = "neurosploit"
version = "3.6.1" version = "3.6.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -888,7 +888,7 @@ dependencies = [
[[package]] [[package]]
name = "neurosploit-harness" name = "neurosploit-harness"
version = "3.6.1" version = "3.6.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"futures", "futures",
+1 -1
View File
@@ -3,7 +3,7 @@ members = ["crates/harness", "app"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "3.6.1" version = "3.6.3"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
repository = "https://github.com/JoasASantos/NeuroSploit" repository = "https://github.com/JoasASantos/NeuroSploit"
+4 -4
View File
@@ -1,4 +1,4 @@
//! NeuroSploit v3.6.1 — interactive harness + CLI (`run` / `whitebox` / `agents` / `models`). //! NeuroSploit v3.6.3 — interactive harness + CLI (`run` / `whitebox` / `agents` / `models`).
mod repl; mod repl;
mod tui; mod tui;
@@ -11,8 +11,8 @@ use std::path::{Path, PathBuf};
#[command( #[command(
name = "neurosploit", name = "neurosploit",
version, version,
about = "NeuroSploit v3.6.1 — multi-model autonomous pentest harness", about = "NeuroSploit v3.6.3 — multi-model autonomous pentest harness",
long_about = "NeuroSploit v3.6.1 — a Rust multi-model harness that drives a pool of LLMs \ long_about = "NeuroSploit v3.6.3 — a Rust multi-model harness that drives a pool of LLMs \
(API key or local subscription: Claude/Codex/Gemini/Grok) to autonomously test a target. \ (API key or local subscription: Claude/Codex/Gemini/Grok) to autonomously test a target. \
After recon it INTELLIGENTLY selects only the agents matching the discovered surface, runs \ After recon it INTELLIGENTLY selects only the agents matching the discovered surface, runs \
them in parallel, then validates every finding by cross-model voting before reporting.\n\n\ them in parallel, then validates every finding by cross-model voting before reporting.\n\n\
@@ -721,7 +721,7 @@ pub(crate) fn spawn_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, mode:
println!(" │ ua : {ua}"); println!(" │ ua : {ua}");
write_status(&workdir, "running", &format!("\"target\":{:?}", cfg.target)); write_status(&workdir, "running", &format!("\"target\":{:?}", cfg.target));
println!(" ┌─ NeuroSploit v3.6.1 · by Joas A Santos & Red Team Leaders"); println!(" ┌─ NeuroSploit v3.6.3 · by Joas A Santos & Red Team Leaders");
println!(" │ run id : {run_id}"); println!(" │ run id : {run_id}");
println!(" │ target : {}", cfg.target); println!(" │ target : {}", cfg.target);
println!(" │ models : {}", cfg.models.join(", ")); println!(" │ models : {}", cfg.models.join(", "));
+107 -26
View File
@@ -1,4 +1,4 @@
//! NeuroSploit v3.6.1 — interactive session (Claude-Code / Codex / Cursor-CLI style). //! NeuroSploit v3.6.3 — interactive session (Claude-Code / Codex / Cursor-CLI style).
//! //!
//! Launched when `neurosploit` runs with no subcommand. A persistent REPL with //! Launched when `neurosploit` runs with no subcommand. A persistent REPL with
//! real line editing (arrow-key history recall, Ctrl-A/E/K, paste), model //! real line editing (arrow-key history recall, Ctrl-A/E/K, paste), model
@@ -52,9 +52,16 @@ impl RunLive {
fn ingest(&mut self, line: &str) { fn ingest(&mut self, line: &str) {
let low = line.to_lowercase(); let low = line.to_lowercase();
self.lines += 1; self.lines += 1;
// Keep a compact activity trail for /logs and the /status sign-of-life // Keep a compact activity trail for /logs and the /status sign-of-life.
// (skip the machine-only finding_json/ai/tokens noise). // Streamed agent events are tagged "@label <event>": keep the actionable
if !low.starts_with("finding_json:") && !low.starts_with("@") && !low.contains("ai:") && !low.starts_with("tokens:") { // ones (commands, net, tools, file edits, phases) so the operator sees
// exactly what each agent is running — drop only long model reasoning
// (ai:), token telemetry (tokens:), and machine JSON (finding_json:).
let payload = line.strip_prefix('@')
.and_then(|r| r.split_once(' ').map(|(_, rest)| rest))
.unwrap_or(line);
let plow = payload.to_lowercase();
if !low.starts_with("finding_json:") && !plow.starts_with("ai:") && !plow.starts_with("tokens:") {
let clean: String = line.chars().take(160).collect(); let clean: String = line.chars().take(160).collect();
self.last = clean.clone(); self.last = clean.clone();
self.feed.push(clean); self.feed.push(clean);
@@ -113,6 +120,10 @@ struct ActiveRun {
resume: Arc<tokio::sync::Notify>, resume: Arc<tokio::sync::Notify>,
/// Fallback models to try first, pushed by /continue <provider:model>. /// Fallback models to try first, pushed by /continue <provider:model>.
fallback: Arc<Mutex<Vec<ModelRef>>>, fallback: Arc<Mutex<Vec<ModelRef>>>,
/// Suppress live background printing while a full-screen picker (dialoguer)
/// is open, so the two don't fight over the terminal and corrupt it. The
/// stream is still ingested (feed/checkpoint), just not printed meanwhile.
quiet: Arc<AtomicBool>,
} }
/// On-disk checkpoint of an in-flight run's findings/commands, written live so a /// On-disk checkpoint of an in-flight run's findings/commands, written live so a
@@ -346,7 +357,7 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
let backends = harness::installed_cli_backends(); let backends = harness::installed_cli_backends();
println!("\x1b[1m"); println!("\x1b[1m");
println!(" ███╗ ██╗███████╗██╗ ██╗██████╗ ██████╗"); println!(" ███╗ ██╗███████╗██╗ ██╗██████╗ ██████╗");
println!(" ████╗ ██║██╔════╝██║ ██║██╔══██╗██╔═══██╗ NeuroSploit v3.6.1"); println!(" ████╗ ██║██╔════╝██║ ██║██╔══██╗██╔═══██╗ NeuroSploit v3.6.3");
println!(" ██╔██╗ ██║█████╗ ██║ ██║██████╔╝██║ ██║ interactive harness"); println!(" ██╔██╗ ██║█████╗ ██║ ██║██████╔╝██║ ██║ interactive harness");
println!(" ██║╚██╗██║██╔══╝ ██║ ██║██╔══██╗██║ ██║ by Joas A Santos"); println!(" ██║╚██╗██║██╔══╝ ██║ ██║██╔══██╗██║ ██║ by Joas A Santos");
println!(" ██║ ╚████║███████╗╚██████╔╝██║ ██║╚██████╔╝ & Red Team Leaders"); println!(" ██║ ╚████║███████╗╚██████╔╝██║ ██║╚██████╔╝ & Red Team Leaders");
@@ -363,6 +374,9 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
if resumed || past > 0 { if resumed || past > 0 {
println!(" ↻ resumed project session from {}{} past run(s)", proj_dir().display(), past); println!(" ↻ resumed project session from {}{} past run(s)", proj_dir().display(), past);
} }
// A recovered interrupted run, carried in memory so `/continue` can relaunch
// the engagement on the same target with these findings folded forward.
let mut resumable: Option<(String, Vec<Finding>)> = None;
// Recover an interrupted run (REPL was quit/crashed mid-engagement): its // Recover an interrupted run (REPL was quit/crashed mid-engagement): its
// live findings were checkpointed to disk — fold them into /runs so // live findings were checkpointed to disk — fold them into /runs so
// /results, /finding and /report still work. // /results, /finding and /report still work.
@@ -377,6 +391,8 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
save_runs(base, &h); save_runs(base, &h);
println!(" \x1b[1;33m↻ recovered interrupted run on {}{} finding(s) saved as run #{}\x1b[0m (/results {id} · /report {id})", println!(" \x1b[1;33m↻ recovered interrupted run on {}{} finding(s) saved as run #{}\x1b[0m (/results {id} · /report {id})",
cp.target, cp.findings.len(), id); cp.target, cp.findings.len(), id);
println!(" \x1b[36m ↳ /continue to keep testing this target — the {} finding(s) carry forward\x1b[0m", cp.findings.len());
resumable = Some((cp.target.clone(), cp.findings.clone()));
} }
clear_checkpoint(); clear_checkpoint();
} }
@@ -395,7 +411,7 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
if !queue.is_empty() && active.as_ref().map(|a| a.done.load(Ordering::Relaxed)).unwrap_or(true) { if !queue.is_empty() && active.as_ref().map(|a| a.done.load(Ordering::Relaxed)).unwrap_or(true) {
let next = queue.remove(0); let next = queue.remove(0);
println!("\n \x1b[1;35m▶ next target\x1b[0m ({} left): {next}", queue.len()); println!("\n \x1b[1;35m▶ next target\x1b[0m ({} left): {next}", queue.len());
active = start_background(base, &s, &mut reader, history.clone(), Some(&next)).await; active = start_background(base, &s, &mut reader, history.clone(), Some(&next), vec![]).await;
} }
println!("{}", context_prompt(&s)); // dim context line above the prompt println!("{}", context_prompt(&s)); // dim context line above the prompt
let Some(line) = reader.read(PROMPT) else { println!("\n bye."); break }; let Some(line) = reader.read(PROMPT) else { println!("\n bye."); break };
@@ -603,6 +619,7 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
if active.as_ref().map(|a| !a.done.load(Ordering::Relaxed)).unwrap_or(false) { if active.as_ref().map(|a| !a.done.load(Ordering::Relaxed)).unwrap_or(false) {
println!(" a run is already active — /status to check, /stop to halt it."); println!(" a run is already active — /status to check, /stop to halt it.");
} else { } else {
resumable = None; // a fresh /run supersedes any recovered interrupted run
save_session(&s); save_session(&s);
// Multiple comma-separated targets → run sequentially (queue the rest). // Multiple comma-separated targets → run sequentially (queue the rest).
let targets = session_targets(&s); let targets = session_targets(&s);
@@ -613,7 +630,7 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
if !queue.is_empty() { if !queue.is_empty() {
println!(" \x1b[1;35m▶ multi-target\x1b[0m: {} URLs — running sequentially", targets.len()); println!(" \x1b[1;35m▶ multi-target\x1b[0m: {} URLs — running sequentially", targets.len());
} }
match start_background(base, &s, &mut reader, history.clone(), first.as_deref()).await { match start_background(base, &s, &mut reader, history.clone(), first.as_deref(), vec![]).await {
Some(a) => { active = Some(a); println!(" \x1b[1;35m▶ running in background\x1b[0m — keep typing · \x1b[36m/status\x1b[0m · \x1b[36m/stop\x1b[0m"); } Some(a) => { active = Some(a); println!(" \x1b[1;35m▶ running in background\x1b[0m — keep typing · \x1b[36m/status\x1b[0m · \x1b[36m/stop\x1b[0m"); }
None => { // no external printer (piped) → blocking fallback None => { // no external printer (piped) → blocking fallback
let mut h = history.lock().unwrap(); let mut h = history.lock().unwrap();
@@ -644,20 +661,46 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
} }
} }
"/continue" | "/resume" => { "/continue" | "/resume" => {
match &active { let paused = active.as_ref().map(|a| a.paused.load(Ordering::Relaxed)).unwrap_or(false);
Some(a) if a.paused.load(Ordering::Relaxed) => { let working = active.as_ref().map(|a| !a.done.load(Ordering::Relaxed)).unwrap_or(false);
if !arg.is_empty() { if paused {
let m = ModelRef::parse(arg); let a = active.as_ref().unwrap();
println!(" \x1b[1;35m▶ resuming with fallback model\x1b[0m {}:{}", m.provider, m.model); if !arg.is_empty() {
a.fallback.lock().unwrap().push(m); let m = ModelRef::parse(arg);
} else { println!(" \x1b[1;35m▶ resuming with fallback model\x1b[0m {}:{}", m.provider, m.model);
println!(" \x1b[1;35m▶ resuming\x1b[0m — retrying with the current model(s)."); a.fallback.lock().unwrap().push(m);
} } else {
a.paused.store(false, Ordering::Relaxed); println!(" \x1b[1;35m▶ resuming\x1b[0m — retrying with the current model(s).");
a.resume.notify_waiters();
} }
Some(a) if !a.done.load(Ordering::Relaxed) => println!(" run is not paused — it's still working. /status to check."), a.paused.store(false, Ordering::Relaxed);
_ => println!(" no paused run. (a run pauses automatically if your tokens/quota run out)"), a.resume.notify_waiters();
} else if working {
println!(" run is not paused — it's still working. /status to check.");
} else if let Some((tgt, prior)) = resumable.take() {
// Continue an interrupted run: relaunch on the same target, carry
// the prior findings forward, and steer agents to extend coverage
// rather than re-report what was already found.
if s.target.is_none() && s.repo.is_none() { s.target = Some(tgt.clone()); }
let titles: Vec<String> = prior.iter().map(|f| format!("[{}] {}", f.severity, f.title)).collect();
let carry = format!(
"CONTINUE a prior interrupted engagement on this same target. These {} finding(s) are \
ALREADY confirmed do NOT re-report them; instead widen coverage: chase untested \
endpoints/params/methods, try new agent classes, and chain from these where possible: {}",
prior.len(), titles.join("; "));
s.instructions = Some(match &s.instructions {
Some(prev) if !prev.trim().is_empty() => format!("{prev}\n\n{carry}"),
_ => carry,
});
println!(" \x1b[1;35m▶ continuing interrupted run\x1b[0m on {tgt}{} prior finding(s) carried forward", prior.len());
match start_background(base, &s, &mut reader, history.clone(), None, prior).await {
Some(a) => { active = Some(a); println!(" \x1b[1;35m▶ running in background\x1b[0m — keep typing · \x1b[36m/status\x1b[0m · \x1b[36m/stop\x1b[0m"); }
None => {
let mut h = history.lock().unwrap();
run(base, &s, &mut h).await; save_runs(base, &h);
}
}
} else {
println!(" no paused or interrupted run. (a run pauses on token/quota exhaustion; an interrupted run is offered for /continue at launch)");
} }
} }
"/runs" | "/history" => list_runs(&history.lock().unwrap()), "/runs" | "/history" => list_runs(&history.lock().unwrap()),
@@ -728,7 +771,12 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
} }
} }
runs.extend(history.lock().unwrap().iter().rev().cloned()); // newest-first runs.extend(history.lock().unwrap().iter().rev().cloned()); // newest-first
// Silence live background output while the full-screen picker is
// open (they'd corrupt each other); restore + point to /logs after.
let live_now = active.as_ref().map(|a| { a.quiet.store(true, Ordering::Relaxed); !a.done.load(Ordering::Relaxed) }).unwrap_or(false);
browse_results(&runs); browse_results(&runs);
if let Some(a) = &active { a.quiet.store(false, Ordering::Relaxed); }
if live_now { println!(" \x1b[2m(run still streaming in background — /logs for what happened while browsing)\x1b[0m"); }
} }
} }
"/finding" | "/findings" => { "/finding" | "/findings" => {
@@ -737,7 +785,9 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
Some(a) if arg.is_empty() && !a.done.load(Ordering::Relaxed) => a.live.lock().unwrap().full.clone(), Some(a) if arg.is_empty() && !a.done.load(Ordering::Relaxed) => a.live.lock().unwrap().full.clone(),
_ => { let h = history.lock().unwrap(); pick(&h, arg).map(|r| r.findings.clone()).unwrap_or_default() } _ => { let h = history.lock().unwrap(); pick(&h, arg).map(|r| r.findings.clone()).unwrap_or_default() }
}; };
if let Some(a) = &active { a.quiet.store(true, Ordering::Relaxed); }
finding_detail(&pool); finding_detail(&pool);
if let Some(a) = &active { a.quiet.store(false, Ordering::Relaxed); }
} }
"/expand" | "/full" => { "/expand" | "/full" => {
// Show full untruncated commands from the active run. // Show full untruncated commands from the active run.
@@ -755,7 +805,11 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
None => println!(" no active run — /expand shows full commands while a run streams."), None => println!(" no active run — /expand shows full commands while a run streams."),
} }
} }
"/report" => open_report(&history.lock().unwrap(), arg), "/report" => {
if let Some(a) = &active { a.quiet.store(true, Ordering::Relaxed); }
open_report(&history.lock().unwrap(), arg);
if let Some(a) = &active { a.quiet.store(false, Ordering::Relaxed); }
}
"/status" => { "/status" => {
// Live status if a run is active, else a past run's status.json. // Live status if a run is active, else a past run's status.json.
match &active { match &active {
@@ -1019,7 +1073,8 @@ async fn run(base: &Path, s: &Session, history: &mut Vec<RunRecord>) {
/// external printer while the REPL keeps accepting commands (/status, /stop). /// external printer while the REPL keeps accepting commands (/status, /stop).
/// Returns None when no external printer is available (piped) → caller blocks. /// Returns None when no external printer is available (piped) → caller blocks.
async fn start_background(base: &Path, s: &Session, reader: &mut Reader, async fn start_background(base: &Path, s: &Session, reader: &mut Reader,
history: Arc<Mutex<Vec<RunRecord>>>, target_override: Option<&str>) -> Option<ActiveRun> { history: Arc<Mutex<Vec<RunRecord>>>, target_override: Option<&str>,
seed: Vec<Finding>) -> Option<ActiveRun> {
// `target_override` runs one specific URL (used by the multi-target queue). // `target_override` runs one specific URL (used by the multi-target queue).
let ov = target_override.map(|t| t.to_string()); let ov = target_override.map(|t| t.to_string());
// The onboarding scope steers infra/cloud/ai/skills; otherwise web black/white/grey. // The onboarding scope steers infra/cloud/ai/skills; otherwise web black/white/grey.
@@ -1077,8 +1132,10 @@ async fn start_background(base: &Path, s: &Session, reader: &mut Reader,
let fallback = sp.fallback.clone(); let fallback = sp.fallback.clone();
let done = Arc::new(AtomicBool::new(false)); let done = Arc::new(AtomicBool::new(false));
let choice = Arc::new(Mutex::new(StopMode::Run)); let choice = Arc::new(Mutex::new(StopMode::Run));
let quiet = Arc::new(AtomicBool::new(false));
let soft_task = soft.clone(); // idle guardrail triggers a soft-stop (validate) let soft_task = soft.clone(); // idle guardrail triggers a soft-stop (validate)
let cancel_task = cancel.clone(); let cancel_task = cancel.clone();
let quiet_task = quiet.clone();
let sub_mcp = s.subscription && mcp; // for the "browser/tools never engaged" diagnostic let sub_mcp = s.subscription && mcp; // for the "browser/tools never engaged" diagnostic
let (live2, done2, hist2, choice2) = (live.clone(), done.clone(), history, choice.clone()); let (live2, done2, hist2, choice2) = (live.clone(), done.clone(), history, choice.clone());
@@ -1104,7 +1161,12 @@ async fn start_background(base: &Path, s: &Session, reader: &mut Reader,
// Exploitation has begun once agents launch / vote — only then arm the guardrail. // Exploitation has begun once agents launch / vote — only then arm the guardrail.
if low.contains("launching agent") || low.starts_with("exploit ") || low.starts_with("test ") if low.contains("launching agent") || low.starts_with("exploit ") || low.starts_with("test ")
|| low.starts_with("ai ") || low.starts_with("skill ") || low.starts_with("vote") { exploiting = true; } || low.starts_with("ai ") || low.starts_with("skill ") || low.starts_with("vote") { exploiting = true; }
if let Some(out) = crate::render_compact(&line) { let _ = printer.print(out); } // Don't print into the terminal while a full-screen picker is
// open (it would corrupt the picker); the line is still in the
// feed for /logs once the picker closes.
if !quiet_task.load(Ordering::Relaxed) {
if let Some(out) = crate::render_compact(&line) { let _ = printer.print(out); }
}
// Checkpoint on each new finding. // Checkpoint on each new finding.
let snap = { let snap = {
let l = live2.lock().unwrap(); let l = live2.lock().unwrap();
@@ -1154,7 +1216,7 @@ async fn start_background(base: &Path, s: &Session, reader: &mut Reader,
} }
// Raw → report from the unvalidated candidates we captured live. // Raw → report from the unvalidated candidates we captured live.
let (findings, validated_word) = if mode_choice == StopMode::Raw { let (mut findings, validated_word) = if mode_choice == StopMode::Raw {
let raw = live2.lock().unwrap().full.clone(); let raw = live2.lock().unwrap().full.clone();
crate::report_raw(&target, &raw, &workdir); crate::report_raw(&target, &raw, &workdir);
(raw, "unvalidated") (raw, "unvalidated")
@@ -1162,6 +1224,13 @@ async fn start_background(base: &Path, s: &Session, reader: &mut Reader,
let out = crate::finalize_run(task_out, &workdir); let out = crate::finalize_run(task_out, &workdir);
(out.findings, "validated") (out.findings, "validated")
}; };
// Continued run (/continue on an interrupted run): fold the carried-forward
// prior findings back in (dedup by title+endpoint) and rewrite the report
// so the merged run shows everything found across both sessions.
if !seed.is_empty() {
findings = merge_findings(seed.clone(), findings);
crate::report_raw(&target, &findings, &workdir);
}
let id = { let id = {
let mut h = hist2.lock().unwrap(); let mut h = hist2.lock().unwrap();
@@ -1177,7 +1246,19 @@ async fn start_background(base: &Path, s: &Session, reader: &mut Reader,
let _ = printer.print(format!("\x1b[36m report: {}\x1b[0m", crate::report_url(&workdir))); let _ = printer.print(format!("\x1b[36m report: {}\x1b[0m", crate::report_url(&workdir)));
done2.store(true, Ordering::Relaxed); done2.store(true, Ordering::Relaxed);
}); });
Some(ActiveRun { live, cancel, soft, done, choice, paused, resume, fallback }) Some(ActiveRun { live, cancel, soft, done, choice, paused, resume, fallback, quiet })
}
/// Merge two finding sets, deduping by (title, endpoint) — used to carry a prior
/// interrupted run's findings forward into a continued run without duplicating.
fn merge_findings(prior: Vec<Finding>, mut fresh: Vec<Finding>) -> Vec<Finding> {
use std::collections::HashSet;
let key = |f: &Finding| format!("{}|{}", f.title.trim().to_lowercase(), f.endpoint.trim().to_lowercase());
let seen: HashSet<String> = fresh.iter().map(key).collect();
for p in prior {
if !seen.contains(&key(&p)) { fresh.push(p); }
}
fresh
} }
/// Project-local store: `<cwd>/.neurosploit/` so each project keeps its own /// Project-local store: `<cwd>/.neurosploit/` so each project keeps its own
@@ -1570,7 +1651,7 @@ fn help() {
h("/status [n]", "live progress + findings while running (or a past run #)"); h("/status [n]", "live progress + findings while running (or a past run #)");
h("/logs [n]", "recent activity feed of the running test (recon/tools/findings)"); h("/logs [n]", "recent activity feed of the running test (recon/tools/findings)");
h("/stop", "stop: [1] validate+report [2] raw report now [3] discard"); h("/stop", "stop: [1] validate+report [2] raw report now [3] discard");
h("/continue", "resume a run paused on token/quota (change /model first to switch)"); h("/continue", "resume a paused (token/quota) OR a recovered interrupted run — carries findings forward");
h("/results [n]", "browse findings (target → vuln → detail; Esc = back)"); h("/results [n]", "browse findings (target → vuln → detail; Esc = back)");
h("/finding [n]", "pick a finding and see its command + PoC + evidence"); h("/finding [n]", "pick a finding and see its command + PoC + evidence");
h("/report [n]", "open a run's report (menu if several)"); h("/report [n]", "open a run's report (menu if several)");
+1 -1
View File
@@ -1,4 +1,4 @@
//! NeuroSploit v3.6.1 — TUI "Mission Control" mode. //! NeuroSploit v3.6.3 — TUI "Mission Control" mode.
//! //!
//! Concurrent panels that update live while the engagement runs in the //! Concurrent panels that update live while the engagement runs in the
//! background, with a composer input that stays active during execution: //! background, with a composer input that stays active during execution:
+1 -1
View File
@@ -1,4 +1,4 @@
//! POMDP belief-state world model (v3.6.1). //! POMDP belief-state world model (v3.6.3).
//! //!
//! The target is only partially observable, so we don't track booleans — we //! The target is only partially observable, so we don't track booleans — we
//! track a **belief**: a property graph whose nodes (host / service / vuln / //! track a **belief**: a property graph whose nodes (host / service / vuln /
@@ -1,4 +1,4 @@
//! Verification / grounding engine (v3.6.1). //! Verification / grounding engine (v3.6.3).
//! //!
//! Hard rule: **no claim enters the world model without a tool receipt** — raw //! Hard rule: **no claim enters the world model without a tool receipt** — raw
//! tool output, not the LLM's paraphrase. This is the empirical anti-hallucination //! tool output, not the LLM's paraphrase. This is the empirical anti-hallucination
+1 -1
View File
@@ -1,4 +1,4 @@
//! NeuroSploit v3.6.1 harness — a robust multi-model runtime for the //! NeuroSploit v3.6.3 harness — a robust multi-model runtime for the
//! markdown-driven autonomous pentest engine. //! markdown-driven autonomous pentest engine.
//! //!
//! The harness loads the `agents_md/` library, drives a *pool* of LLM models //! The harness loads the `agents_md/` library, drives a *pool* of LLM models
+136
View File
@@ -194,6 +194,12 @@ impl ChatClient {
if bin == "claude" { if bin == "claude" {
return self.chat_claude_stream(label, model, &prompt, mcp_config, progress).await; return self.chat_claude_stream(label, model, &prompt, mcp_config, progress).await;
} }
// Codex exec streams JSONL events (`--json`): commands it runs, agent
// messages, file changes, token usage. Surface them live so recon and
// exploitation are visible tool-by-tool instead of a silent black box.
if bin == "codex" {
return self.chat_codex_stream(label, model, &prompt, mcp_config, progress).await;
}
let mut cmd = Command::new(bin); let mut cmd = Command::new(bin);
match bin { match bin {
@@ -366,6 +372,136 @@ impl ChatClient {
} }
Ok(result) Ok(result)
} }
/// Drive `codex exec --json` and surface its JSONL event stream as a live,
/// categorized activity feed (commands, agent messages, file changes, MCP
/// tool calls, token usage). The final agent message is returned as the
/// result. Mirrors `chat_claude_stream` so Codex runs are just as visible.
async fn chat_codex_stream(
&self,
label: &str,
model: &str,
prompt: &str,
mcp_config: Option<&str>,
progress: Option<tokio::sync::mpsc::Sender<String>>,
) -> Result<String> {
let mut cmd = Command::new("codex");
cmd.arg("exec").arg("--json").arg("--model").arg(model)
.arg("--dangerously-bypass-approvals-and-sandbox");
if let Some(mcp) = mcp_config {
for (name, cmdline, args) in mcp_servers_from(mcp) {
cmd.arg("-c").arg(format!("mcp_servers.{name}.command={cmdline}"));
cmd.arg("-c").arg(format!("mcp_servers.{name}.args={args}"));
}
}
cmd.arg("-");
cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true);
let mut child = cmd.spawn().map_err(|e| anyhow!("spawn codex failed: {e}"))?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(prompt.as_bytes()).await?;
// Drop closes stdin so Codex processes the prompt and exits.
}
let stdout = child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?;
let stderr = child.stderr.take().ok_or_else(|| anyhow!("no stderr"))?;
let mut lines = BufReader::new(stdout).lines();
let lbl = if label.is_empty() { String::new() } else { format!("@{label} ") };
let emit = |s: String| {
if let Some(tx) = &progress {
let _ = tx.try_send(format!("{lbl}{s}"));
}
};
// Last agent_message is the model's final answer; keep every one so a
// run that ends on a tool call still returns the most recent reasoning.
let mut result = String::new();
let read = async {
while let Ok(Some(line)) = lines.next_line().await {
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else { continue };
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
match ty {
"item.started" | "item.completed" => {
let Some(item) = v.get("item") else { continue };
let itype = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
match itype {
"command_execution" => {
// Only announce on start (avoid double lines); note failures on completion.
let c = item.get("command").and_then(|x| x.as_str()).unwrap_or("");
// Strip the `/bin/sh -lc '...'` wrapper Codex adds.
let c = c.strip_prefix("/bin/sh -lc ").map(|s| s.trim_matches('\'')).unwrap_or(c);
if ty == "item.started" {
let danger = c.contains("rm -rf") || c.contains("mkfs")
|| c.contains(":(){") || c.contains("dd if=") || c.contains("> /dev/");
emit(format!("{}: {}", if danger { "danger" } else { "exec" }, truncate(c, 200)));
} else if let Some(code) = item.get("exit_code").and_then(|x| x.as_i64()) {
if code != 0 {
emit(format!("exec: (exit {code}) {}", truncate(c, 120)));
}
}
}
"agent_message" => {
if let Some(t) = item.get("text").and_then(|x| x.as_str()) {
let t = t.trim();
if !t.is_empty() {
if ty == "item.completed" { result = t.to_string(); }
emit(format!("ai: {}", truncate(t, 240)));
}
}
}
"file_change" | "patch" => {
let p = item.get("path").and_then(|x| x.as_str())
.or_else(|| item.get("file").and_then(|x| x.as_str())).unwrap_or("file");
emit(format!("edit: {p}"));
}
"mcp_tool_call" => {
let name = item.get("tool").and_then(|x| x.as_str())
.or_else(|| item.get("name").and_then(|x| x.as_str())).unwrap_or("mcp");
emit(format!("tool: {name}"));
}
"web_search" => {
let q = item.get("query").and_then(|x| x.as_str()).unwrap_or("");
emit(format!("net: search {}", truncate(q, 100)));
}
_ => {}
}
}
"turn.completed" => {
let ti = v.pointer("/usage/input_tokens").and_then(|x| x.as_u64());
let to = v.pointer("/usage/output_tokens").and_then(|x| x.as_u64());
if ti.is_some() || to.is_some() {
emit(format!("tokens: in={} out={}", ti.unwrap_or(0), to.unwrap_or(0)));
}
}
_ => {}
}
}
};
// Bound the whole streamed turn (matches the buffered path's cap).
if tokio::time::timeout(Duration::from_secs(900), read).await.is_err() {
return Err(anyhow!("codex stream timed out after 900s"));
}
let status = child.wait().await.ok();
// Drain stderr for auth/rate diagnostics if we got nothing usable.
if result.is_empty() {
let mut errbuf = String::new();
let mut el = BufReader::new(stderr).lines();
while let Ok(Some(l)) = el.next_line().await {
if !errbuf.is_empty() { errbuf.push('\n'); }
errbuf.push_str(&l);
if errbuf.len() > 2000 { break; }
}
let low = errbuf.to_lowercase();
let hard = ["not logged in", "please log in", "please login", "run /login",
"unauthorized", "not authenticated", "invalid api key", "no api key",
"rate limit", "429", "quota", "credit balance", "usage limit"]
.iter().any(|k| low.contains(k));
let code = status.and_then(|s| s.code()).map(|c| c.to_string()).unwrap_or_else(|| "signal".into());
if hard || !errbuf.trim().is_empty() {
return Err(anyhow!("codex subscription CLI exit {}: {}", code, truncate(errbuf.trim(), 240)));
}
return Err(anyhow!("codex stream produced no result"));
}
Ok(result)
}
} }
/// Categorise a Claude tool_use block into a tagged activity-feed event. /// Categorise a Claude tool_use block into a tagged activity-feed event.
+14 -4
View File
@@ -89,10 +89,13 @@ fn tool_doctrine(mcp_on: bool) -> String {
Keep bursts small and non-disruptive this is a control check, not a DoS.\n\ Keep bursts small and non-disruptive this is a control check, not a DoS.\n\
- TOOL DOWNLOAD (authorized): when a public PoC or scanner is needed you MAY `git clone` a specific PoC/exploit \ - TOOL DOWNLOAD (authorized): when a public PoC or scanner is needed you MAY `git clone` a specific PoC/exploit \
repo or download a tool (`git clone`, `wget`, `pip install`, `go install`, `cargo install`) use pinned, \ repo or download a tool (`git clone`, `wget`, `pip install`, `go install`, `cargo install`) use pinned, \
reputable sources; review before running; never run destructive payloads.\n\ reputable sources; review before running; never run destructive payloads. ALWAYS time-box downloads/installs \
(`timeout 90 <install> || echo skip`) and try each at most once if it fails, isn't packaged, has no network \
or hangs, SKIP it and fall back to curl/nc/dig/python3. A missing or un-downloadable tool is NEVER a reason \
to stall: move on with what you have.\n\
- {browser}\n\ - {browser}\n\
- {ua}{proxy}{pocs}\ - {ua}{proxy}{pocs}\
Use only what is installed; degrade gracefully. Never run destructive or DoS actions.\n\n", Use only what is installed; degrade gracefully. Never block on a single tool install. Never run destructive or DoS actions.\n\n",
ua = ua_line(), ua = ua_line(),
proxy = proxy_line(), proxy = proxy_line(),
pocs = pocs_line(), pocs = pocs_line(),
@@ -1300,8 +1303,15 @@ fn recon_intensity_directive(level: usize) -> String {
}; };
format!( format!(
"RECON INTENSITY: {label} — {rounds}. {extra}\n\ "RECON INTENSITY: {label} — {rounds}. {extra}\n\
INSTALL WHAT YOU NEED (authorized): if a recon tool is missing, install it before falling back \ INSTALL WHAT YOU NEED (authorized), BUT NEVER GET STUCK ON AN INSTALL: if a recon tool is missing, \
`apt-get install -y <t>`, `pip install <t>`, `go install <pkg>@latest`, `npm i -g <t>`, or `cargo install <t>`. \ try to install it but TIME-BOX every install and move on if it fails. Always wrap installs like \
`timeout 90 apt-get install -y <t> || timeout 90 go install <pkg>@latest || echo 'skip <t>'` and \
run them non-interactively (`DEBIAN_FRONTEND=noninteractive`, `-y`, no prompts). \
Try a given tool install AT MOST ONCE if it errors, is not packaged, needs a different OS, \
has no network, or hangs past the timeout, SKIP IT immediately and use an already-installed \
alternative or plain `curl`/`nc`/`dig`/`openssl`/`python3`. Do not wait on, retry, or block the \
whole recon for any single tool download a missing tool is never a reason to stall. \
Options `pip install <t>`, `go install <pkg>@latest`, `npm i -g <t>`, or `cargo install <t>` (all time-boxed). \
Recommended arsenal: subfinder/amass/assetfinder (subdomains), httpx/httprobe (probe live), \ Recommended arsenal: subfinder/amass/assetfinder (subdomains), httpx/httprobe (probe live), \
gau/waybackurls/katana/hakrawler/gospider (URL harvest & crawl), gf (pattern-filter urls), \ gau/waybackurls/katana/hakrawler/gospider (URL harvest & crawl), gf (pattern-filter urls), \
arjun/paramspider (params), ffuf/feroxbuster/dirsearch (content discovery), nuclei (targeted templates), \ arjun/paramspider (params), ffuf/feroxbuster/dirsearch (content discovery), nuclei (targeted templates), \
+1 -1
View File
@@ -1,4 +1,4 @@
//! POMDP decision layer (v3.6.1): value-of-information planning + the //! POMDP decision layer (v3.6.3): value-of-information planning + the
//! anti-hallucination gate. //! anti-hallucination gate.
//! //!
//! The choice "scan more vs exploit now" is **not** a heuristic here — it falls //! The choice "scan more vs exploit now" is **not** a heuristic here — it falls
+1 -1
View File
@@ -1,4 +1,4 @@
//! Deterministic HTTP request/response analysis (v3.6.1). //! Deterministic HTTP request/response analysis (v3.6.3).
//! //!
//! Before the LLM recon runs, the harness performs a **real** probe of the //! Before the LLM recon runs, the harness performs a **real** probe of the
//! target and captures observed facts — status, headers, security headers, //! target and captures observed facts — status, headers, security headers,
+3 -3
View File
@@ -97,9 +97,9 @@ pub fn html(target: &str, findings: &[Finding]) -> String {
h4{{margin:12px 0 3px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:#8b5cf6}}\ h4{{margin:12px 0 3px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:#8b5cf6}}\
.b{{color:#8b5cf6;font-weight:800}}</style></head><body>\ .b{{color:#8b5cf6;font-weight:800}}</style></head><body>\
<h1><span class=b>NeuroSploit</span> Penetration Test Report</h1>\ <h1><span class=b>NeuroSploit</span> Penetration Test Report</h1>\
<div class=meta>Target: <b>{t}</b> · v3.6.1 Rust harness · multi-model validated</div>\ <div class=meta>Target: <b>{t}</b> · v3.6.3 Rust harness · multi-model validated</div>\
<div>{chips}</div>{graph_block}<h2>Findings ({n})</h2>{body}\ <div>{chips}</div>{graph_block}<h2>Findings ({n})</h2>{body}\
<p class=meta>Authorized testing only. Findings confirmed by multi-model adversarial voting.<br>NeuroSploit v3.6.1 · by <b>Joas A Santos</b> &amp; <b>Red Team Leaders</b></p></body></html>", <p class=meta>Authorized testing only. Findings confirmed by multi-model adversarial voting.<br>NeuroSploit v3.6.3 · by <b>Joas A Santos</b> &amp; <b>Red Team Leaders</b></p></body></html>",
t = esc(target), chips = chips, n = sorted.len(), body = body, graph_block = graph_block, t = esc(target), chips = chips, n = sorted.len(), body = body, graph_block = graph_block,
) )
} }
@@ -135,7 +135,7 @@ pub fn typst_report(target: &str, findings: &[Finding], dir: &Path) -> std::io::
let mut data = String::new(); let mut data = String::new();
data.push_str(&format!( data.push_str(&format!(
"#let meta = (target: {}, run_id: {}, generated: {}, model: {})\n", "#let meta = (target: {}, run_id: {}, generated: {}, model: {})\n",
tq(target), tq(&run_id), tq("NeuroSploit v3.6.1"), tq("multi-model") tq(target), tq(&run_id), tq("NeuroSploit v3.6.3"), tq("multi-model")
)); ));
data.push_str("#let findings = (\n"); data.push_str("#let findings = (\n");
for f in sorted_findings(findings) { for f in sorted_findings(findings) {