Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: CI

on:
push:
branches: [main]
pull_request:

env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"

jobs:
build-test-lint:
name: build / test / lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install stable Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy

- name: Cache cargo registry and target
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-

- name: Check formatting
run: cargo fmt --all -- --check

- name: Clippy (deny warnings)
run: cargo clippy --all-targets -- -D warnings

- name: Build
run: cargo build --verbose

- name: Test
run: cargo test --verbose
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ None of the hardware specifics are required for the current CPU path — the age
- ✅ NPU driver unblocked — patched `amdxdna.ko` loads on cold boot, `xrt-smi` + `flm validate` green. See [`PHASE-2-RECON.md`](PHASE-2-RECON.md).
- ⛔ NPU **inference** still blocked — FastFlowLM can't handle protocol-7 opcodes required by Qwen3/GGUF models. CPU path remains active until this unblocks.
- ⚠️ Nuclei on the target hardware CPU still times out on full template sweeps with large host lists; `--nuclei-cap` flag added to limit input hosts
- ⚠️ No automated tests yet. All verification has been manual end-to-end runs.
- ✅ Unit tests cover the pure core logic: scope matching, the tool-output parsers (subfinder/httpx/nuclei/dnsx/ffuf), finding dedup + severity classification, and the LLM response parser (`cargo test` — 38 tests). End-to-end behavior against live targets is still verified manually.
- ✅ CI runs `cargo fmt --check`, `cargo clippy -D warnings`, build, and test on every push/PR (`.github/workflows/ci.yml`).

See [`RESEARCH.md`](RESEARCH.md) for the research brief this project is based on, including references to comparable pentest agents (Shannon, PentestGPT, PentAGI, CAI), NPU backend options (FastFlowLM, ort crate + Vitis AI EP), and an honest gap analysis.
65 changes: 32 additions & 33 deletions src/agent/react_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ use super::state::PreflightReport;
use crate::scope::{host_in_scope, normalize_host};
use crate::tools::{
exec_dnsx, exec_ffuf, exec_httpx, exec_nuclei, exec_subfinder, nuclei_templates_root,
parse_dnsx_output, parse_ffuf_output, resolve_wordlist, select_interesting_urls,
ToolExecution, ToolKind,
parse_dnsx_output, parse_ffuf_output, resolve_wordlist, select_interesting_urls, ToolExecution,
ToolKind,
};

use super::state::{preview, RunRecord, StepRecord};
Expand Down Expand Up @@ -47,7 +47,11 @@ pub async fn run_recon(cli: &Cli, domain: &str) -> Result<()> {
println!("[*] nuclei cap : {}", cfg.nuclei_cap);
println!(
"[*] dedup : {}",
if cfg.no_dedup { "off (--no-dedup)" } else { "on" }
if cfg.no_dedup {
"off (--no-dedup)"
} else {
"on"
}
);
println!();

Expand Down Expand Up @@ -369,8 +373,8 @@ pub async fn run_recon(cli: &Cli, domain: &str) -> Result<()> {
// Suspicious-low threshold: if scoped had >50
// hosts and dnsx resolved <2%, treat as DNS
// failure rather than a valid filter.
let suspicious_low = scoped.len() > 50
&& kept * 50 < scoped.len();
let suspicious_low =
scoped.len() > 50 && kept * 50 < scoped.len();
if kept == 0 || suspicious_low {
println!(
"[!] dnsx resolved {}/{} — suspiciously low, falling back to unfiltered list",
Expand Down Expand Up @@ -416,10 +420,8 @@ pub async fn run_recon(cli: &Cli, domain: &str) -> Result<()> {
// Prefer explicit URLs from the LLM; otherwise pull from httpx.
// When falling back to httpx, run the interesting-host
// heuristic so nuclei only scans the top N URLs.
let explicit_urls: Option<Vec<String>> = args
.get("urls")
.and_then(|h| h.as_array())
.map(|arr| {
let explicit_urls: Option<Vec<String>> =
args.get("urls").and_then(|h| h.as_array()).map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
Expand Down Expand Up @@ -507,10 +509,7 @@ pub async fn run_recon(cli: &Cli, domain: &str) -> Result<()> {
// Active mode: ffuf must pick one live URL at a time.
// Prefer the LLM's explicit "url" arg; otherwise fall
// back to the first scoped URL from last httpx run.
let llm_url = args
.get("url")
.and_then(|u| u.as_str())
.map(String::from);
let llm_url = args.get("url").and_then(|u| u.as_str()).map(String::from);
let target_url = llm_url.unwrap_or_else(|| {
last_httpx_urls
.iter()
Expand All @@ -524,11 +523,15 @@ pub async fn run_recon(cli: &Cli, domain: &str) -> Result<()> {
args: args.clone(),
stdout: String::new(),
stderr: String::new(),
error: Some("no URL supplied and no live httpx URL available".into()),
error: Some(
"no URL supplied and no live httpx URL available".into(),
),
duration_ms: t0.elapsed().as_millis(),
}
} else if !host_in_scope(&target_url, &cfg.scope_patterns) {
println!("[!] scope guard: ffuf URL '{target_url}' not in scope, skipping");
println!(
"[!] scope guard: ffuf URL '{target_url}' not in scope, skipping"
);
ToolExecution {
tool: kind,
args: args.clone(),
Expand All @@ -540,7 +543,11 @@ pub async fn run_recon(cli: &Cli, domain: &str) -> Result<()> {
} else {
match resolve_wordlist(cfg.ffuf_wordlist.as_deref()) {
Ok((wl, _is_tmp)) => {
println!("[>] ffuf path-fuzzing {} (wordlist: {})", target_url, wl.display());
println!(
"[>] ffuf path-fuzzing {} (wordlist: {})",
target_url,
wl.display()
);
match exec_ffuf(&target_url, &wl).await {
Ok((so, se)) => ToolExecution {
tool: kind,
Expand Down Expand Up @@ -665,11 +672,8 @@ pub async fn run_recon(cli: &Cli, domain: &str) -> Result<()> {
)
}
ToolKind::Httpx => {
let urls: Vec<String> = last_httpx_urls
.iter()
.take(10)
.cloned()
.collect();
let urls: Vec<String> =
last_httpx_urls.iter().take(10).cloned().collect();
format!(
"{} live hosts responded. First {}: {}",
line_count,
Expand All @@ -678,20 +682,14 @@ pub async fn run_recon(cli: &Cli, domain: &str) -> Result<()> {
)
}
ToolKind::Nuclei => {
let n = all_findings
.iter()
.filter(|f| f.kind == "nuclei")
.count();
let n = all_findings.iter().filter(|f| f.kind == "nuclei").count();
format!(
"nuclei scan complete: {} JSONL lines, {} parsed findings. Next step should be done.",
line_count, n
)
}
ToolKind::Ffuf => {
let n = all_findings
.iter()
.filter(|f| f.kind == "ffuf")
.count();
let n = all_findings.iter().filter(|f| f.kind == "ffuf").count();
format!(
"ffuf path-fuzz complete: {} parsed findings. Next step should be done unless you want to fuzz another live host.",
n
Expand All @@ -706,7 +704,9 @@ pub async fn run_recon(cli: &Cli, domain: &str) -> Result<()> {
});
messages.push(ChatMessage {
role: "user".into(),
content: format!("{observation}\n\nWhat next? Respond with a single JSON action."),
content: format!(
"{observation}\n\nWhat next? Respond with a single JSON action."
),
});

steps.push(StepRecord {
Expand Down Expand Up @@ -746,7 +746,7 @@ pub async fn run_recon(cli: &Cli, domain: &str) -> Result<()> {
}

// Sort raw findings by severity desc for report rendering.
all_findings.sort_by(|a, b| b.severity.cmp(&a.severity));
all_findings.sort_by_key(|f| std::cmp::Reverse(f.severity));
let raw_findings_view = all_findings.clone();

// Dedup (default) or passthrough.
Expand Down Expand Up @@ -796,8 +796,7 @@ pub async fn run_recon(cli: &Cli, domain: &str) -> Result<()> {

std::fs::write(&findings_path, serde_json::to_string_pretty(&record)?)
.context("write findings json")?;
std::fs::write(&report_path, render_report(&record))
.context("write markdown report")?;
std::fs::write(&report_path, render_report(&record)).context("write markdown report")?;

println!();
println!("========== AGENT SUMMARY ==========");
Expand Down
7 changes: 1 addition & 6 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,12 +151,7 @@ impl Config {
active,
ffuf_wordlist,
..
} => (
org.clone(),
asn.clone(),
*active,
ffuf_wordlist.clone(),
),
} => (org.clone(), asn.clone(), *active, ffuf_wordlist.clone()),
};
Self {
model,
Expand Down
2 changes: 1 addition & 1 deletion src/findings/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
pub mod models;
pub mod parse;

pub use models::{DedupedFinding, Finding, Severity};
pub use models::dedup_findings;
pub use models::{DedupedFinding, Finding, Severity};
pub use parse::{extract_hosts_from_subfinder, parse_httpx_output, parse_nuclei_output};
64 changes: 64 additions & 0 deletions src/findings/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,67 @@ pub fn dedup_findings(raw: &[Finding]) -> Vec<DedupedFinding> {
out.sort_by(|a, b| b.severity.cmp(&a.severity).then(b.count.cmp(&a.count)));
out
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn severity_orders_low_to_high() {
assert!(Severity::Info < Severity::Low);
assert!(Severity::Low < Severity::Medium);
assert!(Severity::Medium < Severity::High);
assert!(Severity::High < Severity::Critical);
}

#[test]
fn from_str_loose_handles_aliases_and_unknowns() {
assert_eq!(Severity::from_str_loose("CRITICAL"), Severity::Critical);
assert_eq!(Severity::from_str_loose(" high "), Severity::High);
assert_eq!(Severity::from_str_loose("moderate"), Severity::Medium);
assert_eq!(Severity::from_str_loose("medium"), Severity::Medium);
assert_eq!(Severity::from_str_loose("low"), Severity::Low);
// anything unrecognized falls back to Info
assert_eq!(Severity::from_str_loose("bogus"), Severity::Info);
assert_eq!(Severity::from_str_loose(""), Severity::Info);
}

#[test]
fn dedup_folds_identical_kind_and_details() {
let raw = vec![
Finding::new(Severity::Low, "http-probe", "a.example.com", "same"),
Finding::new(Severity::High, "http-probe", "b.example.com", "same"),
Finding::new(Severity::Low, "http-probe", "a.example.com", "same"),
];
let deduped = dedup_findings(&raw);
assert_eq!(deduped.len(), 1);
let d = &deduped[0];
// severity promoted to the max of the group
assert_eq!(d.severity, Severity::High);
// count reflects number of source rows, not unique targets
assert_eq!(d.count, 3);
// targets deduped, insertion order preserved
assert_eq!(
d.targets,
vec!["a.example.com".to_string(), "b.example.com".to_string()]
);
}

#[test]
fn dedup_keeps_distinct_groups_and_sorts_by_severity() {
let raw = vec![
Finding::new(Severity::Info, "http-probe", "x", "low-thing"),
Finding::new(Severity::Critical, "nuclei", "y", "bad-thing"),
];
let deduped = dedup_findings(&raw);
assert_eq!(deduped.len(), 2);
// sorted severity desc -> Critical first
assert_eq!(deduped[0].severity, Severity::Critical);
assert_eq!(deduped[1].severity, Severity::Info);
}

#[test]
fn dedup_empty_input_yields_empty() {
assert!(dedup_findings(&[]).is_empty());
}
}
Loading
Loading