diff --git a/.gitignore b/.gitignore index 44e03f6..a37e537 100644 --- a/.gitignore +++ b/.gitignore @@ -104,3 +104,7 @@ data/repl_runs.json data/repl_history.txt .neurosploit/ /tmp/* + +# Cloned source repos (whitebox/greybox from a git URL) +repos/ +neurosploit-rs/repos/ diff --git a/RELEASE.md b/RELEASE.md index ba5600f..1486db9 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -47,6 +47,14 @@ and severity-calibrated). - **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 `/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 + ``` ## Notes diff --git a/neurosploit-rs/app/src/main.rs b/neurosploit-rs/app/src/main.rs index b030412..3ef96a6 100644 --- a/neurosploit-rs/app/src/main.rs +++ b/neurosploit-rs/app/src/main.rs @@ -65,8 +65,10 @@ enum Cmd { #[arg(short, long)] verbose: bool, }, - /// White-box: analyse a local repository's source code for vulnerabilities. + /// White-box: analyse a repository's source code for vulnerabilities. Whitebox { + /// Local path, a GitHub URL (https://github.com/owner/repo[.git]) or an + /// `owner/repo` shorthand — git URLs are cloned automatically. path: String, #[arg(long = "model")] models: Vec, @@ -83,7 +85,7 @@ enum Cmd { }, /// Greybox: review a repo's source AND exploit the running app together. Greybox { - /// Path to the source repository. + /// Source repo: local path, a GitHub URL, or `owner/repo` (cloned if a URL). repo: String, /// URL of the running application. #[arg(long)] @@ -230,6 +232,7 @@ async fn main() -> anyhow::Result<()> { print_findings(&out); } 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); cfg.max_agents = max_agents; cfg.vote_n = vote_n; @@ -243,6 +246,7 @@ async fn main() -> anyhow::Result<()> { print_findings(&out); } 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 mut cfg = RunConfig::new(&url); cfg.repo = Some(repo); @@ -260,6 +264,7 @@ async fn main() -> anyhow::Result<()> { print_findings(&out); } 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 mut cfg = RunConfig::new(&url); cfg.max_agents = max_agents; @@ -532,6 +537,45 @@ fn now_ts() -> u64 { 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 `/repos/` and +/// that path is returned; an existing local path is returned unchanged. +pub(crate) fn resolve_source(base: &Path, arg: &str) -> anyhow::Result { + 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()); + } + println!(" [*] cloning {url} → {}", dest.display()); + let status = std::process::Command::new("git") + .args(["clone", "--depth", "1", &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()) +} + /// Blocking yes/no prompt (default yes). Used after a graceful Ctrl-C. fn ask_yes_no(q: &str) -> bool { use std::io::Write; diff --git a/neurosploit-rs/app/src/repl.rs b/neurosploit-rs/app/src/repl.rs index 070a614..b50d152 100644 --- a/neurosploit-rs/app/src/repl.rs +++ b/neurosploit-rs/app/src/repl.rs @@ -392,9 +392,15 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> { s.target = Some(t.clone()); println!(" target: {t}"); } } "/repo" => { - if arg.is_empty() { println!(" repo: {}", s.repo.clone().unwrap_or_else(|| "(none) — set with /repo , clear with /repo clear".into())); } + if arg.is_empty() { println!(" repo: {}", s.repo.clone().unwrap_or_else(|| "(none) — set with /repo , clear with /repo clear".into())); } else if arg == "clear" { s.repo = None; println!(" repo cleared"); } - else { s.repo = Some(arg.to_string()); println!(" repo: {arg}"); } + else { + // 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" => { if arg.is_empty() { println!(" auth: {}", s.auth.clone().unwrap_or_else(|| "(none) — set with /auth
, clear with /auth clear".into())); }