diff --git a/src/list.rs b/src/list.rs index e50d476..16552c1 100644 --- a/src/list.rs +++ b/src/list.rs @@ -265,6 +265,8 @@ pub fn run( Some(&project_name), *page, *page_size, + None, + None, ) { Ok(scans) => { let page = scans.page; @@ -306,6 +308,7 @@ pub fn run( "Status".to_string(), "Repo".to_string(), "Branch".to_string(), + "SHA".to_string(), ]]; for scan in &scans { @@ -321,6 +324,11 @@ pub fn run( } else { formatted_repo }; + let short_sha = scan + .git_sha + .as_deref() + .map(|s| s.chars().take(8).collect::()) + .unwrap_or_else(|| "N/A".to_string()); table.push(vec![ scan.id.clone(), @@ -328,6 +336,7 @@ pub fn run( scan.status.clone(), formatted_repo, scan.branch.clone().unwrap_or("N/A".to_string()), + short_sha, ]); } diff --git a/src/main.rs b/src/main.rs index 14b6900..45cec9f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,6 +81,12 @@ enum Commands { #[arg(long, help = "Only scan uncommitted changes.")] only_uncommitted: bool, + #[arg( + long, + help = "Skip the scan if this commit already has a completed BLAST scan for the same project and branch within the last 24h. Requires a clean git checkout. Only for a default blast scan (not with --fail, --fail-on, --only-uncommitted, --target, --exclude, --scan-type, --policy, or --out-file/--out-format)." + )] + skip_if_scanned: bool, + #[arg( short, long, @@ -489,6 +495,7 @@ fn main() { fail_on, fail, only_uncommitted, + skip_if_scanned, scan_type, policy, out_format, @@ -519,6 +526,28 @@ fn main() { std::process::exit(1); } + if *skip_if_scanned && *scanner != Scanner::Blast { + ::log::error!("--skip-if-scanned is only supported with the blast scanner."); + std::process::exit(1); + } + + if *skip_if_scanned + && (*fail + || fail_on.is_some() + || *only_uncommitted + || target.is_some() + || exclude.is_some() + || scan_type.is_some() + || policy.is_some() + || out_file.is_some() + || out_format.is_some()) + { + ::log::error!( + "--skip-if-scanned only applies to a default blast scan. It cannot be combined with --fail, --fail-on, --only-uncommitted, --target, --exclude, --scan-type, --policy, or --out-file/--out-format." + ); + std::process::exit(1); + } + if out_file.is_some() && *scanner != Scanner::Blast { ::log::error!("out_file is only supported with blast scanner."); std::process::exit(1); @@ -591,6 +620,7 @@ fn main() { fail_on.clone(), fail, only_uncommitted, + skip_if_scanned, scan_type.clone(), policy.clone(), out_format.clone(), diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index 74c53f9..7c74501 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -1,7 +1,8 @@ use crate::config::Config; use crate::targets; use crate::utils; -use crate::utils::api::SCAIssue; +use crate::utils::api::{SCAIssue, ScanResponse}; +use chrono::{DateTime, Duration, Utc}; use std::collections::HashMap; use std::env; use std::error::Error; @@ -16,6 +17,7 @@ pub fn run( fail_on: Option, fail: &bool, only_uncommitted: &bool, + skip_if_scanned: &bool, scan_type: Option, policy: Option, out_format: Option, @@ -45,6 +47,87 @@ pub fn run( } } } + + let project_name = utils::generic::determine_project_name(project_name.as_deref()); + let repo_info = utils::generic::get_repo_info("./").unwrap_or_default(); + + if *skip_if_scanned { + match &repo_info { + None => { + log::error!("--skip-if-scanned can only be used in a git repository."); + std::process::exit(1); + } + // Packaging uses cwd; only skip when cwd is the repo root so a + // packages/a scan cannot suppress a later root scan (or vice versa). + Some(_) if !utils::generic::is_at_repo_root("./") => { + log::error!("--skip-if-scanned must be run from the repository root."); + std::process::exit(1); + } + Some(_) if utils::generic::is_working_tree_dirty("./") => { + log::error!( + "--skip-if-scanned requires a clean working tree (no uncommitted or staged changes)." + ); + std::process::exit(1); + } + Some(info) => { + let Some(sha) = info.sha.as_deref() else { + log::error!("--skip-if-scanned could not determine the commit SHA."); + std::process::exit(1); + }; + // CI branch is skip-lookup only; upload keeps raw git branch metadata. + let Some(branch) = resolve_skip_branch(info.branch.as_deref()) else { + log::error!( + "--skip-if-scanned could not determine the branch (detached HEAD without CI metadata)." + ); + std::process::exit(1); + }; + let git_had_branch = info + .branch + .as_deref() + .map(str::trim) + .filter(|b| !b.is_empty()) + .is_some(); + // Detached uploads store null branch; don't filter those out. + let query_branch = if git_had_branch { + Some(branch.as_str()) + } else { + None + }; + match utils::api::query_scan_list( + &config.get_url(), + Some(&project_name), + Some(1), + None, + query_branch, + Some(sha), + ) { + Ok(resp) => { + let scans = resp.scans.unwrap_or_default(); + let now = Utc::now(); + if let Some(scan) = + find_recent_matching_scan(&scans, sha, &branch, !git_had_branch, now) + { + let age = parse_created_at(&scan.created_at) + .map(|created| format_scan_age(created, now)) + .unwrap_or_else(|| "recently".to_string()); + let short_sha: String = sha.chars().take(8).collect(); + println!( + "Skipping scan: commit {} on {} was already scanned {} (scan {}).", + short_sha, branch, age, scan.id + ); + return; + } + } + Err(e) => { + log::warn!( + "--skip-if-scanned: failed to query recent scans ({e}); scanning normally." + ); + } + } + } + } + } + println!("\nScanning with BLAST 🚀🚀🚀"); if let Some(scan_type) = &scan_type { @@ -59,9 +142,8 @@ pub fn run( println!("\n\n"); let temp_dir = env::temp_dir().join(format!("corgea/tmp/{}", Uuid::new_v4())); fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); - let project_name = utils::generic::determine_project_name(project_name.as_deref()); let zip_path = format!("{}/{}.zip", temp_dir.display(), project_name); - let repo_info = utils::generic::get_repo_info("./").unwrap_or_default(); + match utils::generic::create_path_if_not_exists(&temp_dir) { Ok(_) => (), Err(e) => { @@ -651,6 +733,83 @@ pub fn report_scan_status( Ok(classification_counts) } +/// Branch for skip matching: local git branch, else common CI metadata +/// (`GITHUB_HEAD_REF` / `GITHUB_REF_NAME` / `CI_COMMIT_BRANCH` / `CI_COMMIT_REF_NAME`) +/// so detached-HEAD checkouts in CI can still skip. +fn resolve_skip_branch(repo_branch: Option<&str>) -> Option { + if let Some(branch) = repo_branch.map(str::trim).filter(|b| !b.is_empty()) { + return Some(branch.to_string()); + } + for key in [ + "GITHUB_HEAD_REF", + "GITHUB_REF_NAME", + "CI_COMMIT_BRANCH", + "CI_COMMIT_REF_NAME", + ] { + if let Some(branch) = env::var(key) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + { + return Some(branch); + } + } + None +} + +fn find_recent_matching_scan<'a>( + scans: &'a [ScanResponse], + local_sha: &str, + local_branch: &str, + allow_null_scan_branch: bool, + now: DateTime, +) -> Option<&'a ScanResponse> { + scans.iter().find(|scan| { + if !scan.status.eq_ignore_ascii_case("complete") { + return false; + } + // BLAST uploads store engine as "corgea-blast"; never skip on Semgrep/Snyk/etc. + if !scan.engine.eq_ignore_ascii_case("corgea-blast") { + return false; + } + if scan.git_sha.as_deref() != Some(local_sha) { + return false; + } + let branch_ok = scan.branch.as_deref() == Some(local_branch) + || (allow_null_scan_branch && scan.branch.is_none()); + if !branch_ok { + return false; + } + match parse_created_at(&scan.created_at) { + // Reject future timestamps (clock skew) and anything >= 24h old. + Some(created) => created <= now && now - created < Duration::hours(24), + None => false, + } + }) +} + +fn parse_created_at(raw: &str) -> Option> { + if let Ok(dt) = DateTime::parse_from_rfc3339(raw) { + return Some(dt.with_timezone(&Utc)); + } + if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S") { + return Some(DateTime::::from_naive_utc_and_offset(naive, Utc)); + } + if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S%.f") { + return Some(DateTime::::from_naive_utc_and_offset(naive, Utc)); + } + None +} + +fn format_scan_age(created: DateTime, now: DateTime) -> String { + let minutes = (now - created).num_minutes().max(0); + if minutes < 60 { + format!("{minutes}m ago") + } else { + format!("{}h ago", minutes / 60) + } +} + #[cfg(test)] mod tests { use super::*; @@ -773,4 +932,169 @@ mod tests { &[sca_issue(Some("malicious"))] )); } + + // --skip-if-scanned helpers + + fn scan( + id: &str, + sha: Option<&str>, + branch: Option<&str>, + status: &str, + created_at: &str, + ) -> ScanResponse { + scan_with_engine(id, sha, branch, status, "corgea-blast", created_at) + } + + fn scan_with_engine( + id: &str, + sha: Option<&str>, + branch: Option<&str>, + status: &str, + engine: &str, + created_at: &str, + ) -> ScanResponse { + ScanResponse { + id: id.to_string(), + project: "proj".to_string(), + repo: None, + branch: branch.map(str::to_string), + status: status.to_string(), + engine: engine.to_string(), + created_at: created_at.to_string(), + git_sha: sha.map(str::to_string), + } + } + + #[test] + fn skips_matching_complete_scan_within_24h() { + let now = Utc::now(); + let created = (now - Duration::hours(3)).to_rfc3339(); + let scans = vec![scan( + "s1", + Some("abc123"), + Some("main"), + "complete", + &created, + )]; + let matched = find_recent_matching_scan(&scans, "abc123", "main", false, now); + assert_eq!(matched.map(|s| s.id.as_str()), Some("s1")); + } + + #[test] + fn skips_title_case_complete_status() { + let now = Utc::now(); + let created = (now - Duration::hours(1)).to_rfc3339(); + let scans = vec![scan( + "s1", + Some("abc123"), + Some("main"), + "Complete", + &created, + )]; + assert_eq!( + find_recent_matching_scan(&scans, "abc123", "main", false, now).map(|s| s.id.as_str()), + Some("s1") + ); + } + + #[test] + fn does_not_skip_stale_or_exact_24h_scan() { + let now = Utc::now(); + let stale = (now - Duration::hours(25)).to_rfc3339(); + let exact = (now - Duration::hours(24)).to_rfc3339(); + let scans = vec![ + scan("s1", Some("abc123"), Some("main"), "complete", &stale), + scan("s2", Some("abc123"), Some("main"), "complete", &exact), + ]; + assert!(find_recent_matching_scan(&scans, "abc123", "main", false, now).is_none()); + } + + #[test] + fn does_not_skip_future_created_at() { + let now = Utc::now(); + let created = (now + Duration::hours(1)).to_rfc3339(); + let scans = vec![scan( + "s1", + Some("abc123"), + Some("main"), + "complete", + &created, + )]; + assert!(find_recent_matching_scan(&scans, "abc123", "main", false, now).is_none()); + } + + #[test] + fn does_not_skip_different_branch_or_sha() { + let now = Utc::now(); + let created = (now - Duration::hours(1)).to_rfc3339(); + let scans = vec![ + scan("s1", Some("abc123"), Some("other"), "complete", &created), + scan("s2", Some("ffff"), Some("main"), "complete", &created), + ]; + assert!(find_recent_matching_scan(&scans, "abc123", "main", false, now).is_none()); + } + + #[test] + fn does_not_skip_missing_sha_or_incomplete() { + let now = Utc::now(); + let created = (now - Duration::hours(1)).to_rfc3339(); + let scans = vec![ + scan("s1", None, Some("main"), "complete", &created), + scan("s2", Some("abc123"), Some("main"), "scanning", &created), + ]; + assert!(find_recent_matching_scan(&scans, "abc123", "main", false, now).is_none()); + } + + #[test] + fn skips_null_branch_scan_when_detached_ci_fallback_allowed() { + let now = Utc::now(); + let created = (now - Duration::hours(1)).to_rfc3339(); + let scans = vec![scan("s1", Some("abc123"), None, "complete", &created)]; + assert_eq!( + find_recent_matching_scan(&scans, "abc123", "main", true, now).map(|s| s.id.as_str()), + Some("s1") + ); + assert!(find_recent_matching_scan(&scans, "abc123", "main", false, now).is_none()); + } + + #[test] + fn does_not_skip_non_blast_engine() { + let now = Utc::now(); + let created = (now - Duration::hours(1)).to_rfc3339(); + let scans = vec![ + scan_with_engine( + "s1", + Some("abc123"), + Some("main"), + "complete", + "semgrep", + &created, + ), + scan_with_engine( + "s2", + Some("abc123"), + Some("main"), + "complete", + "snyk", + &created, + ), + ]; + assert!(find_recent_matching_scan(&scans, "abc123", "main", false, now).is_none()); + } + + #[test] + fn resolve_skip_branch_prefers_repo_branch() { + assert_eq!( + resolve_skip_branch(Some("feature/x")).as_deref(), + Some("feature/x") + ); + } + + #[test] + fn format_scan_age_uses_minutes_under_one_hour() { + let now = Utc::now(); + let created = now - Duration::minutes(12); + assert_eq!(format_scan_age(created, now), "12m ago"); + assert_eq!(format_scan_age(now - Duration::hours(3), now), "3h ago"); + } } diff --git a/src/utils/api.rs b/src/utils/api.rs index 47a68bc..91e4d44 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -692,6 +692,8 @@ pub fn query_scan_list( project: Option<&str>, page: Option, page_size: Option, + branch: Option<&str>, + sha: Option<&str>, ) -> Result> { let url = format!("{}{}/scans", url, API_BASE); let page = page.unwrap_or(1); @@ -704,6 +706,12 @@ pub fn query_scan_list( if let Some(project) = project { query_params.push(("project", project.to_string())); } + if let Some(branch) = branch { + query_params.push(("branch", branch.to_string())); + } + if let Some(sha) = sha { + query_params.push(("sha", sha.to_string())); + } let client = http_client(); debug(&format!("Sending request to URL: {}", url)); @@ -940,6 +948,8 @@ pub struct ScanResponse { pub status: String, pub engine: String, pub created_at: String, + #[serde(default)] + pub git_sha: Option, } #[derive(Serialize, Deserialize, Debug)] diff --git a/src/utils/generic.rs b/src/utils/generic.rs index f07d013..e031829 100644 --- a/src/utils/generic.rs +++ b/src/utils/generic.rs @@ -1,5 +1,5 @@ use crate::utils::terminal::{set_text_color, TerminalColor}; -use git2::Repository; +use git2::{Repository, StatusOptions}; use globset::{Glob, GlobSetBuilder}; use ignore::WalkBuilder; use std::env; @@ -268,9 +268,11 @@ pub fn get_env_var_if_exists(var_name: &str) -> Option { } pub fn get_repo_info(dir: &str) -> Result, git2::Error> { - let repo = match Repository::open(Path::new(dir)) { + // discover (not open) so callers in a repo subdirectory still resolve HEAD. + let repo = match Repository::discover(Path::new(dir)) { Ok(repo) => repo, - Err(_) => return Ok(None), + Err(e) if e.code() == git2::ErrorCode::NotFound => return Ok(None), + Err(e) => return Err(e), }; let branch = repo.head().ok().and_then(|head| { @@ -301,6 +303,42 @@ pub fn get_repo_info(dir: &str) -> Result, git2::Error> { })) } +/// True when `dir` is the repository worktree root (not a subdirectory). +pub fn is_at_repo_root(dir: &str) -> bool { + let Ok(repo) = Repository::discover(Path::new(dir)) else { + return false; + }; + let Some(workdir) = repo.workdir() else { + return false; + }; + let Ok(workdir) = workdir.canonicalize() else { + return false; + }; + let Ok(cwd) = Path::new(dir).canonicalize() else { + return false; + }; + workdir == cwd +} + +/// True when the worktree or index differs from HEAD (including untracked files +/// and submodule changes). Returns true on error so callers that skip work fail closed. +pub fn is_working_tree_dirty(dir: &str) -> bool { + let Ok(repo) = Repository::discover(Path::new(dir)) else { + return true; + }; + let mut opts = StatusOptions::new(); + opts.include_untracked(true); + // Include submodule status so a changed gitlink or dirty submodule + // cannot be skipped as if HEAD content were unchanged. + opts.exclude_submodules(false); + opts.include_ignored(false); + let dirty = match repo.statuses(Some(&mut opts)) { + Ok(statuses) => !statuses.is_empty(), + Err(_) => true, + }; + dirty +} + pub fn get_status(status: &str) -> &str { match status.to_lowercase().as_str() { "fix available" | "fix_available" => "Fix Available", @@ -327,6 +365,56 @@ pub struct RepoInfo { mod tests { use super::*; use std::fs; + use std::process::Command; + + #[test] + fn get_repo_info_discovers_from_subdirectory() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + assert!(Command::new("git") + .args(["init"]) + .current_dir(root) + .status() + .unwrap() + .success()); + assert!(Command::new("git") + .args(["config", "user.email", "test@example.com"]) + .current_dir(root) + .status() + .unwrap() + .success()); + assert!(Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(root) + .status() + .unwrap() + .success()); + fs::write(root.join("README"), "hi").unwrap(); + assert!(Command::new("git") + .args(["add", "README"]) + .current_dir(root) + .status() + .unwrap() + .success()); + assert!(Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(root) + .status() + .unwrap() + .success()); + + let nested = root.join("pkg").join("inner"); + fs::create_dir_all(&nested).unwrap(); + let nested_s = nested.to_str().unwrap(); + let root_s = root.to_str().unwrap(); + let info = get_repo_info(nested_s) + .unwrap() + .expect("should find repo from subdirectory"); + assert!(info.sha.is_some()); + assert!(!is_working_tree_dirty(nested_s)); + assert!(is_at_repo_root(root_s)); + assert!(!is_at_repo_root(nested_s)); + } #[test] fn create_zip_from_target_excludes_default_globs() { diff --git a/src/wait.rs b/src/wait.rs index fdd69bb..c324c68 100644 --- a/src/wait.rs +++ b/src/wait.rs @@ -11,8 +11,14 @@ pub fn run(config: &Config, scan_id: Option, project_id: Option) } }; - let scans_result = - utils::api::query_scan_list(&config.get_url(), Some(&project_name), Some(1), None); + let scans_result = utils::api::query_scan_list( + &config.get_url(), + Some(&project_name), + Some(1), + None, + None, + None, + ); let scans: Vec = match scans_result { Ok(result) => result.scans.unwrap_or_default(), Err(e) => {