mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-15 14:10:22 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c311017936 |
@@ -17,15 +17,7 @@ ANTHROPIC_API_KEY=
|
|||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
# gemini: https://aistudio.google.com/app/apikey
|
# gemini: https://aistudio.google.com/app/apikey
|
||||||
# (GOOGLE_API_KEY is also accepted as an alias if GEMINI_API_KEY is unset)
|
|
||||||
GEMINI_API_KEY=
|
GEMINI_API_KEY=
|
||||||
#GOOGLE_API_KEY=
|
|
||||||
|
|
||||||
# azure: Azure OpenAI (OpenAI-compatible). Use `--model azure:<deployment>`
|
|
||||||
# (the model name is your Azure *deployment* name).
|
|
||||||
#AZURE_OPENAI_API_KEY=
|
|
||||||
#AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com
|
|
||||||
#AZURE_OPENAI_API_VERSION=2024-10-21
|
|
||||||
|
|
||||||
# xai: https://console.x.ai/
|
# xai: https://console.x.ai/
|
||||||
XAI_API_KEY=
|
XAI_API_KEY=
|
||||||
|
|||||||
@@ -104,7 +104,3 @@ data/repl_runs.json
|
|||||||
data/repl_history.txt
|
data/repl_history.txt
|
||||||
.neurosploit/
|
.neurosploit/
|
||||||
/tmp/*
|
/tmp/*
|
||||||
|
|
||||||
# Cloned source repos (whitebox/greybox from a git URL)
|
|
||||||
repos/
|
|
||||||
neurosploit-rs/repos/
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<h1 align="center">🧠 NeuroSploit v3.5.4</h1>
|
<h1 align="center">🧠 NeuroSploit v3.5.1</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/JoasASantos/NeuroSploit/stargazers"><img src="https://img.shields.io/github/stars/JoasASantos/NeuroSploit?style=for-the-badge&logo=github&color=8b5cf6" alt="Stars"></a>
|
<a href="https://github.com/JoasASantos/NeuroSploit/stargazers"><img src="https://img.shields.io/github/stars/JoasASantos/NeuroSploit?style=for-the-badge&logo=github&color=8b5cf6" alt="Stars"></a>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Version-3.5.4-blue?style=flat-square">
|
<img src="https://img.shields.io/badge/Version-3.5.1-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-329-red?style=flat-square">
|
<img src="https://img.shields.io/badge/MD%20Agents-329-red?style=flat-square">
|
||||||
@@ -24,15 +24,6 @@
|
|||||||
>
|
>
|
||||||
> 📖 **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.5.4 — Robust attack chaining + fewer false positives:** a
|
|
||||||
> multi-round, decision-driven **post-exploitation** engine takes each confirmed
|
|
||||||
> foothold and expands new directions (cred reuse, privesc, lateral movement,
|
|
||||||
> exfil, new surface), carrying **loot** forward across rounds (`--chain-depth`).
|
|
||||||
> Validation is now **severity-aware** (High/Critical need ≥2 validators & ≥2/3
|
|
||||||
> agreement) with an **adversarial refute pass** that drops findings that can't
|
|
||||||
> withstand a skeptic.
|
|
||||||
> *(v3.5.3 added GitHub/GitLab/Jira **[integrations](TUTORIAL-INTEGRATION.md)**; v3.5.2 the DEPTH doctrine + report-hygiene pass — see [RELEASE.md](RELEASE.md).)*
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**NeuroSploit** turns a URL, a source repository, a running app, or a host/IP into
|
**NeuroSploit** turns a URL, a source repository, a running app, or a host/IP into
|
||||||
@@ -151,41 +142,6 @@ No login? Use an **API key** instead — see [Authentication](#authentication--r
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔌 Integrations (GitHub · GitLab · Jira)
|
|
||||||
|
|
||||||
Wire NeuroSploit into your SDLC. Toggle from the REPL (`/integrations`) or the CLI
|
|
||||||
(`neurosploit integrations enable github|gitlab|jira`). **Tokens are never stored**
|
|
||||||
— only the *name* of the env var is saved; the value is read from your environment.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export GITHUB_TOKEN=ghp_... # PAT with `repo` scope (private repos)
|
|
||||||
neurosploit integrations enable github
|
|
||||||
|
|
||||||
# Review a Pull Request's code (clones the PR head, white-box) and comment back:
|
|
||||||
neurosploit pr digininja/DVWA 42 --subscription --model anthropic:claude-opus-4-8 --comment
|
|
||||||
|
|
||||||
# Watch a branch and re-review on every new commit:
|
|
||||||
neurosploit watch myorg/private-app --branch main --subscription --model anthropic:claude-opus-4-8
|
|
||||||
|
|
||||||
# Private GitLab repo (token-injected clone) — works in whitebox/greybox:
|
|
||||||
export GITLAB_TOKEN=glpat-... ; neurosploit integrations enable gitlab
|
|
||||||
neurosploit whitebox https://gitlab.com/myorg/private-svc --subscription --model anthropic:claude-opus-4-8
|
|
||||||
|
|
||||||
# Open a Jira card per finding (any engagement):
|
|
||||||
export JIRA_EMAIL=you@org.com JIRA_API_TOKEN=... # set base/project once: /integrations setup jira
|
|
||||||
neurosploit whitebox https://github.com/myorg/app --jira --subscription --model anthropic:claude-opus-4-8
|
|
||||||
```
|
|
||||||
|
|
||||||
| Integration | What you get | Env vars |
|
|
||||||
|-------------|--------------|----------|
|
|
||||||
| **GitHub** | private clone · `pr` review + comment · `watch` branch | `GITHUB_TOKEN` |
|
|
||||||
| **GitLab** | private clone for whitebox/greybox | `GITLAB_TOKEN` |
|
|
||||||
| **Jira** | one card per finding (`--jira`) | `JIRA_EMAIL`, `JIRA_API_TOKEN` |
|
|
||||||
|
|
||||||
📖 Step-by-step setup for each tool: **[TUTORIAL-INTEGRATION.md](TUTORIAL-INTEGRATION.md)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
-191
@@ -1,194 +1,3 @@
|
|||||||
# NeuroSploit v3.5.4 — Release Notes
|
|
||||||
|
|
||||||
**Release Date:** July 2026
|
|
||||||
**Codename:** Robust Attack Chaining & False-Positive Reduction
|
|
||||||
**License:** MIT
|
|
||||||
**Credits:** Joas A Santos & Red Team Leaders
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TL;DR
|
|
||||||
|
|
||||||
v3.5.4 makes NeuroSploit both **deeper** and **more precise**: a real multi-round
|
|
||||||
**post-exploitation attack-chaining** engine that expands each foothold in new
|
|
||||||
directions, plus stronger **false-positive** controls so what it reports is
|
|
||||||
trustworthy.
|
|
||||||
|
|
||||||
## Attack chaining (robust, decision-driven)
|
|
||||||
|
|
||||||
Replaces the old single-shot chainer with **`attack_chain()`** — an iterative,
|
|
||||||
per-foothold pivot engine:
|
|
||||||
|
|
||||||
- **Per-foothold decisions.** Each round takes the newest confirmed footholds
|
|
||||||
(best-first, capped per round) and, for **each one**, an agent decides which
|
|
||||||
directions to expand and proves 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** the foothold exposes.
|
|
||||||
- **Loot carried forward.** Credentials/tokens/hosts/endpoints discovered in one
|
|
||||||
round are passed to later rounds and reused (agent returns
|
|
||||||
`{"findings":[...],"loot":[...]}`), so the engine genuinely pivots in new
|
|
||||||
directions instead of re-testing the same spot.
|
|
||||||
- **No pivoting off false positives.** Each round's new findings are validated
|
|
||||||
before they become the next round's footholds.
|
|
||||||
- **Convergence.** Runs up to `chain_depth` rounds **or** stops when a round finds
|
|
||||||
nothing new (loop-until-dry).
|
|
||||||
- **Control.** New `RunConfig.chain_depth` (default **2**) and a `--chain-depth`
|
|
||||||
flag on every engagement command (`0` disables).
|
|
||||||
|
|
||||||
## False-positive reduction
|
|
||||||
|
|
||||||
- **Robust verdict parsing** (`pool::parse_verdict`) — whitespace-insensitive,
|
|
||||||
checks explicit rejection first, counts only explicit confirmations; ambiguous
|
|
||||||
replies are *not* counted as confirmed. Replaces the fragile exact-JSON /
|
|
||||||
loose-`yes` matching.
|
|
||||||
- **Severity-aware quorum** (`pool::quorum_confirmed`) — **High/Critical now need
|
|
||||||
≥2 validators AND ≥2/3 agreement** (a single vote can no longer confirm a
|
|
||||||
Critical); lower severities need a strict majority. Single-model panels fall
|
|
||||||
back to majority so they aren't nuked.
|
|
||||||
- **Adversarial refute pass** — every confirmed High/Critical is re-examined by a
|
|
||||||
skeptical panel that assumes false-positive; findings that can't withstand a
|
|
||||||
majority of skeptics are dropped.
|
|
||||||
- **Stronger validator prompt** with an explicit false-positive checklist
|
|
||||||
(reflected-not-executed, version/banner guesses, self-XSS, error-as-injection,
|
|
||||||
thin evidence, inflated severity).
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Additive and back-compatible; defaults keep behavior sensible if you change
|
|
||||||
nothing. Unit tests cover verdict parsing, quorum, and report-hygiene logic.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# NeuroSploit v3.5.3 — Release Notes
|
|
||||||
|
|
||||||
**Release Date:** June 2026
|
|
||||||
**Codename:** Integrations (GitHub · GitLab · Jira)
|
|
||||||
**License:** MIT
|
|
||||||
**Credits:** Joas A Santos & Red Team Leaders
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TL;DR
|
|
||||||
|
|
||||||
v3.5.3 plugs NeuroSploit into your SDLC: review **private** GitHub/GitLab repos
|
|
||||||
and **Pull Requests**, **watch** a branch and re-review on every commit, and open
|
|
||||||
a **Jira card per finding** — all toggleable via a new `/integrations` command.
|
|
||||||
|
|
||||||
## Highlights
|
|
||||||
|
|
||||||
- **GitHub integration**
|
|
||||||
- **Private repos**: when enabled, `whitebox` / `greybox --repo` / `tui --repo`
|
|
||||||
inject your `GITHUB_TOKEN` into the clone URL (token never printed/stored).
|
|
||||||
- **`neurosploit pr <owner/repo> <number>`** — clones the **PR head**
|
|
||||||
(`refs/pull/N/head`), runs a white-box review, optionally **posts a summary
|
|
||||||
comment** back on the PR (`--comment`) and/or **opens Jira cards** (`--jira`).
|
|
||||||
- **`neurosploit watch <owner/repo> --branch <b> --interval <s>`** — polls the
|
|
||||||
branch and runs a white-box review **each time a new commit lands**.
|
|
||||||
- **GitLab integration** — private clone (token-injected) for `whitebox`/`greybox`
|
|
||||||
against `gitlab.com` or a self-hosted base.
|
|
||||||
- **Jira integration** — `--jira` on any engagement (or `pr`/`watch`) opens **one
|
|
||||||
card per finding** (summary, severity, CVSS, CWE, location, PoC, evidence,
|
|
||||||
remediation) in your project via the Jira REST API.
|
|
||||||
- **`/integrations` (REPL) + `neurosploit integrations` (CLI)** — `show`,
|
|
||||||
`enable`/`disable <github|gitlab|jira>`, and `setup <jira|gitlab|github>`
|
|
||||||
(interactive). Config persists to `<project>/.neurosploit/integrations.json`.
|
|
||||||
**Secrets are never stored** — only the env-var *name* is saved; values come
|
|
||||||
from the environment at use time.
|
|
||||||
- New harness module `integrations` + app commands `pr` / `watch` /
|
|
||||||
`integrations`, plus a `--jira` flag on `run` / `whitebox`.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
Step-by-step for tokens, scopes and configuration is in
|
|
||||||
**[TUTORIAL-INTEGRATION.md](TUTORIAL-INTEGRATION.md)** and summarized in the README.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Additive and back-compatible: all existing modes/flags are unchanged; if no
|
|
||||||
integration is enabled the behavior is identical to v3.5.2.
|
|
||||||
- Tokens use env vars: `GITHUB_TOKEN`, `GITLAB_TOKEN`, `JIRA_EMAIL` +
|
|
||||||
`JIRA_API_TOKEN` (names configurable per integration).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# NeuroSploit v3.5.2 — Release Notes
|
|
||||||
|
|
||||||
**Release Date:** June 2026
|
|
||||||
**Codename:** Exploitation Depth & Report Hygiene
|
|
||||||
**License:** MIT
|
|
||||||
**Credits:** Joas A Santos & Red Team Leaders
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TL;DR
|
|
||||||
|
|
||||||
v3.5.2 hard-codes the discipline that separates a great pentest from a noisy
|
|
||||||
one — distilled from reviewing real AI-pentest output that kept stopping at
|
|
||||||
*"exposed"* instead of *"exploited"*. The engine now pushes every exposure to
|
|
||||||
demonstrated impact, **chains** findings, decodes/fingerprints artifacts and
|
|
||||||
correlates CVEs, audits tokens, and keeps the final report honest (deduplicated
|
|
||||||
and severity-calibrated).
|
|
||||||
|
|
||||||
## Highlights
|
|
||||||
|
|
||||||
- **DEPTH doctrine (exploit, don't just expose).** A new doctrine is injected
|
|
||||||
into every exploitation prompt (black/grey/chain): any info-disclosure,
|
|
||||||
exposed service/catalog/WSDL, leaked credential/token, or reachable dev host
|
|
||||||
**must be USED** before it can be a finding — call it, decode it, log in, hit
|
|
||||||
the dev host. If it was only observed, it's reported as a **lead**, not a
|
|
||||||
confirmed High/Critical.
|
|
||||||
- **Finding chaining.** Reuse any session/JWT/cookie/credential obtained in one
|
|
||||||
step across all other modules; pivot access into IDOR/privesc/exfil and report
|
|
||||||
the **chain**, not isolated parts (e.g. captcha-bypass→admin JWT→authenticated
|
|
||||||
surface; enum + no-rate-limit→password spraying).
|
|
||||||
- **Decode & fingerprint → CVE.** Decode opaque tokens/paths (base64/JSON/marshal)
|
|
||||||
and pin exact library/gem/plugin/CMS versions, then correlate to known CVEs and
|
|
||||||
attempt a safe PoC.
|
|
||||||
- **Token auditor.** JWT alg-confusion (RS→HS), `alg:none`, kid/jku injection,
|
|
||||||
real signature verification, **weak HS256 secret cracking**, and token
|
|
||||||
lifecycle (logout/expiry/refresh).
|
|
||||||
- **Report-hygiene & depth pass (deterministic, in the harness).** After
|
|
||||||
validation the run now:
|
|
||||||
- **calibrates severity to proven impact** — an unproven High/Critical
|
|
||||||
(hedged language, no payload, thin evidence) is capped to Medium and
|
|
||||||
re-titled "(potential)";
|
|
||||||
- flags **"exposed → exploited" gaps** — exposures on a host with no actual
|
|
||||||
exploit get an advisory to go use them;
|
|
||||||
- advises **consolidating hygiene** classes (headers/cookies/TLS/HSTS/
|
|
||||||
clickjacking/disclosure) repeated across many assets into ONE finding with
|
|
||||||
an affected-asset table, instead of inflating the count one-per-host.
|
|
||||||
- **5 new doctrine meta-agents** (`agents_md/meta/`): `exploit_depth_doctrine`,
|
|
||||||
`finding_chainer`, `artifact_decoder`, `token_auditor`, `report_calibrator`
|
|
||||||
(meta agents 17 → 22; total library 343 → 348).
|
|
||||||
- **Source from a GitHub URL.** `whitebox` / `greybox --repo` (and the REPL
|
|
||||||
`/repo`) now accept a **git URL** (`https://github.com/owner/repo[.git]`) or an
|
|
||||||
`owner/repo` shorthand — the repo is cloned (shallow) into `<base>/repos/` and
|
|
||||||
reviewed automatically, no manual `git clone` needed:
|
|
||||||
```bash
|
|
||||||
neurosploit whitebox https://github.com/digininja/DVWA \
|
|
||||||
--subscription --model anthropic:claude-opus-4-8 -v
|
|
||||||
```
|
|
||||||
- **Azure OpenAI provider** (resolves #21). OpenAI-compatible: set
|
|
||||||
`AZURE_OPENAI_ENDPOINT` (+ optional `AZURE_OPENAI_API_VERSION`, default
|
|
||||||
`2024-10-21`) and `AZURE_OPENAI_API_KEY`, then `--model azure:<deployment>`
|
|
||||||
(the model name is your Azure *deployment* name; auth via the `api-key`
|
|
||||||
header).
|
|
||||||
- **`GOOGLE_API_KEY` alias for Gemini** (resolves #25 confusion). Gemini's API
|
|
||||||
path reads `GEMINI_API_KEY`, and now also accepts `GOOGLE_API_KEY` (Google's
|
|
||||||
standard env var) when the former is unset. Local providers (ollama/litellm)
|
|
||||||
still need **no** key at all.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Pure-additive and back-compatible: existing modes, REPL, TUI, pause/continue,
|
|
||||||
crash-recovery and reports are unchanged. The hygiene pass only annotates and
|
|
||||||
down-calibrates unproven severities — it never invents or drops findings.
|
|
||||||
- New unit tests cover the calibration and depth-audit logic
|
|
||||||
(`harness::hygiene`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# NeuroSploit v3.5.1 — Release Notes
|
# NeuroSploit v3.5.1 — Release Notes
|
||||||
|
|
||||||
**Release Date:** June 2026
|
**Release Date:** June 2026
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
# NeuroSploit — Integrations Setup Guide (v3.5.3)
|
|
||||||
|
|
||||||
Connect NeuroSploit to **GitHub**, **GitLab** and **Jira** so it can review private
|
|
||||||
repositories and Pull Requests, watch branches for new code, and file a Jira
|
|
||||||
**card per vulnerability**.
|
|
||||||
|
|
||||||
> ⚠️ **Authorized testing only.** Use integrations against code/projects you own or
|
|
||||||
> are explicitly permitted to test.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Table of contents
|
|
||||||
1. [How it works (config & secrets)](#1-how-it-works)
|
|
||||||
2. [The `/integrations` command](#2-the-integrations-command)
|
|
||||||
3. [GitHub](#3-github)
|
|
||||||
4. [GitLab](#4-gitlab)
|
|
||||||
5. [Jira](#5-jira)
|
|
||||||
6. [Recipes](#6-recipes)
|
|
||||||
7. [Troubleshooting](#7-troubleshooting)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. How it works
|
|
||||||
|
|
||||||
- Integration config is **per project**, stored at
|
|
||||||
`<cwd>/.neurosploit/integrations.json`.
|
|
||||||
- **Secrets are never written to disk.** The config only stores the **name** of
|
|
||||||
the environment variable that holds each token (e.g. `GITHUB_TOKEN`). The real
|
|
||||||
value is read from your environment at use time. Keep tokens in your shell /
|
|
||||||
secret manager, not in the repo.
|
|
||||||
- Enable/disable per integration; each is independent.
|
|
||||||
|
|
||||||
Default env-var names (configurable):
|
|
||||||
|
|
||||||
| Integration | Token env var(s) |
|
|
||||||
|-------------|------------------|
|
|
||||||
| GitHub | `GITHUB_TOKEN` |
|
|
||||||
| GitLab | `GITLAB_TOKEN` |
|
|
||||||
| Jira | `JIRA_EMAIL` + `JIRA_API_TOKEN` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. The `/integrations` command
|
|
||||||
|
|
||||||
In the **REPL** (`neurosploit` with no args):
|
|
||||||
|
|
||||||
```
|
|
||||||
/integrations # show status of all three
|
|
||||||
/integrations enable github # toggle on (also: gitlab | jira)
|
|
||||||
/integrations disable jira # toggle off
|
|
||||||
/integrations setup jira # interactive: base URL, project key, issue type
|
|
||||||
/integrations setup gitlab # set the GitLab base (gitlab.com or self-hosted)
|
|
||||||
/integrations setup github # set the API base (change only for GitHub Enterprise)
|
|
||||||
```
|
|
||||||
|
|
||||||
From the **CLI**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
neurosploit integrations # show status
|
|
||||||
neurosploit integrations enable github # enable / disable <github|gitlab|jira>
|
|
||||||
```
|
|
||||||
|
|
||||||
`show` prints whether each is on and whether the token env var is currently set
|
|
||||||
(`✓ token` / `⚠ token env not set`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. GitHub
|
|
||||||
|
|
||||||
**a. Create a token.** GitHub → *Settings → Developer settings → Personal access
|
|
||||||
tokens*. A classic PAT with the **`repo`** scope (read access to the private repos
|
|
||||||
you'll test) is enough. Fine-grained tokens also work (grant *Contents: Read* and,
|
|
||||||
for PR comments, *Pull requests: Read & write*).
|
|
||||||
|
|
||||||
**b. Export it and enable:**
|
|
||||||
```bash
|
|
||||||
export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
|
|
||||||
neurosploit integrations enable github
|
|
||||||
```
|
|
||||||
|
|
||||||
**c. What you can now do:**
|
|
||||||
|
|
||||||
- **Clone & review a private repo** (token is injected into the clone URL,
|
|
||||||
never printed):
|
|
||||||
```bash
|
|
||||||
neurosploit whitebox https://github.com/myorg/private-app \
|
|
||||||
--subscription --model anthropic:claude-opus-4-8 -v
|
|
||||||
```
|
|
||||||
- **Review a Pull Request's code** — clones the PR head (`refs/pull/N/head`):
|
|
||||||
```bash
|
|
||||||
neurosploit pr myorg/private-app 128 \
|
|
||||||
--subscription --model anthropic:claude-opus-4-8 --comment
|
|
||||||
```
|
|
||||||
- `--comment` posts a Markdown findings summary back on the PR.
|
|
||||||
- `--jira` also opens a card per finding (needs Jira configured).
|
|
||||||
- **Watch a branch** and re-review on every new commit:
|
|
||||||
```bash
|
|
||||||
neurosploit watch myorg/private-app --branch main --interval 300 \
|
|
||||||
--subscription --model anthropic:claude-opus-4-8
|
|
||||||
```
|
|
||||||
It polls the branch tip via the GitHub API and runs a white-box review whenever
|
|
||||||
the SHA changes (Ctrl-C to stop).
|
|
||||||
|
|
||||||
**GitHub Enterprise:** `/integrations setup github` and set the API base to your
|
|
||||||
GHE URL (e.g. `https://ghe.mycorp.com/api/v3`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. GitLab
|
|
||||||
|
|
||||||
**a. Create a token.** GitLab → *Preferences → Access Tokens* (or a project/group
|
|
||||||
token) with the **`read_repository`** scope (add `api` if you want more later).
|
|
||||||
|
|
||||||
**b. Export it and enable:**
|
|
||||||
```bash
|
|
||||||
export GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx
|
|
||||||
neurosploit integrations enable gitlab
|
|
||||||
# self-hosted? set the base:
|
|
||||||
# /integrations setup gitlab → https://gitlab.mycorp.com
|
|
||||||
```
|
|
||||||
|
|
||||||
**c. Review a private GitLab repo** (token-injected clone, works in whitebox &
|
|
||||||
greybox):
|
|
||||||
```bash
|
|
||||||
neurosploit whitebox https://gitlab.com/myorg/private-svc \
|
|
||||||
--subscription --model anthropic:claude-opus-4-8 -v
|
|
||||||
```
|
|
||||||
|
|
||||||
> To review a specific Merge Request, check out its source branch and point
|
|
||||||
> `whitebox` at that clone, or pass the MR source branch URL.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Jira
|
|
||||||
|
|
||||||
**a. Create an API token.** https://id.atlassian.com/manage-profile/security/api-tokens
|
|
||||||
→ *Create API token*. Note the email of the Atlassian account that owns it.
|
|
||||||
|
|
||||||
**b. Export credentials:**
|
|
||||||
```bash
|
|
||||||
export JIRA_EMAIL=you@yourorg.com
|
|
||||||
export JIRA_API_TOKEN=xxxxxxxxxxxxxxxxxxxx
|
|
||||||
```
|
|
||||||
|
|
||||||
**c. Configure base URL + project (once):**
|
|
||||||
```
|
|
||||||
# in the REPL:
|
|
||||||
/integrations setup jira
|
|
||||||
Jira base URL (https://your-org.atlassian.net): https://yourorg.atlassian.net
|
|
||||||
Jira project key (e.g. SEC): SEC
|
|
||||||
Issue type [Bug]: Bug
|
|
||||||
```
|
|
||||||
This enables Jira and saves the base URL / project key / issue type to
|
|
||||||
`.neurosploit/integrations.json` (no secrets).
|
|
||||||
|
|
||||||
**d. Open cards.** Add `--jira` to any engagement (or `pr` / `watch`). One card is
|
|
||||||
created per **validated** finding, with severity, CVSS, CWE, location, PoC,
|
|
||||||
evidence and remediation:
|
|
||||||
```bash
|
|
||||||
neurosploit whitebox https://github.com/myorg/app --jira \
|
|
||||||
--subscription --model anthropic:claude-opus-4-8 -v
|
|
||||||
```
|
|
||||||
The created issue keys are printed (e.g. `🪪 Jira cards opened: SEC-481, SEC-482`).
|
|
||||||
|
|
||||||
> Uses the Jira REST API (`POST /rest/api/2/issue`) with Basic auth
|
|
||||||
> (`JIRA_EMAIL` : `JIRA_API_TOKEN`). The `issuetype` must exist in your project
|
|
||||||
> (use `Vulnerability` if your project defines it).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Recipes
|
|
||||||
|
|
||||||
**PR gate in CI** (block a PR if Critical/High findings appear):
|
|
||||||
```bash
|
|
||||||
export GITHUB_TOKEN=... # CI secret
|
|
||||||
neurosploit integrations enable github
|
|
||||||
neurosploit pr "$REPO" "$PR_NUMBER" --model anthropic:claude-opus-4-8 --comment --jira
|
|
||||||
```
|
|
||||||
|
|
||||||
**Nightly drift review** of a private app, filing Jira cards:
|
|
||||||
```bash
|
|
||||||
neurosploit integrations enable github
|
|
||||||
neurosploit integrations enable jira
|
|
||||||
neurosploit watch myorg/app --branch main --interval 3600 --jira \
|
|
||||||
--model anthropic:claude-opus-4-8
|
|
||||||
```
|
|
||||||
|
|
||||||
**Local private-repo audit** (no PR), cards to Jira:
|
|
||||||
```bash
|
|
||||||
neurosploit whitebox https://github.com/myorg/app --jira \
|
|
||||||
--subscription --model anthropic:claude-opus-4-8 -v
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Troubleshooting
|
|
||||||
|
|
||||||
- **`⚠ token env not set`** — the integration is enabled but the env var isn't
|
|
||||||
exported in this shell. Export it (`export GITHUB_TOKEN=...`) and re-run.
|
|
||||||
- **`git clone failed` on a private repo** — confirm the token scope (`repo` /
|
|
||||||
`read_repository`) and that the integration is enabled (`neurosploit
|
|
||||||
integrations`). The token is only injected when the matching integration is on.
|
|
||||||
- **`jira create failed: 400`** — the `issuetype` name doesn't exist in the
|
|
||||||
project, or a required field is enforced. Try `Bug`, or set your project's type
|
|
||||||
via `/integrations setup jira`.
|
|
||||||
- **`jira ... not set`** — export `JIRA_EMAIL` and `JIRA_API_TOKEN`.
|
|
||||||
- **GitHub comment fails (403/404)** — the token needs *Pull requests: write*
|
|
||||||
(fine-grained) or `repo` (classic), and you must have access to the repo.
|
|
||||||
- **Tokens in CI** — pass them as masked secrets; NeuroSploit never logs or
|
|
||||||
stores token values.
|
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
# NeuroSploit — Tutorial & User Guide (v3.5.4)
|
# NeuroSploit — Tutorial & User Guide (v3.5.1)
|
||||||
|
|
||||||
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.5.4
|
neurosploit --version # neurosploit 3.5.1
|
||||||
neurosploit agents # {"vulns":196,...,"chains":12,"total":329}
|
neurosploit agents # {"vulns":196,...,"chains":12,"total":329}
|
||||||
neurosploit models # all providers & models
|
neurosploit models # all providers & models
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
# Artifact Decoder & CVE Correlator Agent
|
|
||||||
|
|
||||||
> Meta-agent (v3.5.2 doctrine). Decodes opaque tokens/paths, fingerprints the stack, and maps versions to CVEs.
|
|
||||||
|
|
||||||
## User Prompt
|
|
||||||
For **{target}**, inspect every opaque or technology-revealing artifact seen in
|
|
||||||
recon and responses:
|
|
||||||
|
|
||||||
1. **Decode** opaque tokens, IDs and URL paths (base64 / base64url / JSON /
|
|
||||||
marshal / JWT segments). A decoded value often reveals the framework or an
|
|
||||||
internal file path (e.g. a Dragonfly job `[["f","...file"]]`, a signed-URL
|
|
||||||
structure, a serialized object).
|
|
||||||
2. **Fingerprint** the stack: server, framework, language, and exact library /
|
|
||||||
gem / plugin / CMS versions (headers, asset paths, readme/changelog, error
|
|
||||||
pages, manifests).
|
|
||||||
3. **Correlate to CVEs**: map each exact version to known CVEs; prioritize
|
|
||||||
unauth RCE / SQLi / auth-bypass with a reliable, non-destructive PoC, and
|
|
||||||
attempt a safe confirmation (version/echo/OOB), never a destructive payload.
|
|
||||||
|
|
||||||
Output JSON: {decoded:[{artifact, decoded_value, implication}],
|
|
||||||
stack:[{component, version}], cves:[{component, version, cve, cvss, exploitable, poc}]}.
|
|
||||||
|
|
||||||
## System Prompt
|
|
||||||
You decode the opaque and correlate the obvious. Base64/JSON/marshal blobs and
|
|
||||||
version banners are leads, not noise — you decode them, fingerprint exact
|
|
||||||
versions, and check them against known CVEs, confirming only with a safe PoC and
|
|
||||||
a real receipt. Authorized engagement; no destructive or DoS actions. Credits: Joas A Santos and Red Team Leaders.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Exploitation Depth Doctrine Agent
|
|
||||||
|
|
||||||
> Meta-agent (v3.5.2 doctrine). Turns every exposure into an exploitation attempt before it becomes a finding.
|
|
||||||
|
|
||||||
## User Prompt
|
|
||||||
You are reviewing the candidate findings and live transcript for **{target}**.
|
|
||||||
|
|
||||||
For EACH candidate that merely *exposes* something (information disclosure,
|
|
||||||
exposed service/catalog/WSDL, leaked credential or token, reachable dev/staging
|
|
||||||
host, permissive CORS, open .git), drive it one step further BEFORE it is
|
|
||||||
reported:
|
|
||||||
|
|
||||||
1. **Use what was exposed.** Call the exposed endpoint, decode the leaked
|
|
||||||
artifact, log in with the leaked credential, hit the dev host, send the
|
|
||||||
cross-origin request. Capture the real request/response.
|
|
||||||
2. **Decide honestly.** If using it proved impact → keep/raise severity with the
|
|
||||||
new evidence. If it could not be used → down-rate to a LEAD (low confidence),
|
|
||||||
never a confirmed High/Critical.
|
|
||||||
3. **Report the gap.** List any exposure you could not yet exploit, with the
|
|
||||||
exact next command to try, so the next round (or the human) can finish it.
|
|
||||||
|
|
||||||
Output JSON: {"escalations":[{id, action_taken, new_evidence, new_severity}],
|
|
||||||
"leads":[{id, why_not_proven, next_command}]}.
|
|
||||||
|
|
||||||
## System Prompt
|
|
||||||
You are a senior exploitation lead. Detection is not a finding — impact is. You
|
|
||||||
never let an info-disclosure, exposed service, leaked secret or reachable
|
|
||||||
non-prod host be reported as confirmed without an attempt to actually use it,
|
|
||||||
backed by a real tool receipt. Unproven impact is a lead, not a High. Authorized
|
|
||||||
engagement; no destructive or DoS actions. Credits: Joas A Santos and Red Team Leaders.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# Finding Chainer Agent
|
|
||||||
|
|
||||||
> Meta-agent (v3.5.2 doctrine). Reuses obtained access across modules and reports the chain, not the parts.
|
|
||||||
|
|
||||||
## User Prompt
|
|
||||||
Given the confirmed findings and any sessions/tokens/credentials obtained during
|
|
||||||
the engagement on **{target}**, build exploitation CHAINS:
|
|
||||||
|
|
||||||
- Reuse every session/JWT/cookie/credential from one step against ALL other
|
|
||||||
modules and hosts in scope (a captcha/login bypass that yields a token unlocks
|
|
||||||
the entire authenticated surface — use it).
|
|
||||||
- Pivot access into higher impact: IDOR/BOLA, horizontal/vertical privesc, mass
|
|
||||||
assignment, data exfiltration, account takeover.
|
|
||||||
- Combine separate weaknesses (e.g. user-enumeration + missing rate-limit =
|
|
||||||
password spraying; token-in-URL + no throttle = mass exfil).
|
|
||||||
|
|
||||||
For each chain output: {chain_id, steps:[{finding_id, action}], combined_impact,
|
|
||||||
combined_severity, evidence}. Prefer ONE well-evidenced chain over several
|
|
||||||
isolated low-severity items.
|
|
||||||
|
|
||||||
## System Prompt
|
|
||||||
You are an exploit-chaining specialist. Isolated findings understate risk; the
|
|
||||||
real story is the chain. You always try to reuse obtained access across the
|
|
||||||
whole scope and escalate to business impact, reporting the combined chain with
|
|
||||||
concrete evidence. Authorized engagement; no destructive or DoS actions. Credits: Joas A Santos and Red Team Leaders.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Report Calibrator Agent
|
|
||||||
|
|
||||||
> Meta-agent (v3.5.2 doctrine). Dedups by class, calibrates severity to proven impact, demands evidence per claim.
|
|
||||||
|
|
||||||
## User Prompt
|
|
||||||
Before the final report for **{target}**, clean and calibrate the findings:
|
|
||||||
|
|
||||||
1. **Consolidate hygiene by class.** Merge repeated hygiene findings (missing
|
|
||||||
security headers, clickjacking, cookie flags, weak TLS, HSTS, version/banner
|
|
||||||
disclosure) into ONE finding per class with an affected-asset TABLE — do not
|
|
||||||
inflate the count one-per-host.
|
|
||||||
2. **Calibrate severity to PROVEN impact.** High/Critical requires demonstrated
|
|
||||||
impact with evidence. Unproven DoS/abuse, "could/may/potential" language, or a
|
|
||||||
finding with no concrete payload/PoC → cap to Low/Medium or mark
|
|
||||||
"(potential)". Recompute the CVSS vector to match the proven impact.
|
|
||||||
3. **Evidence per claim.** Every finding — and every item in the "tests
|
|
||||||
performed" log — must carry a concrete request/response receipt; flag any
|
|
||||||
claim that has none, and any contradiction between the test log and the
|
|
||||||
findings.
|
|
||||||
|
|
||||||
Output JSON: {merged:[{class, severity, assets:[...]}],
|
|
||||||
recalibrated:[{id, old_severity, new_severity, reason}],
|
|
||||||
unevidenced:[{id_or_test, missing}]}.
|
|
||||||
|
|
||||||
## System Prompt
|
|
||||||
You are a meticulous report editor. You group hygiene by class with an
|
|
||||||
asset table, calibrate every severity to demonstrated impact (no inflated
|
|
||||||
High/Critical, no padding the count with duplicates), and require a real
|
|
||||||
receipt behind every claim — including each line of the tests-performed log.
|
|
||||||
Honest, deduplicated, evidence-backed reporting only. Credits: Joas A Santos and Red Team Leaders.
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Token & JWT Auditor Agent
|
|
||||||
|
|
||||||
> Meta-agent (v3.5.2 doctrine). Attacks tokens: alg-confusion, none, kid/jku, signature checks, weak HS256 secrets.
|
|
||||||
|
|
||||||
## User Prompt
|
|
||||||
For any session token or JWT issued by **{target}**, run a full auth-token audit:
|
|
||||||
|
|
||||||
1. **Decode** the header/payload; note alg (HS*/RS*/none), kid, jku, exp, claims.
|
|
||||||
2. **Algorithm attacks**: try `alg:none`, RS→HS confusion (sign with the public
|
|
||||||
key as HMAC secret), and kid/jku injection. Confirm whether the server
|
|
||||||
actually verifies the signature (tamper a claim and replay).
|
|
||||||
3. **Weak secret**: for HS256, attempt to crack the signing secret offline
|
|
||||||
(wordlist/rules); a static or guessable shared secret (e.g. an `x-auth-*`
|
|
||||||
header value) is a strong lead — if cracked, forge a token for any user.
|
|
||||||
4. **Lifecycle**: test reuse after logout, expiry enforcement, and refresh-token
|
|
||||||
revocation.
|
|
||||||
|
|
||||||
Output JSON: {token_type, alg, verified:true|false,
|
|
||||||
attacks:[{name, result, evidence}], forged_token_possible:true|false}.
|
|
||||||
|
|
||||||
## System Prompt
|
|
||||||
You are a token-security specialist. Every JWT/session token gets audited for
|
|
||||||
algorithm confusion, none, kid/jku injection, real signature verification, weak
|
|
||||||
HS256 secrets, and lifecycle (logout/expiry/refresh). A forged or replayable
|
|
||||||
token is account takeover — you prove it with a real receipt. Authorized
|
|
||||||
engagement; no destructive or DoS actions. Credits: Joas A Santos and Red Team Leaders.
|
|
||||||
@@ -9,11 +9,10 @@ You are performing reconnaissance on **{target}** to map DNS records and infrast
|
|||||||
**METHODOLOGY:**
|
**METHODOLOGY:**
|
||||||
|
|
||||||
### 1. Records
|
### 1. Records
|
||||||
- Enumerate A/AAAA/CNAME/MX/NS/SOA/SRV/TXT
|
- Enumerate A/AAAA/CNAME/MX/TXT/NS/SOA; check SPF/DMARC/DKIM
|
||||||
- Check DKIM/DMARC/SPF
|
|
||||||
|
|
||||||
### 2. Misconfig
|
### 2. Misconfig
|
||||||
- Test dangling CNAMEs, wildcard records, AND zone transfer (AXFR)
|
- Test zone transfer (AXFR), wildcard records, dangling CNAMEs
|
||||||
|
|
||||||
### 3. Relate
|
### 3. Relate
|
||||||
- Cluster shared infrastructure and providers
|
- Cluster shared infrastructure and providers
|
||||||
@@ -34,4 +33,4 @@ FINDING:
|
|||||||
```
|
```
|
||||||
|
|
||||||
## System Prompt
|
## System Prompt
|
||||||
You are a DNS recon specialist. Report only records you actually resolved, with the query evidence.
|
You are a DNS-recon specialist. Report only records you actually resolved, with the query evidence.
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ function Ok ($m) { Write-Host " + $m" -ForegroundColor Green }
|
|||||||
function Warn($m){ Write-Host " ! $m" -ForegroundColor Yellow }
|
function Warn($m){ Write-Host " ! $m" -ForegroundColor Yellow }
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host " NeuroSploit installer (Windows) — v3.5.4" -ForegroundColor Cyan
|
Write-Host " NeuroSploit installer (Windows) — v3.5.1" -ForegroundColor Cyan
|
||||||
$arch = $env:PROCESSOR_ARCHITECTURE
|
$arch = $env:PROCESSOR_ARCHITECTURE
|
||||||
Say "Platform: Windows / $arch"
|
Say "Platform: Windows / $arch"
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -871,7 +871,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "neurosploit"
|
name = "neurosploit"
|
||||||
version = "3.5.4"
|
version = "3.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -888,7 +888,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "neurosploit-harness"
|
name = "neurosploit-harness"
|
||||||
version = "3.5.4"
|
version = "3.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"futures",
|
"futures",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ members = ["crates/harness", "app"]
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "3.5.4"
|
version = "3.5.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/JoasASantos/NeuroSploit"
|
repository = "https://github.com/JoasASantos/NeuroSploit"
|
||||||
|
|||||||
+11
-291
@@ -1,4 +1,4 @@
|
|||||||
//! NeuroSploit v3.5.4 — interactive harness + CLI (`run` / `whitebox` / `agents` / `models`).
|
//! NeuroSploit v3.5.1 — 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.5.4 — multi-model autonomous pentest harness",
|
about = "NeuroSploit v3.5.1 — multi-model autonomous pentest harness",
|
||||||
long_about = "NeuroSploit v3.5.4 — a Rust multi-model harness that drives a pool of LLMs \
|
long_about = "NeuroSploit v3.5.1 — 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\
|
||||||
@@ -46,9 +46,6 @@ enum Cmd {
|
|||||||
max_agents: usize,
|
max_agents: usize,
|
||||||
#[arg(long, default_value_t = 3)]
|
#[arg(long, default_value_t = 3)]
|
||||||
vote_n: usize,
|
vote_n: usize,
|
||||||
/// Attack-chaining rounds (post-exploitation pivots; 0 disables).
|
|
||||||
#[arg(long, default_value_t = 2)]
|
|
||||||
chain_depth: usize,
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
offline: bool,
|
offline: bool,
|
||||||
/// Use local agentic CLI subscription (Claude/Codex/Gemini/Grok login).
|
/// Use local agentic CLI subscription (Claude/Codex/Gemini/Grok login).
|
||||||
@@ -64,17 +61,12 @@ enum Cmd {
|
|||||||
/// Free-text focus, e.g. "injection and broken access control".
|
/// Free-text focus, e.g. "injection and broken access control".
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
focus: Option<String>,
|
focus: Option<String>,
|
||||||
/// Open a Jira card per finding (needs the jira integration enabled).
|
|
||||||
#[arg(long)]
|
|
||||||
jira: bool,
|
|
||||||
/// Verbose: log each agent as it launches, recon, and votes.
|
/// Verbose: log each agent as it launches, recon, and votes.
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
},
|
},
|
||||||
/// White-box: analyse a repository's source code for vulnerabilities.
|
/// White-box: analyse a local repository's source code for vulnerabilities.
|
||||||
Whitebox {
|
Whitebox {
|
||||||
/// Local path, a GitHub URL (https://github.com/owner/repo[.git]) or an
|
|
||||||
/// `owner/repo` shorthand — git URLs are cloned automatically.
|
|
||||||
path: String,
|
path: String,
|
||||||
#[arg(long = "model")]
|
#[arg(long = "model")]
|
||||||
models: Vec<String>,
|
models: Vec<String>,
|
||||||
@@ -82,22 +74,16 @@ enum Cmd {
|
|||||||
max_agents: usize,
|
max_agents: usize,
|
||||||
#[arg(long, default_value_t = 2)]
|
#[arg(long, default_value_t = 2)]
|
||||||
vote_n: usize,
|
vote_n: usize,
|
||||||
/// Attack-chaining rounds (post-exploitation pivots; 0 disables).
|
|
||||||
#[arg(long, default_value_t = 2)]
|
|
||||||
chain_depth: usize,
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
offline: bool,
|
offline: bool,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
subscription: bool,
|
subscription: bool,
|
||||||
/// Open a Jira card per finding (needs the jira integration enabled).
|
|
||||||
#[arg(long)]
|
|
||||||
jira: bool,
|
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
},
|
},
|
||||||
/// Greybox: review a repo's source AND exploit the running app together.
|
/// Greybox: review a repo's source AND exploit the running app together.
|
||||||
Greybox {
|
Greybox {
|
||||||
/// Source repo: local path, a GitHub URL, or `owner/repo` (cloned if a URL).
|
/// Path to the source repository.
|
||||||
repo: String,
|
repo: String,
|
||||||
/// URL of the running application.
|
/// URL of the running application.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -114,9 +100,6 @@ enum Cmd {
|
|||||||
max_agents: usize,
|
max_agents: usize,
|
||||||
#[arg(long, default_value_t = 3)]
|
#[arg(long, default_value_t = 3)]
|
||||||
vote_n: usize,
|
vote_n: usize,
|
||||||
/// Attack-chaining rounds (post-exploitation pivots; 0 disables).
|
|
||||||
#[arg(long, default_value_t = 2)]
|
|
||||||
chain_depth: usize,
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
offline: bool,
|
offline: bool,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -142,9 +125,6 @@ enum Cmd {
|
|||||||
max_agents: usize,
|
max_agents: usize,
|
||||||
#[arg(long, default_value_t = 3)]
|
#[arg(long, default_value_t = 3)]
|
||||||
vote_n: usize,
|
vote_n: usize,
|
||||||
/// Attack-chaining rounds (post-exploitation pivots; 0 disables).
|
|
||||||
#[arg(long, default_value_t = 2)]
|
|
||||||
chain_depth: usize,
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
subscription: bool,
|
subscription: bool,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -166,9 +146,6 @@ enum Cmd {
|
|||||||
max_agents: usize,
|
max_agents: usize,
|
||||||
#[arg(long, default_value_t = 3)]
|
#[arg(long, default_value_t = 3)]
|
||||||
vote_n: usize,
|
vote_n: usize,
|
||||||
/// Attack-chaining rounds (post-exploitation pivots; 0 disables).
|
|
||||||
#[arg(long, default_value_t = 2)]
|
|
||||||
chain_depth: usize,
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
offline: bool,
|
offline: bool,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -176,55 +153,6 @@ enum Cmd {
|
|||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
},
|
},
|
||||||
/// Review a GitHub Pull Request's code (clones the PR head, white-box).
|
|
||||||
/// Optionally comments back on the PR and/or opens Jira cards per finding.
|
|
||||||
Pr {
|
|
||||||
/// `owner/repo` or a GitHub URL.
|
|
||||||
repo: String,
|
|
||||||
/// Pull request number.
|
|
||||||
number: u64,
|
|
||||||
#[arg(long = "model")]
|
|
||||||
models: Vec<String>,
|
|
||||||
#[arg(long, default_value_t = 2)]
|
|
||||||
vote_n: usize,
|
|
||||||
/// Attack-chaining rounds (post-exploitation pivots; 0 disables).
|
|
||||||
#[arg(long, default_value_t = 2)]
|
|
||||||
chain_depth: usize,
|
|
||||||
#[arg(long)]
|
|
||||||
subscription: bool,
|
|
||||||
/// Post a summary comment back on the PR (needs github integration on).
|
|
||||||
#[arg(long)]
|
|
||||||
comment: bool,
|
|
||||||
/// Open a Jira card per finding (needs jira integration on).
|
|
||||||
#[arg(long)]
|
|
||||||
jira: bool,
|
|
||||||
#[arg(short, long)]
|
|
||||||
verbose: bool,
|
|
||||||
},
|
|
||||||
/// Watch a GitHub repo branch; white-box review each time a new commit lands.
|
|
||||||
Watch {
|
|
||||||
/// `owner/repo` or a GitHub URL.
|
|
||||||
repo: String,
|
|
||||||
#[arg(long, default_value = "main")]
|
|
||||||
branch: String,
|
|
||||||
/// Poll interval in seconds.
|
|
||||||
#[arg(long, default_value_t = 300)]
|
|
||||||
interval: u64,
|
|
||||||
#[arg(long = "model")]
|
|
||||||
models: Vec<String>,
|
|
||||||
#[arg(long)]
|
|
||||||
subscription: bool,
|
|
||||||
#[arg(long)]
|
|
||||||
jira: bool,
|
|
||||||
#[arg(short, long)]
|
|
||||||
verbose: bool,
|
|
||||||
},
|
|
||||||
/// Manage integrations: `integrations [show|enable|disable] [github|gitlab|jira]`.
|
|
||||||
Integrations {
|
|
||||||
#[arg(default_value = "show")]
|
|
||||||
action: String,
|
|
||||||
name: Option<String>,
|
|
||||||
},
|
|
||||||
/// Show agent library counts.
|
/// Show agent library counts.
|
||||||
Agents,
|
Agents,
|
||||||
/// List providers and models.
|
/// List providers and models.
|
||||||
@@ -285,12 +213,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Cmd::Run { url, models, max_agents, vote_n, chain_depth, offline, subscription, mcp, creds, focus, jira, verbose } => {
|
Cmd::Run { url, models, max_agents, vote_n, offline, subscription, mcp, creds, focus, verbose } => {
|
||||||
let url = if url.starts_with("http") { url } else { format!("https://{url}") };
|
let url = if url.starts_with("http") { url } else { format!("https://{url}") };
|
||||||
let mut cfg = RunConfig::new(&url);
|
let mut cfg = RunConfig::new(&url);
|
||||||
cfg.max_agents = max_agents;
|
cfg.max_agents = max_agents;
|
||||||
cfg.vote_n = vote_n;
|
cfg.vote_n = vote_n;
|
||||||
cfg.chain_depth = chain_depth;
|
|
||||||
cfg.offline = offline;
|
cfg.offline = offline;
|
||||||
cfg.subscription = subscription;
|
cfg.subscription = subscription;
|
||||||
cfg.verbose = verbose;
|
cfg.verbose = verbose;
|
||||||
@@ -301,15 +228,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
apply_creds(&mut cfg, creds.as_deref()).await;
|
apply_creds(&mut cfg, creds.as_deref()).await;
|
||||||
let out = run_engagement(&base, cfg, mcp, false).await?;
|
let out = run_engagement(&base, cfg, mcp, false).await?;
|
||||||
print_findings(&out);
|
print_findings(&out);
|
||||||
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, offline, subscription, jira, verbose } => {
|
Cmd::Whitebox { path, models, max_agents, vote_n, offline, subscription, verbose } => {
|
||||||
let path = resolve_source(&base, &path)?; // local path OR github URL/owner/repo
|
|
||||||
let mut cfg = RunConfig::new(&path);
|
let mut cfg = RunConfig::new(&path);
|
||||||
cfg.max_agents = max_agents;
|
cfg.max_agents = max_agents;
|
||||||
cfg.vote_n = vote_n;
|
cfg.vote_n = vote_n;
|
||||||
cfg.chain_depth = chain_depth;
|
|
||||||
cfg.offline = offline;
|
cfg.offline = offline;
|
||||||
cfg.subscription = subscription;
|
cfg.subscription = subscription;
|
||||||
cfg.verbose = verbose;
|
cfg.verbose = verbose;
|
||||||
@@ -318,17 +241,13 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
let out = run_engagement(&base, cfg, false, true).await?;
|
let out = run_engagement(&base, cfg, false, true).await?;
|
||||||
print_findings(&out);
|
print_findings(&out);
|
||||||
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, offline, subscription, mcp, verbose } => {
|
Cmd::Greybox { repo, url, models, creds, focus, max_agents, vote_n, offline, subscription, mcp, 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 url = if url.starts_with("http") { url } else { format!("https://{url}") };
|
||||||
let mut cfg = RunConfig::new(&url);
|
let mut cfg = RunConfig::new(&url);
|
||||||
cfg.repo = Some(repo);
|
cfg.repo = Some(repo);
|
||||||
cfg.max_agents = max_agents;
|
cfg.max_agents = max_agents;
|
||||||
cfg.vote_n = vote_n;
|
cfg.vote_n = vote_n;
|
||||||
cfg.chain_depth = chain_depth;
|
|
||||||
cfg.offline = offline;
|
cfg.offline = offline;
|
||||||
cfg.subscription = subscription;
|
cfg.subscription = subscription;
|
||||||
cfg.verbose = verbose;
|
cfg.verbose = verbose;
|
||||||
@@ -340,13 +259,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let out = run_greybox_engagement(&base, cfg, mcp).await?;
|
let out = run_greybox_engagement(&base, cfg, mcp).await?;
|
||||||
print_findings(&out);
|
print_findings(&out);
|
||||||
}
|
}
|
||||||
Cmd::Tui { url, models, repo, creds, focus, max_agents, vote_n, chain_depth, subscription, mcp } => {
|
Cmd::Tui { url, models, repo, creds, focus, max_agents, vote_n, subscription, mcp } => {
|
||||||
let repo = match repo { Some(r) => Some(resolve_source(&base, &r)?), None => None }; // github URL ok
|
|
||||||
let url = if url.starts_with("http") { url } else { format!("https://{url}") };
|
let url = if url.starts_with("http") { url } else { format!("https://{url}") };
|
||||||
let mut cfg = RunConfig::new(&url);
|
let mut cfg = RunConfig::new(&url);
|
||||||
cfg.max_agents = max_agents;
|
cfg.max_agents = max_agents;
|
||||||
cfg.vote_n = vote_n;
|
cfg.vote_n = vote_n;
|
||||||
cfg.chain_depth = chain_depth;
|
|
||||||
cfg.subscription = subscription;
|
cfg.subscription = subscription;
|
||||||
cfg.instructions = focus;
|
cfg.instructions = focus;
|
||||||
cfg.repo = repo.clone();
|
cfg.repo = repo.clone();
|
||||||
@@ -357,11 +274,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let mode = if repo.is_some() { Mode::Grey } else { Mode::Black };
|
let mode = if repo.is_some() { Mode::Grey } else { Mode::Black };
|
||||||
tui::run(&base, cfg, mcp, mode).await?;
|
tui::run(&base, cfg, mcp, mode).await?;
|
||||||
}
|
}
|
||||||
Cmd::Host { target, models, creds, focus, max_agents, vote_n, chain_depth, offline, subscription, verbose } => {
|
Cmd::Host { target, models, creds, focus, max_agents, vote_n, offline, subscription, verbose } => {
|
||||||
let mut cfg = RunConfig::new(&target);
|
let mut cfg = RunConfig::new(&target);
|
||||||
cfg.max_agents = max_agents;
|
cfg.max_agents = max_agents;
|
||||||
cfg.vote_n = vote_n;
|
cfg.vote_n = vote_n;
|
||||||
cfg.chain_depth = chain_depth;
|
|
||||||
cfg.offline = offline;
|
cfg.offline = offline;
|
||||||
cfg.subscription = subscription;
|
cfg.subscription = subscription;
|
||||||
cfg.verbose = verbose;
|
cfg.verbose = verbose;
|
||||||
@@ -373,77 +289,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let out = run_mode(&base, cfg, false, Mode::Host).await?;
|
let out = run_mode(&base, cfg, false, Mode::Host).await?;
|
||||||
print_findings(&out);
|
print_findings(&out);
|
||||||
}
|
}
|
||||||
Cmd::Pr { repo, number, models, vote_n, chain_depth, subscription, comment, jira, verbose } => {
|
|
||||||
let ig = harness::integrations::Integrations::load(&repl::proj_dir());
|
|
||||||
let owner_repo = normalize_repo(&repo);
|
|
||||||
let path = clone_pr(&base, &ig, &owner_repo, number)?;
|
|
||||||
println!(" 🔍 white-box review of {owner_repo} PR #{number}");
|
|
||||||
let mut cfg = RunConfig::new(&path);
|
|
||||||
cfg.vote_n = vote_n;
|
|
||||||
cfg.chain_depth = chain_depth;
|
|
||||||
cfg.subscription = subscription;
|
|
||||||
cfg.verbose = verbose;
|
|
||||||
cfg.instructions = Some(format!("This is the code of pull request #{number} of {owner_repo}. Focus on vulnerabilities introduced or touched by this change."));
|
|
||||||
if !models.is_empty() { cfg.models = models; }
|
|
||||||
let out = run_engagement(&base, cfg, false, true).await?;
|
|
||||||
print_findings(&out);
|
|
||||||
post_integrations(&ig, &format!("{owner_repo}#{number}"), &out, jira, comment, Some((&owner_repo, number))).await;
|
|
||||||
}
|
|
||||||
Cmd::Watch { repo, branch, interval, models, subscription, jira, verbose } => {
|
|
||||||
let ig = harness::integrations::Integrations::load(&repl::proj_dir());
|
|
||||||
let owner_repo = normalize_repo(&repo);
|
|
||||||
println!(" 👀 watching {owner_repo}@{branch} every {interval}s — Ctrl-C to stop");
|
|
||||||
let mut last = String::new();
|
|
||||||
loop {
|
|
||||||
match ig.github_latest_sha(&owner_repo, &branch).await {
|
|
||||||
Ok(sha) if sha != last => {
|
|
||||||
let short = &sha[..7.min(sha.len())];
|
|
||||||
println!("\n 🔔 {} commit {short} on {owner_repo}@{branch} — reviewing",
|
|
||||||
if last.is_empty() { "current" } else { "new" });
|
|
||||||
// fresh clone of the branch tip
|
|
||||||
let dest = base.join("repos").join(sanitize(&format!("{owner_repo}-{branch}")));
|
|
||||||
std::fs::remove_dir_all(&dest).ok();
|
|
||||||
let url = ig.authed_clone_url(&format!("https://github.com/{owner_repo}"));
|
|
||||||
if run_git(&["clone", "--depth", "1", "--branch", &branch, &url, &dest.display().to_string()]).is_ok() {
|
|
||||||
let mut cfg = RunConfig::new(&dest.display().to_string());
|
|
||||||
cfg.subscription = subscription;
|
|
||||||
cfg.verbose = verbose;
|
|
||||||
if !models.is_empty() { cfg.models = models.clone(); }
|
|
||||||
if let Ok(out) = run_engagement(&base, cfg, false, true).await {
|
|
||||||
print_findings(&out);
|
|
||||||
post_integrations(&ig, &format!("{owner_repo}@{short}"), &out, jira, false, None).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
last = sha;
|
|
||||||
}
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) => eprintln!(" watch: {e}"),
|
|
||||||
}
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(interval.max(15))).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Cmd::Integrations { action, name } => {
|
|
||||||
let dir = repl::proj_dir();
|
|
||||||
let mut ig = harness::integrations::Integrations::load(&dir);
|
|
||||||
match action.as_str() {
|
|
||||||
"enable" | "disable" => {
|
|
||||||
let on = action == "enable";
|
|
||||||
match name.as_deref() {
|
|
||||||
Some("github") => ig.github.enabled = on,
|
|
||||||
Some("gitlab") => ig.gitlab.enabled = on,
|
|
||||||
Some("jira") => ig.jira.enabled = on,
|
|
||||||
_ => { eprintln!(" usage: integrations {action} <github|gitlab|jira>"); return Ok(()); }
|
|
||||||
}
|
|
||||||
ig.save(&dir)?;
|
|
||||||
println!(" {} {}", name.unwrap_or_default(), if on { "enabled ✓" } else { "disabled" });
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
println!(" integrations · {}", dir.display());
|
|
||||||
for l in ig.status_lines() { println!(" {l}"); }
|
|
||||||
println!(" toggle: `neurosploit integrations enable github|gitlab|jira` · full setup in the REPL: /integrations");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -534,7 +379,7 @@ pub(crate) fn spawn_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, mode:
|
|||||||
cfg.rl_path = Some(base.join("data").join("rl_state_rs.json").display().to_string());
|
cfg.rl_path = Some(base.join("data").join("rl_state_rs.json").display().to_string());
|
||||||
write_status(&workdir, "running", &format!("\"target\":{:?}", cfg.target));
|
write_status(&workdir, "running", &format!("\"target\":{:?}", cfg.target));
|
||||||
|
|
||||||
println!(" ┌─ NeuroSploit v3.5.4 · by Joas A Santos & Red Team Leaders");
|
println!(" ┌─ NeuroSploit v3.5.1 · 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(", "));
|
||||||
@@ -687,131 +532,6 @@ fn now_ts() -> u64 {
|
|||||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a source argument (white-box `path` / grey-box `--repo`) to a local
|
|
||||||
/// directory. A git URL (`https://…`, `git@…`, `ssh://…`, `*.git`) or a GitHub
|
|
||||||
/// `owner/repo` shorthand is **cloned** (shallow) into `<base>/repos/<name>` and
|
|
||||||
/// that path is returned; an existing local path is returned unchanged.
|
|
||||||
pub(crate) fn resolve_source(base: &Path, arg: &str) -> anyhow::Result<String> {
|
|
||||||
let is_url = arg.starts_with("http://") || arg.starts_with("https://")
|
|
||||||
|| arg.starts_with("git@") || arg.starts_with("ssh://") || arg.ends_with(".git");
|
|
||||||
// `owner/repo` GitHub shorthand: no scheme, exactly one slash, not a real path.
|
|
||||||
let is_shorthand = !is_url
|
|
||||||
&& !Path::new(arg).exists()
|
|
||||||
&& arg.matches('/').count() == 1
|
|
||||||
&& !arg.starts_with('.') && !arg.starts_with('/') && !arg.starts_with('~')
|
|
||||||
&& arg.chars().all(|c| c.is_ascii_alphanumeric() || "._-/".contains(c));
|
|
||||||
if !is_url && !is_shorthand {
|
|
||||||
return Ok(arg.to_string()); // already a local path
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = if is_shorthand { format!("https://github.com/{arg}") } else { arg.to_string() };
|
|
||||||
let name = sanitize(url.trim_end_matches('/').trim_end_matches(".git").rsplit('/').next().unwrap_or("repo"));
|
|
||||||
let repos_dir = base.join("repos");
|
|
||||||
std::fs::create_dir_all(&repos_dir).ok();
|
|
||||||
let dest = repos_dir.join(&name);
|
|
||||||
|
|
||||||
if dest.join(".git").is_dir() {
|
|
||||||
println!(" [*] repo cache hit → {} (delete it to re-clone)", dest.display());
|
|
||||||
return Ok(dest.display().to_string());
|
|
||||||
}
|
|
||||||
// If a GitHub/GitLab integration is enabled, inject its token so PRIVATE
|
|
||||||
// repos clone without an interactive prompt (token never printed).
|
|
||||||
let ig = harness::integrations::Integrations::load(&repl::proj_dir());
|
|
||||||
let clone_url = ig.authed_clone_url(&url);
|
|
||||||
let private = clone_url != url;
|
|
||||||
println!(" [*] cloning {url}{} → {}", if private { " (private, via token)" } else { "" }, dest.display());
|
|
||||||
let status = std::process::Command::new("git")
|
|
||||||
.args(["clone", "--depth", "1", &clone_url, &dest.display().to_string()])
|
|
||||||
.status()
|
|
||||||
.map_err(|e| anyhow::anyhow!("could not start `git clone` (is git installed?): {e}"))?;
|
|
||||||
if !status.success() {
|
|
||||||
std::fs::remove_dir_all(&dest).ok();
|
|
||||||
anyhow::bail!("git clone failed for {url}");
|
|
||||||
}
|
|
||||||
Ok(dest.display().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normalize a GitHub repo reference to `owner/name`.
|
|
||||||
fn normalize_repo(s: &str) -> String {
|
|
||||||
s.trim()
|
|
||||||
.trim_end_matches('/')
|
|
||||||
.trim_end_matches(".git")
|
|
||||||
.replace("https://github.com/", "")
|
|
||||||
.replace("http://github.com/", "")
|
|
||||||
.replace("git@github.com:", "")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run a git command, returning Ok(()) on success.
|
|
||||||
fn run_git(args: &[&str]) -> anyhow::Result<()> {
|
|
||||||
let status = std::process::Command::new("git").args(args).status()
|
|
||||||
.map_err(|e| anyhow::anyhow!("could not run git (is it installed?): {e}"))?;
|
|
||||||
if !status.success() { anyhow::bail!("git {:?} failed", args.first().unwrap_or(&"")); }
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clone a repo and check out a Pull Request's HEAD (`refs/pull/N/head`).
|
|
||||||
fn clone_pr(base: &Path, ig: &harness::integrations::Integrations, owner_repo: &str, number: u64) -> anyhow::Result<String> {
|
|
||||||
let dest = base.join("repos").join(sanitize(&format!("{owner_repo}-pr{number}")));
|
|
||||||
std::fs::create_dir_all(base.join("repos")).ok();
|
|
||||||
std::fs::remove_dir_all(&dest).ok(); // always fresh — PR code changes
|
|
||||||
let url = ig.authed_clone_url(&format!("https://github.com/{owner_repo}"));
|
|
||||||
let private = url.contains('@');
|
|
||||||
println!(" [*] cloning {owner_repo}{} + PR #{number} head → {}", if private { " (private)" } else { "" }, dest.display());
|
|
||||||
let d = dest.display().to_string();
|
|
||||||
run_git(&["clone", "--depth", "1", &url, &d])?;
|
|
||||||
run_git(&["-C", &d, "fetch", "--depth", "1", "origin", &format!("pull/{number}/head:pr-{number}")])?;
|
|
||||||
run_git(&["-C", &d, "checkout", &format!("pr-{number}")])?;
|
|
||||||
Ok(d)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// After a run, optionally open Jira cards and/or comment on a GitHub PR.
|
|
||||||
async fn post_integrations(
|
|
||||||
ig: &harness::integrations::Integrations,
|
|
||||||
target: &str,
|
|
||||||
out: &RunOutput,
|
|
||||||
jira: bool,
|
|
||||||
comment: bool,
|
|
||||||
gh_pr: Option<(&str, u64)>,
|
|
||||||
) {
|
|
||||||
if jira && ig.jira.enabled && !out.findings.is_empty() {
|
|
||||||
let (keys, errs) = ig.jira_cards_for(target, &out.findings).await;
|
|
||||||
if !keys.is_empty() { println!(" 🪪 Jira cards opened: {}", keys.join(", ")); }
|
|
||||||
for e in errs { eprintln!(" jira: {e}"); }
|
|
||||||
}
|
|
||||||
if comment && ig.github.enabled {
|
|
||||||
if let Some((repo, number)) = gh_pr {
|
|
||||||
match ig.github_comment(repo, number, &pr_comment_body(out)).await {
|
|
||||||
Ok(()) => println!(" 💬 commented results on {repo}#{number}"),
|
|
||||||
Err(e) => eprintln!(" github comment: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Markdown summary of a run, for a PR comment.
|
|
||||||
fn pr_comment_body(out: &RunOutput) -> String {
|
|
||||||
let mut by = std::collections::BTreeMap::new();
|
|
||||||
for f in &out.findings { *by.entry(f.severity.as_str()).or_insert(0) += 1; }
|
|
||||||
let chips: Vec<String> = by.iter().map(|(k, v)| format!("{k}: {v}")).collect();
|
|
||||||
let mut s = format!(
|
|
||||||
"### 🧠 NeuroSploit white-box review\n\n**{} validated finding(s)** — {}\n\n",
|
|
||||||
out.findings.len(),
|
|
||||||
if chips.is_empty() { "none".into() } else { chips.join(" · ") }
|
|
||||||
);
|
|
||||||
if out.findings.is_empty() {
|
|
||||||
s.push_str("_No vulnerabilities confirmed in the reviewed code._\n");
|
|
||||||
} else {
|
|
||||||
s.push_str("| Severity | Finding | CWE | Location |\n|---|---|---|---|\n");
|
|
||||||
for f in &out.findings {
|
|
||||||
s.push_str(&format!("| {} | {} | {} | {} |\n",
|
|
||||||
f.severity, f.title.replace('|', "\\|"), f.cwe,
|
|
||||||
f.endpoint.replace('|', "\\|")));
|
|
||||||
}
|
|
||||||
s.push_str("\n_Findings validated by multi-model voting. Authorized testing only._\n");
|
|
||||||
}
|
|
||||||
s
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Blocking yes/no prompt (default yes). Used after a graceful Ctrl-C.
|
/// Blocking yes/no prompt (default yes). Used after a graceful Ctrl-C.
|
||||||
fn ask_yes_no(q: &str) -> bool {
|
fn ask_yes_no(q: &str) -> bool {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! NeuroSploit v3.5.4 — interactive session (Claude-Code / Codex / Cursor-CLI style).
|
//! NeuroSploit v3.5.1 — 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
|
||||||
@@ -120,7 +120,7 @@ const COMMANDS: &[&str] = &[
|
|||||||
"/help", "/show", "/config", "/providers", "/model", "/key", "/sub", "/target",
|
"/help", "/show", "/config", "/providers", "/model", "/key", "/sub", "/target",
|
||||||
"/repo", "/auth", "/creds", "/focus", "/attach", "/context", "/mcp", "/offline",
|
"/repo", "/auth", "/creds", "/focus", "/attach", "/context", "/mcp", "/offline",
|
||||||
"/votes", "/agents", "/theme", "/clear", "/run", "/stop", "/continue", "/runs", "/results", "/report",
|
"/votes", "/agents", "/theme", "/clear", "/run", "/stop", "/continue", "/runs", "/results", "/report",
|
||||||
"/status", "/diff", "/retest", "/integrations", "/quit",
|
"/status", "/diff", "/retest", "/quit",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// rustyline helper: Tab-completes `/commands` and `@filesystem-paths`,
|
/// rustyline helper: Tab-completes `/commands` and `@filesystem-paths`,
|
||||||
@@ -299,7 +299,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.5.4");
|
println!(" ████╗ ██║██╔════╝██║ ██║██╔══██╗██╔═══██╗ NeuroSploit v3.5.1");
|
||||||
println!(" ██╔██╗ ██║█████╗ ██║ ██║██████╔╝██║ ██║ interactive harness");
|
println!(" ██╔██╗ ██║█████╗ ██║ ██║██████╔╝██║ ██║ interactive harness");
|
||||||
println!(" ██║╚██╗██║██╔══╝ ██║ ██║██╔══██╗██║ ██║ by Joas A Santos");
|
println!(" ██║╚██╗██║██╔══╝ ██║ ██║██╔══██╗██║ ██║ by Joas A Santos");
|
||||||
println!(" ██║ ╚████║███████╗╚██████╔╝██║ ██║╚██████╔╝ & Red Team Leaders");
|
println!(" ██║ ╚████║███████╗╚██████╔╝██║ ██║╚██████╔╝ & Red Team Leaders");
|
||||||
@@ -392,15 +392,9 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
|||||||
s.target = Some(t.clone()); println!(" target: {t}"); }
|
s.target = Some(t.clone()); println!(" target: {t}"); }
|
||||||
}
|
}
|
||||||
"/repo" => {
|
"/repo" => {
|
||||||
if arg.is_empty() { println!(" repo: {}", s.repo.clone().unwrap_or_else(|| "(none) — set with /repo <path | github-url | owner/repo>, clear with /repo clear".into())); }
|
if arg.is_empty() { println!(" repo: {}", s.repo.clone().unwrap_or_else(|| "(none) — set with /repo <path>, clear with /repo clear".into())); }
|
||||||
else if arg == "clear" { s.repo = None; println!(" repo cleared"); }
|
else if arg == "clear" { s.repo = None; println!(" repo cleared"); }
|
||||||
else {
|
else { s.repo = Some(arg.to_string()); println!(" repo: {arg}"); }
|
||||||
// Accept a local path OR a GitHub URL / owner-repo shorthand (cloned on set).
|
|
||||||
match crate::resolve_source(base, arg) {
|
|
||||||
Ok(p) => { s.repo = Some(p.clone()); println!(" repo: {p}"); }
|
|
||||||
Err(e) => println!(" \x1b[31mcould not resolve repo: {e}\x1b[0m"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"/auth" => {
|
"/auth" => {
|
||||||
if arg.is_empty() { println!(" auth: {}", s.auth.clone().unwrap_or_else(|| "(none) — set with /auth <header>, clear with /auth clear".into())); }
|
if arg.is_empty() { println!(" auth: {}", s.auth.clone().unwrap_or_else(|| "(none) — set with /auth <header>, clear with /auth clear".into())); }
|
||||||
@@ -430,7 +424,6 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
"/mcp" => { s.mcp = !matches!(arg, "off" | "false" | "0" | "no"); println!(" Playwright MCP: {}", onoff(s.mcp)); }
|
"/mcp" => { s.mcp = !matches!(arg, "off" | "false" | "0" | "no"); println!(" Playwright MCP: {}", onoff(s.mcp)); }
|
||||||
"/offline" => { s.offline = !matches!(arg, "off" | "false" | "0" | "no"); println!(" offline: {}", onoff(s.offline)); }
|
"/offline" => { s.offline = !matches!(arg, "off" | "false" | "0" | "no"); println!(" offline: {}", onoff(s.offline)); }
|
||||||
"/integrations" | "/integration" => integrations_cmd(arg),
|
|
||||||
"/votes" => { s.vote_n = arg.parse().unwrap_or(s.vote_n); println!(" votes: {}", s.vote_n); }
|
"/votes" => { s.vote_n = arg.parse().unwrap_or(s.vote_n); println!(" votes: {}", s.vote_n); }
|
||||||
"/agents" => { s.max_agents = arg.parse().unwrap_or(s.max_agents); println!(" max agents: {}", s.max_agents); }
|
"/agents" => { s.max_agents = arg.parse().unwrap_or(s.max_agents); println!(" max agents: {}", s.max_agents); }
|
||||||
"/clear" => { print!("\x1b[2J\x1b[H"); }
|
"/clear" => { print!("\x1b[2J\x1b[H"); }
|
||||||
@@ -940,64 +933,6 @@ fn sev_rank(s: &str) -> u8 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read one line synchronously (for the /stop choice prompt).
|
/// Read one line synchronously (for the /stop choice prompt).
|
||||||
/// `/integrations` — show / enable / disable / setup GitHub, GitLab, Jira.
|
|
||||||
fn integrations_cmd(arg: &str) {
|
|
||||||
let dir = proj_dir();
|
|
||||||
let mut ig = harness::integrations::Integrations::load(&dir);
|
|
||||||
let mut parts = arg.splitn(2, char::is_whitespace);
|
|
||||||
let sub = parts.next().unwrap_or("").trim();
|
|
||||||
let name = parts.next().unwrap_or("").trim();
|
|
||||||
match sub {
|
|
||||||
"" | "show" | "status" => {
|
|
||||||
println!(" \x1b[1mintegrations\x1b[0m · {}", dir.display());
|
|
||||||
for l in ig.status_lines() { println!(" {l}"); }
|
|
||||||
println!(" \x1b[2m/integrations enable|disable <github|gitlab|jira> · /integrations setup <jira|gitlab|github>\x1b[0m");
|
|
||||||
println!(" \x1b[2mtokens come from env vars (never stored): GITHUB_TOKEN · GITLAB_TOKEN · JIRA_EMAIL + JIRA_API_TOKEN\x1b[0m");
|
|
||||||
}
|
|
||||||
"enable" | "disable" => {
|
|
||||||
let on = sub == "enable";
|
|
||||||
match name {
|
|
||||||
"github" => ig.github.enabled = on,
|
|
||||||
"gitlab" => ig.gitlab.enabled = on,
|
|
||||||
"jira" => ig.jira.enabled = on,
|
|
||||||
_ => { println!(" usage: /integrations {sub} <github|gitlab|jira>"); return; }
|
|
||||||
}
|
|
||||||
let _ = ig.save(&dir);
|
|
||||||
println!(" {name} {}", if on { "enabled ✓" } else { "disabled" });
|
|
||||||
}
|
|
||||||
"setup" => match name {
|
|
||||||
"jira" => {
|
|
||||||
let base = ask_line(" Jira base URL (https://your-org.atlassian.net):");
|
|
||||||
if !base.trim().is_empty() { ig.jira.base_url = base.trim().trim_end_matches('/').to_string(); }
|
|
||||||
let proj = ask_line(" Jira project key (e.g. SEC):");
|
|
||||||
if !proj.trim().is_empty() { ig.jira.project_key = proj.trim().to_string(); }
|
|
||||||
let it = ask_line(" Issue type [Bug]:");
|
|
||||||
if !it.trim().is_empty() { ig.jira.issue_type = it.trim().to_string(); }
|
|
||||||
ig.jira.enabled = true;
|
|
||||||
let _ = ig.save(&dir);
|
|
||||||
println!(" ✓ jira configured (project {}, {}). Now export {} and {} in your shell.",
|
|
||||||
ig.jira.project_key, ig.jira.base_url, ig.jira.email_env, ig.jira.token_env);
|
|
||||||
}
|
|
||||||
"gitlab" => {
|
|
||||||
let b = ask_line(" GitLab base [https://gitlab.com]:");
|
|
||||||
if !b.trim().is_empty() { ig.gitlab.base = b.trim().trim_end_matches('/').to_string(); }
|
|
||||||
ig.gitlab.enabled = true;
|
|
||||||
let _ = ig.save(&dir);
|
|
||||||
println!(" ✓ gitlab enabled (base {}). Export {} (PAT with read_repository).", ig.gitlab.base, ig.gitlab.token_env);
|
|
||||||
}
|
|
||||||
"github" => {
|
|
||||||
let a = ask_line(" GitHub API base [https://api.github.com] (change for GHE):");
|
|
||||||
if !a.trim().is_empty() { ig.github.api = a.trim().trim_end_matches('/').to_string(); }
|
|
||||||
ig.github.enabled = true;
|
|
||||||
let _ = ig.save(&dir);
|
|
||||||
println!(" ✓ github enabled (api {}). Export {} (PAT with repo scope).", ig.github.api, ig.github.token_env);
|
|
||||||
}
|
|
||||||
_ => println!(" usage: /integrations setup <jira|gitlab|github>"),
|
|
||||||
},
|
|
||||||
_ => println!(" usage: /integrations [show | enable <name> | disable <name> | setup <name>]"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ask_line(prompt: &str) -> String {
|
fn ask_line(prompt: &str) -> String {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
print!("{prompt} ");
|
print!("{prompt} ");
|
||||||
@@ -1106,9 +1041,6 @@ fn help() {
|
|||||||
h("/runs", "list runs · /results [n] · /report [n]");
|
h("/runs", "list runs · /results [n] · /report [n]");
|
||||||
h("/diff /retest [n]", "what changed vs last run · re-verify a past run");
|
h("/diff /retest [n]", "what changed vs last run · re-verify a past run");
|
||||||
|
|
||||||
println!("\n \x1b[2mINTEGRATIONS\x1b[0m");
|
|
||||||
h("/integrations", "show · enable/disable github|gitlab|jira · setup <name>");
|
|
||||||
|
|
||||||
println!("\n \x1b[2mOPTIONS\x1b[0m");
|
println!("\n \x1b[2mOPTIONS\x1b[0m");
|
||||||
h("/mcp on|off", "Playwright MCP browser /offline on|off self-test");
|
h("/mcp on|off", "Playwright MCP browser /offline on|off self-test");
|
||||||
h("/votes <n>", "validator votes /agents <n> cap agents");
|
h("/votes <n>", "validator votes /agents <n> cap agents");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! NeuroSploit v3.5.4 — TUI "Mission Control" mode.
|
//! NeuroSploit v3.5.1 — 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,4 +1,4 @@
|
|||||||
//! POMDP belief-state world model (v3.5.4).
|
//! POMDP belief-state world model (v3.5.1).
|
||||||
//!
|
//!
|
||||||
//! 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.5.4).
|
//! Verification / grounding engine (v3.5.1).
|
||||||
//!
|
//!
|
||||||
//! 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,186 +0,0 @@
|
|||||||
//! Report-hygiene & exploitation-depth pass (v3.5.2).
|
|
||||||
//!
|
|
||||||
//! Encodes the post-engagement discipline learned from reviewing real
|
|
||||||
//! AI-pentest output, applied deterministically after validation:
|
|
||||||
//! 1. **Calibrate severity to PROVEN impact** — an unproven High/Critical
|
|
||||||
//! (hedged language, no payload, thin evidence) is capped to Medium and
|
|
||||||
//! re-titled "(potential)". No inflated severities.
|
|
||||||
//! 2. **Exposed → exploited** — flag info-disclosure / exposed-service /
|
|
||||||
//! leaked-credential findings on a host that has no actual exploit, so the
|
|
||||||
//! operator knows to *use* what was exposed (or down-rate it to a lead).
|
|
||||||
//! 3. **Consolidate hygiene** — when the same hygiene class (missing headers,
|
|
||||||
//! clickjacking, cookie flags, TLS, info-disclosure…) repeats across many
|
|
||||||
//! assets, advise merging into ONE finding with an affected-asset table,
|
|
||||||
//! instead of inflating the count one-per-host.
|
|
||||||
//!
|
|
||||||
//! All functions are pure/deterministic; only `calibrate` mutates findings
|
|
||||||
//! (severity/title/confidence). The rest return advisory strings streamed to
|
|
||||||
//! the operator and recorded with the run.
|
|
||||||
use crate::types::Finding;
|
|
||||||
|
|
||||||
fn host_of(endpoint: &str) -> String {
|
|
||||||
let s = endpoint.trim();
|
|
||||||
let s = s.split("://").last().unwrap_or(s);
|
|
||||||
let s = s.split('/').next().unwrap_or(s);
|
|
||||||
s.split('?').next().unwrap_or(s).to_lowercase()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sev_rank(s: &str) -> u8 {
|
|
||||||
match s.to_lowercase().as_str() {
|
|
||||||
x if x.starts_with("crit") => 4,
|
|
||||||
x if x.starts_with("high") => 3,
|
|
||||||
x if x.starts_with("med") => 2,
|
|
||||||
x if x.starts_with("low") => 1,
|
|
||||||
_ => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn short(s: &str) -> String {
|
|
||||||
s.chars().take(64).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hedging words that signal an impact was described but not demonstrated
|
|
||||||
/// (English + Portuguese, since engagements are bilingual).
|
|
||||||
const WEASEL: &[&str] = &[
|
|
||||||
"could ", "may ", "might ", "potential", "possible", "possibly", "teóric", "theoret",
|
|
||||||
"poderia", "possív", "potencial", "if the ", "caso o", "caso a", "would allow", "permitiria",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// A finding that *exposes* something (recon/disclosure) rather than being an
|
|
||||||
/// exploit with demonstrated impact.
|
|
||||||
fn is_exposure(f: &Finding) -> bool {
|
|
||||||
let cwe = f.cwe.to_lowercase();
|
|
||||||
let t = f.title.to_lowercase();
|
|
||||||
["200", "527", "538", "942", "497", "209", "548", "16"].iter().any(|c| cwe.contains(c))
|
|
||||||
|| [
|
|
||||||
"disclosure", "exposed", "exposi", "exposure", "catalog", "catálogo", "cors",
|
|
||||||
"banner", "version", "versão", "header", "cabeçalho", ".git", "enumerat",
|
|
||||||
"fingerprint", "wsdl", "swagger", "missing security", "outdated", "eol",
|
|
||||||
]
|
|
||||||
.iter()
|
|
||||||
.any(|k| t.contains(k))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads as unproven: hedged or thin evidence AND no concrete payload.
|
|
||||||
fn looks_unproven(f: &Finding) -> bool {
|
|
||||||
let blob = format!("{} {} {}", f.title, f.impact, f.evidence).to_lowercase();
|
|
||||||
let hedged = WEASEL.iter().any(|w| blob.contains(w));
|
|
||||||
let weak_ev = f.evidence.trim().chars().count() < 40;
|
|
||||||
let no_payload = f.payload.trim().is_empty();
|
|
||||||
(hedged || weak_ev) && no_payload
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normalized hygiene class, for consolidation advice.
|
|
||||||
fn class_of(f: &Finding) -> &'static str {
|
|
||||||
let t = f.title.to_lowercase();
|
|
||||||
if t.contains("header") || t.contains("cabeçalho") { "missing-security-headers" }
|
|
||||||
else if t.contains("clickjack") || t.contains("frame") { "clickjacking" }
|
|
||||||
else if t.contains("hsts") || t.contains("strict-transport") { "missing-hsts" }
|
|
||||||
else if t.contains("cookie") { "cookie-flags" }
|
|
||||||
else if t.contains("tls") || t.contains("ssl") { "weak-tls" }
|
|
||||||
else if t.contains("cors") { "cors-misconfig" }
|
|
||||||
else if t.contains("version") || t.contains("versão") || t.contains("banner") || t.contains("eol") || t.contains("outdated") { "version-disclosure" }
|
|
||||||
else { "information-disclosure" }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cap inflated, unproven High/Critical findings to Medium. Returns advisories.
|
|
||||||
pub fn calibrate(findings: &mut [Finding]) -> Vec<String> {
|
|
||||||
let mut notes = Vec::new();
|
|
||||||
for f in findings.iter_mut() {
|
|
||||||
if sev_rank(&f.severity) >= 3 && looks_unproven(f) {
|
|
||||||
let old = f.severity.clone();
|
|
||||||
f.severity = "Medium".into();
|
|
||||||
f.confidence = f.confidence.min(0.5);
|
|
||||||
let low = f.title.to_lowercase();
|
|
||||||
if !low.contains("potential") && !low.contains("potencial") {
|
|
||||||
f.title = format!("{} (potential — impact not demonstrated)", f.title);
|
|
||||||
}
|
|
||||||
notes.push(format!(
|
|
||||||
"severity calibrated: \"{}\" {old} → Medium (impact not demonstrated)",
|
|
||||||
short(&f.title)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
notes
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "Exposed → exploited": exposures on a host with no real exploit get flagged.
|
|
||||||
pub fn depth_audit(findings: &[Finding]) -> Vec<String> {
|
|
||||||
let exploited: std::collections::HashSet<String> = findings
|
|
||||||
.iter()
|
|
||||||
.filter(|f| !is_exposure(f) && sev_rank(&f.severity) >= 2)
|
|
||||||
.map(|f| host_of(&f.endpoint))
|
|
||||||
.collect();
|
|
||||||
let mut notes = Vec::new();
|
|
||||||
for f in findings.iter().filter(|f| is_exposure(f)) {
|
|
||||||
if !exploited.contains(&host_of(&f.endpoint)) {
|
|
||||||
notes.push(format!(
|
|
||||||
"depth gap: \"{}\" exposed but not exploited — USE it (call the endpoint / decode the artifact / log in / hit the dev host) to prove impact, or down-rate to a lead",
|
|
||||||
short(&f.title)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
notes.truncate(8);
|
|
||||||
notes
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Advise consolidating hygiene classes that repeat across multiple assets.
|
|
||||||
pub fn hygiene_summary(findings: &[Finding]) -> Vec<String> {
|
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
|
||||||
let mut groups: BTreeMap<&'static str, BTreeSet<String>> = BTreeMap::new();
|
|
||||||
for f in findings.iter().filter(|f| is_exposure(f)) {
|
|
||||||
groups.entry(class_of(f)).or_default().insert(host_of(&f.endpoint));
|
|
||||||
}
|
|
||||||
let mut notes = Vec::new();
|
|
||||||
for (class, hosts) in groups {
|
|
||||||
if hosts.len() > 1 {
|
|
||||||
notes.push(format!(
|
|
||||||
"hygiene: '{class}' affects {} assets — consolidate into ONE finding with an affected-asset table (don't inflate the count one-per-host)",
|
|
||||||
hosts.len()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
notes
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
fn f(title: &str, sev: &str, cwe: &str, ep: &str, ev: &str, payload: &str) -> Finding {
|
|
||||||
let mut x = Finding::default();
|
|
||||||
x.title = title.into(); x.severity = sev.into(); x.cwe = cwe.into();
|
|
||||||
x.endpoint = ep.into(); x.evidence = ev.into(); x.payload = payload.into();
|
|
||||||
x
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unproven_high_is_capped() {
|
|
||||||
let mut v = vec![f("Flooding DoS", "High", "CWE-770", "https://a/x", "could overload", "")];
|
|
||||||
let notes = calibrate(&mut v);
|
|
||||||
assert_eq!(v[0].severity, "Medium");
|
|
||||||
assert_eq!(notes.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn proven_high_is_kept() {
|
|
||||||
let mut v = vec![f("SQLi", "High", "CWE-89", "https://a/x",
|
|
||||||
"id=1' UNION SELECT version()-- returned 8.0.32 in the response body, proving injection", "1' OR '1'='1")];
|
|
||||||
calibrate(&mut v);
|
|
||||||
assert_eq!(v[0].severity, "High");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn exposure_without_exploit_flagged() {
|
|
||||||
let v = vec![f("Information Disclosure - .git exposed", "Low", "CWE-527", "https://a/.git", "leaked", "")];
|
|
||||||
assert_eq!(depth_audit(&v).len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn exposure_with_exploit_on_same_host_not_flagged() {
|
|
||||||
let v = vec![
|
|
||||||
f("Information Disclosure - banner", "Low", "CWE-200", "https://a/x", "Server: IIS", ""),
|
|
||||||
f("SQL Injection", "High", "CWE-89", "https://a/login", "dumped users", "1'--"),
|
|
||||||
];
|
|
||||||
assert!(depth_audit(&v).is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
//! External integrations (v3.5.3): GitHub / GitLab (private repos, PR/MR code
|
|
||||||
//! review, commit watching) and Jira (open one vulnerability card per finding).
|
|
||||||
//!
|
|
||||||
//! Config persists to `<project>/.neurosploit/integrations.json`. **Secrets are
|
|
||||||
//! never stored** — only the *name* of the env var holding each token is saved;
|
|
||||||
//! the value is read from the environment at use time.
|
|
||||||
use crate::types::Finding;
|
|
||||||
use anyhow::{anyhow, Result};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
|
||||||
pub struct GithubCfg {
|
|
||||||
pub enabled: bool,
|
|
||||||
pub token_env: String, // e.g. GITHUB_TOKEN (a PAT with `repo` scope for private repos)
|
|
||||||
pub api: String, // https://api.github.com (or GHE base)
|
|
||||||
}
|
|
||||||
impl Default for GithubCfg {
|
|
||||||
fn default() -> Self { Self { enabled: false, token_env: "GITHUB_TOKEN".into(), api: "https://api.github.com".into() } }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
|
||||||
pub struct GitlabCfg {
|
|
||||||
pub enabled: bool,
|
|
||||||
pub token_env: String, // GITLAB_TOKEN
|
|
||||||
pub base: String, // https://gitlab.com (or self-hosted)
|
|
||||||
}
|
|
||||||
impl Default for GitlabCfg {
|
|
||||||
fn default() -> Self { Self { enabled: false, token_env: "GITLAB_TOKEN".into(), base: "https://gitlab.com".into() } }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
|
||||||
pub struct JiraCfg {
|
|
||||||
pub enabled: bool,
|
|
||||||
pub base_url: String, // https://your-org.atlassian.net
|
|
||||||
pub email_env: String, // JIRA_EMAIL
|
|
||||||
pub token_env: String, // JIRA_API_TOKEN
|
|
||||||
pub project_key: String,
|
|
||||||
pub issue_type: String, // Bug / Vulnerability / Task
|
|
||||||
}
|
|
||||||
impl Default for JiraCfg {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self { enabled: false, base_url: String::new(), email_env: "JIRA_EMAIL".into(),
|
|
||||||
token_env: "JIRA_API_TOKEN".into(), project_key: String::new(), issue_type: "Bug".into() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Default)]
|
|
||||||
pub struct Integrations {
|
|
||||||
pub github: GithubCfg,
|
|
||||||
pub gitlab: GitlabCfg,
|
|
||||||
pub jira: JiraCfg,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn env(name: &str) -> Option<String> {
|
|
||||||
std::env::var(name).ok().filter(|v| !v.trim().is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn client() -> reqwest::Client {
|
|
||||||
reqwest::Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
|
||||||
.build()
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Integrations {
|
|
||||||
pub fn path(dir: &Path) -> std::path::PathBuf { dir.join("integrations.json") }
|
|
||||||
|
|
||||||
pub fn load(dir: &Path) -> Self {
|
|
||||||
std::fs::read_to_string(Self::path(dir))
|
|
||||||
.ok()
|
|
||||||
.and_then(|t| serde_json::from_str(&t).ok())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save(&self, dir: &Path) -> Result<()> {
|
|
||||||
std::fs::create_dir_all(dir).ok();
|
|
||||||
std::fs::write(Self::path(dir), serde_json::to_string_pretty(self)?)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn github_token(&self) -> Option<String> { env(&self.github.token_env) }
|
|
||||||
pub fn gitlab_token(&self) -> Option<String> { env(&self.gitlab.token_env) }
|
|
||||||
|
|
||||||
/// Inject a token into an https git URL so private repos can be cloned.
|
|
||||||
/// No-op if the matching integration is off, the token env is unset, or the
|
|
||||||
/// URL doesn't match the configured host.
|
|
||||||
pub fn authed_clone_url(&self, url: &str) -> String {
|
|
||||||
if self.github.enabled {
|
|
||||||
if let Some(rest) = url.strip_prefix("https://github.com/") {
|
|
||||||
if let Some(tok) = self.github_token() {
|
|
||||||
return format!("https://x-access-token:{tok}@github.com/{rest}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if self.gitlab.enabled {
|
|
||||||
let host = self.gitlab.base.trim_start_matches("https://").trim_start_matches("http://").trim_end_matches('/');
|
|
||||||
let prefix = format!("https://{host}/");
|
|
||||||
if let Some(rest) = url.strip_prefix(&prefix) {
|
|
||||||
if let Some(tok) = self.gitlab_token() {
|
|
||||||
return format!("https://oauth2:{tok}@{host}/{rest}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
url.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Post a comment on a GitHub PR/issue (`repo` = `owner/name`).
|
|
||||||
pub async fn github_comment(&self, repo: &str, number: u64, body: &str) -> Result<()> {
|
|
||||||
let tok = self.github_token().ok_or_else(|| anyhow!("{} not set", self.github.token_env))?;
|
|
||||||
let url = format!("{}/repos/{}/issues/{}/comments", self.github.api.trim_end_matches('/'), repo, number);
|
|
||||||
let resp = client().post(&url)
|
|
||||||
.header("User-Agent", "NeuroSploit")
|
|
||||||
.header("Accept", "application/vnd.github+json")
|
|
||||||
.bearer_auth(tok)
|
|
||||||
.json(&serde_json::json!({ "body": body }))
|
|
||||||
.send().await?;
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(anyhow!("github comment failed: {} {}", resp.status(), resp.text().await.unwrap_or_default()));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Latest commit SHA of a branch via the GitHub API (for `watch`).
|
|
||||||
pub async fn github_latest_sha(&self, repo: &str, branch: &str) -> Result<String> {
|
|
||||||
let url = format!("{}/repos/{}/commits/{}", self.github.api.trim_end_matches('/'), repo, branch);
|
|
||||||
let mut req = client().get(&url)
|
|
||||||
.header("User-Agent", "NeuroSploit")
|
|
||||||
.header("Accept", "application/vnd.github+json");
|
|
||||||
if let Some(t) = self.github_token() { req = req.bearer_auth(t); }
|
|
||||||
let resp = req.send().await?;
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(anyhow!("github commits API {}: {}", resp.status(), resp.text().await.unwrap_or_default()));
|
|
||||||
}
|
|
||||||
let v: serde_json::Value = resp.json().await?;
|
|
||||||
v["sha"].as_str().map(|s| s.to_string()).ok_or_else(|| anyhow!("no sha in response"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create one Jira issue. Returns the issue key (e.g. SEC-123).
|
|
||||||
pub async fn jira_card(&self, summary: &str, description: &str) -> Result<String> {
|
|
||||||
let email = env(&self.jira.email_env).ok_or_else(|| anyhow!("{} not set", self.jira.email_env))?;
|
|
||||||
let token = env(&self.jira.token_env).ok_or_else(|| anyhow!("{} not set", self.jira.token_env))?;
|
|
||||||
if self.jira.base_url.is_empty() || self.jira.project_key.is_empty() {
|
|
||||||
return Err(anyhow!("jira base_url/project_key not configured (run /integrations setup jira)"));
|
|
||||||
}
|
|
||||||
let url = format!("{}/rest/api/2/issue", self.jira.base_url.trim_end_matches('/'));
|
|
||||||
let payload = serde_json::json!({
|
|
||||||
"fields": {
|
|
||||||
"project": { "key": self.jira.project_key },
|
|
||||||
"summary": summary,
|
|
||||||
"description": description,
|
|
||||||
"issuetype": { "name": self.jira.issue_type },
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let resp = client().post(&url)
|
|
||||||
.basic_auth(email, Some(token))
|
|
||||||
.header("Accept", "application/json")
|
|
||||||
.json(&payload)
|
|
||||||
.send().await?;
|
|
||||||
let status = resp.status();
|
|
||||||
let text = resp.text().await.unwrap_or_default();
|
|
||||||
if !status.is_success() {
|
|
||||||
return Err(anyhow!("jira create failed: {} {}", status, text));
|
|
||||||
}
|
|
||||||
let v: serde_json::Value = serde_json::from_str(&text)?;
|
|
||||||
Ok(v["key"].as_str().unwrap_or("?").to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open one Jira card per finding. Returns (created keys, errors).
|
|
||||||
pub async fn jira_cards_for(&self, target: &str, findings: &[Finding]) -> (Vec<String>, Vec<String>) {
|
|
||||||
let (mut keys, mut errs) = (Vec::new(), Vec::new());
|
|
||||||
for f in findings {
|
|
||||||
let summary = format!("[{}] {} — {}", f.severity, f.title, target);
|
|
||||||
let description = format!(
|
|
||||||
"*Target:* {target}\n*Severity:* {} | *CVSS:* {} | *CWE:* {}\n*Location:* {}\n\n*Impact:*\n{}\n\n*PoC / payload:*\n{{code}}{}{{code}}\n\n*Evidence:*\n{{code}}{}{{code}}\n\n*Remediation:*\n{}\n\n_Filed automatically by NeuroSploit._",
|
|
||||||
f.severity, f.cvss, f.cwe, f.endpoint, f.impact, f.payload, f.evidence, f.remediation
|
|
||||||
);
|
|
||||||
match self.jira_card(&summary, &description).await {
|
|
||||||
Ok(k) => keys.push(k),
|
|
||||||
Err(e) => errs.push(format!("{}: {e}", f.title)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(keys, errs)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Human-readable status (for `/integrations` and the CLI).
|
|
||||||
pub fn status_lines(&self) -> Vec<String> {
|
|
||||||
let badge = |on: bool, tok: bool| if !on { "off".to_string() }
|
|
||||||
else if tok { "on ✓ token".to_string() } else { "on ⚠ token env not set".to_string() };
|
|
||||||
vec![
|
|
||||||
format!("github : {:<18} (clone private repos · PR review · watch) env={}", badge(self.github.enabled, self.github_token().is_some()), self.github.token_env),
|
|
||||||
format!("gitlab : {:<18} (clone private repos · MR review) env={}", badge(self.gitlab.enabled, self.gitlab_token().is_some()), self.gitlab.token_env),
|
|
||||||
format!("jira : {:<18} (open a card per finding) project={} base={}",
|
|
||||||
badge(self.jira.enabled, env(&self.jira.token_env).is_some()),
|
|
||||||
if self.jira.project_key.is_empty() { "-" } else { &self.jira.project_key },
|
|
||||||
if self.jira.base_url.is_empty() { "-" } else { &self.jira.base_url }),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//! NeuroSploit v3.5.4 harness — a robust multi-model runtime for the
|
//! NeuroSploit v3.5.1 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
|
||||||
@@ -11,8 +11,6 @@ pub mod attack_graph;
|
|||||||
pub mod belief;
|
pub mod belief;
|
||||||
pub mod creds;
|
pub mod creds;
|
||||||
pub mod grounding;
|
pub mod grounding;
|
||||||
pub mod hygiene;
|
|
||||||
pub mod integrations;
|
|
||||||
pub mod pomdp;
|
pub mod pomdp;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
|
|||||||
@@ -49,12 +49,6 @@ pub fn providers() -> Vec<Provider> {
|
|||||||
models: vec!["gpt-4o", "claude-3-7-sonnet", "gemini/gemini-2.5-pro"] },
|
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",
|
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"] },
|
models: vec!["anthropic/claude-opus-4-8", "qwen/qwen-2.5-coder-32b-instruct", "deepseek/deepseek-r1", "meta-llama/llama-3.3-70b-instruct"] },
|
||||||
// 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.
|
|
||||||
// base_url is resolved from the endpoint at call time; auth uses an api-key header.
|
|
||||||
Provider { key: "azure", label: "Azure OpenAI", base_url: "", env_key: "AZURE_OPENAI_API_KEY", kind: "api",
|
|
||||||
models: vec!["gpt-4o", "gpt-4o-mini", "gpt-5.1", "o4-mini"] },
|
|
||||||
Provider { key: "ollama", label: "Ollama (local)", base_url: "http://localhost:11434/v1", env_key: "OLLAMA_API_KEY", kind: "api",
|
Provider { key: "ollama", label: "Ollama (local)", base_url: "http://localhost:11434/v1", env_key: "OLLAMA_API_KEY", kind: "api",
|
||||||
models: vec!["qwen2.5-coder:32b", "qwq:32b", "deepseek-r1:32b", "llama3.3:70b"] },
|
models: vec!["qwen2.5-coder:32b", "qwq:32b", "deepseek-r1:32b", "llama3.3:70b"] },
|
||||||
]
|
]
|
||||||
@@ -64,17 +58,6 @@ pub fn provider_for(key: &str) -> Option<Provider> {
|
|||||||
providers().into_iter().find(|p| p.key == key)
|
providers().into_iter().find(|p| p.key == key)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a provider's API key from the environment, honoring common aliases.
|
|
||||||
/// For Gemini we also accept `GOOGLE_API_KEY` (Google's standard env var name)
|
|
||||||
/// when `GEMINI_API_KEY` is unset.
|
|
||||||
fn resolve_key(p: &Provider) -> String {
|
|
||||||
let mut k = std::env::var(p.env_key).unwrap_or_default();
|
|
||||||
if k.is_empty() && p.key == "gemini" {
|
|
||||||
k = std::env::var("GOOGLE_API_KEY").unwrap_or_default();
|
|
||||||
}
|
|
||||||
k
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `provider:model` selection.
|
/// A `provider:model` selection.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ModelRef {
|
pub struct ModelRef {
|
||||||
@@ -114,32 +97,17 @@ impl ChatClient {
|
|||||||
pub async fn chat(&self, m: &ModelRef, system: &str, user: &str) -> Result<String> {
|
pub async fn chat(&self, m: &ModelRef, system: &str, user: &str) -> Result<String> {
|
||||||
let p = provider_for(&m.provider)
|
let p = provider_for(&m.provider)
|
||||||
.ok_or_else(|| anyhow!("unknown provider '{}'", m.provider))?;
|
.ok_or_else(|| anyhow!("unknown provider '{}'", m.provider))?;
|
||||||
let key = resolve_key(&p);
|
let key = std::env::var(p.env_key).unwrap_or_default();
|
||||||
if key.is_empty() && p.key != "ollama" && p.key != "litellm" {
|
if key.is_empty() && p.key != "ollama" && p.key != "litellm" {
|
||||||
let hint = if p.key == "gemini" { format!("{} (or GOOGLE_API_KEY)", p.env_key) } else { p.env_key.to_string() };
|
return Err(anyhow!("no API key ({}) for provider '{}'", p.env_key, p.key));
|
||||||
return Err(anyhow!("no API key ({}) for provider '{}'", hint, p.key));
|
|
||||||
}
|
}
|
||||||
// Azure OpenAI uses a per-resource endpoint + deployment + api-version,
|
|
||||||
// and authenticates with an `api-key` header instead of Bearer.
|
|
||||||
let azure = p.key == "azure";
|
|
||||||
let url = if azure {
|
|
||||||
let endpoint = std::env::var("AZURE_OPENAI_ENDPOINT").unwrap_or_default();
|
|
||||||
if endpoint.is_empty() {
|
|
||||||
return Err(anyhow!("set AZURE_OPENAI_ENDPOINT (e.g. https://<resource>.openai.azure.com) for the azure provider"));
|
|
||||||
}
|
|
||||||
let ver = std::env::var("AZURE_OPENAI_API_VERSION").unwrap_or_else(|_| "2024-10-21".to_string());
|
|
||||||
// `model` is the Azure DEPLOYMENT name (use `azure:<deployment>`).
|
|
||||||
format!("{}/openai/deployments/{}/chat/completions?api-version={}",
|
|
||||||
endpoint.trim_end_matches('/'), m.model, ver)
|
|
||||||
} else {
|
|
||||||
// Allow an env base-URL override (LiteLLM gateway, self-hosted proxies, …).
|
// Allow an env base-URL override (LiteLLM gateway, self-hosted proxies, …).
|
||||||
let base = match p.key {
|
let base = match p.key {
|
||||||
"litellm" => std::env::var("LITELLM_BASE_URL").unwrap_or_else(|_| p.base_url.to_string()),
|
"litellm" => std::env::var("LITELLM_BASE_URL").unwrap_or_else(|_| p.base_url.to_string()),
|
||||||
"ollama" => std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| p.base_url.to_string()),
|
"ollama" => std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| p.base_url.to_string()),
|
||||||
_ => p.base_url.to_string(),
|
_ => p.base_url.to_string(),
|
||||||
};
|
};
|
||||||
format!("{}/chat/completions", base.trim_end_matches('/'))
|
let url = format!("{}/chat/completions", base.trim_end_matches('/'));
|
||||||
};
|
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"model": m.model,
|
"model": m.model,
|
||||||
"max_tokens": 4096,
|
"max_tokens": 4096,
|
||||||
@@ -151,7 +119,7 @@ impl ChatClient {
|
|||||||
});
|
});
|
||||||
let mut req = self.http.post(&url).json(&body);
|
let mut req = self.http.post(&url).json(&body);
|
||||||
if !key.is_empty() {
|
if !key.is_empty() {
|
||||||
if azure { req = req.header("api-key", &key); } else { req = req.bearer_auth(&key); }
|
req = req.bearer_auth(&key);
|
||||||
}
|
}
|
||||||
let resp = req.send().await?;
|
let resp = req.send().await?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
|
|||||||
@@ -60,11 +60,7 @@ fn tool_doctrine(mcp_on: bool) -> String {
|
|||||||
Use only what is installed; degrade gracefully. Never run destructive or DoS actions.\n\n"
|
Use only what is installed; degrade gracefully. Never run destructive or DoS actions.\n\n"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const VOTE_SYS: &str = "You are an adversarial security validator. Decide if the candidate finding is a REAL, reproducible, exploitable vulnerability whose EVIDENCE actually proves impact. Reject common false positives: input merely reflected but not executed; version/banner guesses with no working PoC; self-XSS; theoretical issues; an error message or stack trace mistaken for injection; missing, generic, or non-reproducible evidence; severity inflated beyond what the evidence demonstrates. Confirm only if the provided evidence (request/response) concretely proves the vulnerability. Reply with JSON {\"verdict\":\"confirmed\"|\"rejected\",\"reason\":\"...\"}. Default to rejected when uncertain.";
|
const VOTE_SYS: &str = "You are an adversarial security validator. Decide if the candidate finding is a REAL, reproducible, exploitable vulnerability with proof. Reply with JSON {\"verdict\":\"confirmed\"|\"rejected\",\"reason\":\"...\"}. Default to rejected when uncertain.";
|
||||||
/// Adversarial second pass for High/Critical findings: assume false positive
|
|
||||||
/// until the evidence forces otherwise. A finding that can't withstand the
|
|
||||||
/// skeptics is dropped.
|
|
||||||
const REFUTE_SYS: &str = "You are a skeptical senior reviewer trying to DISPROVE a reported vulnerability. Assume it is a FALSE POSITIVE unless the evidence forces otherwise. Scrutinize: does the evidence PROVE execution/impact, or only that input was reflected/accepted? Is there a real working PoC, or just a version/banner/theory? Could it be self-XSS, an error message, or an unreachable path? Reply JSON {\"verdict\":\"confirmed\"|\"rejected\",\"reason\":\"...\"} where confirmed means the vulnerability is REAL and proven by the evidence. When in doubt, reject.";
|
|
||||||
const CODE_VOTE_SYS: &str = "You are an adversarial source-code reviewer. Decide if the reported issue is a REAL vulnerability in the provided code (reachable, exploitable, not a false positive). Reply JSON {\"verdict\":\"confirmed\"|\"rejected\",\"reason\":\"...\"}.";
|
const CODE_VOTE_SYS: &str = "You are an adversarial source-code reviewer. Decide if the reported issue is a REAL vulnerability in the provided code (reachable, exploitable, not a false positive). Reply JSON {\"verdict\":\"confirmed\"|\"rejected\",\"reason\":\"...\"}.";
|
||||||
|
|
||||||
/// ReAct loop directive: make the agent reason → act with a tool → observe →
|
/// ReAct loop directive: make the agent reason → act with a tool → observe →
|
||||||
@@ -73,16 +69,6 @@ const REACT_DOCTRINE: &str = "METHOD (ReAct): work in explicit Thought → Actio
|
|||||||
Each Action runs ONE concrete tool command (e.g. a curl request); read its real Observation before the next Thought. \
|
Each Action runs ONE concrete tool command (e.g. a curl request); read its real Observation before the next Thought. \
|
||||||
Base every claim on an actual observed response — never assume. Stop when you've either proven an issue or exhausted reasonable checks. Be token-efficient: no filler, no repetition.\n\n";
|
Base every claim on an actual observed response — never assume. Stop when you've either proven an issue or exhausted reasonable checks. Be token-efficient: no filler, no repetition.\n\n";
|
||||||
|
|
||||||
/// DEPTH doctrine (v3.5.2): push past detection to demonstrated impact, and
|
|
||||||
/// chain. Distilled from reviewing real AI-pentest output that kept stopping at
|
|
||||||
/// "exposed" instead of "exploited".
|
|
||||||
const DEPTH_DOCTRINE: &str = "DEPTH (exploit, don't just expose):\n\
|
|
||||||
- Exposed → exploited: any info-disclosure, exposed service/catalog/WSDL, leaked credential/token, or non-prod (dev/staging) host you find MUST be USED before you report it — call the exposed endpoint, decode the leaked artifact, log in with the leaked credential, hit the dev host. If you only observed it but never used it, report it as a LEAD (low confidence), not a confirmed finding.\n\
|
|
||||||
- Chain across steps: reuse any session/JWT/cookie/credential you obtain in one step against every other module; if one bug yields access, pivot it into IDOR/privesc/data-exfil and report the CHAIN, not isolated parts.\n\
|
|
||||||
- Decode & fingerprint → CVE: decode opaque tokens/paths (base64/JSON/marshal) and fingerprint the stack (server, framework, library/gem/plugin versions); map exact versions to known CVEs and attempt a safe, non-destructive PoC.\n\
|
|
||||||
- Audit tokens: for any JWT, check alg-confusion (RS→HS), alg:none, kid/jku injection, whether the signature is actually verified, and weak/guessable HS256 secrets.\n\
|
|
||||||
- Calibrate honestly: claim High/Critical ONLY when impact is DEMONSTRATED; unproven DoS/abuse is Low/Info or a lead, never inflated.\n\n";
|
|
||||||
|
|
||||||
/// Black-box web engagement: recon → parallel exploit → N-model vote → report.
|
/// Black-box web engagement: recon → parallel exploit → N-model vote → report.
|
||||||
pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<String>) -> RunOutput {
|
pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<String>) -> RunOutput {
|
||||||
pool.set_progress(tx.clone());
|
pool.set_progress(tx.clone());
|
||||||
@@ -182,13 +168,12 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
|||||||
let user = format!(
|
let user = format!(
|
||||||
"AUTHORIZED engagement — you have explicit permission to test {target}. \
|
"AUTHORIZED engagement — you have explicit permission to test {target}. \
|
||||||
Do not ask for confirmation — proceed and PROVE each issue.\n\n\
|
Do not ask for confirmation — proceed and PROVE each issue.\n\n\
|
||||||
{directives}{react}{depth}{doctrine}{body}\n\nWhen done, reply with ONLY a JSON array of confirmed findings (may be empty []). \
|
{directives}{react}{doctrine}{body}\n\nWhen done, reply with ONLY a JSON array of confirmed findings (may be empty []). \
|
||||||
Each item: {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}}. \
|
Each item: {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}}. \
|
||||||
`evidence` must contain the concrete proof (request/response excerpt).",
|
`evidence` must contain the concrete proof (request/response excerpt).",
|
||||||
target = target,
|
target = target,
|
||||||
directives = directives,
|
directives = directives,
|
||||||
react = REACT_DOCTRINE,
|
react = REACT_DOCTRINE,
|
||||||
depth = DEPTH_DOCTRINE,
|
|
||||||
doctrine = tool_doctrine(mcp_on),
|
doctrine = tool_doctrine(mcp_on),
|
||||||
body = ag.user.replace("{target}", &target).replace("{recon_json}", &recon),
|
body = ag.user.replace("{target}", &target).replace("{recon_json}", &recon),
|
||||||
);
|
);
|
||||||
@@ -221,11 +206,14 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
|||||||
// ---- 4. Validate by N-model voting ---------------------------------
|
// ---- 4. Validate by N-model voting ---------------------------------
|
||||||
let mut findings = validate(candidates, pool, VOTE_SYS, cfg.vote_n, &tx).await;
|
let mut findings = validate(candidates, pool, VOTE_SYS, cfg.vote_n, &tx).await;
|
||||||
|
|
||||||
// ---- 5. Attack chaining: multi-round post-exploitation pivots ------
|
// ---- 5. Chain confirmed findings into deeper impact ----------------
|
||||||
let chained = attack_chain(pool, &cfg, &recon, &findings, &lib.chains, &tx).await;
|
let chained = chain_round(pool, &cfg.target, &recon, &operator_directives(&cfg), &findings, &lib.chains, &tx).await;
|
||||||
findings.extend(chained);
|
if !chained.is_empty() {
|
||||||
|
let extra = validate(dedup_findings(chained), pool, VOTE_SYS, cfg.vote_n, &tx).await;
|
||||||
|
let _ = tx.send(format!("chaining added {} validated finding(s)", extra.len())).await;
|
||||||
|
findings.extend(extra);
|
||||||
findings = dedup_findings(findings);
|
findings = dedup_findings(findings);
|
||||||
let findings = refute_pass(findings, pool, cfg.vote_n, &tx).await;
|
}
|
||||||
finish(cfg, lib, recon, transcript, findings, selected, &mut rl, tx).await
|
finish(cfg, lib, recon, transcript, findings, selected, &mut rl, tx).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +275,6 @@ pub async fn run_whitebox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: S
|
|||||||
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
|
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
|
||||||
let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating", candidates.len())).await;
|
let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating", candidates.len())).await;
|
||||||
let findings = validate(candidates, pool, CODE_VOTE_SYS, cfg.vote_n, &tx).await;
|
let findings = validate(candidates, pool, CODE_VOTE_SYS, cfg.vote_n, &tx).await;
|
||||||
let findings = refute_pass(findings, pool, cfg.vote_n, &tx).await;
|
|
||||||
finish(cfg, lib, "{}".into(), transcript, findings, selected, &mut rl, tx).await
|
finish(cfg, lib, "{}".into(), transcript, findings, selected, &mut rl, tx).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,11 +387,11 @@ pub async fn run_greybox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Se
|
|||||||
}
|
}
|
||||||
let user = format!(
|
let user = format!(
|
||||||
"AUTHORIZED greybox engagement on {target} — you also have the source review below. \
|
"AUTHORIZED greybox engagement on {target} — you also have the source review below. \
|
||||||
Proceed and PROVE each issue against the LIVE app.\n\n{directives}{leads}{react}{depth}{doctrine}{body}\n\n\
|
Proceed and PROVE each issue against the LIVE app.\n\n{directives}{leads}{react}{doctrine}{body}\n\n\
|
||||||
Reply ONLY a JSON array of confirmed findings (may be []): \
|
Reply ONLY a JSON array of confirmed findings (may be []): \
|
||||||
{{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}}.",
|
{{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}}.",
|
||||||
target = target, directives = directives, leads = leads,
|
target = target, directives = directives, leads = leads,
|
||||||
react = REACT_DOCTRINE, depth = DEPTH_DOCTRINE, doctrine = tool_doctrine(mcp_on),
|
react = REACT_DOCTRINE, doctrine = tool_doctrine(mcp_on),
|
||||||
body = ag.user.replace("{target}", &target).replace("{recon_json}", &recon),
|
body = ag.user.replace("{target}", &target).replace("{recon_json}", &recon),
|
||||||
);
|
);
|
||||||
match pool.complete_routed(Task::Exploit, &ag.name, &ag.system, &user).await {
|
match pool.complete_routed(Task::Exploit, &ag.name, &ag.system, &user).await {
|
||||||
@@ -423,172 +410,50 @@ pub async fn run_greybox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Se
|
|||||||
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
|
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
|
||||||
let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating", candidates.len())).await;
|
let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating", candidates.len())).await;
|
||||||
let mut findings = validate(candidates, pool, VOTE_SYS, cfg.vote_n, &tx).await;
|
let mut findings = validate(candidates, pool, VOTE_SYS, cfg.vote_n, &tx).await;
|
||||||
let chained = attack_chain(pool, &cfg, &recon, &findings, &lib.chains, &tx).await;
|
let chained = chain_round(pool, &cfg.target, &recon, &operator_directives(&cfg), &findings, &lib.chains, &tx).await;
|
||||||
findings.extend(chained);
|
if !chained.is_empty() {
|
||||||
|
let extra = validate(dedup_findings(chained), pool, VOTE_SYS, cfg.vote_n, &tx).await;
|
||||||
|
let _ = tx.send(format!("chaining added {} validated finding(s)", extra.len())).await;
|
||||||
|
findings.extend(extra);
|
||||||
findings = dedup_findings(findings);
|
findings = dedup_findings(findings);
|
||||||
let findings = refute_pass(findings, pool, cfg.vote_n, &tx).await;
|
}
|
||||||
finish(cfg, lib, recon, transcript, findings, selected, &mut rl, tx).await
|
finish(cfg, lib, recon, transcript, findings, selected, &mut rl, tx).await
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHAIN_SYS: &str = "You are a post-exploitation & attack-chaining specialist. You are given ONE confirmed foothold plus any loot already gathered. DECIDE the most promising directions to expand from THIS foothold and pursue them with real tools: post-exploitation (loot credentials/tokens/keys/config/source), credential reuse, privilege escalation (horizontal AND vertical), lateral movement to adjacent services/hosts, data exfiltration, and reaching NEW attack surface the foothold exposes (e.g. SSRF→cloud metadata creds→IAM, SQLi→DB dump→credential reuse→admin, arbitrary file read→secrets→RCE, IDOR→account takeover, auth bypass→internal APIs). PROVE each escalated step with a real tool receipt. Report ONLY NEW findings beyond the input, plus any new loot you discovered (creds, tokens, hosts, internal endpoints) so later stages can reuse it. Authorized engagement; never destructive/DoS.";
|
const CHAIN_SYS: &str = "You are an exploit-chaining specialist. Given already-CONFIRMED findings, chain them into deeper impact — e.g. SSRF→cloud metadata creds, SQLi→DB dump→credential reuse, IDOR→account takeover, arbitrary file read→secrets→RCE, auth bypass→admin. Use your tools to actually carry the chain forward and PROVE the escalated impact. Report ONLY NEW findings beyond the inputs.";
|
||||||
|
|
||||||
/// One orchestration round: take the confirmed findings and try to chain them
|
/// One orchestration round: take the confirmed findings and try to chain them
|
||||||
/// into higher-impact follow-ups, reusing the recon/auth context. Returns the
|
/// into higher-impact follow-ups, reusing the recon/auth context. Returns the
|
||||||
/// (unvalidated) new candidate findings produced by chaining.
|
/// (unvalidated) new candidate findings produced by chaining.
|
||||||
/// Dedup / identity key for a finding (cwe|endpoint|title-prefix).
|
async fn chain_round(pool: &ModelPool, target: &str, recon: &str, directives: &str,
|
||||||
fn finding_key(f: &Finding) -> String {
|
|
||||||
format!("{}|{}|{}", f.cwe.to_lowercase(), f.endpoint.to_lowercase(),
|
|
||||||
f.title.to_lowercase().chars().take(40).collect::<String>())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sev_rank(sev: &str) -> u8 {
|
|
||||||
match sev.to_lowercase().as_str() {
|
|
||||||
x if x.starts_with("crit") => 4,
|
|
||||||
x if x.starts_with("high") => 3,
|
|
||||||
x if x.starts_with("med") => 2,
|
|
||||||
x if x.starts_with("low") => 1,
|
|
||||||
_ => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Max footholds expanded per round (keeps token cost bounded).
|
|
||||||
const CHAIN_SEEDS_PER_ROUND: usize = 6;
|
|
||||||
|
|
||||||
/// Robust attack-chaining engine (v3.5.4): iterative, decision-driven,
|
|
||||||
/// post-exploitation pivoting. Each round takes the newest confirmed footholds,
|
|
||||||
/// and for EACH one an agent decides which directions to expand (post-ex, cred
|
|
||||||
/// reuse, privesc, lateral, exfil, new surface), proves new impact, and reports
|
|
||||||
/// new findings + **loot** (creds/tokens/hosts/endpoints). Loot is carried
|
|
||||||
/// forward so later rounds reuse it. New validated findings become the next
|
|
||||||
/// round's footholds; the loop stops at `chain_depth` rounds or when a round
|
|
||||||
/// yields nothing new (loop-until-dry). Findings are validated each round so we
|
|
||||||
/// never pivot off a false positive.
|
|
||||||
async fn attack_chain(pool: &ModelPool, cfg: &RunConfig, recon: &str,
|
|
||||||
confirmed: &[Finding], chains: &[Agent], tx: &Sender<String>) -> Vec<Finding> {
|
confirmed: &[Finding], chains: &[Agent], tx: &Sender<String>) -> Vec<Finding> {
|
||||||
let max_rounds = cfg.chain_depth;
|
if confirmed.is_empty() {
|
||||||
if max_rounds == 0 || confirmed.is_empty() || pool.stop_exploiting() {
|
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
|
let summary: String = confirmed.iter().take(20)
|
||||||
|
.map(|f| format!("- [{}] {} @ {} ({})", f.severity, f.title, f.endpoint, f.cwe))
|
||||||
|
.collect::<Vec<_>>().join("\n");
|
||||||
|
// Offer the known chain recipes as a menu so the LLM applies proven multi-stage paths.
|
||||||
let recipes: String = chains.iter().map(|a| format!("- {}", a.title.replace(" Agent", ""))).collect::<Vec<_>>().join("\n");
|
let recipes: String = chains.iter().map(|a| format!("- {}", a.title.replace(" Agent", ""))).collect::<Vec<_>>().join("\n");
|
||||||
let recipe_block = if recipes.is_empty() { String::new() } else { format!("KNOWN CHAIN RECIPES (apply any that fit):\n{recipes}\n\n") };
|
let recipe_block = if recipes.is_empty() { String::new() } else { format!("KNOWN CHAIN RECIPES (apply any that fit):\n{recipes}\n\n") };
|
||||||
let recon_ctx: String = recon.chars().take(2000).collect();
|
let _ = tx.send(format!("chaining {} confirmed finding(s) for deeper impact…", confirmed.len())).await;
|
||||||
let directives = operator_directives(cfg);
|
let recon_ctx: String = recon.chars().take(2500).collect();
|
||||||
|
|
||||||
let mut all_new: Vec<Finding> = Vec::new();
|
|
||||||
let mut loot: Vec<String> = Vec::new();
|
|
||||||
let mut seen: std::collections::HashSet<String> = confirmed.iter().map(finding_key).collect();
|
|
||||||
|
|
||||||
// Frontier = footholds to expand this round; start with confirmed, best-first.
|
|
||||||
let mut frontier: Vec<Finding> = confirmed.to_vec();
|
|
||||||
frontier.sort_by(|a, b| sev_rank(&b.severity).cmp(&sev_rank(&a.severity)));
|
|
||||||
|
|
||||||
for round in 1..=max_rounds {
|
|
||||||
if pool.stop_exploiting() || frontier.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let seeds: Vec<Finding> = frontier.iter().take(CHAIN_SEEDS_PER_ROUND).cloned().collect();
|
|
||||||
let _ = tx.send(format!("⛓ attack-chain round {round}/{max_rounds} — expanding {} foothold(s), {} loot item(s)", seeds.len(), loot.len())).await;
|
|
||||||
|
|
||||||
let loot_snapshot = loot.clone();
|
|
||||||
let results: Vec<(Vec<Finding>, Vec<String>)> = stream::iter(seeds.into_iter())
|
|
||||||
.map(|seed| {
|
|
||||||
let (dir, rc, rb, ls, txc) = (directives.clone(), recon_ctx.clone(), recipe_block.clone(), loot_snapshot.clone(), tx.clone());
|
|
||||||
async move { chain_from_seed(pool, &cfg.target, &dir, &rc, &rb, &seed, &ls, round, max_rounds, &txc).await }
|
|
||||||
})
|
|
||||||
.buffer_unordered(4)
|
|
||||||
.collect()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Merge round output: accumulate loot, gather candidate findings.
|
|
||||||
let mut round_cands: Vec<Finding> = Vec::new();
|
|
||||||
for (fs, lt) in results {
|
|
||||||
for l in lt {
|
|
||||||
if !loot.iter().any(|x| x.eq_ignore_ascii_case(&l)) { loot.push(l); }
|
|
||||||
}
|
|
||||||
round_cands.extend(fs);
|
|
||||||
}
|
|
||||||
// Keep only genuinely NEW findings (unseen key).
|
|
||||||
let fresh: Vec<Finding> = dedup_findings(round_cands)
|
|
||||||
.into_iter()
|
|
||||||
.filter(|f| seen.insert(finding_key(f)))
|
|
||||||
.collect();
|
|
||||||
if fresh.is_empty() {
|
|
||||||
let _ = tx.send("⛓ no new paths this round — chain exhausted".into()).await;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Validate before pivoting further (don't chain off false positives).
|
|
||||||
let validated = validate(fresh, pool, VOTE_SYS, cfg.vote_n, tx).await;
|
|
||||||
let _ = tx.send(format!("⛓ round {round}: +{} validated finding(s), {} loot item(s) total", validated.len(), loot.len())).await;
|
|
||||||
if validated.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
all_new.extend(validated.clone());
|
|
||||||
// Next round expands the freshly-validated footholds, best-first.
|
|
||||||
frontier = validated;
|
|
||||||
frontier.sort_by(|a, b| sev_rank(&b.severity).cmp(&sev_rank(&a.severity)));
|
|
||||||
}
|
|
||||||
if !all_new.is_empty() {
|
|
||||||
let _ = tx.send(format!("⛓ attack-chaining added {} finding(s) across pivots", all_new.len())).await;
|
|
||||||
}
|
|
||||||
all_new
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Expand ONE foothold: the agent decides directions, does post-exploitation and
|
|
||||||
/// pivots, and returns new findings + discovered loot.
|
|
||||||
async fn chain_from_seed(pool: &ModelPool, target: &str, directives: &str, recon_ctx: &str,
|
|
||||||
recipe_block: &str, seed: &Finding, loot: &[String],
|
|
||||||
round: usize, max: usize, tx: &Sender<String>) -> (Vec<Finding>, Vec<String>) {
|
|
||||||
if pool.stop_exploiting() {
|
|
||||||
return (vec![], vec![]);
|
|
||||||
}
|
|
||||||
let loot_block = if loot.is_empty() {
|
|
||||||
"(none yet)".to_string()
|
|
||||||
} else {
|
|
||||||
loot.iter().take(30).map(|l| format!("- {l}")).collect::<Vec<_>>().join("\n")
|
|
||||||
};
|
|
||||||
let short: String = seed.title.chars().take(28).collect();
|
|
||||||
let user = format!(
|
let user = format!(
|
||||||
"AUTHORIZED engagement on {target}.\n\n{directives}{react}{depth}{doctrine}\
|
"AUTHORIZED engagement on {target}.\n\n{directives}{react}{doctrine}{recipe_block}\
|
||||||
FOOTHOLD TO EXPAND (round {round}/{max}):\n- [{}] {} @ {} ({})\n payload: {}\n evidence: {}\n\n\
|
CONFIRMED FINDINGS TO CHAIN:\n{summary}\n\nRecon:\n{recon_ctx}\n\n\
|
||||||
LOOT GATHERED (reuse it):\n{loot_block}\n\n{recipe_block}RECON:\n{recon_ctx}\n\n\
|
Chain these into deeper impact (e.g. SQLi→RCE→LPE, SSRF→cloud creds, upload→LFI→RCE) and PROVE each stage. \
|
||||||
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 a JSON array of NEW findings \
|
||||||
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).",
|
(may be []): {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}}.",
|
||||||
seed.severity, seed.title, seed.endpoint, seed.cwe, seed.payload, seed.evidence,
|
react = REACT_DOCTRINE, doctrine = tool_doctrine(pool.mcp_config.is_some()),
|
||||||
react = REACT_DOCTRINE, depth = DEPTH_DOCTRINE, doctrine = tool_doctrine(pool.mcp_config.is_some()),
|
|
||||||
);
|
);
|
||||||
let label = format!("chain:{short}");
|
match pool.complete_routed(Task::Exploit, "chain", CHAIN_SYS, &user).await {
|
||||||
match pool.complete_routed(Task::Exploit, &label, CHAIN_SYS, &user).await {
|
|
||||||
Ok((m, text)) => {
|
Ok((m, text)) => {
|
||||||
let (f, lt) = extract_chain(&text, "chain");
|
let f = extract_findings(&text, "chain");
|
||||||
if !f.is_empty() || !lt.is_empty() {
|
let _ = tx.send(format!("chain via {} → {} new candidate(s)", m.label(), f.len())).await;
|
||||||
let _ = tx.send(format!("chain[{short}] via {} → {} new finding(s), {} loot", m.label(), f.len(), lt.len())).await;
|
f
|
||||||
}
|
}
|
||||||
(f, lt)
|
Err(e) => { let _ = tx.send(format!("chaining failed: {e}")).await; vec![] }
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
let _ = tx.send(format!("chain[{short}] failed: {e}")).await;
|
|
||||||
(vec![], vec![])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a chain agent reply into (new findings, loot). Accepts the object form
|
|
||||||
/// `{"findings":[...],"loot":[...]}` and falls back to a bare findings array.
|
|
||||||
fn extract_chain(text: &str, agent: &str) -> (Vec<Finding>, Vec<String>) {
|
|
||||||
if let (Some(a), Some(b)) = (text.find('{'), text.rfind('}')) {
|
|
||||||
if b > a {
|
|
||||||
if let Ok(serde_json::Value::Object(o)) = serde_json::from_str::<serde_json::Value>(&text[a..=b]) {
|
|
||||||
if o.contains_key("findings") {
|
|
||||||
let findings = o.get("findings").map(|v| extract_findings(&v.to_string(), agent)).unwrap_or_default();
|
|
||||||
let loot = o.get("loot").and_then(|v| v.as_array())
|
|
||||||
.map(|arr| arr.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
return (findings, loot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(extract_findings(text, agent), vec![])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------- shared
|
// --------------------------------------------------------------------------- shared
|
||||||
@@ -727,11 +592,11 @@ async fn validate(candidates: Vec<Finding>, pool: &ModelPool, sys: &str, vote_n:
|
|||||||
let finder = finder.clone();
|
let finder = finder.clone();
|
||||||
async move {
|
async move {
|
||||||
let q = format!(
|
let q = format!(
|
||||||
"Finding: {} | severity {} | {} | at {} | payload {} | evidence {} | impact {}",
|
"Finding: {} | severity {} | {} | at {} | payload {} | evidence {}",
|
||||||
f.title, f.severity, f.cwe, f.endpoint, f.payload, f.evidence, f.impact
|
f.title, f.severity, f.cwe, f.endpoint, f.payload, f.evidence
|
||||||
);
|
);
|
||||||
let (yes, total) = pool.vote(sys, &q, vote_n, finder.as_deref()).await;
|
let (yes, total) = pool.vote(sys, &q, vote_n, finder.as_deref()).await;
|
||||||
f.validated = crate::pool::quorum_confirmed(&f.severity, yes, total);
|
f.validated = total > 0 && yes * 2 >= total;
|
||||||
f.votes = format!("{yes}/{total}");
|
f.votes = format!("{yes}/{total}");
|
||||||
if f.confidence == 0.0 && total > 0 {
|
if f.confidence == 0.0 && total > 0 {
|
||||||
f.confidence = yes as f64 / total as f64;
|
f.confidence = yes as f64 / total as f64;
|
||||||
@@ -746,37 +611,6 @@ async fn validate(candidates: Vec<Finding>, pool: &ModelPool, sys: &str, vote_n:
|
|||||||
validated.into_iter().filter(|f| f.validated).collect()
|
validated.into_iter().filter(|f| f.validated).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adversarial refutation pass: every confirmed **High/Critical** finding is
|
|
||||||
/// re-examined by a skeptical panel that tries to prove it's a false positive.
|
|
||||||
/// A finding that fails to withstand a majority of skeptics is dropped. Lower
|
|
||||||
/// severities pass through unchanged. Runs only when a real panel exists.
|
|
||||||
async fn refute_pass(findings: Vec<Finding>, pool: &ModelPool, vote_n: usize, tx: &Sender<String>) -> Vec<Finding> {
|
|
||||||
let finder = pool.candidates.first().map(|m| m.label());
|
|
||||||
let mut kept = Vec::new();
|
|
||||||
for mut f in findings {
|
|
||||||
let s = f.severity.to_lowercase();
|
|
||||||
let high = s.starts_with("crit") || s.starts_with("high");
|
|
||||||
if !high || pool.stop_exploiting() {
|
|
||||||
kept.push(f);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let q = format!(
|
|
||||||
"Finding: {} | severity {} | {} | at {} | payload {} | evidence {} | impact {}",
|
|
||||||
f.title, f.severity, f.cwe, f.endpoint, f.payload, f.evidence, f.impact
|
|
||||||
);
|
|
||||||
let (yes, total) = pool.vote(REFUTE_SYS, &q, vote_n.max(2), finder.as_deref()).await;
|
|
||||||
// Survive on no-response (infra failure) or a surviving majority.
|
|
||||||
let survives = total == 0 || yes * 2 > total;
|
|
||||||
if survives {
|
|
||||||
if total > 0 { f.votes = format!("{} · refute {yes}/{total}", f.votes); }
|
|
||||||
kept.push(f);
|
|
||||||
} else {
|
|
||||||
let _ = tx.send(format!("vote {} → dropped by adversarial refute ({yes}/{total})", f.title)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
kept
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: String, mut findings: Vec<Finding>,
|
async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: String, mut findings: Vec<Finding>,
|
||||||
selected: Vec<Agent>, rl: &mut RlState, tx: Sender<String>) -> RunOutput {
|
selected: Vec<Agent>, rl: &mut RlState, tx: Sender<String>) -> RunOutput {
|
||||||
// --- Grounding gate: no claim without a tool receipt (anti-hallucination) ---
|
// --- Grounding gate: no claim without a tool receipt (anti-hallucination) ---
|
||||||
@@ -789,20 +623,6 @@ async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: Strin
|
|||||||
let _ = tx.send(format!("grounding gate: demoted {demoted}/{before} ungrounded claim(s) (no tool receipt)")).await;
|
let _ = tx.send(format!("grounding gate: demoted {demoted}/{before} ungrounded claim(s) (no tool receipt)")).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- v3.5.2 report-hygiene & exploitation-depth pass ---
|
|
||||||
// Calibrate inflated/unproven High-Critical to Medium, flag exposures that
|
|
||||||
// were never exploited ("exposed → exploited"), and advise consolidating
|
|
||||||
// hygiene findings duplicated across many assets.
|
|
||||||
for n in crate::hygiene::calibrate(&mut findings) {
|
|
||||||
let _ = tx.send(format!("calibrate: {n}")).await;
|
|
||||||
}
|
|
||||||
for n in crate::hygiene::depth_audit(&findings) {
|
|
||||||
let _ = tx.send(format!("notify: {n}")).await;
|
|
||||||
}
|
|
||||||
for n in crate::hygiene::hygiene_summary(&findings) {
|
|
||||||
let _ = tx.send(format!("notify: {n}")).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- POMDP belief: build from grounded findings, report residual uncertainty ---
|
// --- POMDP belief: build from grounded findings, report residual uncertainty ---
|
||||||
let mut wm = crate::belief::WorldModel::new();
|
let mut wm = crate::belief::WorldModel::new();
|
||||||
wm.deterministic = whitebox;
|
wm.deterministic = whitebox;
|
||||||
@@ -994,7 +814,13 @@ fn conf(v: Option<&serde_json::Value>) -> f64 {
|
|||||||
fn dedup_findings(mut v: Vec<Finding>) -> Vec<Finding> {
|
fn dedup_findings(mut v: Vec<Finding>) -> Vec<Finding> {
|
||||||
v.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
|
v.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
v.into_iter().filter(|f| seen.insert(finding_key(f))).collect()
|
v.into_iter()
|
||||||
|
.filter(|f| {
|
||||||
|
let key = format!("{}|{}|{}", f.cwe.to_lowercase(), f.endpoint.to_lowercase(),
|
||||||
|
f.title.to_lowercase().chars().take(40).collect::<String>());
|
||||||
|
seen.insert(key)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn norm_sev(s: &str) -> String {
|
fn norm_sev(s: &str) -> String {
|
||||||
@@ -1137,9 +963,11 @@ pub async fn run_host(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sende
|
|||||||
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
|
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
|
||||||
let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating", candidates.len())).await;
|
let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating", candidates.len())).await;
|
||||||
let mut findings = validate(candidates, pool, VOTE_SYS, cfg.vote_n, &tx).await;
|
let mut findings = validate(candidates, pool, VOTE_SYS, cfg.vote_n, &tx).await;
|
||||||
let chained = attack_chain(pool, &cfg, &recon, &findings, &lib.chains, &tx).await;
|
let chained = chain_round(pool, &cfg.target, &recon, &operator_directives(&cfg), &findings, &lib.chains, &tx).await;
|
||||||
findings.extend(chained);
|
if !chained.is_empty() {
|
||||||
|
let extra = validate(dedup_findings(chained), pool, VOTE_SYS, cfg.vote_n, &tx).await;
|
||||||
|
findings.extend(extra);
|
||||||
findings = dedup_findings(findings);
|
findings = dedup_findings(findings);
|
||||||
let findings = refute_pass(findings, pool, cfg.vote_n, &tx).await;
|
}
|
||||||
finish(cfg, lib, recon, transcript, findings, selected, &mut rl, tx).await
|
finish(cfg, lib, recon, transcript, findings, selected, &mut rl, tx).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! POMDP decision layer (v3.5.4): value-of-information planning + the
|
//! POMDP decision layer (v3.5.1): 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
|
||||||
|
|||||||
@@ -312,7 +312,12 @@ impl ModelPool {
|
|||||||
};
|
};
|
||||||
if let Ok(text) = self.one("validate", m, system, user).await {
|
if let Ok(text) = self.one("validate", m, system, user).await {
|
||||||
total += 1;
|
total += 1;
|
||||||
if parse_verdict(&text) == Verdict::Confirmed {
|
let t = text.to_lowercase();
|
||||||
|
if t.contains("\"verdict\": \"confirmed\"")
|
||||||
|
|| t.trim_start().starts_with("yes")
|
||||||
|
|| t.contains("confirmed: true")
|
||||||
|
|| t.contains("is_real\": true")
|
||||||
|
{
|
||||||
confirmed += 1;
|
confirmed += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,97 +333,3 @@ async fn wait_cancelled(flag: &Arc<AtomicBool>) {
|
|||||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A validator's verdict on a candidate finding.
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
|
||||||
pub enum Verdict {
|
|
||||||
Confirmed,
|
|
||||||
Rejected,
|
|
||||||
/// No clear yes/no — treated conservatively as NOT confirmed.
|
|
||||||
Unclear,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Robustly parse a validator reply into a verdict. Whitespace-insensitive
|
|
||||||
/// (so `{"verdict":"confirmed"}` and `{ "verdict": "confirmed" }` both match),
|
|
||||||
/// checks explicit rejection first, and only counts an *explicit* confirmation.
|
|
||||||
/// Anything ambiguous is `Unclear` (does not count as confirmed) — biasing the
|
|
||||||
/// pipeline against false positives.
|
|
||||||
pub fn parse_verdict(text: &str) -> Verdict {
|
|
||||||
let lower = text.to_lowercase();
|
|
||||||
let dense: String = lower.chars().filter(|c| !c.is_whitespace()).collect();
|
|
||||||
|
|
||||||
// Explicit rejection wins (conservative).
|
|
||||||
let rejected = [
|
|
||||||
"\"verdict\":\"rejected\"", "\"verdict\":\"reject\"", "verdict:rejected",
|
|
||||||
"\"is_real\":false", "\"isreal\":false", "\"confirmed\":false", "\"real\":false",
|
|
||||||
"\"exploitable\":false", "\"valid\":false",
|
|
||||||
];
|
|
||||||
if rejected.iter().any(|k| dense.contains(k)) {
|
|
||||||
return Verdict::Rejected;
|
|
||||||
}
|
|
||||||
// Explicit confirmation.
|
|
||||||
let confirmed = [
|
|
||||||
"\"verdict\":\"confirmed\"", "verdict:confirmed",
|
|
||||||
"\"is_real\":true", "\"isreal\":true", "\"confirmed\":true", "\"real\":true",
|
|
||||||
"\"exploitable\":true", "\"valid\":true",
|
|
||||||
];
|
|
||||||
if confirmed.iter().any(|k| dense.contains(k)) {
|
|
||||||
return Verdict::Confirmed;
|
|
||||||
}
|
|
||||||
// Fallback: only a leading, unambiguous "yes" counts as confirmation.
|
|
||||||
if lower.trim_start().starts_with("yes") {
|
|
||||||
return Verdict::Confirmed;
|
|
||||||
}
|
|
||||||
Verdict::Unclear
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod verdict_tests {
|
|
||||||
use super::*;
|
|
||||||
#[test]
|
|
||||||
fn parses_json_and_prose() {
|
|
||||||
assert_eq!(parse_verdict(r#"{"verdict":"confirmed","reason":"x"}"#), Verdict::Confirmed);
|
|
||||||
assert_eq!(parse_verdict(r#"{ "verdict": "confirmed" }"#), Verdict::Confirmed);
|
|
||||||
assert_eq!(parse_verdict(r#"{ "verdict": "rejected" }"#), Verdict::Rejected);
|
|
||||||
assert_eq!(parse_verdict(r#"{"is_real": false}"#), Verdict::Rejected);
|
|
||||||
assert_eq!(parse_verdict("Yes, the evidence proves RCE."), Verdict::Confirmed);
|
|
||||||
assert_eq!(parse_verdict("This looks theoretical."), Verdict::Unclear); // not counted
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn rejection_beats_confirmation_when_both_present() {
|
|
||||||
// an answer that says confirmed:false must not be read as confirmed
|
|
||||||
assert_eq!(parse_verdict(r#"{"confirmed": false, "note": "verdict was confirmed earlier"}"#), Verdict::Rejected);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn quorum_is_severity_aware() {
|
|
||||||
// high/critical: need >=2 votes AND >=2/3
|
|
||||||
assert!(!quorum_confirmed("High", 1, 2));
|
|
||||||
assert!(quorum_confirmed("High", 2, 2));
|
|
||||||
assert!(quorum_confirmed("Critical", 2, 3));
|
|
||||||
assert!(!quorum_confirmed("Critical", 1, 3));
|
|
||||||
// single validator: majority applies to all
|
|
||||||
assert!(quorum_confirmed("Critical", 1, 1));
|
|
||||||
// low/medium: strict majority (more than half)
|
|
||||||
assert!(quorum_confirmed("Low", 1, 1));
|
|
||||||
assert!(!quorum_confirmed("Medium", 1, 2));
|
|
||||||
assert!(quorum_confirmed("Low", 2, 3));
|
|
||||||
assert!(!quorum_confirmed("Low", 0, 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Severity-aware confirmation quorum. False High/Critical findings are the most
|
|
||||||
/// costly, so they require ≥2 validators AND ≥2/3 agreement; lower severities
|
|
||||||
/// pass on a strict majority (more than half). With only one validator available
|
|
||||||
/// (single-model panel) the majority rule applies to all severities.
|
|
||||||
pub fn quorum_confirmed(severity: &str, yes: usize, total: usize) -> bool {
|
|
||||||
if total == 0 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let s = severity.to_lowercase();
|
|
||||||
let high = s.starts_with("crit") || s.starts_with("high");
|
|
||||||
if high && total >= 2 {
|
|
||||||
yes * 3 >= total * 2 // ≥ two-thirds
|
|
||||||
} else {
|
|
||||||
yes * 2 > total // strict majority
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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.5.4 Rust harness · multi-model validated</div>\
|
<div class=meta>Target: <b>{t}</b> · v3.5.1 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.5.4 · by <b>Joas A Santos</b> & <b>Red Team Leaders</b></p></body></html>",
|
<p class=meta>Authorized testing only. Findings confirmed by multi-model adversarial voting.<br>NeuroSploit v3.5.1 · by <b>Joas A Santos</b> & <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.5.4"), tq("multi-model")
|
tq(target), tq(&run_id), tq("NeuroSploit v3.5.1"), 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) {
|
||||||
|
|||||||
@@ -123,20 +123,11 @@ pub struct RunConfig {
|
|||||||
/// agents (skipping recon-based selection) — used by the category picker.
|
/// agents (skipping recon-based selection) — used by the category picker.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pinned: Vec<String>,
|
pub pinned: Vec<String>,
|
||||||
/// Attack-chaining depth: how many post-exploitation pivot rounds to run
|
|
||||||
/// from confirmed findings (0 disables chaining). Each round expands the
|
|
||||||
/// newest footholds in new directions, carrying discovered loot forward.
|
|
||||||
#[serde(default = "default_chain_depth")]
|
|
||||||
pub chain_depth: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_vote() -> usize {
|
fn default_vote() -> usize {
|
||||||
3
|
3
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_chain_depth() -> usize {
|
|
||||||
2
|
|
||||||
}
|
|
||||||
fn default_concurrency() -> usize {
|
fn default_concurrency() -> usize {
|
||||||
8
|
8
|
||||||
}
|
}
|
||||||
@@ -158,7 +149,6 @@ impl RunConfig {
|
|||||||
auth: None,
|
auth: None,
|
||||||
repo: None,
|
repo: None,
|
||||||
pinned: Vec::new(),
|
pinned: Vec::new(),
|
||||||
chain_depth: 2,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
NeuroSploit v3.5.2 — exploitation-depth & report-hygiene doctrine agents.
|
|
||||||
|
|
||||||
Distilled from reviewing real AI-pentest output that kept stopping at
|
|
||||||
"exposed" instead of "exploited". Emits meta-agents to agents_md/meta/ that
|
|
||||||
push the engine past detection to demonstrated impact, chain findings, decode
|
|
||||||
artifacts/correlate CVEs, audit tokens, and keep the report honest (dedup +
|
|
||||||
severity calibration). Credits: Joas A Santos & Red Team Leaders.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
OUT = os.path.join(ROOT, "agents_md", "meta")
|
|
||||||
|
|
||||||
CREDITS = "Credits: Joas A Santos and Red Team Leaders."
|
|
||||||
|
|
||||||
|
|
||||||
def render(a):
|
|
||||||
L = [f"# {a['title']}\n",
|
|
||||||
f"> Meta-agent (v3.5.2 doctrine). {a['tagline']}\n",
|
|
||||||
"## User Prompt",
|
|
||||||
a["user"].strip(), "",
|
|
||||||
"## System Prompt",
|
|
||||||
a["system"].strip() + " " + CREDITS]
|
|
||||||
return "\n".join(L) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
AGENTS = [
|
|
||||||
{"name": "exploit_depth_doctrine",
|
|
||||||
"title": "Exploitation Depth Doctrine Agent",
|
|
||||||
"tagline": "Turns every exposure into an exploitation attempt before it becomes a finding.",
|
|
||||||
"user": """
|
|
||||||
You are reviewing the candidate findings and live transcript for **{target}**.
|
|
||||||
|
|
||||||
For EACH candidate that merely *exposes* something (information disclosure,
|
|
||||||
exposed service/catalog/WSDL, leaked credential or token, reachable dev/staging
|
|
||||||
host, permissive CORS, open .git), drive it one step further BEFORE it is
|
|
||||||
reported:
|
|
||||||
|
|
||||||
1. **Use what was exposed.** Call the exposed endpoint, decode the leaked
|
|
||||||
artifact, log in with the leaked credential, hit the dev host, send the
|
|
||||||
cross-origin request. Capture the real request/response.
|
|
||||||
2. **Decide honestly.** If using it proved impact → keep/raise severity with the
|
|
||||||
new evidence. If it could not be used → down-rate to a LEAD (low confidence),
|
|
||||||
never a confirmed High/Critical.
|
|
||||||
3. **Report the gap.** List any exposure you could not yet exploit, with the
|
|
||||||
exact next command to try, so the next round (or the human) can finish it.
|
|
||||||
|
|
||||||
Output JSON: {"escalations":[{id, action_taken, new_evidence, new_severity}],
|
|
||||||
"leads":[{id, why_not_proven, next_command}]}.
|
|
||||||
""",
|
|
||||||
"system": """
|
|
||||||
You are a senior exploitation lead. Detection is not a finding — impact is. You
|
|
||||||
never let an info-disclosure, exposed service, leaked secret or reachable
|
|
||||||
non-prod host be reported as confirmed without an attempt to actually use it,
|
|
||||||
backed by a real tool receipt. Unproven impact is a lead, not a High. Authorized
|
|
||||||
engagement; no destructive or DoS actions.
|
|
||||||
"""},
|
|
||||||
|
|
||||||
{"name": "finding_chainer",
|
|
||||||
"title": "Finding Chainer Agent",
|
|
||||||
"tagline": "Reuses obtained access across modules and reports the chain, not the parts.",
|
|
||||||
"user": """
|
|
||||||
Given the confirmed findings and any sessions/tokens/credentials obtained during
|
|
||||||
the engagement on **{target}**, build exploitation CHAINS:
|
|
||||||
|
|
||||||
- Reuse every session/JWT/cookie/credential from one step against ALL other
|
|
||||||
modules and hosts in scope (a captcha/login bypass that yields a token unlocks
|
|
||||||
the entire authenticated surface — use it).
|
|
||||||
- Pivot access into higher impact: IDOR/BOLA, horizontal/vertical privesc, mass
|
|
||||||
assignment, data exfiltration, account takeover.
|
|
||||||
- Combine separate weaknesses (e.g. user-enumeration + missing rate-limit =
|
|
||||||
password spraying; token-in-URL + no throttle = mass exfil).
|
|
||||||
|
|
||||||
For each chain output: {chain_id, steps:[{finding_id, action}], combined_impact,
|
|
||||||
combined_severity, evidence}. Prefer ONE well-evidenced chain over several
|
|
||||||
isolated low-severity items.
|
|
||||||
""",
|
|
||||||
"system": """
|
|
||||||
You are an exploit-chaining specialist. Isolated findings understate risk; the
|
|
||||||
real story is the chain. You always try to reuse obtained access across the
|
|
||||||
whole scope and escalate to business impact, reporting the combined chain with
|
|
||||||
concrete evidence. Authorized engagement; no destructive or DoS actions.
|
|
||||||
"""},
|
|
||||||
|
|
||||||
{"name": "artifact_decoder",
|
|
||||||
"title": "Artifact Decoder & CVE Correlator Agent",
|
|
||||||
"tagline": "Decodes opaque tokens/paths, fingerprints the stack, and maps versions to CVEs.",
|
|
||||||
"user": """
|
|
||||||
For **{target}**, inspect every opaque or technology-revealing artifact seen in
|
|
||||||
recon and responses:
|
|
||||||
|
|
||||||
1. **Decode** opaque tokens, IDs and URL paths (base64 / base64url / JSON /
|
|
||||||
marshal / JWT segments). A decoded value often reveals the framework or an
|
|
||||||
internal file path (e.g. a Dragonfly job `[["f","...file"]]`, a signed-URL
|
|
||||||
structure, a serialized object).
|
|
||||||
2. **Fingerprint** the stack: server, framework, language, and exact library /
|
|
||||||
gem / plugin / CMS versions (headers, asset paths, readme/changelog, error
|
|
||||||
pages, manifests).
|
|
||||||
3. **Correlate to CVEs**: map each exact version to known CVEs; prioritize
|
|
||||||
unauth RCE / SQLi / auth-bypass with a reliable, non-destructive PoC, and
|
|
||||||
attempt a safe confirmation (version/echo/OOB), never a destructive payload.
|
|
||||||
|
|
||||||
Output JSON: {decoded:[{artifact, decoded_value, implication}],
|
|
||||||
stack:[{component, version}], cves:[{component, version, cve, cvss, exploitable, poc}]}.
|
|
||||||
""",
|
|
||||||
"system": """
|
|
||||||
You decode the opaque and correlate the obvious. Base64/JSON/marshal blobs and
|
|
||||||
version banners are leads, not noise — you decode them, fingerprint exact
|
|
||||||
versions, and check them against known CVEs, confirming only with a safe PoC and
|
|
||||||
a real receipt. Authorized engagement; no destructive or DoS actions.
|
|
||||||
"""},
|
|
||||||
|
|
||||||
{"name": "token_auditor",
|
|
||||||
"title": "Token & JWT Auditor Agent",
|
|
||||||
"tagline": "Attacks tokens: alg-confusion, none, kid/jku, signature checks, weak HS256 secrets.",
|
|
||||||
"user": """
|
|
||||||
For any session token or JWT issued by **{target}**, run a full auth-token audit:
|
|
||||||
|
|
||||||
1. **Decode** the header/payload; note alg (HS*/RS*/none), kid, jku, exp, claims.
|
|
||||||
2. **Algorithm attacks**: try `alg:none`, RS→HS confusion (sign with the public
|
|
||||||
key as HMAC secret), and kid/jku injection. Confirm whether the server
|
|
||||||
actually verifies the signature (tamper a claim and replay).
|
|
||||||
3. **Weak secret**: for HS256, attempt to crack the signing secret offline
|
|
||||||
(wordlist/rules); a static or guessable shared secret (e.g. an `x-auth-*`
|
|
||||||
header value) is a strong lead — if cracked, forge a token for any user.
|
|
||||||
4. **Lifecycle**: test reuse after logout, expiry enforcement, and refresh-token
|
|
||||||
revocation.
|
|
||||||
|
|
||||||
Output JSON: {token_type, alg, verified:true|false,
|
|
||||||
attacks:[{name, result, evidence}], forged_token_possible:true|false}.
|
|
||||||
""",
|
|
||||||
"system": """
|
|
||||||
You are a token-security specialist. Every JWT/session token gets audited for
|
|
||||||
algorithm confusion, none, kid/jku injection, real signature verification, weak
|
|
||||||
HS256 secrets, and lifecycle (logout/expiry/refresh). A forged or replayable
|
|
||||||
token is account takeover — you prove it with a real receipt. Authorized
|
|
||||||
engagement; no destructive or DoS actions.
|
|
||||||
"""},
|
|
||||||
|
|
||||||
{"name": "report_calibrator",
|
|
||||||
"title": "Report Calibrator Agent",
|
|
||||||
"tagline": "Dedups by class, calibrates severity to proven impact, demands evidence per claim.",
|
|
||||||
"user": """
|
|
||||||
Before the final report for **{target}**, clean and calibrate the findings:
|
|
||||||
|
|
||||||
1. **Consolidate hygiene by class.** Merge repeated hygiene findings (missing
|
|
||||||
security headers, clickjacking, cookie flags, weak TLS, HSTS, version/banner
|
|
||||||
disclosure) into ONE finding per class with an affected-asset TABLE — do not
|
|
||||||
inflate the count one-per-host.
|
|
||||||
2. **Calibrate severity to PROVEN impact.** High/Critical requires demonstrated
|
|
||||||
impact with evidence. Unproven DoS/abuse, "could/may/potential" language, or a
|
|
||||||
finding with no concrete payload/PoC → cap to Low/Medium or mark
|
|
||||||
"(potential)". Recompute the CVSS vector to match the proven impact.
|
|
||||||
3. **Evidence per claim.** Every finding — and every item in the "tests
|
|
||||||
performed" log — must carry a concrete request/response receipt; flag any
|
|
||||||
claim that has none, and any contradiction between the test log and the
|
|
||||||
findings.
|
|
||||||
|
|
||||||
Output JSON: {merged:[{class, severity, assets:[...]}],
|
|
||||||
recalibrated:[{id, old_severity, new_severity, reason}],
|
|
||||||
unevidenced:[{id_or_test, missing}]}.
|
|
||||||
""",
|
|
||||||
"system": """
|
|
||||||
You are a meticulous report editor. You group hygiene by class with an
|
|
||||||
asset table, calibrate every severity to demonstrated impact (no inflated
|
|
||||||
High/Critical, no padding the count with duplicates), and require a real
|
|
||||||
receipt behind every claim — including each line of the tests-performed log.
|
|
||||||
Honest, deduplicated, evidence-backed reporting only.
|
|
||||||
"""},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
os.makedirs(OUT, exist_ok=True)
|
|
||||||
for a in AGENTS:
|
|
||||||
open(os.path.join(OUT, a["name"] + ".md"), "w").write(render(a))
|
|
||||||
print(f"wrote {len(AGENTS)} v3.5.2 doctrine meta-agents to {OUT}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -25,7 +25,7 @@ cat <<'BANNER'
|
|||||||
|
|
||||||
███╗ ██╗███████╗██╗ ██╗██████╗ ██████╗
|
███╗ ██╗███████╗██╗ ██╗██████╗ ██████╗
|
||||||
████╗ ██║██╔════╝██║ ██║██╔══██╗██╔═══██╗ NeuroSploit installer
|
████╗ ██║██╔════╝██║ ██║██╔══██╗██╔═══██╗ NeuroSploit installer
|
||||||
██╔██╗ ██║█████╗ ██║ ██║██████╔╝██║ ██║ v3.5.4 — Rust harness
|
██╔██╗ ██║█████╗ ██║ ██║██████╔╝██║ ██║ v3.5.1 — Rust harness
|
||||||
██║╚██╗██║██╔══╝ ██║ ██║██╔══██╗██║ ██║ by Joas A Santos
|
██║╚██╗██║██╔══╝ ██║ ██║██╔══██╗██║ ██║ by Joas A Santos
|
||||||
██║ ╚████║███████╗╚██████╔╝██║ ██║╚██████╔╝ & Red Team Leaders
|
██║ ╚████║███████╗╚██████╔╝██║ ██║╚██████╔╝ & Red Team Leaders
|
||||||
╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝
|
╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝
|
||||||
|
|||||||
Reference in New Issue
Block a user