mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-15 22:20:21 +02:00
feat(3.6.6): local/uncensored llama.cpp provider, clippy clean, CI (#40)
Version bump 3.6.5 -> 3.6.6. Local & uncensored models - New `llamacpp:` provider (llama-server, OpenAI-compatible, localhost:8080, no API key, CPU-only or GPU-offloaded). Override via LLAMACPP_BASE_URL; model name is the loaded gguf (pass-through). 15 -> 16 providers. - README: local/uncensored highlight, provider table + key-less note, badges. Quality - clippy clean under `-D warnings`: clamp(), sort_by_key(Reverse), struct-literal init, too_many_arguments allows, scoped await_holding_lock on the REPL blocking fallback (guard intentionally held across run().await), plus clippy --fix set. CI - examples/github-actions/ci.yml: cargo build/test/clippy -D warnings for the neurosploit-rs workspace (template, kept out of .github/workflows). Claude-Session: https://claude.ai/code/session_01QDses7zTSa9YF7pPRjphvh Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3786d7c559
commit
f913af211d
@@ -1,4 +1,4 @@
|
|||||||
<h1 align="center">🧠 NeuroSploit v3.6.5</h1>
|
<h1 align="center">🧠 NeuroSploit v3.6.6</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://trendshift.io/repositories/22624?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-22624" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/22624/daily?language=Python" alt="JoasASantos%2FNeuroSploit | Trendshift" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/22624?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-22624" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/22624/daily?language=Python" alt="JoasASantos%2FNeuroSploit | Trendshift" width="250" height="55"/></a>
|
||||||
@@ -12,11 +12,11 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Version-3.6.5-blue?style=flat-square">
|
<img src="https://img.shields.io/badge/Version-3.6.6-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-430-red?style=flat-square">
|
<img src="https://img.shields.io/badge/MD%20Agents-430-red?style=flat-square">
|
||||||
<img src="https://img.shields.io/badge/Models-15%20providers-success?style=flat-square">
|
<img src="https://img.shields.io/badge/Models-16%20providers-success?style=flat-square">
|
||||||
<img src="https://img.shields.io/badge/Modes-Black%20%7C%20White%20%7C%20Grey%20%7C%20Host%20%7C%20AI-9cf?style=flat-square">
|
<img src="https://img.shields.io/badge/Modes-Black%20%7C%20White%20%7C%20Grey%20%7C%20Host%20%7C%20AI-9cf?style=flat-square">
|
||||||
<img src="https://img.shields.io/badge/Auth-API%20key%20%7C%20Subscription-orange?style=flat-square">
|
<img src="https://img.shields.io/badge/Auth-API%20key%20%7C%20Subscription-orange?style=flat-square">
|
||||||
</p>
|
</p>
|
||||||
@@ -108,6 +108,11 @@ Control TUI**.
|
|||||||
- 📸 **Proof screenshots in reports** — agents capture visual proof per finding
|
- 📸 **Proof screenshots in reports** — agents capture visual proof per finding
|
||||||
(`evidence/<finding-id>-N.png`), embedded beside its vulnerability in the
|
(`evidence/<finding-id>-N.png`), embedded beside its vulnerability in the
|
||||||
Typst/HTML/Markdown reports.
|
Typst/HTML/Markdown reports.
|
||||||
|
- 🖥️ **Local, uncensored & CPU-only models** — `ollama:` and `llamacpp:` run the
|
||||||
|
whole engagement on your box with **no API key** and **no data leaving the
|
||||||
|
host**. `llamacpp:` speaks to a `llama-server` OpenAI-compatible endpoint
|
||||||
|
(`LLAMACPP_BASE_URL`, default localhost:8080); the `model` is whatever gguf you
|
||||||
|
loaded. Ideal for offline/air-gapped work and unfiltered offensive prompting.
|
||||||
- 🕵️ **Burp/ZAP proxy** — `/proxy <url>` (or `/burp`) routes agent traffic
|
- 🕵️ **Burp/ZAP proxy** — `/proxy <url>` (or `/burp`) routes agent traffic
|
||||||
through your local intercepting proxy so you can inspect & replay in Burp.
|
through your local intercepting proxy so you can inspect & replay in Burp.
|
||||||
- 🗺️ **Attack graph & kill chain** — findings mapped to OWASP / CWE / MITRE
|
- 🗺️ **Attack graph & kill chain** — findings mapped to OWASP / CWE / MITRE
|
||||||
@@ -412,7 +417,7 @@ export GROQ_API_KEY=... # groq:*
|
|||||||
export TOGETHER_API_KEY=... # together:*
|
export TOGETHER_API_KEY=... # together:*
|
||||||
export MOONSHOT_API_KEY=... # moonshot:* (Kimi K3/K2)
|
export MOONSHOT_API_KEY=... # moonshot:* (Kimi K3/K2)
|
||||||
export OPENROUTER_API_KEY=... # openrouter:*
|
export OPENROUTER_API_KEY=... # openrouter:*
|
||||||
# ollama needs no key (local)
|
# ollama / llamacpp need no key (local)
|
||||||
|
|
||||||
# then run via API (note: NO --subscription)
|
# then run via API (note: NO --subscription)
|
||||||
./target/release/neurosploit run http://testphp.vulnweb.com/ \
|
./target/release/neurosploit run http://testphp.vulnweb.com/ \
|
||||||
@@ -442,9 +447,16 @@ Or put the keys in a `.env` and source it (`cp .env.example .env`; edit; `set -a
|
|||||||
| `moonshot:` | `MOONSHOT_API_KEY` | api.moonshot.ai |
|
| `moonshot:` | `MOONSHOT_API_KEY` | api.moonshot.ai |
|
||||||
| `openrouter:` | `OPENROUTER_API_KEY` | openrouter.ai |
|
| `openrouter:` | `OPENROUTER_API_KEY` | openrouter.ai |
|
||||||
| `ollama:` | _(none)_ | localhost:11434 |
|
| `ollama:` | _(none)_ | localhost:11434 |
|
||||||
|
| `llamacpp:` | _(none)_ | localhost:8080 |
|
||||||
|
|
||||||
Run `./target/release/neurosploit models` for the full provider/model list.
|
Run `./target/release/neurosploit models` for the full provider/model list.
|
||||||
|
|
||||||
|
> **Local, uncensored & CPU-only** — `ollama:` and `llamacpp:` run entirely on
|
||||||
|
> your box with no API key and no data leaving the host. `llamacpp:` targets a
|
||||||
|
> [`llama-server`](https://github.com/ggml-org/llama.cpp) OpenAI-compatible
|
||||||
|
> endpoint (override with `LLAMACPP_BASE_URL`); the `model` is whatever gguf you
|
||||||
|
> loaded. Ideal for offline engagements and unfiltered offensive prompting.
|
||||||
|
|
||||||
#### 2) Via subscription (no API key)
|
#### 2) Via subscription (no API key)
|
||||||
|
|
||||||
`--subscription` drives your local agentic-CLI login instead of an API key —
|
`--subscription` drives your local agentic-CLI login instead of an API key —
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ variables → Actions), or swap the `MODEL`/key for a provider you use. The buil
|
|||||||
|----------|--------------|
|
|----------|--------------|
|
||||||
| `neurosploit-pr-gate.yml` | Reviews every pull request and **blocks the merge** on a confirmed critical (fails the check + sets a `neurosploit/security` commit status + posts a REQUEST_CHANGES review). |
|
| `neurosploit-pr-gate.yml` | Reviews every pull request and **blocks the merge** on a confirmed critical (fails the check + sets a `neurosploit/security` commit status + posts a REQUEST_CHANGES review). |
|
||||||
| `neurosploit-mention.yml` | Comment **`@neurosploit`** on a PR/issue (writers only) to trigger a scan. Text after the mention steers it, in any language; a URL runs a black-box test, otherwise it reviews the PR. |
|
| `neurosploit-mention.yml` | Comment **`@neurosploit`** on a PR/issue (writers only) to trigger a scan. Text after the mention steers it, in any language; a URL runs a black-box test, otherwise it reviews the PR. |
|
||||||
|
| `ci.yml` | Rust CI for the `neurosploit-rs/` workspace — `cargo build` / `test` / `clippy -D warnings` on every push & PR. |
|
||||||
|
|
||||||
## Enforce the PR gate as a merge block
|
## Enforce the PR gate as a merge block
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# NeuroSploit — Rust CI
|
||||||
|
#
|
||||||
|
# Build, test and lint the `neurosploit-rs/` Cargo workspace on every push and
|
||||||
|
# pull request. Copy to `.github/workflows/ci.yml` in your fork to enable it.
|
||||||
|
#
|
||||||
|
# Note: this lives here (not in `.github/workflows/`) as a template — like the
|
||||||
|
# other files in this folder — so the upstream repo doesn't run it on itself.
|
||||||
|
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths: ["neurosploit-rs/**", ".github/workflows/ci.yml"]
|
||||||
|
pull_request:
|
||||||
|
paths: ["neurosploit-rs/**"]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-test:
|
||||||
|
name: build & test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: neurosploit-rs
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
run: rustup toolchain install stable --profile minimal --component clippy
|
||||||
|
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
neurosploit-rs/target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('neurosploit-rs/Cargo.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cargo build --workspace --locked
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: cargo test --workspace --locked
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||||
Generated
+2
-2
@@ -871,7 +871,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "neurosploit"
|
name = "neurosploit"
|
||||||
version = "3.6.5"
|
version = "3.6.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -888,7 +888,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "neurosploit-harness"
|
name = "neurosploit-harness"
|
||||||
version = "3.6.5"
|
version = "3.6.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"futures",
|
"futures",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ members = ["crates/harness", "app"]
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "3.6.5"
|
version = "3.6.6"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/JoasASantos/NeuroSploit"
|
repository = "https://github.com/JoasASantos/NeuroSploit"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! NeuroSploit v3.6.5 — interactive harness + CLI (`run` / `whitebox` / `agents` / `models`).
|
//! NeuroSploit v3.6.6 — interactive harness + CLI (`run` / `whitebox` / `agents` / `models`).
|
||||||
|
|
||||||
mod repl;
|
mod repl;
|
||||||
mod tui;
|
mod tui;
|
||||||
@@ -11,8 +11,8 @@ use std::path::{Path, PathBuf};
|
|||||||
#[command(
|
#[command(
|
||||||
name = "neurosploit",
|
name = "neurosploit",
|
||||||
version,
|
version,
|
||||||
about = "NeuroSploit v3.6.5 — multi-model autonomous pentest harness",
|
about = "NeuroSploit v3.6.6 — multi-model autonomous pentest harness",
|
||||||
long_about = "NeuroSploit v3.6.5 — a Rust multi-model harness that drives a pool of LLMs \
|
long_about = "NeuroSploit v3.6.6 — a Rust multi-model harness that drives a pool of LLMs \
|
||||||
(API key or local subscription: Claude/Codex/Gemini/Grok) to autonomously test a target. \
|
(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\
|
||||||
@@ -542,7 +542,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
std::fs::remove_dir_all(&dest).ok();
|
std::fs::remove_dir_all(&dest).ok();
|
||||||
let url = ig.authed_clone_url(&format!("https://github.com/{owner_repo}"));
|
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() {
|
if run_git(&["clone", "--depth", "1", "--branch", &branch, &url, &dest.display().to_string()]).is_ok() {
|
||||||
let mut cfg = RunConfig::new(&dest.display().to_string());
|
let mut cfg = RunConfig::new(dest.display().to_string());
|
||||||
cfg.subscription = subscription;
|
cfg.subscription = subscription;
|
||||||
cfg.verbose = verbose;
|
cfg.verbose = verbose;
|
||||||
if !models.is_empty() { cfg.models = models.clone(); }
|
if !models.is_empty() { cfg.models = models.clone(); }
|
||||||
@@ -751,7 +751,7 @@ pub(crate) fn spawn_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, mode:
|
|||||||
println!(" │ ua : {ua}");
|
println!(" │ ua : {ua}");
|
||||||
write_status(&workdir, "running", &format!("\"target\":{:?}", cfg.target));
|
write_status(&workdir, "running", &format!("\"target\":{:?}", cfg.target));
|
||||||
|
|
||||||
println!(" ┌─ NeuroSploit v3.6.5 · by Joas A Santos & Red Team Leaders");
|
println!(" ┌─ NeuroSploit v3.6.6 · 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(", "));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! NeuroSploit v3.6.5 — interactive session (Claude-Code / Codex / Cursor-CLI style).
|
//! NeuroSploit v3.6.6 — 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
|
||||||
@@ -361,12 +361,16 @@ impl Reader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The blocking (piped, no external printer) fallback holds the history
|
||||||
|
// MutexGuard across `run().await` on purpose — run() mutates that history for
|
||||||
|
// the whole async operation and no other task contends for it there.
|
||||||
|
#[allow(clippy::await_holding_lock)]
|
||||||
pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
||||||
let lib = agents::load(base);
|
let lib = agents::load(base);
|
||||||
let backends = harness::installed_cli_backends();
|
let backends = harness::installed_cli_backends();
|
||||||
println!("\x1b[1m");
|
println!("\x1b[1m");
|
||||||
println!(" ███╗ ██╗███████╗██╗ ██╗██████╗ ██████╗");
|
println!(" ███╗ ██╗███████╗██╗ ██╗██████╗ ██████╗");
|
||||||
println!(" ████╗ ██║██╔════╝██║ ██║██╔══██╗██╔═══██╗ NeuroSploit v3.6.5");
|
println!(" ████╗ ██║██╔════╝██║ ██║██╔══██╗██╔═══██╗ NeuroSploit v3.6.6");
|
||||||
println!(" ██╔██╗ ██║█████╗ ██║ ██║██████╔╝██║ ██║ interactive harness");
|
println!(" ██╔██╗ ██║█████╗ ██║ ██║██████╔╝██║ ██║ interactive harness");
|
||||||
println!(" ██║╚██╗██║██╔══╝ ██║ ██║██╔══██╗██║ ██║ by Joas A Santos");
|
println!(" ██║╚██╗██║██╔══╝ ██║ ██║██╔══██╗██║ ██║ by Joas A Santos");
|
||||||
println!(" ██║ ╚████║███████╗╚██████╔╝██║ ██║╚██████╔╝ & Red Team Leaders");
|
println!(" ██║ ╚████║███████╗╚██████╔╝██║ ██║╚██████╔╝ & Red Team Leaders");
|
||||||
@@ -1626,12 +1630,9 @@ fn browse_results(history: &[RunRecord]) {
|
|||||||
};
|
};
|
||||||
print_finding_detail(&f[fi]);
|
print_finding_detail(&f[fi]);
|
||||||
// Enter → back to the vuln list; Esc → back to the target list.
|
// Enter → back to the vuln list; Esc → back to the target list.
|
||||||
match dialoguer::Select::with_theme(&ColorfulTheme::default())
|
if let Ok(None) = dialoguer::Select::with_theme(&ColorfulTheme::default())
|
||||||
.with_prompt("↵ back to vulnerabilities · Esc = back to targets")
|
.with_prompt("↵ back to vulnerabilities · Esc = back to targets")
|
||||||
.items(&["back"]).default(0).interact_opt() {
|
.items(&["back"]).default(0).interact_opt() { break }
|
||||||
Ok(None) => break,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2118,7 +2119,7 @@ fn attach_path(spec: &str, s: &mut Session) -> usize {
|
|||||||
Ok(content) => {
|
Ok(content) => {
|
||||||
let body = match range.and_then(parse_range) {
|
let body = match range.and_then(parse_range) {
|
||||||
Some((a, b)) => content.lines().enumerate()
|
Some((a, b)) => content.lines().enumerate()
|
||||||
.filter(|(i, _)| *i + 1 >= a && *i + 1 <= b)
|
.filter(|(i, _)| *i + 1 >= a && *i < b)
|
||||||
.map(|(_, l)| l).collect::<Vec<_>>().join("\n"),
|
.map(|(_, l)| l).collect::<Vec<_>>().join("\n"),
|
||||||
None => content.chars().take(8000).collect(),
|
None => content.chars().take(8000).collect(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! NeuroSploit v3.6.5 — TUI "Mission Control" mode.
|
//! NeuroSploit v3.6.6 — 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:
|
||||||
@@ -169,7 +169,7 @@ pub async fn run(base: &Path, mut cfg: RunConfig, mcp: bool, mode: Mode) -> anyh
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let out;
|
|
||||||
loop {
|
loop {
|
||||||
// drain engagement events
|
// drain engagement events
|
||||||
while let Ok(line) = rx.try_recv() { ui.ingest(line); }
|
while let Ok(line) = rx.try_recv() { ui.ingest(line); }
|
||||||
@@ -206,17 +206,14 @@ pub async fn run(base: &Path, mut cfg: RunConfig, mcp: bool, mode: Mode) -> anyh
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
out = (&mut task).await.unwrap_or_default();
|
let out = (&mut task).await.unwrap_or_default();
|
||||||
|
|
||||||
// ---- restore terminal ----
|
// ---- restore terminal ----
|
||||||
execute!(stdout(), terminal::LeaveAlternateScreen)?;
|
execute!(stdout(), terminal::LeaveAlternateScreen)?;
|
||||||
terminal::disable_raw_mode()?;
|
terminal::disable_raw_mode()?;
|
||||||
|
|
||||||
// generate report unless discarded; print a plain summary after leaving the TUI
|
// generate report unless discarded; print a plain summary after leaving the TUI
|
||||||
match harness::report::typst_report(&out.target, &out.findings, &workdir) {
|
if let Ok(p) = harness::report::typst_report(&out.target, &out.findings, &workdir) { println!(" report → {}", p.display()) }
|
||||||
Ok(p) => println!(" report → {}", p.display()),
|
|
||||||
Err(_) => {}
|
|
||||||
}
|
|
||||||
crate::write_status_pub(&workdir, if cancel.load(Ordering::Relaxed) { "stopped" } else { "complete" }, "");
|
crate::write_status_pub(&workdir, if cancel.load(Ordering::Relaxed) { "stopped" } else { "complete" }, "");
|
||||||
println!(" ✓ {} validated finding(s) · {}", out.findings.len(), workdir.display());
|
println!(" ✓ {} validated finding(s) · {}", out.findings.len(), workdir.display());
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -147,10 +147,11 @@ pub fn hygiene_summary(findings: &[Finding]) -> Vec<String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
fn f(title: &str, sev: &str, cwe: &str, ep: &str, ev: &str, payload: &str) -> Finding {
|
fn f(title: &str, sev: &str, cwe: &str, ep: &str, ev: &str, payload: &str) -> Finding {
|
||||||
let mut x = Finding::default();
|
Finding {
|
||||||
x.title = title.into(); x.severity = sev.into(); x.cwe = cwe.into();
|
title: title.into(), severity: sev.into(), cwe: cwe.into(),
|
||||||
x.endpoint = ep.into(); x.evidence = ev.into(); x.payload = payload.into();
|
endpoint: ep.into(), evidence: ev.into(), payload: payload.into(),
|
||||||
x
|
..Default::default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ pub fn providers() -> Vec<Provider> {
|
|||||||
models: vec!["gpt-4o", "gpt-4o-mini", "gpt-5.1", "o4-mini"] },
|
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"] },
|
||||||
|
// llama.cpp server (`llama-server`, OpenAI-compatible). Runs CPU-only or
|
||||||
|
// GPU-offloaded, fully local & uncensored — no API key. Point at your
|
||||||
|
// server with LLAMACPP_BASE_URL (default http://localhost:8080/v1); the
|
||||||
|
// `model` name is whatever gguf you loaded (pass-through).
|
||||||
|
Provider { key: "llamacpp", label: "llama.cpp (local)", base_url: "http://localhost:8080/v1", env_key: "LLAMACPP_API_KEY", kind: "api",
|
||||||
|
models: vec!["qwen2.5-coder-32b-instruct", "dolphin-2.9-llama3-70b", "deepseek-r1-distill-qwen-32b", "llama-3.3-70b-instruct"] },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +124,7 @@ impl ChatClient {
|
|||||||
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 = resolve_key(&p);
|
||||||
if key.is_empty() && p.key != "ollama" && p.key != "litellm" {
|
if key.is_empty() && p.key != "ollama" && p.key != "litellm" && p.key != "llamacpp" {
|
||||||
let hint = if p.key == "gemini" { format!("{} (or GOOGLE_API_KEY)", p.env_key) } else { p.env_key.to_string() };
|
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 '{}'", hint, p.key));
|
return Err(anyhow!("no API key ({}) for provider '{}'", hint, p.key));
|
||||||
}
|
}
|
||||||
@@ -139,6 +145,7 @@ impl ChatClient {
|
|||||||
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()),
|
||||||
|
"llamacpp" => std::env::var("LLAMACPP_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('/'))
|
format!("{}/chat/completions", base.trim_end_matches('/'))
|
||||||
@@ -178,6 +185,7 @@ impl ChatClient {
|
|||||||
/// When `mcp_config` is set (a path to an `.mcp.json`), Claude/Codex run with
|
/// When `mcp_config` is set (a path to an `.mcp.json`), Claude/Codex run with
|
||||||
/// the MCP servers enabled and tool autonomy, so agents can actually drive
|
/// the MCP servers enabled and tool autonomy, so agents can actually drive
|
||||||
/// **Playwright** (browse, execute JS, screenshot) during execution.
|
/// **Playwright** (browse, execute JS, screenshot) during execution.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn chat_cli(
|
pub async fn chat_cli(
|
||||||
&self,
|
&self,
|
||||||
label: &str,
|
label: &str,
|
||||||
|
|||||||
@@ -375,8 +375,8 @@ fn identify_asset(p: &crate::probe::Probe) -> String {
|
|||||||
let tech = if p.tech.is_empty() { String::new() } else { format!(" [{}]", p.tech.join(", ")) };
|
let tech = if p.tech.is_empty() { String::new() } else { format!(" [{}]", p.tech.join(", ")) };
|
||||||
// Prefer a KNOWN product; else the org/brand from the page; else the title.
|
// Prefer a KNOWN product; else the org/brand from the page; else the title.
|
||||||
let name = product
|
let name = product
|
||||||
.or_else(|| if brand.is_empty() { None } else { Some(brand) })
|
.or(if brand.is_empty() { None } else { Some(brand) })
|
||||||
.or_else(|| if title.is_empty() { None } else { Some(title) });
|
.or(if title.is_empty() { None } else { Some(title) });
|
||||||
match name {
|
match name {
|
||||||
Some(n) => format!("{n}{tech}"),
|
Some(n) => format!("{n}{tech}"),
|
||||||
None => if tech.is_empty() { "unidentified web asset".into() } else { format!("web asset{tech}") },
|
None => if tech.is_empty() { "unidentified web asset".into() } else { format!("web asset{tech}") },
|
||||||
@@ -678,7 +678,7 @@ pub async fn run_greybox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Se
|
|||||||
if !cfg.offline && !context.is_empty() {
|
if !cfg.offline && !context.is_empty() {
|
||||||
let code_cap = if cfg.max_agents > 0 { cfg.max_agents.min(lib.code.len()) } else { lib.code.len().min(12) };
|
let code_cap = if cfg.max_agents > 0 { cfg.max_agents.min(lib.code.len()) } else { lib.code.len().min(12) };
|
||||||
let code_agents: Vec<Agent> = lib.code.iter().take(code_cap).cloned().collect();
|
let code_agents: Vec<Agent> = lib.code.iter().take(code_cap).cloned().collect();
|
||||||
let leads: Vec<Finding> = stream::iter(code_agents.into_iter())
|
let leads: Vec<Finding> = stream::iter(code_agents)
|
||||||
.map(|ag| {
|
.map(|ag| {
|
||||||
let ctx = context.clone();
|
let ctx = context.clone();
|
||||||
let txc = tx.clone();
|
let txc = tx.clone();
|
||||||
@@ -841,7 +841,7 @@ async fn attack_chain(pool: &ModelPool, cfg: &RunConfig, recon: &str,
|
|||||||
|
|
||||||
// Frontier = footholds to expand this round; start with confirmed, best-first.
|
// Frontier = footholds to expand this round; start with confirmed, best-first.
|
||||||
let mut frontier: Vec<Finding> = confirmed.to_vec();
|
let mut frontier: Vec<Finding> = confirmed.to_vec();
|
||||||
frontier.sort_by(|a, b| sev_rank(&b.severity).cmp(&sev_rank(&a.severity)));
|
frontier.sort_by_key(|f| std::cmp::Reverse(sev_rank(&f.severity)));
|
||||||
|
|
||||||
for round in 1..=max_rounds {
|
for round in 1..=max_rounds {
|
||||||
if pool.stop_exploiting() || frontier.is_empty() {
|
if pool.stop_exploiting() || frontier.is_empty() {
|
||||||
@@ -851,7 +851,7 @@ async fn attack_chain(pool: &ModelPool, cfg: &RunConfig, recon: &str,
|
|||||||
let _ = tx.send(format!("⛓ attack-chain round {round}/{max_rounds} — expanding {} foothold(s), {} loot item(s)", seeds.len(), loot.len())).await;
|
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 loot_snapshot = loot.clone();
|
||||||
let results: Vec<(Vec<Finding>, Vec<String>)> = stream::iter(seeds.into_iter())
|
let results: Vec<(Vec<Finding>, Vec<String>)> = stream::iter(seeds)
|
||||||
.map(|seed| {
|
.map(|seed| {
|
||||||
let (dir, rc, rb, ls, txc) = (directives.clone(), recon_ctx.clone(), recipe_block.clone(), loot_snapshot.clone(), tx.clone());
|
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 }
|
async move { chain_from_seed(pool, &cfg.target, &dir, &rc, &rb, &seed, &ls, round, max_rounds, &txc).await }
|
||||||
@@ -886,7 +886,7 @@ async fn attack_chain(pool: &ModelPool, cfg: &RunConfig, recon: &str,
|
|||||||
all_new.extend(validated.clone());
|
all_new.extend(validated.clone());
|
||||||
// Next round expands the freshly-validated footholds, best-first.
|
// Next round expands the freshly-validated footholds, best-first.
|
||||||
frontier = validated;
|
frontier = validated;
|
||||||
frontier.sort_by(|a, b| sev_rank(&b.severity).cmp(&sev_rank(&a.severity)));
|
frontier.sort_by_key(|f| std::cmp::Reverse(sev_rank(&f.severity)));
|
||||||
}
|
}
|
||||||
if !all_new.is_empty() {
|
if !all_new.is_empty() {
|
||||||
let _ = tx.send(format!("⛓ attack-chaining added {} finding(s) across pivots", all_new.len())).await;
|
let _ = tx.send(format!("⛓ attack-chaining added {} finding(s) across pivots", all_new.len())).await;
|
||||||
@@ -896,6 +896,7 @@ async fn attack_chain(pool: &ModelPool, cfg: &RunConfig, recon: &str,
|
|||||||
|
|
||||||
/// Expand ONE foothold: the agent decides directions, does post-exploitation and
|
/// Expand ONE foothold: the agent decides directions, does post-exploitation and
|
||||||
/// pivots, and returns new findings + discovered loot.
|
/// pivots, and returns new findings + discovered loot.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn chain_from_seed(pool: &ModelPool, target: &str, directives: &str, recon_ctx: &str,
|
async fn chain_from_seed(pool: &ModelPool, target: &str, directives: &str, recon_ctx: &str,
|
||||||
recipe_block: &str, seed: &Finding, loot: &[String],
|
recipe_block: &str, seed: &Finding, loot: &[String],
|
||||||
round: usize, max: usize, tx: &Sender<String>) -> (Vec<Finding>, Vec<String>) {
|
round: usize, max: usize, tx: &Sender<String>) -> (Vec<Finding>, Vec<String>) {
|
||||||
@@ -1071,7 +1072,7 @@ fn heuristic_select(ranked: &[Agent], recon: &str, focus: &str, cap: usize) -> V
|
|||||||
(score, a)
|
(score, a)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
scored.sort_by(|x, y| y.0.cmp(&x.0));
|
scored.sort_by_key(|x| std::cmp::Reverse(x.0));
|
||||||
let mut out: Vec<Agent> = scored.iter().filter(|(s, _)| *s > 0).map(|(_, a)| (*a).clone()).collect();
|
let mut out: Vec<Agent> = scored.iter().filter(|(s, _)| *s > 0).map(|(_, a)| (*a).clone()).collect();
|
||||||
if out.is_empty() {
|
if out.is_empty() {
|
||||||
out = ranked.to_vec();
|
out = ranked.to_vec();
|
||||||
@@ -1082,7 +1083,7 @@ fn heuristic_select(ranked: &[Agent], recon: &str, focus: &str, cap: usize) -> V
|
|||||||
async fn validate(candidates: Vec<Finding>, pool: &ModelPool, sys: &str, vote_n: usize, tx: &Sender<String>) -> Vec<Finding> {
|
async fn validate(candidates: Vec<Finding>, pool: &ModelPool, sys: &str, vote_n: usize, tx: &Sender<String>) -> Vec<Finding> {
|
||||||
// Prefer a model other than the primary (likely finder) to adjudicate.
|
// Prefer a model other than the primary (likely finder) to adjudicate.
|
||||||
let finder = pool.candidates.first().map(|m| m.label());
|
let finder = pool.candidates.first().map(|m| m.label());
|
||||||
let validated: Vec<Finding> = stream::iter(candidates.into_iter())
|
let validated: Vec<Finding> = stream::iter(candidates)
|
||||||
.map(|mut f| {
|
.map(|mut f| {
|
||||||
let txc = tx.clone();
|
let txc = tx.clone();
|
||||||
let finder = finder.clone();
|
let finder = finder.clone();
|
||||||
@@ -1157,6 +1158,7 @@ async fn refute_pass(findings: Vec<Finding>, pool: &ModelPool, vote_n: usize, tx
|
|||||||
kept
|
kept
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
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, gmode: crate::grounding::GroundMode, source_ctx: String,
|
selected: Vec<Agent>, rl: &mut RlState, gmode: crate::grounding::GroundMode, source_ctx: String,
|
||||||
tx: Sender<String>) -> RunOutput {
|
tx: Sender<String>) -> RunOutput {
|
||||||
@@ -1246,7 +1248,7 @@ async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: Strin
|
|||||||
let mut wm = crate::belief::WorldModel::new();
|
let mut wm = crate::belief::WorldModel::new();
|
||||||
wm.deterministic = whitebox;
|
wm.deterministic = whitebox;
|
||||||
for f in &findings {
|
for f in &findings {
|
||||||
wm.add(&f.id, crate::belief::Kind::Exploit, &f.title, f.confidence.max(0.05).min(0.99));
|
wm.add(&f.id, crate::belief::Kind::Exploit, &f.title, f.confidence.clamp(0.05, 0.99));
|
||||||
}
|
}
|
||||||
let unc = wm.uncertainty(None);
|
let unc = wm.uncertainty(None);
|
||||||
if !findings.is_empty() {
|
if !findings.is_empty() {
|
||||||
@@ -1275,7 +1277,7 @@ async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: Strin
|
|||||||
// confirmed high-severity bugs float to the top of selection on future runs.
|
// confirmed high-severity bugs float to the top of selection on future runs.
|
||||||
let mut hit: std::collections::HashMap<&str, f64> = Default::default();
|
let mut hit: std::collections::HashMap<&str, f64> = Default::default();
|
||||||
for f in &findings {
|
for f in &findings {
|
||||||
let base = severity_reward(&f.severity) * f.confidence.max(0.2).min(1.0);
|
let base = severity_reward(&f.severity) * f.confidence.clamp(0.2, 1.0);
|
||||||
let r = if f.review_status == "needs-review" { 0.15 } else { base };
|
let r = if f.review_status == "needs-review" { 0.15 } else { base };
|
||||||
let e = hit.entry(f.agent.as_str()).or_insert(0.0);
|
let e = hit.entry(f.agent.as_str()).or_insert(0.0);
|
||||||
*e = (*e + r).clamp(-1.0, 1.0);
|
*e = (*e + r).clamp(-1.0, 1.0);
|
||||||
|
|||||||
@@ -372,6 +372,23 @@ pub fn parse_verdict(text: &str) -> Verdict {
|
|||||||
Verdict::Unclear
|
Verdict::Unclear
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod verdict_tests {
|
mod verdict_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -405,20 +422,3 @@ mod verdict_tests {
|
|||||||
assert!(!quorum_confirmed("Low", 0, 2));
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ fn extract_brand(body: &str) -> String {
|
|||||||
// strip the marker + a year, take the first capitalised words.
|
// strip the marker + a year, take the first capitalised words.
|
||||||
let cleaned = seg.replace('©', " ").replace("©", " ");
|
let cleaned = seg.replace('©', " ").replace("©", " ");
|
||||||
let cleaned = cleaned.trim_start_matches(|c: char| !c.is_alphabetic());
|
let cleaned = cleaned.trim_start_matches(|c: char| !c.is_alphabetic());
|
||||||
let name: String = cleaned.split(|c: char| c == '<' || c == '.' || c == '|' || c == '\n')
|
let name: String = cleaned.split(['<', '.', '|', '\n'])
|
||||||
.next().unwrap_or("").chars().filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '&' || *c == '-')
|
.next().unwrap_or("").chars().filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '&' || *c == '-')
|
||||||
.collect::<String>().trim().to_string();
|
.collect::<String>().trim().to_string();
|
||||||
// drop a leading year like "2024 "
|
// drop a leading year like "2024 "
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ fn needs_review(f: &Finding) -> bool { f.review_status == "needs-review" }
|
|||||||
|
|
||||||
/// Strip Markdown emphasis/backticks so prose renders cleanly inside Typst.
|
/// Strip Markdown emphasis/backticks so prose renders cleanly inside Typst.
|
||||||
fn strip_md(s: &str) -> String {
|
fn strip_md(s: &str) -> String {
|
||||||
s.replace("**", "").replace('`', "").replace('*', "")
|
s.replace("**", "").replace(['`', '*'], "")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Written prose executive summary: names the asset, the counts, and the top risks.
|
/// Written prose executive summary: names the asset, the counts, and the top risks.
|
||||||
@@ -313,7 +313,7 @@ pub fn markdown(target: &str, findings: &[Finding], meta: &EngagementMeta) -> St
|
|||||||
if !meta.title.is_empty() { out.push_str(&format!("- **Page title:** {}\n", meta.title)); }
|
if !meta.title.is_empty() { out.push_str(&format!("- **Page title:** {}\n", meta.title)); }
|
||||||
if !meta.tech.is_empty() { out.push_str(&format!("- **Technology:** {}\n", meta.tech.join(", "))); }
|
if !meta.tech.is_empty() { out.push_str(&format!("- **Technology:** {}\n", meta.tech.join(", "))); }
|
||||||
if !meta.server.is_empty() { out.push_str(&format!("- **Server:** {}\n", meta.server)); }
|
if !meta.server.is_empty() { out.push_str(&format!("- **Server:** {}\n", meta.server)); }
|
||||||
out.push_str("\n");
|
out.push('\n');
|
||||||
|
|
||||||
// --- Executive summary ---
|
// --- Executive summary ---
|
||||||
out.push_str("## Executive summary\n\n");
|
out.push_str("## Executive summary\n\n");
|
||||||
@@ -333,7 +333,7 @@ pub fn markdown(target: &str, findings: &[Finding], meta: &EngagementMeta) -> St
|
|||||||
out.push_str(&format!("| {} | {} | {} | {} | {} | {} |\n",
|
out.push_str(&format!("| {} | {} | {} | {} | {} | {} |\n",
|
||||||
i + 1, f.title.replace('|', "\\|"), f.severity, owc.replace('|', "\\|"), status, auth));
|
i + 1, f.title.replace('|', "\\|"), f.severity, owc.replace('|', "\\|"), status, auth));
|
||||||
}
|
}
|
||||||
out.push_str("\n");
|
out.push('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Test accounts created (from the vault cleanup finding) ---
|
// --- Test accounts created (from the vault cleanup finding) ---
|
||||||
|
|||||||
Reference in New Issue
Block a user