Compare commits

...
9 Commits
Author SHA1 Message Date
Joas A SantosandGitHub 3ddb22ee25 Merge pull request #42 from JoasASantos/feat/opencode-hermes-providers
feat(3.6.9): OpenCode Zen + Nous Research (Hermes) providers
2026-08-13 12:26:23 -03:00
CyberSecurityUPandClaude Opus 5 69c5e3ddb9 feat(3.6.9): OpenCode Zen + Nous Research (Hermes) providers
Add two new model providers, both usable via API key or --subscription
(local CLI login, no key):

- opencode: OpenCode Zen gateway (OPENCODE_API_KEY, opencode.ai/zen/v1).
  Subscription mode drives the `opencode` CLI (`opencode run --auto`).
  Supports the Playwright MCP (--mcp): our .mcp.json is converted to
  OpenCode's own config schema and injected via OPENCODE_CONFIG.

- nous: Nous Research / Hermes models (NOUS_API_KEY,
  inference-api.nousresearch.com/v1). Subscription mode drives the
  `hermes` CLI (NousResearch/hermes-agent) on the user's Nous Portal
  OAuth login (`hermes setup --portal`), via `hermes chat -q`. No
  CLI-level MCP hook — falls back to Hermes's own built-in toolsets
  (web/terminal/computer-use).

Both wired into cli_binary_for, installed_cli_backends, cli_login_status
(prompt passed as argv, not stdin — neither CLI reads stdin for this).

Bump version 3.6.8 -> 3.6.9 across Cargo.toml, README, TUTORIAL, setup.sh,
install.ps1, and in-binary version strings. README/.env.example updated
with the new provider rows and subscription-login table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHFAVCHMvRkTy9Wgw7SayG
2026-08-11 23:47:12 -03:00
CyberSecurityUPandClaude Opus 4.6 1f8ccb6f9e fix(3.6.8): recon time budget — 5min cap prevents recon from eating entire run
- Add RECON_TOTAL_BUDGET_SECS (300s) total wall-clock cap across all rounds
- Per-round budget directive in prompt: 30-50 commands max, stop early if enough intel
- Elapsed time check between rounds: skip remaining if budget exhausted
- Remaining time communicated to follow-up rounds for self-pacing
- RELEASE.md updated with recon budget section

Previously: subscription CLI recon ran 150+ commands over 15 min, exploitation never started.
Now: recon caps at 5 min total, then proceeds to agent exploitation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-08 10:10:43 -03:00
CyberSecurityUPandClaude Opus 4.6 3c49a83578 fix(3.6.8): auth resilience — circuit breaker pauses run on token revocation, preserves findings
- Add is_auth_failure() detector (401, OAuth revoked, session expired, invalid key)
- Circuit breaker: 3 consecutive auth failures auto-pause instead of burning 66 agents
- Auth-aware park_exhausted(): clear message + fallback provider switch via /continue
- No retry burn on auth errors (immediate return like exhaustion)
- Recon preserves HTTP probe facts when model auth fails
- REPL phase tracking: paused (auth) distinct from paused (quota)
- RELEASE.md updated with auth resilience section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-08 09:14:21 -03:00
CyberSecurityUPandClaude Opus 4.6 e956b482b9 fix(3.6.8): JSON parse resilience + diagnostics for local model failures
- extract_findings: log when model output has no JSON (was silent drop)
- extract_findings: auto-fix trailing-comma JSON (common LLM mistake)
- pipeline: emit response tail when agent returns 0 parseable findings
- Helps diagnose why small/local models produce 0 findings on valid targets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-07 09:51:50 -03:00
CyberSecurityUPandClaude Opus 4.6 105c62af61 docs: bump version references to 3.6.8 across README, TUTORIAL, setup.sh, install.ps1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011739wMqPJJPttTLLX6YoQH
2026-08-06 15:27:22 -03:00
CyberSecurityUPandClaude Opus 4.6 a0a477a2bf fix(3.6.8): better Ollama error messages, empty-evidence findings go to needs-review, single-model vote warning
- models.rs: detect connection-refused and timeout on local providers
  (ollama/litellm/llamacpp), show actionable error instead of raw reqwest
- pipeline.rs: findings with empty evidence skip adversarial vote (which
  always rejects per 'default to rejected' prompt) and go straight to
  needs-review for human triage
- pipeline.rs: warn when single-model panel + vote_n=1 (same model
  validates its own findings = weaker validation)
- Bump version 3.6.7 → 3.6.8

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011739wMqPJJPttTLLX6YoQH
2026-08-06 15:21:37 -03:00
cb19e2194d feat(3.6.7): CVE exploitation pipeline, PoC-in-report, any-primitive chaining, --only, whitebox doctrine (#41)
Version 3.6.6 -> 3.6.7. +5 agents (430 -> 435).

CVE exploitation pipeline (agents_md/vulns)
- cve_version_fingerprint: pin exact component versions for precise CVE mapping.
- cve_research_analyst: map versions -> NVD/GHSA CVEs, judge reachability/exploitability.
- cve_poc_finder: locate/vet/adapt a public PoC, run non-destructively.
- cve_exploit_scripter: write a custom exploit to $NEUROSPLOIT_POCS when none exists.

Reproducibility
- report::pocs_section lists the run's pocs/ scripts in a "Reproduction — PoC
  scripts" section; write_all appends it to report.md. Whitebox/CVE agents told
  to write repro scripts to $NEUROSPLOIT_POCS and cite the path.

Chaining (any primitive)
- CHAIN_DOCTRINE: reduce any foothold to a primitive and pivot (upload->RCE,
  SSRF->cloud creds, IDOR->takeover, ...), reuse looted creds, reason about
  business logic. New chain_cve_to_rce_to_pivot recipe. Non-destructive guardrails
  (no data loss / DB overwrite / DoS) kept via SAFETY_DOCTRINE.

Re-test one vuln
- --only <agent> on run/whitebox/greybox sets cfg.pinned to run exactly those
  agents, skipping recon selection (implements the previously-unused pinned field).

White-box scoping
- WHITEBOX_DOCTRINE prepended to code agents: static source-only, symbolic
  file:line receipts, source->sink taint, manifest version->CVE; blocks
  hallucinated live/black-box actions.

Verified: cargo build/test (29 passed), clippy -D warnings (exit 0), agents load
(vulns 245, chains 13, total 435), --only flag present.


Claude-Session: https://claude.ai/code/session_01QDses7zTSa9YF7pPRjphvh

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 10:44:18 -03:00
CyberSecurityUPandClaude Fable 5 76b56898d1 docs(RELEASE): add v3.6.6 section (llama.cpp local provider, clippy, CI)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QDses7zTSa9YF7pPRjphvh
2026-08-03 23:50:14 -03:00
21 changed files with 842 additions and 87 deletions
+10
View File
@@ -51,6 +51,16 @@ TOGETHER_API_KEY=
# openrouter: https://openrouter.ai/keys
OPENROUTER_API_KEY=
# opencode: https://opencode.ai/auth (OpenCode Zen gateway)
# Or skip the key entirely and use --subscription with the
# `opencode` CLI logged into your own Zen/plan account.
OPENCODE_API_KEY=
# nous: Nous Portal (https://portal.nousresearch.com) — Hermes models.
# Or skip the key entirely and use --subscription with the
# `hermes` CLI (`hermes setup --portal` for OAuth login).
NOUS_API_KEY=
# ollama: local, no key needed. Override the endpoint if not default:
#OLLAMA_BASE_URL=http://localhost:11434/v1
+1
View File
@@ -108,3 +108,4 @@ data/repl_history.txt
# Cloned source repos (whitebox/greybox from a git URL)
repos/
neurosploit-rs/repos/
target/
+38 -12
View File
@@ -1,4 +1,4 @@
<h1 align="center">🧠 NeuroSploit v3.6.6</h1>
<h1 align="center">🧠 NeuroSploit v3.6.9</h1>
<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>
@@ -12,10 +12,10 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Version-3.6.6-blue?style=flat-square">
<img src="https://img.shields.io/badge/Version-3.6.9-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/License-MIT-green?style=flat-square">
<img src="https://img.shields.io/badge/MD%20Agents-430-red?style=flat-square">
<img src="https://img.shields.io/badge/MD%20Agents-435-red?style=flat-square">
<img src="https://img.shields.io/badge/Models-16%20providers-success?style=flat-square">
<img src="https://img.shields.io/badge/Modes-Black%20%7C%20White%20%7C%20Grey%20%7C%20Host%20%7C%20AI-9cf?style=flat-square">
<img src="https://img.shields.io/badge/Auth-API%20key%20%7C%20Subscription-orange?style=flat-square">
@@ -36,7 +36,7 @@ LLMs** — via **API key** or local **subscription** (Claude Code / Codex / Gemi
Grok) — recons the target, **intelligently selects only the agents that match the
discovered surface**, runs them in parallel, **chains** findings into deeper
impact, and **validates every claim by cross-model voting + tool-receipt
grounding** before reporting. It ships **430 markdown agents** and a **Mission
grounding** before reporting. It ships **435 markdown agents** and a **Mission
Control TUI**.
### Engagement modes
@@ -69,9 +69,14 @@ Control TUI**.
flags, CORS reflection, tech fingerprint, linked JS, 404 baseline, high-signal
paths) and feeds those observed facts into recon, so agent selection and
exploitation decisions are grounded in evidence — not the model's guess.
- 🔗 **Attack chaining** 12 multi-stage chain agents (SQLi→RCE→LPE, SSRF→AWS
creds, upload→LFI→RCE→LPE, default-creds→domain, …); each stage proven before
advancing.
- 🔗 **Attack chaining — any primitive pivots.** 13 multi-stage chain agents
(SQLi→RCE→LPE, SSRF→cloud creds, upload→LFI→RCE→LPE, CVE→RCE→pivot, …) **plus a
chaining doctrine** that turns *any* confirmed foothold into the next step:
reduce it to a primitive (exec / read / write / request-forgery / identity /
secret) and pivot — file-upload→RCE, SSRF→metadata creds, IDOR→takeover — reusing
looted creds and reasoning about **business logic** (payment/tenancy/workflow
abuse). Each stage proven; strictly non-destructive (no data loss, no DB
overwrite, no DoS).
- ☁️ **Cloud testing** — AWS / GCP / Azure agents that drive the provider CLIs
(`aws`/`gcloud`/`az`). Connect via `creds.yaml`: AWS keys, a Google
service-account JSON, or an Azure service principal — see
@@ -84,12 +89,23 @@ Control TUI**.
attacker→**LLM-judge** loop (baseline refusal → technique → verdict) and proves
the bypass with a **benign, redacted** receipt. Maps to OWASP LLM Top 10 (2025),
MCP threats & OWASP AI Exchange; Skill/plugin & **n8n** files audited white-box.
- 🧰 **Misconfig & CVE hunting, safely**dedicated agents for absurd
misconfigs (exposed `.git`/`.env`, debug/actuator, default creds, dashboards,
CORS), a **CVE Hunter** (smart, targeted `nuclei`), a **PoC Developer** (writes
reproducible scripts to the run's `pocs/`), and **rate-limit** testing — all
under a strict **data-safety/PII guardrail** (no destructive or state-changing
- 🧰 **Misconfig & CVE hunting → exploitation, safely**a full CVE pipeline:
**version fingerprint** (pin exact versions) → **research analyst** (map to
NVD/GHSA CVEs, judge reachability) → **PoC finder** (locate/vet/adapt a public
PoC) → **exploit scripter** (write a custom exploit when none exists). Every PoC
is written to the run's **`pocs/` folder and referenced in the report** so
findings are reproducible. Plus absurd-misconfig agents (exposed `.git`/`.env`,
debug/actuator, default creds, dashboards, CORS) and rate-limit testing — all
under a strict **data-safety/PII guardrail** (no destructive/state-changing
actions; PII proven with a masked sample, never dumped).
- 🎯 **Re-test one vulnerability**`--only <agent>` (repeatable /
comma-separated) runs exactly the agent(s) you name and skips recon-based
selection — re-test a single finding fast. Works on `run` / `whitebox` /
`greybox`; `neurosploit agents` lists the names.
- 🔬 **White-box stays white-box** — code agents run under a static-review
doctrine (symbolic `file:line` receipts, source-to-sink taint tracing, manifest
version→CVE) that forbids hallucinated live/black-box network actions, and can
emit a repro PoC to `pocs/`.
- 🗣️ **Natural-language REPL** — in the interactive session, just describe what
you want, in any language: *"testa https://loja.com com opus, foco em SQLi,
fora de escopo /admin, roda"*. A hybrid parser sets target/models/focus/
@@ -417,6 +433,8 @@ export GROQ_API_KEY=... # groq:*
export TOGETHER_API_KEY=... # together:*
export MOONSHOT_API_KEY=... # moonshot:* (Kimi K3/K2)
export OPENROUTER_API_KEY=... # openrouter:*
export OPENCODE_API_KEY=... # opencode:* (OpenCode Zen gateway)
export NOUS_API_KEY=... # nous:* (Nous Portal — Hermes)
# ollama / llamacpp need no key (local)
# then run via API (note: NO --subscription)
@@ -446,6 +464,8 @@ Or put the keys in a `.env` and source it (`cp .env.example .env`; edit; `set -a
| `together:` | `TOGETHER_API_KEY` | api.together.xyz |
| `moonshot:` | `MOONSHOT_API_KEY` | api.moonshot.ai |
| `openrouter:` | `OPENROUTER_API_KEY` | openrouter.ai |
| `opencode:` | `OPENCODE_API_KEY` | opencode.ai/zen (OpenCode Zen gateway) |
| `nous:` | `NOUS_API_KEY` | inference-api.nousresearch.com (Hermes 4) |
| `ollama:` | _(none)_ | localhost:11434 |
| `llamacpp:` | _(none)_ | localhost:8080 |
@@ -468,6 +488,12 @@ install and log into one of the CLIs first:
| `openai:` | `codex` | `codex` login |
| `gemini:` | `gemini` | `gemini` login |
| `xai:` | `grok` | `grok` login |
| `opencode:` | `opencode` | `opencode auth login` (or `/connect` in the TUI) — Zen/plan account |
| `nous:` | `hermes` | `hermes setup --portal` — Nous Portal OAuth |
`opencode:` also gets the Playwright MCP (`--mcp`) like anthropic/openai do.
`nous:` relies on Hermes's own built-in toolsets (web/terminal/computer-use)
instead — it has no CLI-level MCP hook.
```bash
./target/release/neurosploit run http://testphp.vulnweb.com/ \
+116
View File
@@ -1,3 +1,119 @@
# NeuroSploit v3.6.8 — Release Notes
**Release Date:** August 2026
**Codename:** Chain & Exploit
**License:** MIT
**Credits:** Joas A Santos & Red Team Leaders
## v3.6.8 — Auth resilience, circuit breaker, recon budget, Ollama error handling, empty-evidence validation
### Recon Time Budget (NEW)
- **5-minute total recon budget.** Recon phase is now time-boxed to 300 seconds
across ALL rounds. Previously, a single recon round could run 150+ commands
over 15 minutes via subscription CLI, leaving no time for exploitation.
- **Per-round budget directive.** Each recon round receives a prompt instruction
with its share of the time budget (e.g. "~100 seconds for this round") and a
command count guideline (30-50 commands max). The model is instructed to
prioritise high-signal actions and stop early when enough intel is gathered.
- **Elapsed time check between rounds.** Before starting each follow-up round,
the pipeline checks elapsed time. If the budget is exhausted, recon stops
immediately and proceeds to exploitation with the intelligence gathered so far.
### Auth Resilience & Circuit Breaker (NEW)
- **`is_auth_failure()` detector.** New function recognises OAuth token revocation
(401), session expiry, invalid/revoked API keys, and "not logged in" errors from
subscription CLIs. Distinct from `is_exhaustion()` (quota/rate-limit) — auth
failures are non-recoverable without re-login or provider switch.
- **Circuit breaker (3 consecutive auth failures → auto-pause).** A shared atomic
counter tracks consecutive auth failures across ALL agents. After 3 failures the
pool pauses the run BEFORE burning through the remaining agents on a dead token.
Previously, a revoked OAuth token caused all 66+ agents to silently return 0
findings with no pause or warning.
- **Auth-aware park: findings preserved, fallback offered.** When auth fails the
run parks with a clear message:
`⏸ authentication failed (...). Run is PAUSED — all findings so far are SAFE.`
The user can `/continue openai:gpt-5.1` (or any provider) to switch and resume.
All `LiveCheckpoint` findings on disk are preserved across the pause.
- **No retry burn on auth failure.** `one()` returns immediately on auth errors
instead of retrying 3 times against a dead token (same as quota exhaustion).
- **Recon preserves probe facts on auth failure.** When model recon fails with an
auth error, the HTTP probe data is still returned and the pipeline continues
with probe-only intelligence instead of silently dropping everything.
- **Phase tracking for auth pauses.** The REPL status line shows `paused (auth)`
(distinct from `paused (quota)`) so the operator knows the root cause at a glance.
### Bugfixes
- **Better Ollama/local provider error messages.** Connection-refused and timeout
errors now name the provider, URL, and suggest checking if the server is running.
Previously showed raw reqwest errors.
- **Empty-evidence findings skip the vote and go straight to `needs-review`.**
Findings with no evidence are unverifiable by the adversarial validator (which
always rejects "no evidence" per its system prompt). Now they bypass the vote
and are flagged for human review instead of being silently dropped.
- **Single-model + vote_n=1 warning.** When only one model is configured and
vote_n is 1, the pipeline emits a warning that validation is weaker (same model
validates its own findings).
- **JSON parse resilience for local models.** `extract_findings` now logs when a
model returns text but no parseable JSON (previously silent drop — 0 findings
with no diagnostic). Also auto-fixes trailing-comma JSON (`[...,]`) which small
models commonly produce.
- **Visible diagnostics when agents return 0 findings.** Pipeline emits the
response tail so the operator can see what the model actually returned (helps
debug model quality issues with local/small models).
---
## v3.6.7 Highlights
- **CVE exploitation pipeline — 4 new agents.** `cve_version_fingerprint` (pin
exact versions) → `cve_research_analyst` (map to NVD/GHSA, judge reachability) →
`cve_poc_finder` (locate/vet/adapt a public PoC) → `cve_exploit_scripter` (write
a custom exploit when none exists). Focus: actually exploiting vulns that have
CVEs, not just flagging versions.
- **PoCs land in the run's `pocs/` folder and are listed in the report.** Every
agent writes runnable proofs to `$NEUROSPLOIT_POCS`; the report gains a
**"Reproduction — PoC scripts"** section so findings replay end-to-end.
- **Chaining for any primitive.** New `CHAIN_DOCTRINE` + a `chain_cve_to_rce_to_pivot`
recipe turn any confirmed foothold into the next step (upload→RCE, SSRF→cloud
creds, IDOR→takeover, CVE→RCE→pivot), reusing looted creds and reasoning about
**business logic** — strictly non-destructive (no data loss / DB overwrite / DoS).
- **`--only <agent>` — re-test a single vulnerability.** Runs exactly the named
agent(s), skipping recon selection. On `run` / `whitebox` / `greybox`; repeatable
or comma/semicolon-separated. (Implements the previously-dead `pinned` allowlist.)
- **White-box stays white-box.** A `WHITEBOX_DOCTRINE` keeps code agents in static
source-review mode (symbolic `file:line` receipts, source→sink taint, manifest
version→CVE) and blocks hallucinated live/black-box network actions; agents can
emit a repro PoC.
- **435 markdown agents** (was 430).
**Full changelog:** https://github.com/JoasASantos/NeuroSploit/compare/v3.6.6...v3.6.7
---
# NeuroSploit v3.6.6 — Release Notes
**Release Date:** August 2026
**Codename:** Local & Uncensored
**License:** MIT
**Credits:** Joas A Santos & Red Team Leaders
## Highlights
- **Local, uncensored & CPU-only — new `llamacpp:` provider.** Drives a
`llama-server` OpenAI-compatible endpoint (`localhost:8080`), **no API key**,
no data off-host, CPU-only or GPU-offloaded. Override with `LLAMACPP_BASE_URL`;
`model` = the gguf you loaded (pass-through). **15 → 16 providers.**
- **clippy clean under `-D warnings`** across the workspace.
- **Rust CI template** — `examples/github-actions/ci.yml` (build / test / clippy)
for the `neurosploit-rs/` workspace.
**Full changelog:** https://github.com/JoasASantos/NeuroSploit/compare/v3.6.5...v3.6.6
---
# NeuroSploit v3.6.5 — Release Notes
**Release Date:** July 2026
+2 -2
View File
@@ -1,4 +1,4 @@
# NeuroSploit — Tutorial & User Guide (v3.6.5)
# NeuroSploit — Tutorial & User Guide (v3.6.9)
A complete, hands-on guide to installing, configuring and running NeuroSploit —
the autonomous, multi-model penetration-testing harness.
@@ -98,7 +98,7 @@ Agents **degrade gracefully**: if `rustscan` is absent they use `nmap`; if neith
### Verify
```bash
neurosploit --version # neurosploit 3.6.5
neurosploit --version # neurosploit 3.6.9
neurosploit agents # {"vulns":241,...,"ai":30,...,"total":430}
neurosploit models # all providers & models
```
@@ -0,0 +1,42 @@
# Known-CVE → RCE → Pivot Chain Agent
## User Prompt
You are executing a multi-stage ATTACK CHAIN against **{target}**: a known CVE in a fingerprinted component → code execution → post-exploitation pivot.
**Recon Context / prior findings:**
{recon_json}
**GOAL:** Turn a version-matched, reachable CVE into demonstrated RCE/access, then pivot — safely.
**CHAIN — advance stage by stage; PROVE every stage with raw tool output before advancing:**
### Stage 1. Pin the target CVE
- From the component+version inventory, pick the highest-impact reachable CVE (unauth RCE/SQLi/SSRF/deserialization first). Confirm preconditions are met
### Stage 2. Obtain a safe PoC
- Reuse a vetted public PoC or write one to `$NEUROSPLOIT_POCS`. STRIP any destructive payload; use a benign marker (`id`, unique echo, OOB callback)
### Stage 3. Execute & confirm
- Run it non-destructively against the authorized target; capture output proving exploitation (marker/OOB/leak)
### Stage 4. Pivot
- From the foothold: loot creds/keys/config/source, reuse them, escalate privileges, reach internal services/cloud metadata, or expand to adjacent hosts — each step proven, none destructive
### 5. Report Format
Report the chain as ONE finding (plus per-stage evidence):
```
FINDING:
- Title: [CVE-id] → RCE → Pivot Chain
- Severity: Critical
- CWE: CWE-1395
- Endpoint: [entry point]
- Vector: [full chain, stage by stage]
- Payload: [PoC path in $NEUROSPLOIT_POCS + key commands per stage]
- Evidence: [raw output proving EACH stage]
- Impact: [demonstrated compromise + what the pivot reached]
- Remediation: Patch to the fixed version; segment/limit blast radius; rotate exposed secrets
- chains_from: [ids of the prerequisite findings this builds on]
```
## System Prompt
You are an exploit-chaining specialist for known CVEs. Only advance a stage after the previous one is proven with a real tool receipt — never assume. Save any PoC to $NEUROSPLOIT_POCS and cite it. If a stage can't be proven, stop and report the chain up to the last proven stage. AUTHORIZED engagement. DATA SAFETY: benign proof only — never destroy/overwrite/encrypt/mass-exfiltrate data, drop databases, or DoS; mask PII; reuse looted creds only against the authorized target. Credits: Joas A Santos & Red Team Leaders.
+39
View File
@@ -0,0 +1,39 @@
# CVE Exploit Scripter Agent
## User Prompt
You are testing **{target}**: when no clean public PoC exists for a confirmed-candidate CVE, WRITE a custom exploitation script and prove it safely.
**Recon Context:**
{recon_json}
**METHODOLOGY:**
### 1. Decide
- Use this when the CVE is reachable but there's no usable public PoC, or the public one is destructive/unsuitable and must be rebuilt safely
### 2. Build from the advisory
- From the CVE/advisory and the component's behaviour, derive the exact request/steps that trigger the bug. Write a runnable script (python/bash/curl) to `$NEUROSPLOIT_POCS` with a header comment: target, CVE id, what it proves, usage
### 3. Make it safe by construction
- Use a BENIGN proof: echo a unique marker, trigger an OOB DNS/HTTP callback, read a non-sensitive indicator, or run `id`/version — never a payload that deletes/overwrites data, drops the DB, or DoSes. Idempotent and minimal
### 4. Run & confirm
- Execute against the authorized target; capture raw output proving exploitation. Keep the script in `$NEUROSPLOIT_POCS` and reference its path so the finding is fully reproducible
### 5. Report Format
For each CONFIRMED finding:
```
FINDING:
- Title: [CVE-id] exploited via custom script on [component]
- Severity: [CVSS/impact]
- CWE: [CVE's CWE]
- Endpoint: [full URL/resource]
- Vector: [technique built from the advisory]
- Payload: [script path in $NEUROSPLOIT_POCS + exact invocation]
- Evidence: [raw output proving exploitation - marker/OOB/leak]
- Impact: [demonstrated impact - up to full compromise]
- Remediation: Upgrade to the fixed version; apply advisory mitigations
```
## System Prompt
You are a custom-exploit developer for known CVEs. AUTHORIZED engagement. Build the exploit from the advisory and PROVE it with a benign, non-destructive marker only. ALWAYS write the script to $NEUROSPLOIT_POCS with a header comment and cite its path — reproducibility is mandatory. Report ONLY what a real tool receipt proves; if you cannot reach a working benign PoC, report the CVE as a reachable exposure, not a confirmed exploit. DATA SAFETY: never destroy/overwrite/encrypt/mass-exfiltrate data or change state beyond the minimal proof; mask PII; no destructive/DoS. Credits: Joas A Santos and Red Team Leaders.
+39
View File
@@ -0,0 +1,39 @@
# CVE PoC Finder Agent
## User Prompt
You are testing **{target}**: find, vet and run a PUBLIC proof-of-concept for a confirmed-candidate CVE, safely.
**Recon Context:**
{recon_json}
**METHODOLOGY:**
### 1. Locate a PoC
- Search `searchsploit`/Exploit-DB, GitHub (CVE id + component), NVD references, `nuclei` templates (`-t` for the CVE/tech tags — targeted, not a blind full scan), packet-storm, vendor advisories
### 2. Vet before you run
- READ the PoC first. Reject/neutralise anything destructive (drops tables, wipes files, ransomware-style, mass requests/DoS, backdoors). Understand exactly what it does and what it proves
### 3. Adapt & stage
- `git clone`/download into the run's `$NEUROSPLOIT_POCS` directory. Parameterise it for THIS target (URL, port, path, auth). Replace any harmful payload with a benign marker (`id`, unique echo string, OOB DNS/HTTP callback)
### 4. Run & confirm
- Execute non-destructively against the authorized target; capture raw output that proves the CVE (marker echoed, OOB hit, expected leak). Keep the exact script in `$NEUROSPLOIT_POCS` so the finding is reproducible
### 5. Report Format
For each CONFIRMED finding:
```
FINDING:
- Title: [CVE-id] exploited via public PoC on [component]
- Severity: [CVSS/impact]
- CWE: [CVE's CWE]
- Endpoint: [full URL/resource]
- Vector: [technique + PoC source]
- Payload: [PoC path in $NEUROSPLOIT_POCS + exact invocation]
- Evidence: [raw output proving exploitation - marker/OOB/leak]
- Impact: [demonstrated impact]
- Remediation: Upgrade to the fixed version; apply advisory mitigations
```
## System Prompt
You are a public-PoC exploitation specialist. AUTHORIZED engagement. ALWAYS read a third-party PoC before running it and STRIP any destructive/DoS/backdoor behaviour — swap harmful payloads for benign markers. Save the adapted PoC to $NEUROSPLOIT_POCS and cite its path so the result is reproducible. Report ONLY what a real tool receipt proves. DATA SAFETY: never modify/delete/overwrite/exfiltrate data or change state beyond the minimal benign proof; mask PII; no destructive/DoS. Credits: Joas A Santos and Red Team Leaders.
+40
View File
@@ -0,0 +1,40 @@
# CVE Research Analyst Agent
## User Prompt
You are testing **{target}**: research known CVEs for the fingerprinted components and decide which are actually exploitable HERE.
**Recon Context:**
{recon_json}
**METHODOLOGY:**
### 1. Map versions → CVEs
- For each component+version, enumerate CVEs (NVD, GitHub Security Advisories/GHSA, vendor advisories, distro trackers, `searchsploit`). Record CVE id, CVSS, affected/fixed versions, vulnerability class
### 2. Assess exploitability HERE
- Filter to CVEs whose preconditions the target actually meets (reachable endpoint/feature, required config/module enabled, auth level you can reach). Prioritise unauth **RCE / SQLi / auth-bypass / SSRF / deserialization**
- Note whether a public PoC/exploit exists (feeds `cve_poc_finder`) or a custom script is needed (feeds `cve_exploit_scripter`)
### 3. Rank
- Order candidates by (impact × exploitability × reachability). Discard theoretical/unreachable CVEs
### 4. Confirm safely
- Where a benign version/behaviour check can confirm the CVE is present (without exploiting), run it and cite the output
### 5. Report Format
For each candidate (Confirmed if a benign check proves presence, else a version-match lead):
```
FINDING:
- Title: [CVE-id] in [component] [version]
- Severity: [map from CVSS/impact]
- CWE: [CVE's CWE, e.g. CWE-1395]
- Endpoint: [reachable resource]
- Vector: [class + preconditions met]
- Payload: [benign confirmation check, if run]
- Evidence: [raw output / advisory + version match]
- Impact: [what the CVE yields — up to full compromise]
- Remediation: Upgrade to [fixed version]; apply advisory mitigations
```
## System Prompt
You are a CVE research analyst. AUTHORIZED engagement. Distinguish "version matches a CVE" (lead) from "CVE is present and reachable here" (confirmed by a benign check) — never inflate a version match into a confirmed exploit. Cite the advisory and the exact affected/fixed version. Hand exploitation to the PoC finder / exploit scripter. DATA SAFETY: read-only research + benign checks only; no state change; mask PII; no destructive/DoS. Credits: Joas A Santos and Red Team Leaders.
@@ -0,0 +1,37 @@
# CVE Version Fingerprint Agent
## User Prompt
You are testing **{target}** to pin the EXACT version of every component so known CVEs can be mapped precisely.
**Recon Context:**
{recon_json}
**METHODOLOGY:**
### 1. Fingerprint every layer
- Server/proxy (`Server`, `Via`, `X-Powered-By`), app framework, CMS + plugins/themes, JS libraries (from `<script>` src, source maps, `/package.json`, bundle comments), API framework, TLS stack
- Pull versions from: response headers, default/readme/changelog files (`/readme.html`, `/CHANGELOG.md`, `/*.txt`), favicon hash, static asset hashes, error pages, `/.well-known`, `robots.txt`, JS build manifests
### 2. Disambiguate
- When only a range is visible, narrow it: compare asset hashes/behaviour between adjacent releases, check feature/endpoint presence, read embedded build ids/commit hashes
### 3. Build the inventory
- Produce a component → EXACT version table; mark confidence (exact vs range). This inventory feeds `cve_research_analyst` / `cve_hunter`
### 4. Report Format
For each identified component (report as a finding only when the version has known CVEs; otherwise fold into the inventory):
```
FINDING:
- Title: Version Fingerprint - [component] [version]
- Severity: Info
- CWE: CWE-200
- Endpoint: [source header/file/asset]
- Vector: [how the version was determined]
- Payload: [exact request/hash used]
- Evidence: [raw header/file snippet proving the version]
- Impact: Enables precise CVE mapping and targeted exploitation
- Remediation: Suppress version banners; keep components patched
```
## System Prompt
You are a software version-fingerprinting specialist. AUTHORIZED engagement. Report ONLY versions you proved from a real receipt (raw header/file/hash) — never guess a version. Prefer EXACT versions; state confidence when only a range is provable. Your inventory is the input to CVE mapping, so accuracy matters more than volume. DATA SAFETY: read-only; no state change; mask any PII. No destructive/DoS actions. Credits: Joas A Santos and Red Team Leaders.
+2 -2
View File
@@ -14,7 +14,7 @@ function Ok ($m) { Write-Host " + $m" -ForegroundColor Green }
function Warn($m){ Write-Host " ! $m" -ForegroundColor Yellow }
Write-Host ""
Write-Host " NeuroSploit installer (Windows) — v3.6.1" -ForegroundColor Cyan
Write-Host " NeuroSploit installer (Windows) — v3.6.9" -ForegroundColor Cyan
# arch → asset arch (only x64 prebuilt today; arm64 falls back to source)
$rawArch = $env:PROCESSOR_ARCHITECTURE
@@ -29,7 +29,7 @@ $ref = $env:NEUROSPLOIT_REF
if (-not $ref) {
try { $ref = (Invoke-RestMethod "https://api.github.com/repos/$slug/releases/latest").tag_name } catch { }
}
if (-not $ref) { $ref = "v3.6.1" }
if (-not $ref) { $ref = "v3.6.9" }
Say "Release: $ref"
New-Item -ItemType Directory -Force -Path $dir | Out-Null
+2 -2
View File
@@ -871,7 +871,7 @@ dependencies = [
[[package]]
name = "neurosploit"
version = "3.6.6"
version = "3.6.9"
dependencies = [
"anyhow",
"clap",
@@ -888,7 +888,7 @@ dependencies = [
[[package]]
name = "neurosploit-harness"
version = "3.6.6"
version = "3.6.9"
dependencies = [
"anyhow",
"futures",
+1 -1
View File
@@ -3,7 +3,7 @@ members = ["crates/harness", "app"]
resolver = "2"
[workspace.package]
version = "3.6.6"
version = "3.6.9"
edition = "2021"
license = "MIT"
repository = "https://github.com/JoasASantos/NeuroSploit"
+38 -9
View File
@@ -1,4 +1,4 @@
//! NeuroSploit v3.6.6 — interactive harness + CLI (`run` / `whitebox` / `agents` / `models`).
//! NeuroSploit v3.6.9 — interactive harness + CLI (`run` / `whitebox` / `agents` / `models`).
mod repl;
mod tui;
@@ -11,9 +11,9 @@ use std::path::{Path, PathBuf};
#[command(
name = "neurosploit",
version,
about = "NeuroSploit v3.6.6 — multi-model autonomous pentest harness",
long_about = "NeuroSploit v3.6.6 — 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. \
about = "NeuroSploit v3.6.9 — multi-model autonomous pentest harness",
long_about = "NeuroSploit v3.6.9 — a Rust multi-model harness that drives a pool of LLMs \
(API key or local subscription: Claude/Codex/Gemini/Grok/OpenCode/Hermes) to autonomously test a target. \
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\
Run with NO arguments for an interactive wizard.\n\n\
@@ -54,7 +54,7 @@ enum Cmd {
recon: usize,
#[arg(long)]
offline: bool,
/// Use local agentic CLI subscription (Claude/Codex/Gemini/Grok login).
/// Use local agentic CLI subscription (Claude/Codex/Gemini/Grok/OpenCode/Hermes login).
#[arg(long)]
subscription: bool,
/// Enable Playwright MCP (auto-installed if missing; backends that don't
@@ -77,6 +77,11 @@ enum Cmd {
/// Open a Jira card per finding (needs the jira integration enabled).
#[arg(long)]
jira: bool,
/// Re-test ONLY these agent(s), skipping recon-based selection — repeatable
/// or comma/semicolon-separated (e.g. `--only sqli --only cve_hunter`).
/// Run `neurosploit agents` for the names.
#[arg(long = "only")]
only: Vec<String>,
/// Verbose: log each agent as it launches, recon, and votes.
#[arg(short, long)]
verbose: bool,
@@ -105,6 +110,9 @@ enum Cmd {
/// Open a Jira card per finding (needs the jira integration enabled).
#[arg(long)]
jira: bool,
/// Re-test ONLY these code agent(s) — repeatable or comma/semicolon-separated.
#[arg(long = "only")]
only: Vec<String>,
#[arg(short, long)]
verbose: bool,
},
@@ -139,6 +147,9 @@ enum Cmd {
subscription: bool,
#[arg(long)]
mcp: bool,
/// Re-test ONLY these agent(s) — repeatable or comma/semicolon-separated.
#[arg(long = "only")]
only: Vec<String>,
#[arg(short, long)]
verbose: bool,
},
@@ -379,7 +390,7 @@ async fn main() -> anyhow::Result<()> {
}
}
}
Cmd::Run { url, models, max_agents, vote_n, chain_depth, recon, offline, subscription, mcp, creds, focus, objective, out_of_scope, jira, verbose } => {
Cmd::Run { url, models, max_agents, vote_n, chain_depth, recon, offline, subscription, mcp, creds, focus, objective, out_of_scope, jira, only, verbose } => {
let url = if url.starts_with("http") { url } else { format!("https://{url}") };
let mut cfg = RunConfig::new(&url);
cfg.max_agents = max_agents;
@@ -392,6 +403,7 @@ async fn main() -> anyhow::Result<()> {
cfg.instructions = focus;
cfg.objective = objective;
cfg.out_of_scope = out_of_scope;
cfg.pinned = parse_only(&only);
if !models.is_empty() {
cfg.models = models;
}
@@ -401,7 +413,7 @@ async fn main() -> anyhow::Result<()> {
let ig = harness::integrations::Integrations::load(&repl::proj_dir());
post_integrations(&ig, &url, &out, jira, false, None).await;
}
Cmd::Whitebox { path, models, max_agents, vote_n, chain_depth, recon, offline, subscription, jira, verbose } => {
Cmd::Whitebox { path, models, max_agents, vote_n, chain_depth, recon, offline, subscription, jira, only, verbose } => {
let path = resolve_source(&base, &path)?; // local path OR github URL/owner/repo
let mut cfg = RunConfig::new(&path);
cfg.max_agents = max_agents;
@@ -411,6 +423,7 @@ async fn main() -> anyhow::Result<()> {
cfg.offline = offline;
cfg.subscription = subscription;
cfg.verbose = verbose;
cfg.pinned = parse_only(&only);
if !models.is_empty() {
cfg.models = models;
}
@@ -419,7 +432,7 @@ async fn main() -> anyhow::Result<()> {
let ig = harness::integrations::Integrations::load(&repl::proj_dir());
post_integrations(&ig, &path, &out, jira, false, None).await;
}
Cmd::Greybox { repo, url, models, creds, focus, max_agents, vote_n, chain_depth, recon, offline, subscription, mcp, verbose } => {
Cmd::Greybox { repo, url, models, creds, focus, max_agents, vote_n, chain_depth, recon, offline, subscription, mcp, only, verbose } => {
let repo = resolve_source(&base, &repo)?; // local path OR github URL/owner/repo
let url = if url.starts_with("http") { url } else { format!("https://{url}") };
let mut cfg = RunConfig::new(&url);
@@ -432,6 +445,7 @@ async fn main() -> anyhow::Result<()> {
cfg.subscription = subscription;
cfg.verbose = verbose;
cfg.instructions = focus;
cfg.pinned = parse_only(&only);
if !models.is_empty() {
cfg.models = models;
}
@@ -751,7 +765,7 @@ pub(crate) fn spawn_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, mode:
println!(" │ ua : {ua}");
write_status(&workdir, "running", &format!("\"target\":{:?}", cfg.target));
println!(" ┌─ NeuroSploit v3.6.6 · by Joas A Santos & Red Team Leaders");
println!(" ┌─ NeuroSploit v3.6.9 · by Joas A Santos & Red Team Leaders");
println!(" │ run id : {run_id}");
println!(" │ target : {}", cfg.target);
println!(" │ models : {}", cfg.models.join(", "));
@@ -896,6 +910,21 @@ pub(crate) fn print_findings(out: &RunOutput) {
}
}
/// Parse repeated `--only` values into a clean agent allowlist. Accepts repeats
/// and comma/semicolon-separated lists (`--only sqli,xss` == `--only sqli --only xss`).
fn parse_only(vals: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for v in vals {
for part in v.split([',', ';']) {
let name = part.trim();
if !name.is_empty() && !out.iter().any(|x| x == name) {
out.push(name.to_string());
}
}
}
out
}
fn sanitize(s: &str) -> String {
let s = s.replace("https://", "").replace("http://", "");
let mut o: String = s.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect();
+3 -2
View File
@@ -1,4 +1,4 @@
//! NeuroSploit v3.6.6 — interactive session (Claude-Code / Codex / Cursor-CLI style).
//! NeuroSploit v3.6.9 — interactive session (Claude-Code / Codex / Cursor-CLI style).
//!
//! Launched when `neurosploit` runs with no subcommand. A persistent REPL with
//! real line editing (arrow-key history recall, Ctrl-A/E/K, paste), model
@@ -68,6 +68,7 @@ impl RunLive {
if self.feed.len() > 200 { self.feed.remove(0); }
}
if low.contains("token/quota exhausted") || low.contains("run is paused") { self.phase = "paused (quota)".into(); }
else if low.contains("authentication failed") || low.contains("auth failed") || low.contains("circuit breaker") { self.phase = "paused (auth)".into(); }
else if low.contains("resumed — retrying") { self.phase = "exploiting".into(); }
else if low.starts_with("recon") || low.starts_with("ai-recon") || low.contains("recon round") || low.contains("intensity") || low.starts_with("probe:") { self.phase = "recon".into(); }
else if low.contains("selected") && low.contains("agent") {
@@ -370,7 +371,7 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
let backends = harness::installed_cli_backends();
println!("\x1b[1m");
println!(" ███╗ ██╗███████╗██╗ ██╗██████╗ ██████╗");
println!(" ████╗ ██║██╔════╝██║ ██║██╔══██╗██╔═══██╗ NeuroSploit v3.6.6");
println!(" ████╗ ██║██╔════╝██║ ██║██╔══██╗██╔═══██╗ NeuroSploit v3.6.9");
println!(" ██╔██╗ ██║█████╗ ██║ ██║██████╔╝██║ ██║ interactive harness");
println!(" ██║╚██╗██║██╔══╝ ██║ ██║██╔══██╗██║ ██║ by Joas A Santos");
println!(" ██║ ╚████║███████╗╚██████╔╝██║ ██║╚██████╔╝ & Red Team Leaders");
+1 -1
View File
@@ -1,4 +1,4 @@
//! NeuroSploit v3.6.6 — TUI "Mission Control" mode.
//! NeuroSploit v3.6.9 — TUI "Mission Control" mode.
//!
//! Concurrent panels that update live while the engagement runs in the
//! background, with a composer input that stays active during execution:
+126 -10
View File
@@ -52,6 +52,21 @@ pub fn providers() -> Vec<Provider> {
models: vec!["gpt-4o", "claude-3-7-sonnet", "gemini/gemini-2.5-pro"] },
Provider { key: "openrouter", label: "OpenRouter", base_url: "https://openrouter.ai/api/v1", env_key: "OPENROUTER_API_KEY", kind: "api",
models: vec!["anthropic/claude-opus-4-8", "qwen/qwen-2.5-coder-32b-instruct", "deepseek/deepseek-r1", "meta-llama/llama-3.3-70b-instruct"] },
// OpenCode Zen — the curated OpenAI-compatible gateway behind the
// `opencode` CLI (https://opencode.ai/zen). Works two ways, like
// anthropic/openai/xai/gemini above: as a plain API-key provider here,
// or (with --subscription) driven through the locally-installed
// `opencode` agentic CLI on the user's own Zen/plan login — no key
// needed in that mode. `kind: "cli"` reflects the latter.
Provider { key: "opencode", label: "OpenCode Zen", base_url: "https://opencode.ai/zen/v1", env_key: "OPENCODE_API_KEY", kind: "cli",
models: vec!["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"] },
// Nous Research — Hermes models via the Nous Portal. As an API-key
// provider here (OpenAI-compatible `inference-api.nousresearch.com`),
// or (with --subscription) driven through the `hermes` CLI
// (NousResearch/hermes-agent) on the user's OAuth Portal login
// (`hermes setup --portal`) — 300+ routed frontier models, no key.
Provider { key: "nous", label: "Nous Research (Hermes)", base_url: "https://inference-api.nousresearch.com/v1", env_key: "NOUS_API_KEY", kind: "cli",
models: vec!["Hermes-4-405B", "Hermes-4-70B", "DeepHermes-3-Mistral-24B-Preview"] },
// Azure OpenAI (OpenAI-compatible). Set AZURE_OPENAI_ENDPOINT (e.g.
// https://<resource>.openai.azure.com), optionally AZURE_OPENAI_API_VERSION
// (default 2024-10-21), and use `azure:<your-deployment-name>` as the model.
@@ -163,7 +178,20 @@ impl ChatClient {
if !key.is_empty() {
if azure { req = req.header("api-key", &key); } else { req = req.bearer_auth(&key); }
}
let resp = req.send().await?;
let resp = req.send().await.map_err(|e| {
if e.is_connect() {
let local = matches!(p.key, "ollama" | "litellm" | "llamacpp");
if local {
anyhow!("{} connection refused at {} — is the server running? ({})", p.key, url, e)
} else {
anyhow!("{} connection error: {}", p.key, e)
}
} else if e.is_timeout() {
anyhow!("{} request timed out (120s) for model '{}' — model may be too large for available memory", p.key, m.model)
} else {
anyhow!("{} request error: {}", p.key, e)
}
})?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
@@ -213,6 +241,11 @@ impl ChatClient {
}
let mut cmd = Command::new(bin);
// Most agentic CLIs here take the prompt on stdin; opencode and hermes
// take it as a trailing positional argument instead — track which so we
// don't also pipe it into stdin below (that would just hang the child
// waiting on a request it already got as an argv value).
let mut prompt_via_stdin = true;
match bin {
// Codex non-interactive exec (uses the ChatGPT/Codex login), prompt on stdin.
"codex" => {
@@ -237,13 +270,46 @@ impl ChatClient {
"grok" => {
cmd.arg("--model").arg(model);
}
// OpenCode CLI (`opencode run`) — non-interactive one-shot, prompt
// as a positional arg, not stdin. `--auto` auto-approves anything
// not explicitly denied (our equivalent of --dangerously-skip-permissions).
// MCP (Playwright) is injected via a generated opencode.json pointed
// at through OPENCODE_CONFIG rather than a CLI flag (opencode has none).
"opencode" => {
prompt_via_stdin = false;
cmd.arg("run").arg("--model").arg(model).arg("--auto");
if let Some(mcp) = mcp_config {
match write_opencode_mcp_config(mcp) {
Ok(cfg) => { cmd.env("OPENCODE_CONFIG", cfg); }
Err(e) => eprintln!(" [!] opencode MCP config failed: {e}"),
}
}
cmd.arg(&prompt);
}
// Hermes Agent CLI (NousResearch/hermes-agent) — single-query mode.
// `-q` is the prompt-supplying flag (not a stdin read); `--provider
// nous` pins the Nous Portal OAuth login; `-Q` quiets banner/spinner
// for programmatic use; `--yolo` bypasses dangerous-command prompts.
// No CLI-level MCP hook — Hermes falls back to its own built-in
// toolsets (web/terminal/computer-use) rather than our Playwright MCP.
"hermes" => {
prompt_via_stdin = false;
cmd.arg("chat").arg("-m").arg(model).arg("--provider").arg("nous")
.arg("-Q").arg("--yolo").arg("-q").arg(&prompt);
}
_ => {}
}
cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true);
let mut child = cmd.spawn().map_err(|e| anyhow!("spawn {} failed: {}", bin, e))?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(prompt.as_bytes()).await?;
// Drop closes stdin so the CLI processes the prompt and exits.
if prompt_via_stdin {
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(prompt.as_bytes()).await?;
// Drop closes stdin so the CLI processes the prompt and exits.
}
} else {
// Prompt went in as an argv value; close stdin immediately so
// nothing lingers waiting on it (opencode/hermes never read it).
drop(child.stdin.take());
}
// Cap a single agentic CLI turn so a stuck tool-loop can't hang the run.
let out = match tokio::time::timeout(Duration::from_secs(600), child.wait_with_output()).await {
@@ -564,6 +630,8 @@ pub fn cli_binary_for(provider: &str) -> Option<&'static str> {
"openai" => Some("codex"),
"xai" => Some("grok"),
"gemini" => Some("gemini"),
"opencode" => Some("opencode"),
"nous" => Some("hermes"),
_ => None,
}
}
@@ -577,7 +645,7 @@ pub fn binary_in_path(name: &str) -> bool {
/// Which subscription CLI backends are installed locally.
pub fn installed_cli_backends() -> Vec<&'static str> {
["claude", "codex", "grok", "gemini"].into_iter().filter(|b| binary_in_path(b)).collect()
["claude", "codex", "grok", "gemini", "opencode", "hermes"].into_iter().filter(|b| binary_in_path(b)).collect()
}
/// Login state of a subscription CLI backend.
@@ -597,15 +665,23 @@ pub async fn cli_login_status(provider: &str) -> LoginStatus {
let Some(bin) = cli_binary_for(provider) else { return LoginStatus::NotInstalled };
if !binary_in_path(bin) { return LoginStatus::NotInstalled; }
let mut cmd = Command::new(bin);
// opencode/hermes take the probe prompt as an argv value, not stdin.
let prompt_via_stdin = !matches!(bin, "opencode" | "hermes");
match bin {
"claude" => { cmd.arg("-p").arg("--output-format").arg("text").arg("--dangerously-skip-permissions"); }
"codex" => { cmd.arg("exec").arg("--dangerously-bypass-approvals-and-sandbox").arg("-"); }
"opencode" => { cmd.arg("run").arg("--auto").arg("Reply with exactly: OK"); }
"hermes" => { cmd.arg("chat").arg("--provider").arg("nous").arg("-Q").arg("--yolo").arg("-q").arg("Reply with exactly: OK"); }
_ => { cmd.arg("-p"); } // grok / gemini: prompt on stdin
}
cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true);
let mut child = match cmd.spawn() { Ok(c) => c, Err(_) => return LoginStatus::Unknown };
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(b"Reply with exactly: OK").await;
if prompt_via_stdin {
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(b"Reply with exactly: OK").await;
}
} else {
drop(child.stdin.take());
}
let out = match tokio::time::timeout(Duration::from_secs(45), child.wait_with_output()).await {
Ok(Ok(o)) => o,
@@ -629,10 +705,50 @@ pub async fn cli_login_status(provider: &str) -> LoginStatus {
}
/// Does this provider's agentic CLI accept a Playwright MCP config?
/// Claude Code and Codex do; Gemini/Grok CLIs don't take an MCP-config flag, so
/// they fall back to their own built-in tools.
/// Claude Code, Codex, and OpenCode do (OpenCode via a generated
/// `opencode.json` + `OPENCODE_CONFIG`, see `write_opencode_mcp_config`).
/// Gemini/Grok/Hermes have no CLI-level MCP hook, so they fall back to their
/// own built-in tools (Hermes ships web/terminal/computer-use natively).
pub fn mcp_supported(provider: &str) -> bool {
matches!(provider, "anthropic" | "openai")
matches!(provider, "anthropic" | "openai" | "opencode")
}
/// Convert our `.mcp.json` (`{"mcpServers": {name: {command, args}}}`) into
/// OpenCode's own config schema (`{"mcp": {name: {"type":"local","command":
/// [command, ...args], "enabled": true}}}`) and write it next to the source
/// file. OpenCode has no `--mcp-config` flag; it's pointed at a config file
/// via the `OPENCODE_CONFIG` env var instead (set by the `opencode` arm of
/// `chat_cli`), so this doesn't touch the user's own `opencode.json`.
fn write_opencode_mcp_config(mcp_json_path: &str) -> Result<std::path::PathBuf> {
let txt = std::fs::read_to_string(mcp_json_path)
.map_err(|e| anyhow!("read {mcp_json_path}: {e}"))?;
let v: serde_json::Value = serde_json::from_str(&txt)
.map_err(|e| anyhow!("parse {mcp_json_path}: {e}"))?;
let servers = v.get("mcpServers").cloned().unwrap_or(v);
let mut mcp = serde_json::Map::new();
if let Some(obj) = servers.as_object() {
for (name, s) in obj {
let command = s.get("command").and_then(|c| c.as_str()).unwrap_or("").to_string();
if command.is_empty() { continue; }
let mut argv = vec![serde_json::Value::String(command)];
if let Some(args) = s.get("args").and_then(|a| a.as_array()) {
argv.extend(args.iter().cloned());
}
mcp.insert(name.clone(), serde_json::json!({
"type": "local",
"command": argv,
"enabled": true
}));
}
}
let cfg = serde_json::json!({
"$schema": "https://opencode.ai/config.json",
"mcp": mcp
});
let path = std::path::Path::new(mcp_json_path).with_file_name("opencode.json");
std::fs::write(&path, serde_json::to_string_pretty(&cfg).unwrap_or_default())
.map_err(|e| anyhow!("write {}: {e}", path.display()))?;
Ok(path)
}
/// Best-effort ensure the Playwright MCP server is available locally. Requires
+177 -24
View File
@@ -313,6 +313,37 @@ const DECISION_DOCTRINE: &str = "DECIDE WHERE TO ATTACK (analyse, then act):\n\
- Build PoCs when needed: for issues that need an artifact to prove (clickjacking an HTML page that frames the target; CSRF an auto-submitting HTML form; a multi-step or timing exploit a script), WRITE the PoC to the run's PoC dir, run/validate it, and cite the file in the evidence.\n\
- Test control BYPASSES: when something returns 401/403/redirect or is 'blocked', try to bypass it (verb tampering, path/case/encoding normalization, X-Original-URL / X-Rewrite-URL / X-Forwarded-* headers, missing-vs-invalid token, direct object/API access) and confirm the bypass with the two requests.\n\n";
/// CHAIN doctrine: turn ANY foothold into the next step. A primitive→next-step
/// playbook (not an exhaustive script) so the agent always has a concrete pivot
/// to reason about, plus a push to chain toward BUSINESS impact — all under the
/// non-destructive SAFETY_DOCTRINE (prove RCE/access with a benign marker, never
/// harm data or state).
const CHAIN_DOCTRINE: &str = "CHAIN THE FOOTHOLD (pivot to deeper, provable impact — any primitive can chain):\n\
- Think in primitives, not labels: reduce the foothold to what it GIVES you (code exec, file read, file write, request forgery, a trusted identity, a leaked secret, arbitrary object access) and pick the next step from that.\n\
- Pivot playbook (attempt the fitting ones, prove each with a benign receipt):\n\
· File upload / write RCE: upload a webshell/handler to an executable path or poison a config/`.htaccess`/cron/serialized file; prove with a benign marker (`id`, unique echo, OOB DNS), not damage.\n\
· SSRF cloud/host takeover: hit `169.254.169.254` (IMDSv1/v2), GCP/Azure metadata, internal admin/actuator, `file://`/`gopher://`; loot temp creds/tokens and REUSE them.\n\
· SQLi RCE/LPE: stacked queries, `INTO OUTFILE`/`COPY TO`, UDF, `xp_cmdshell`, read secrets/creds; then reuse creds to log in and escalate.\n\
· LFI/path traversal RCE: log/session/wrapper poisoning, `/proc/self/environ`, read source & secrets; combine with an upload for exec.\n\
· XXE SSRF/file read creds; deserialization/SSTI RCE via a gadget/template sink; prove exec with a marker.\n\
· IDOR/BOLA/mass-assignment account/tenant takeover or role escalation (`role=admin`); open-redirect/XSS/CORS token/session theft ATO.\n\
· Exposed `.git`/backup/`.env`/secrets reconstruct source & keys auth to internal APIs, cloud, DB; default/leaked creds domain/service compromise.\n\
- Reuse loot relentlessly: every credential/JWT/cookie/API key/host you obtain is input to the next step carry it forward across modules and try it everywhere it might be accepted.\n\
- Understand the BUSINESS & LOGIC: reason about what the app is FOR (payments, orders, tenancy, KYC, entitlements) and chain toward business impact payment/price/coupon abuse, cross-tenant data access, entitlement/limit bypass, workflow/state-machine skips (skip approval/verification steps), race conditions on balance/stock. These compound: each finding updates your model of the app for the next probe.\n\
- Stop at proof: demonstrate the impact with the SMALLEST safe step and report the CHAIN end-to-end; never destroy, overwrite, encrypt, mass-exfiltrate, or DoS to 'prove' it.\n\n";
/// WHITEBOX doctrine: this is a STATIC source review — keep the agent in the code,
/// not on the wire. Prevents whitebox runs from hallucinating black-box network
/// actions (curl/nuclei/live requests) they cannot perform here, and pushes for
/// a symbolic `file:line` receipt plus a runnable repro PoC where it adds value.
const WHITEBOX_DOCTRINE: &str = "MODE: WHITE-BOX STATIC SOURCE REVIEW. You are reading source code, NOT a live target.\n\
- Source-only: reason strictly about the provided code. Do NOT curl, run nuclei, browse, or claim any live/HTTP/network result there is no running app here. Any \"I sent a request / got a response\" claim is a hallucination and will be rejected.\n\
- Symbolic receipt: EVERY finding's evidence is a `file:line` citation plus the exact vulnerable code quoted verbatim. The code citation IS the proof. No `file:line` + code quote do not report it.\n\
- Trace, don't guess: follow tainted input from its SOURCE (request param, env, deserialization, file) to a dangerous SINK (SQL/exec/eval/template/path/SSRF/deserialize). Report only when source reaches sink without effective sanitization; note the path (`entry sink`).\n\
- Version CVE (static): read dependency manifests (package.json, requirements.txt, go.mod, pom.xml, Gemfile.lock, Cargo.lock) and pin exact versions; map to known CVEs and cite the manifest line. Flag reachable, exploitable ones over merely-outdated ones.\n\
- Repro PoC (optional but valued): when a finding warrants it, WRITE a proof/repro script to $NEUROSPLOIT_POCS e.g. the exact malicious input + the request/CLI call that would trigger the sink, or a unit-style harness exercising the vulnerable function with a header comment (file:line it proves, how to run). Cite the PoC path in the evidence. Mark clearly that it demonstrates the code path (static-derived), not a live hit.\n\
- Calibrate: High/Critical only when the sink is reachable and exploitable from untrusted input; guarded/unreachable code is Low or a lead.\n\n";
/// Methodology directions for a modern JS SPA backed by a REST/GraphQL API
/// (Angular/React/Vue front + Node/Express-style API — the shape of OWASP Juice
/// Shop and many real apps). These are DIRECTIONS on HOW to hunt each vuln class,
@@ -456,20 +487,37 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
// Use the model to pick the agents whose preconditions match the recon —
// the harness reasons about *which* specialists to run, not all of them.
// Exception: when the operator pinned an explicit set (--only), run EXACTLY
// those and skip recon-based selection — used to re-test a single vuln.
let focus = cfg.instructions.clone().unwrap_or_default();
let chosen = select_agents(pool, &recon, &focus, &ranked, &tx).await;
let selected: Vec<Agent> = if !chosen.is_empty() {
let selected: Vec<Agent> = if !cfg.pinned.is_empty() {
let sel: Vec<Agent> =
ranked.iter().filter(|a| chosen.iter().any(|c| c == &a.name)).cloned().collect();
ranked.iter().filter(|a| cfg.pinned.iter().any(|p| p == &a.name)).cloned().collect();
if sel.is_empty() {
heuristic_select(&ranked, &recon, &focus, cap)
let _ = tx.send(format!("--only matched no agent ({}) — falling back to recon selection",
cfg.pinned.join(", "))).await;
let chosen = select_agents(pool, &recon, &focus, &ranked, &tx).await;
ranked.iter().filter(|a| chosen.iter().any(|c| c == &a.name)).take(cap).cloned().collect()
} else {
sel.into_iter().take(cap).collect()
let _ = tx.send(format!("--only: running exactly {} pinned agent(s): {}", sel.len(),
sel.iter().map(|a| a.name.clone()).collect::<Vec<_>>().join(", "))).await;
sel
}
} else {
// LLM selection failed/empty → recon+focus keyword heuristic, not a blind flat list.
let _ = tx.send("selection empty — using recon-keyword heuristic".into()).await;
heuristic_select(&ranked, &recon, &focus, cap)
let chosen = select_agents(pool, &recon, &focus, &ranked, &tx).await;
if !chosen.is_empty() {
let sel: Vec<Agent> =
ranked.iter().filter(|a| chosen.iter().any(|c| c == &a.name)).cloned().collect();
if sel.is_empty() {
heuristic_select(&ranked, &recon, &focus, cap)
} else {
sel.into_iter().take(cap).collect()
}
} else {
// LLM selection failed/empty → recon+focus keyword heuristic, not a blind flat list.
let _ = tx.send("selection empty — using recon-keyword heuristic".into()).await;
heuristic_select(&ranked, &recon, &focus, cap)
}
};
// Dedup: never run the same agent twice in one engagement.
let mut selected: Vec<Agent> = {
@@ -479,7 +527,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
// No creds given → always run the registration/form agent FIRST so the run
// reaches the authenticated surface (and the operator sees it happen). It
// self-registers one test account under the anti-flood guardrail.
if cfg.auth.as_deref().unwrap_or("").trim().is_empty() {
if cfg.pinned.is_empty() && cfg.auth.as_deref().unwrap_or("").trim().is_empty() {
if let Some(reg) = lib.vulns.iter().find(|a| a.name == "account_registration_and_forms") {
if !selected.iter().any(|a| a.name == reg.name) {
let _ = tx.send("no creds set — running account_registration_and_forms first to reach the authenticated surface".into()).await;
@@ -550,6 +598,10 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
Ok((m, text)) => {
let f = extract_findings(&text, &ag.name);
let _ = txc.send(format!("exploit {} via {}{} candidate(s)", ag.name, m.label(), f.len())).await;
if f.is_empty() && !text.trim().is_empty() && text.trim() != "[]" {
let tail: String = text.chars().rev().take(120).collect::<String>().chars().rev().collect();
let _ = txc.send(format!("⚠ agent {} returned text but 0 parseable findings (model may have produced malformed JSON). Tail: {:?}", ag.name, tail)).await;
}
// Live findings feed: surface each candidate the moment it appears.
for c in &f {
let _ = txc.send(format!("finding: [{}] {} @ {}", c.severity, c.title, c.endpoint)).await;
@@ -558,7 +610,12 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
(ag.name.clone(), text, f)
}
Err(e) => {
let _ = txc.send(format!("exploit {} failed: {e}", ag.name)).await;
let is_auth = crate::pool::is_auth_failure(&e);
if is_auth {
let _ = txc.send(format!("⚠ exploit {} auth failed — findings so far are SAFE, run is pausing: {e}", ag.name)).await;
} else {
let _ = txc.send(format!("exploit {} failed: {e}", ag.name)).await;
}
(ag.name.clone(), format!("ERROR: {e}"), vec![])
}
}
@@ -571,6 +628,9 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
let transcript = transcript_of(&raw);
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating by {}-model vote", candidates.len(), cfg.vote_n)).await;
if pool.candidates.len() == 1 && cfg.vote_n <= 1 {
let _ = tx.send("⚠ single-model panel with vote_n=1 — validation is weaker (same model validates its own findings). Consider --vote-n 2 or adding a second model for cross-validation.".into()).await;
}
// ---- 4. Validate by N-model voting ---------------------------------
let mut findings = validate(candidates, pool, VOTE_SYS, cfg.vote_n, &tx).await;
@@ -597,7 +657,22 @@ pub async fn run_whitebox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: S
}
let mut rl = cfg.rl_path.as_ref().map(|p| RlState::load(Path::new(p))).unwrap_or_default();
let mut ranked: Vec<Agent> = if lib.code.is_empty() { lib.vulns.clone() } else { lib.code.clone() };
let pool_agents: Vec<Agent> = if lib.code.is_empty() { lib.vulns.clone() } else { lib.code.clone() };
let mut ranked: Vec<Agent> = if cfg.pinned.is_empty() {
pool_agents
} else {
// Operator pinned an explicit agent set (--only): re-test exactly those.
let sel: Vec<Agent> = pool_agents.iter()
.filter(|a| cfg.pinned.iter().any(|p| p == &a.name)).cloned().collect();
if sel.is_empty() {
let _ = tx.send(format!("--only matched no code agent ({}) — reviewing with the full set",
cfg.pinned.join(", "))).await;
pool_agents
} else {
let _ = tx.send(format!("--only: reviewing with exactly {} pinned agent(s)", sel.len())).await;
sel
}
};
ranked.sort_by(|a, b| rl.weight(&b.name).partial_cmp(&rl.weight(&a.name)).unwrap_or(std::cmp::Ordering::Equal));
let cap = if cfg.max_agents > 0 { cfg.max_agents.min(ranked.len()) } else { ranked.len() };
let selected: Vec<Agent> = ranked.into_iter().take(cap).collect();
@@ -616,11 +691,15 @@ pub async fn run_whitebox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: S
let user = format!(
"{}\n\nSOURCE CODE TO REVIEW:\n```\n{}\n```\n\nReply ONLY with a JSON array of findings (may be empty []). \
Each item: {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}} \
where `endpoint` is the file:line and `evidence` quotes the vulnerable code.",
where `endpoint` is the file:line and `evidence` quotes the vulnerable code. \
When a finding warrants a runnable proof, write a repro script to $NEUROSPLOIT_POCS and put its path in `payload`.",
ag.user.replace("{target}", "the provided repository").replace("{recon_json}", "{}"),
ctx
);
match pool.complete_routed(Task::Exploit, &ag.name, &ag.system, &user).await {
// Prepend the white-box doctrine so code agents stay in static
// source-review mode and never hallucinate live/black-box actions.
let sys = format!("{}{}", WHITEBOX_DOCTRINE, ag.system);
match pool.complete_routed(Task::Exploit, &ag.name, &sys, &user).await {
Ok((m, text)) => {
let f = extract_findings(&text, &ag.name);
let _ = txc.send(format!("analyze {} via {}{} candidate(s)", ag.name, m.label(), f.len())).await;
@@ -910,13 +989,13 @@ async fn chain_from_seed(pool: &ModelPool, target: &str, directives: &str, recon
};
let short: String = seed.title.chars().take(28).collect();
let user = format!(
"AUTHORIZED engagement on {target}.\n\n{directives}{react}{depth}{decision}{safety}{doctrine}\
"AUTHORIZED engagement on {target}.\n\n{directives}{react}{depth}{decision}{chain}{safety}{doctrine}\
FOOTHOLD TO EXPAND (round {round}/{max}):\n- [{}] {} @ {} ({})\n payload: {}\n evidence: {}\n\n\
LOOT GATHERED (reuse it):\n{loot_block}\n\n{recipe_block}RECON:\n{recon_ctx}\n\n\
From THIS foothold, DECIDE the best directions and PROVE new impact post-exploitation (loot creds/keys/config/source), credential reuse, privilege escalation (horizontal & vertical), lateral movement to adjacent services/hosts, data exfiltration, and NEW attack surface it exposes. Every claim needs a real tool receipt.\n\n\
Reply ONLY JSON: {{\"findings\":[{{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}}],\"loot\":[\"cred:user:pass@host\",\"token:...\",\"host:10.0.0.5\",\"endpoint:/internal/api\"]}} (empty arrays are fine).",
seed.severity, seed.title, seed.endpoint, seed.cwe, seed.payload, seed.evidence,
react = REACT_DOCTRINE, depth = DEPTH_DOCTRINE, decision = DECISION_DOCTRINE, safety = SAFETY_DOCTRINE, doctrine = tool_doctrine(pool.mcp_config.is_some()),
react = REACT_DOCTRINE, depth = DEPTH_DOCTRINE, decision = DECISION_DOCTRINE, chain = CHAIN_DOCTRINE, safety = SAFETY_DOCTRINE, doctrine = tool_doctrine(pool.mcp_config.is_some()),
);
let label = format!("chain:{short}");
match pool.complete_routed(Task::Exploit, &label, CHAIN_SYS, &user).await {
@@ -1081,9 +1160,26 @@ fn heuristic_select(ranked: &[Agent], recon: &str, focus: &str, cap: usize) -> V
}
async fn validate(candidates: Vec<Finding>, pool: &ModelPool, sys: &str, vote_n: usize, tx: &Sender<String>) -> Vec<Finding> {
// Fast-track: findings with no evidence are unverifiable — skip the vote
// and flag for human review instead of wasting a validator call that will
// always reject ("default to rejected when uncertain" + empty evidence).
let (have_evidence, no_evidence): (Vec<_>, Vec<_>) = candidates.into_iter().partition(|f| {
let e = f.evidence.trim();
!e.is_empty() && e != "N/A" && e != "n/a" && e != "none" && e != "-"
});
let mut flagged: Vec<Finding> = no_evidence.into_iter().map(|mut f| {
f.validated = false;
f.review_status = "needs-review".into();
f.review_reason = "no concrete evidence provided by agent — manual verification required".into();
f.votes = "0/0".into();
f
}).collect();
for f in &flagged {
let _ = tx.send(format!("vote {} → needs-review (no evidence)", f.title)).await;
}
// Prefer a model other than the primary (likely finder) to adjudicate.
let finder = pool.candidates.first().map(|m| m.label());
let validated: Vec<Finding> = stream::iter(candidates)
let validated: Vec<Finding> = stream::iter(have_evidence)
.map(|mut f| {
let txc = tx.clone();
let finder = finder.clone();
@@ -1117,7 +1213,9 @@ async fn validate(candidates: Vec<Finding>, pool: &ModelPool, sys: &str, vote_n:
.collect()
.await;
// Keep confirmed AND needs-review (human decides); drop only zero-support noise.
validated.into_iter().filter(|f| f.validated || f.review_status == "needs-review").collect()
// Include no-evidence flagged findings so the human loop sees them.
flagged.extend(validated.into_iter().filter(|f| f.validated || f.review_status == "needs-review"));
flagged
}
/// Adversarial refutation pass: every confirmed **High/Critical** finding is
@@ -1369,12 +1467,27 @@ fn extract_findings(text: &str, agent: &str) -> Vec<Finding> {
(Some(a), Some(b)) if b > a => &text[a..=b],
_ => match (text.find('{'), text.rfind('}')) {
(Some(a), Some(b)) if b > a => &text[a..=b],
_ => return vec![],
_ => {
if !text.trim().is_empty() && text.trim() != "[]" {
eprintln!("[extract_findings] agent {agent}: model returned text but no JSON array/object found (len={}); raw tail: {:?}",
text.len(), &text[text.len().saturating_sub(200)..]);
}
return vec![];
}
},
};
let val: serde_json::Value = match serde_json::from_str(slice) {
Ok(v) => v,
Err(_) => return vec![],
Err(e) => {
eprintln!("[extract_findings] agent {agent}: JSON parse failed: {e}; slice head: {:?}",
&slice[..slice.len().min(300)]);
// Attempt to salvage: strip trailing comma before ] (common LLM mistake)
let fixed = slice.replace(",]", "]").replace(",}", "}");
match serde_json::from_str(&fixed) {
Ok(v) => v,
Err(_) => return vec![],
}
}
};
let items: Vec<serde_json::Value> = match val {
serde_json::Value::Array(a) => a,
@@ -1775,6 +1888,11 @@ fn recon_intensity_directive(level: usize) -> String {
(8) TLS/headers/cookies. Report counts (how many subdomains/urls/params/endpoints you actually found).\n\n")
}
/// Max wall-clock seconds for the ENTIRE recon phase (all rounds combined).
/// This prevents recon from eating the whole run — exploitation must start.
/// Per-round cap = total / (rounds + 1) so later rounds get equal time.
const RECON_TOTAL_BUDGET_SECS: u64 = 300; // 5 minutes total
/// Intense, multi-round recon: an initial deep pass, then follow-up rounds that
/// EXPAND the surface (chase discovered subdomains/endpoints/params, install
/// tools, dig where the previous round found signal). Returns the merged recon
@@ -1786,22 +1904,53 @@ async fn deep_recon(cfg: &RunConfig, pool: &ModelPool, probe_facts: &str, tx: &S
let intensity_dir = recon_intensity_directive(intensity);
let dir = operator_directives(cfg);
let mut accum = format!("OBSERVED HTTP PROBE:\n{probe_facts}");
let recon_start = std::time::Instant::now();
let total_rounds = 1 + extra_rounds;
// Time-budget directive: subscription CLIs run commands autonomously, so they
// need an explicit cap to avoid running 150+ commands in a single round.
let budget_dir = format!(
"TIME BUDGET: you have ~{budget_secs} seconds for THIS recon round. Be EFFICIENT: \
prioritise high-signal actions (JS analysis, API mapping, SQLi/auth probes) over \
exhaustive crawling. AIM for 30-50 commands max per round enough to map the surface, \
not so many that exploitation never starts. STOP EARLY if you have enough intel to \
select agents. When done, EMIT YOUR RESULTS IMMEDIATELY do not start another pass.\n\n",
budget_secs = RECON_TOTAL_BUDGET_SECS / total_rounds as u64,
);
// Initial deep pass.
let user = format!("{dir}{intensity_dir}{doctrine}OBSERVED HTTP PROBE (build on these, verify, go deeper):\n{probe_facts}\n\nTarget: {}", cfg.target);
let _ = tx.send(format!("recon: intensity {} — actively enumerating (installing tools as needed)…", intensity)).await;
let user = format!("{dir}{budget_dir}{intensity_dir}{doctrine}OBSERVED HTTP PROBE (build on these, verify, go deeper):\n{probe_facts}\n\nTarget: {}", cfg.target);
let _ = tx.send(format!("recon: intensity {} — actively enumerating (budget {}s total, {} round(s))…", intensity, RECON_TOTAL_BUDGET_SECS, total_rounds)).await;
match pool.complete_routed(Task::Recon, "recon", RECON_SYS, &user).await {
Ok((m, t)) => { let _ = tx.send(format!("recon round 1 complete via {}", m.label())).await; accum.push_str(&format!("\n\nMODEL RECON (round 1):\n{t}")); }
Err(e) => { let _ = tx.send(format!("recon round 1 failed ({e}) — probe facts only")).await; return accum; }
Err(e) => {
let is_auth = crate::pool::is_auth_failure(&e);
if is_auth {
let _ = tx.send(format!("recon round 1 auth failed ({e}) — continuing with probe facts; run will pause before exploit phase")).await;
accum.push_str(&format!("\n\nMODEL RECON (round 1):\n{e}"));
} else {
let _ = tx.send(format!("recon round 1 failed ({e}) — probe facts only")).await;
}
return accum;
}
}
// Follow-up expansion rounds — each digs further using what's known so far.
for r in 0..extra_rounds {
if pool.stop_exploiting() { break; }
// Time budget check: if recon has already consumed the total budget, stop
// and proceed to exploitation with whatever intelligence we gathered.
let elapsed = recon_start.elapsed().as_secs();
if elapsed >= RECON_TOTAL_BUDGET_SECS {
let _ = tx.send(format!("recon: time budget exhausted ({elapsed}s/{RECON_TOTAL_BUDGET_SECS}s) — proceeding to exploitation with current intel")).await;
break;
}
let remaining = RECON_TOTAL_BUDGET_SECS - elapsed;
let round = r + 2;
let known: String = accum.chars().rev().take(3000).collect::<String>().chars().rev().collect();
let follow = format!(
"{dir}{intensity_dir}{doctrine}CONTINUE the recon — this is round {round}. Here is what recon has found so far:\n{known}\n\n\
"{dir}TIME BUDGET: you have ~{remaining} seconds remaining for recon. Be CONCISE — focus only on the highest-value leads.\n\n\
{intensity_dir}{doctrine}CONTINUE the recon this is round {round}. Here is what recon has found so far:\n{known}\n\n\
Now EXPAND: pick the most promising leads and go deeper resolve & probe any NEW subdomains/hosts, crawl \
and harvest URLs for endpoints not yet mapped, run content/parameter discovery where you saw interesting \
paths, fingerprint exact versions of anything unclear, and enumerate the API/GraphQL further. Install any \
@@ -1813,7 +1962,11 @@ async fn deep_recon(cfg: &RunConfig, pool: &ModelPool, probe_facts: &str, tx: &S
if novel.len() > 20 { let _ = tx.send(format!("recon round {round} via {} — expanded surface", m.label())).await; accum.push_str(&format!("\n\nMODEL RECON (round {round}):\n{novel}")); }
else { let _ = tx.send(format!("recon round {round}: no new surface — recon converged")).await; break; }
}
Err(e) => { let _ = tx.send(format!("recon round {round} failed ({e})")).await; break; }
Err(e) => {
let is_auth = crate::pool::is_auth_failure(&e);
let _ = tx.send(format!("recon round {round} {} ({e})", if is_auth { "auth failed" } else { "failed" })).await;
break;
}
}
}
accum
+85 -19
View File
@@ -20,6 +20,24 @@ pub fn is_exhaustion(e: &anyhow::Error) -> bool {
.any(|k| s.contains(k))
}
/// Does this error look like an **authentication / authorization failure**
/// (revoked OAuth, expired session, invalid API key) — distinct from transient
/// quota/rate issues? Auth failures are non-recoverable without re-login, so
/// the run should pause immediately and offer fallback providers.
pub fn is_auth_failure(e: &anyhow::Error) -> bool {
let s = format!("{e:#}").to_lowercase();
[
"401", "403", "unauthorized", "token has been revoked",
"token revoked", "access token", "oauth", "session expired",
"not authenticated", "not logged in", "please log in",
"please login", "invalid api key", "invalid_api_key",
"api key expired", "authentication failed", "failed to authenticate",
"run /login",
]
.iter()
.any(|k| s.contains(k))
}
/// Task type used by the model router to pick the best model for the step.
#[derive(Clone, Copy, Debug)]
pub enum Task {
@@ -64,6 +82,10 @@ pub struct ModelPool {
/// Fallback models the user added via `/continue <provider:model>` while
/// paused — tried first on the next attempt.
fallback: Arc<Mutex<Vec<ModelRef>>>,
/// Circuit breaker: consecutive auth/exhaustion failures across agents.
/// When this exceeds `AUTH_FAIL_THRESHOLD`, the pool auto-pauses instead of
/// burning through the remaining agents on a dead token.
consecutive_auth_fails: Arc<std::sync::atomic::AtomicUsize>,
}
impl ModelPool {
@@ -96,9 +118,20 @@ impl ModelPool {
paused: Arc::new(AtomicBool::new(false)),
resume: Arc::new(Notify::new()),
fallback: Arc::new(Mutex::new(Vec::new())),
consecutive_auth_fails: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
}
}
/// Reset the consecutive auth-failure counter (called on any successful completion).
fn reset_auth_fails(&self) {
self.consecutive_auth_fails.store(0, std::sync::atomic::Ordering::Relaxed);
}
/// Increment the consecutive auth-failure counter and return the new count.
fn inc_auth_fails(&self) -> usize {
self.consecutive_auth_fails.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1
}
/// Attach a progress channel so the subscription CLI streams structured
/// activity (commands run, files read, tools called) live.
pub fn set_progress(&self, tx: tokio::sync::mpsc::Sender<String>) {
@@ -146,20 +179,32 @@ impl ModelPool {
self.paused.load(Ordering::Relaxed)
}
/// Park the run on token/quota exhaustion: keep ALL state, emit a notice,
/// and wait until the user runs `/continue` (or cancels). Returns when the
/// run should retry (pause cleared) or give up (cancelled).
async fn park_exhausted(&self, err: &anyhow::Error) {
/// Consecutive auth/quota failures needed to trip the circuit breaker and
/// auto-pause the run. Low threshold: 3 consecutive failures on the same
/// provider is enough signal that the token is dead.
const AUTH_FAIL_THRESHOLD: usize = 3;
/// Park the run on token/quota exhaustion or auth failure: keep ALL state,
/// emit a notice, and wait until the user runs `/continue` (or cancels).
/// Returns when the run should retry (pause cleared) or give up (cancelled).
async fn park_exhausted(&self, err: &anyhow::Error, is_auth: bool) {
self.paused.store(true, Ordering::Relaxed);
if let Some(tx) = self.progress() {
let msg = format!("{err:#}");
let short = msg.lines().next().unwrap_or(&msg);
let _ = tx
.send(format!(
let notice = if is_auth {
format!(
"notify: ⏸ authentication failed ({}). Run is PAUSED — all findings so far are SAFE. \
Fix: /continue <provider:model> to switch provider, or re-login and /continue.",
short.chars().take(120).collect::<String>()
)
} else {
format!(
"notify: ⏸ token/quota exhausted ({}). Run is PAUSED — type /continue when your quota renews, or switch with /model <provider:model> then /continue.",
short.chars().take(120).collect::<String>()
))
.await;
)
};
let _ = tx.send(notice).await;
}
while self.paused.load(Ordering::Relaxed) && !self.is_cancelled() {
let notified = self.resume.notified();
@@ -169,8 +214,9 @@ impl ModelPool {
}
}
if !self.is_cancelled() {
self.reset_auth_fails(); // user resumed, reset counter
if let Some(tx) = self.progress() {
let _ = tx.send("notify: ▶ resumed — retrying exhausted step.".to_string()).await;
let _ = tx.send("notify: ▶ resumed — retrying with updated credentials/model.".to_string()).await;
}
}
}
@@ -212,9 +258,9 @@ impl ModelPool {
};
match r {
Ok(t) => return Ok(t),
// Don't burn retries on exhaustion — surface it so the caller
// can park and let the user /continue.
Err(e) if is_exhaustion(&e) => return Err(e),
// Don't burn retries on exhaustion or auth failure — surface
// immediately so the caller can park and let the user /continue.
Err(e) if is_exhaustion(&e) || is_auth_failure(&e) => return Err(e),
Err(e) => last = e,
}
}
@@ -233,6 +279,20 @@ impl ModelPool {
if self.is_cancelled() {
return Err(anyhow!("cancelled"));
}
// Circuit breaker: if we've seen N consecutive auth failures across
// agents, pause immediately — don't burn another agent on a dead token.
let fail_count = self.consecutive_auth_fails.load(std::sync::atomic::Ordering::Relaxed);
if fail_count >= Self::AUTH_FAIL_THRESHOLD && !self.is_cancelled() {
self.park_exhausted(
&anyhow!("circuit breaker: {} consecutive auth failures — token/session likely dead", fail_count),
true,
).await;
if self.is_cancelled() {
return Err(anyhow!("cancelled"));
}
// After resume, retry with potentially new fallback models.
continue;
}
// User-supplied fallback models (via /continue) are tried first.
let mut order = self.route(task);
if let Ok(fb) = self.fallback.lock() {
@@ -244,25 +304,31 @@ impl ModelPool {
}
let mut last = anyhow!("no candidate models");
let mut exhausted = false;
let mut auth_failed = false;
for m in &order {
if self.is_cancelled() {
return Err(anyhow!("cancelled"));
}
match self.one(label, m, system, user).await {
Ok(text) => return Ok((m.clone(), text)),
Ok(text) => {
self.reset_auth_fails(); // success resets circuit breaker
return Ok((m.clone(), text));
}
Err(e) => {
if is_exhaustion(&e) {
if is_auth_failure(&e) {
auth_failed = true;
self.inc_auth_fails();
} else if is_exhaustion(&e) {
exhausted = true;
}
last = e;
}
}
}
// Every candidate failed. If it was token/quota exhaustion, park the
// run until the user runs /continue, then retry the whole order (now
// including any fallback model they added). Otherwise, give up.
if exhausted && !self.is_cancelled() {
self.park_exhausted(&last).await;
// Every candidate failed. Park the run (keeping all state) so the user
// can fix auth or wait for quota renewal, then /continue.
if (auth_failed || exhausted) && !self.is_cancelled() {
self.park_exhausted(&last, auth_failed).await;
continue;
}
return Err(last);
+41 -1
View File
@@ -413,11 +413,51 @@ pub fn json_report(target: &str, findings: &[Finding], run_id: &str, meta: &Enga
/// Write the full report bundle: Markdown, JSON, HTML, and the Typst/PDF.
/// Returns the primary artifact path (PDF if typst present, else the .typ).
/// A "## Reproduction — PoC scripts" section listing the runnable proof-of-concept
/// scripts agents wrote to `<run>/pocs/`. Each is a self-contained artifact the
/// operator can re-run to replicate a finding, so the report ships with a live
/// reproduction kit — not just prose. Empty string when no PoCs were produced.
pub fn pocs_section(dir: &Path) -> String {
let pocs = dir.join("pocs");
let mut entries: Vec<(String, String)> = Vec::new();
if let Ok(rd) = std::fs::read_dir(&pocs) {
for e in rd.flatten() {
let p = e.path();
if !p.is_file() { continue; }
let name = match p.file_name().and_then(|s| s.to_str()) { Some(n) => n.to_string(), None => continue };
// First non-empty comment line doubles as a one-line description.
let desc = std::fs::read_to_string(&p).ok()
.and_then(|t| t.lines()
.map(|l| l.trim())
.find(|l| l.starts_with('#') || l.starts_with("//") || l.starts_with("/*"))
.map(|l| l.trim_start_matches(['#', '/', '*', ' ']).trim().to_string()))
.unwrap_or_default();
entries.push((name, desc));
}
}
if entries.is_empty() { return String::new(); }
entries.sort();
let mut s = String::from("## Reproduction — PoC scripts\n\n");
s.push_str("Runnable proofs written to `pocs/` during the engagement. Re-run any of \
them to replicate the corresponding finding.\n\n");
for (name, desc) in entries {
if desc.is_empty() {
s.push_str(&format!("- `pocs/{name}`\n"));
} else {
s.push_str(&format!("- `pocs/{name}` — {desc}\n"));
}
}
s.push('\n');
s
}
pub fn write_all(target: &str, findings: &[Finding], dir: &Path) -> std::io::Result<PathBuf> {
std::fs::create_dir_all(dir)?;
let run_id = dir.file_name().and_then(|s| s.to_str()).unwrap_or("run").to_string();
let meta = read_meta(dir);
std::fs::write(dir.join("report.md"), markdown(target, findings, &meta))?;
let mut md = markdown(target, findings, &meta);
md.push_str(&pocs_section(dir));
std::fs::write(dir.join("report.md"), md)?;
std::fs::write(dir.join("report.json"), json_report(target, findings, &run_id, &meta))?;
std::fs::write(dir.join("report.html"), html(target, findings, &meta))?;
typst_report(target, findings, dir)
+2 -2
View File
@@ -28,7 +28,7 @@ cat <<'BANNER'
███╗ ██╗███████╗██╗ ██╗██████╗ ██████╗
████╗ ██║██╔════╝██║ ██║██╔══██╗██╔═══██╗ NeuroSploit installer
██╔██╗ ██║█████╗ ██║ ██║██████╔╝██║ ██║ v3.6.1 — Rust harness
██╔██╗ ██║█████╗ ██║ ██║██████╔╝██║ ██║ v3.6.9 — Rust harness
██║╚██╗██║██╔══╝ ██║ ██║██╔══██╗██║ ██║ by Joas A Santos
██║ ╚████║███████╗╚██████╔╝██║ ██║╚██████╔╝ & Red Team Leaders
╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝
@@ -63,7 +63,7 @@ if [ -z "$REF" ]; then
REF="$(dl "https://api.github.com/repos/${REPO_SLUG}/releases/latest" /dev/stdout 2>/dev/null \
| grep -m1 '"tag_name"' | sed -E 's/.*"tag_name" *: *"([^"]+)".*/\1/' || true)"
fi
[ -z "$REF" ] && REF="v3.6.1"
[ -z "$REF" ] && REF="v3.6.9"
say "Release: $REF"
installed=0