Skip to content

Commit c3d01da

Browse files
Peterc3-devPeter Clemente IIIclaude
authored
wave2-polish: tests for core logic, clippy/fmt clean, CI (#1)
- Add unit tests (8 -> 38) covering pure core logic: scope matching, subfinder/httpx/nuclei/dnsx output parsers, finding dedup + severity classification, and the LLM response parser - Fix all clippy warnings; repo now passes `cargo clippy --all-targets -D warnings` - Apply `cargo fmt` across the tree (formatting only, no logic change) - Add GitHub Actions CI: fmt --check, clippy -D warnings, build, test - Update README honest-state section (tests + CI now exist) Co-authored-by: Peter Clemente III <peterc3@live.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8262fd9 commit c3d01da

14 files changed

Lines changed: 496 additions & 83 deletions

File tree

.github/workflows/ci.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
env:
9+
CARGO_TERM_COLOR: always
10+
RUSTFLAGS: "-D warnings"
11+
12+
jobs:
13+
build-test-lint:
14+
name: build / test / lint
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- name: Install stable Rust toolchain
20+
uses: dtolnay/rust-toolchain@stable
21+
with:
22+
components: rustfmt, clippy
23+
24+
- name: Cache cargo registry and target
25+
uses: actions/cache@v4
26+
with:
27+
path: |
28+
~/.cargo/registry
29+
~/.cargo/git
30+
target
31+
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
32+
restore-keys: ${{ runner.os }}-cargo-
33+
34+
- name: Check formatting
35+
run: cargo fmt --all -- --check
36+
37+
- name: Clippy (deny warnings)
38+
run: cargo clippy --all-targets -- -D warnings
39+
40+
- name: Build
41+
run: cargo build --verbose
42+
43+
- name: Test
44+
run: cargo test --verbose

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ None of the hardware specifics are required for the current CPU path — the age
5959
- ✅ NPU driver unblocked — patched `amdxdna.ko` loads on cold boot, `xrt-smi` + `flm validate` green. See [`PHASE-2-RECON.md`](PHASE-2-RECON.md).
6060
- ⛔ NPU **inference** still blocked — FastFlowLM can't handle protocol-7 opcodes required by Qwen3/GGUF models. CPU path remains active until this unblocks.
6161
- ⚠️ 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
62-
- ⚠️ No automated tests yet. All verification has been manual end-to-end runs.
62+
- ✅ 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.
63+
- ✅ CI runs `cargo fmt --check`, `cargo clippy -D warnings`, build, and test on every push/PR (`.github/workflows/ci.yml`).
6364

6465
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.

src/agent/react_loop.rs

Lines changed: 32 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ use super::state::PreflightReport;
1717
use crate::scope::{host_in_scope, normalize_host};
1818
use crate::tools::{
1919
exec_dnsx, exec_ffuf, exec_httpx, exec_nuclei, exec_subfinder, nuclei_templates_root,
20-
parse_dnsx_output, parse_ffuf_output, resolve_wordlist, select_interesting_urls,
21-
ToolExecution, ToolKind,
20+
parse_dnsx_output, parse_ffuf_output, resolve_wordlist, select_interesting_urls, ToolExecution,
21+
ToolKind,
2222
};
2323

2424
use super::state::{preview, RunRecord, StepRecord};
@@ -47,7 +47,11 @@ pub async fn run_recon(cli: &Cli, domain: &str) -> Result<()> {
4747
println!("[*] nuclei cap : {}", cfg.nuclei_cap);
4848
println!(
4949
"[*] dedup : {}",
50-
if cfg.no_dedup { "off (--no-dedup)" } else { "on" }
50+
if cfg.no_dedup {
51+
"off (--no-dedup)"
52+
} else {
53+
"on"
54+
}
5155
);
5256
println!();
5357

@@ -369,8 +373,8 @@ pub async fn run_recon(cli: &Cli, domain: &str) -> Result<()> {
369373
// Suspicious-low threshold: if scoped had >50
370374
// hosts and dnsx resolved <2%, treat as DNS
371375
// failure rather than a valid filter.
372-
let suspicious_low = scoped.len() > 50
373-
&& kept * 50 < scoped.len();
376+
let suspicious_low =
377+
scoped.len() > 50 && kept * 50 < scoped.len();
374378
if kept == 0 || suspicious_low {
375379
println!(
376380
"[!] dnsx resolved {}/{} — suspiciously low, falling back to unfiltered list",
@@ -416,10 +420,8 @@ pub async fn run_recon(cli: &Cli, domain: &str) -> Result<()> {
416420
// Prefer explicit URLs from the LLM; otherwise pull from httpx.
417421
// When falling back to httpx, run the interesting-host
418422
// heuristic so nuclei only scans the top N URLs.
419-
let explicit_urls: Option<Vec<String>> = args
420-
.get("urls")
421-
.and_then(|h| h.as_array())
422-
.map(|arr| {
423+
let explicit_urls: Option<Vec<String>> =
424+
args.get("urls").and_then(|h| h.as_array()).map(|arr| {
423425
arr.iter()
424426
.filter_map(|v| v.as_str().map(String::from))
425427
.collect()
@@ -507,10 +509,7 @@ pub async fn run_recon(cli: &Cli, domain: &str) -> Result<()> {
507509
// Active mode: ffuf must pick one live URL at a time.
508510
// Prefer the LLM's explicit "url" arg; otherwise fall
509511
// back to the first scoped URL from last httpx run.
510-
let llm_url = args
511-
.get("url")
512-
.and_then(|u| u.as_str())
513-
.map(String::from);
512+
let llm_url = args.get("url").and_then(|u| u.as_str()).map(String::from);
514513
let target_url = llm_url.unwrap_or_else(|| {
515514
last_httpx_urls
516515
.iter()
@@ -524,11 +523,15 @@ pub async fn run_recon(cli: &Cli, domain: &str) -> Result<()> {
524523
args: args.clone(),
525524
stdout: String::new(),
526525
stderr: String::new(),
527-
error: Some("no URL supplied and no live httpx URL available".into()),
526+
error: Some(
527+
"no URL supplied and no live httpx URL available".into(),
528+
),
528529
duration_ms: t0.elapsed().as_millis(),
529530
}
530531
} else if !host_in_scope(&target_url, &cfg.scope_patterns) {
531-
println!("[!] scope guard: ffuf URL '{target_url}' not in scope, skipping");
532+
println!(
533+
"[!] scope guard: ffuf URL '{target_url}' not in scope, skipping"
534+
);
532535
ToolExecution {
533536
tool: kind,
534537
args: args.clone(),
@@ -540,7 +543,11 @@ pub async fn run_recon(cli: &Cli, domain: &str) -> Result<()> {
540543
} else {
541544
match resolve_wordlist(cfg.ffuf_wordlist.as_deref()) {
542545
Ok((wl, _is_tmp)) => {
543-
println!("[>] ffuf path-fuzzing {} (wordlist: {})", target_url, wl.display());
546+
println!(
547+
"[>] ffuf path-fuzzing {} (wordlist: {})",
548+
target_url,
549+
wl.display()
550+
);
544551
match exec_ffuf(&target_url, &wl).await {
545552
Ok((so, se)) => ToolExecution {
546553
tool: kind,
@@ -665,11 +672,8 @@ pub async fn run_recon(cli: &Cli, domain: &str) -> Result<()> {
665672
)
666673
}
667674
ToolKind::Httpx => {
668-
let urls: Vec<String> = last_httpx_urls
669-
.iter()
670-
.take(10)
671-
.cloned()
672-
.collect();
675+
let urls: Vec<String> =
676+
last_httpx_urls.iter().take(10).cloned().collect();
673677
format!(
674678
"{} live hosts responded. First {}: {}",
675679
line_count,
@@ -678,20 +682,14 @@ pub async fn run_recon(cli: &Cli, domain: &str) -> Result<()> {
678682
)
679683
}
680684
ToolKind::Nuclei => {
681-
let n = all_findings
682-
.iter()
683-
.filter(|f| f.kind == "nuclei")
684-
.count();
685+
let n = all_findings.iter().filter(|f| f.kind == "nuclei").count();
685686
format!(
686687
"nuclei scan complete: {} JSONL lines, {} parsed findings. Next step should be done.",
687688
line_count, n
688689
)
689690
}
690691
ToolKind::Ffuf => {
691-
let n = all_findings
692-
.iter()
693-
.filter(|f| f.kind == "ffuf")
694-
.count();
692+
let n = all_findings.iter().filter(|f| f.kind == "ffuf").count();
695693
format!(
696694
"ffuf path-fuzz complete: {} parsed findings. Next step should be done unless you want to fuzz another live host.",
697695
n
@@ -706,7 +704,9 @@ pub async fn run_recon(cli: &Cli, domain: &str) -> Result<()> {
706704
});
707705
messages.push(ChatMessage {
708706
role: "user".into(),
709-
content: format!("{observation}\n\nWhat next? Respond with a single JSON action."),
707+
content: format!(
708+
"{observation}\n\nWhat next? Respond with a single JSON action."
709+
),
710710
});
711711

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

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

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

797797
std::fs::write(&findings_path, serde_json::to_string_pretty(&record)?)
798798
.context("write findings json")?;
799-
std::fs::write(&report_path, render_report(&record))
800-
.context("write markdown report")?;
799+
std::fs::write(&report_path, render_report(&record)).context("write markdown report")?;
801800

802801
println!();
803802
println!("========== AGENT SUMMARY ==========");

src/config.rs

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,12 +151,7 @@ impl Config {
151151
active,
152152
ffuf_wordlist,
153153
..
154-
} => (
155-
org.clone(),
156-
asn.clone(),
157-
*active,
158-
ffuf_wordlist.clone(),
159-
),
154+
} => (org.clone(), asn.clone(), *active, ffuf_wordlist.clone()),
160155
};
161156
Self {
162157
model,

src/findings/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33
pub mod models;
44
pub mod parse;
55

6-
pub use models::{DedupedFinding, Finding, Severity};
76
pub use models::dedup_findings;
7+
pub use models::{DedupedFinding, Finding, Severity};
88
pub use parse::{extract_hosts_from_subfinder, parse_httpx_output, parse_nuclei_output};

src/findings/models.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,67 @@ pub fn dedup_findings(raw: &[Finding]) -> Vec<DedupedFinding> {
124124
out.sort_by(|a, b| b.severity.cmp(&a.severity).then(b.count.cmp(&a.count)));
125125
out
126126
}
127+
128+
#[cfg(test)]
129+
mod tests {
130+
use super::*;
131+
132+
#[test]
133+
fn severity_orders_low_to_high() {
134+
assert!(Severity::Info < Severity::Low);
135+
assert!(Severity::Low < Severity::Medium);
136+
assert!(Severity::Medium < Severity::High);
137+
assert!(Severity::High < Severity::Critical);
138+
}
139+
140+
#[test]
141+
fn from_str_loose_handles_aliases_and_unknowns() {
142+
assert_eq!(Severity::from_str_loose("CRITICAL"), Severity::Critical);
143+
assert_eq!(Severity::from_str_loose(" high "), Severity::High);
144+
assert_eq!(Severity::from_str_loose("moderate"), Severity::Medium);
145+
assert_eq!(Severity::from_str_loose("medium"), Severity::Medium);
146+
assert_eq!(Severity::from_str_loose("low"), Severity::Low);
147+
// anything unrecognized falls back to Info
148+
assert_eq!(Severity::from_str_loose("bogus"), Severity::Info);
149+
assert_eq!(Severity::from_str_loose(""), Severity::Info);
150+
}
151+
152+
#[test]
153+
fn dedup_folds_identical_kind_and_details() {
154+
let raw = vec![
155+
Finding::new(Severity::Low, "http-probe", "a.example.com", "same"),
156+
Finding::new(Severity::High, "http-probe", "b.example.com", "same"),
157+
Finding::new(Severity::Low, "http-probe", "a.example.com", "same"),
158+
];
159+
let deduped = dedup_findings(&raw);
160+
assert_eq!(deduped.len(), 1);
161+
let d = &deduped[0];
162+
// severity promoted to the max of the group
163+
assert_eq!(d.severity, Severity::High);
164+
// count reflects number of source rows, not unique targets
165+
assert_eq!(d.count, 3);
166+
// targets deduped, insertion order preserved
167+
assert_eq!(
168+
d.targets,
169+
vec!["a.example.com".to_string(), "b.example.com".to_string()]
170+
);
171+
}
172+
173+
#[test]
174+
fn dedup_keeps_distinct_groups_and_sorts_by_severity() {
175+
let raw = vec![
176+
Finding::new(Severity::Info, "http-probe", "x", "low-thing"),
177+
Finding::new(Severity::Critical, "nuclei", "y", "bad-thing"),
178+
];
179+
let deduped = dedup_findings(&raw);
180+
assert_eq!(deduped.len(), 2);
181+
// sorted severity desc -> Critical first
182+
assert_eq!(deduped[0].severity, Severity::Critical);
183+
assert_eq!(deduped[1].severity, Severity::Info);
184+
}
185+
186+
#[test]
187+
fn dedup_empty_input_yields_empty() {
188+
assert!(dedup_findings(&[]).is_empty());
189+
}
190+
}

0 commit comments

Comments
 (0)