From 1c0f2663add1656cf9579e9c0de56190b0c27572 Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Mon, 13 Jul 2026 11:48:45 +0300 Subject: [PATCH 1/7] COR-1639: CLI-01: Scan Optimization (SHA Tracking) --- src/list.rs | 9 +++ src/main.rs | 20 +++++ src/scanners/blast.rs | 182 +++++++++++++++++++++++++++++++++++++++++- src/utils/api.rs | 10 +++ src/wait.rs | 10 ++- 5 files changed, 227 insertions(+), 4 deletions(-) diff --git a/src/list.rs b/src/list.rs index 00790ba..1b686db 100644 --- a/src/list.rs +++ b/src/list.rs @@ -263,6 +263,8 @@ pub fn run( Some(&project_name), *page, *page_size, + None, + None, ) { Ok(scans) => { let page = scans.page; @@ -304,6 +306,7 @@ pub fn run( "Status".to_string(), "Repo".to_string(), "Branch".to_string(), + "SHA".to_string(), ]]; for scan in &scans { @@ -319,6 +322,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(), @@ -326,6 +334,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 de89adb..4b10671 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 scan for the same project and branch within the last 24h. Cannot be combined with --fail or --fail-on." + )] + skip_if_scanned: bool, + #[arg( short, long, @@ -486,6 +492,7 @@ fn main() { fail_on, fail, only_uncommitted, + skip_if_scanned, scan_type, policy, out_format, @@ -518,6 +525,18 @@ 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()) { + ::log::error!( + "--skip-if-scanned cannot be used with --fail or --fail-on (skipping would bypass those checks)." + ); + 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); @@ -590,6 +609,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 4525aa7..ed83a10 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -1,6 +1,8 @@ use crate::config::Config; use crate::targets; use crate::utils; +use crate::utils::api::ScanResponse; +use chrono::{DateTime, Duration, Utc}; use std::collections::HashMap; use std::env; use std::error::Error; @@ -15,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, @@ -44,6 +47,41 @@ 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 { + if let Some(ref info) = repo_info { + // Require both SHA and branch so detached-HEAD / unknown-branch + // never matches a null-branch scan by accident. + if let (Some(sha), Some(branch)) = (info.sha.as_deref(), info.branch.as_deref()) { + if let Ok(resp) = utils::api::query_scan_list( + &config.get_url(), + Some(&project_name), + Some(1), + None, + Some(branch), + Some(sha), + ) { + let scans = resp.scans.unwrap_or_default(); + let now = Utc::now(); + if let Some(scan) = find_recent_matching_scan(&scans, sha, Some(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; + } + } + } + } + } + println!("\nScanning with BLAST 🚀🚀🚀"); if let Some(scan_type) = &scan_type { @@ -58,9 +96,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) => { @@ -557,3 +594,144 @@ pub fn report_scan_status( println!("{:<20} | {}", "Total", total_issues); Ok(classification_counts) } + +fn find_recent_matching_scan<'a>( + scans: &'a [ScanResponse], + local_sha: &str, + local_branch: Option<&str>, + now: DateTime, +) -> Option<&'a ScanResponse> { + scans.iter().find(|scan| { + if scan.status != "complete" { + return false; + } + if scan.git_sha.as_deref() != Some(local_sha) { + return false; + } + if scan.branch.as_deref() != local_branch { + 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::*; + + fn scan( + id: &str, + sha: Option<&str>, + branch: Option<&str>, + status: &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: "blast".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", Some("main"), now); + assert_eq!(matched.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", Some("main"), 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", Some("main"), 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", Some("main"), 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", Some("main"), now).is_none()); + } + + #[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 a504760..3a5ae86 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/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) => { From 343bffe1e08f75e5ee83c96240966c7d33ff9c56 Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Mon, 13 Jul 2026 12:31:43 +0300 Subject: [PATCH 2/7] Address PR review: tighten skip-if-scanned guards and fix CI clippy Reject skip with partial-scan/output flags, case-insensitive complete status, and satisfy clippy::question_mark on CI's Rust 1.97. Co-authored-by: Cursor --- src/main.rs | 16 +++++++++++++--- src/scanners/blast.rs | 19 ++++++++++++++++++- src/verify_deps/registry.rs | 5 ++--- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4b10671..873dc19 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,7 +83,7 @@ enum Commands { #[arg( long, - help = "Skip the scan if this commit already has a completed scan for the same project and branch within the last 24h. Cannot be combined with --fail or --fail-on." + help = "Skip the scan if this commit already has a completed scan for the same project and branch within the last 24h. 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, @@ -530,9 +530,19 @@ fn main() { std::process::exit(1); } - if *skip_if_scanned && (*fail || fail_on.is_some()) { + 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 cannot be used with --fail or --fail-on (skipping would bypass those checks)." + "--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); } diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index ed83a10..b81b79f 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -602,7 +602,7 @@ fn find_recent_matching_scan<'a>( now: DateTime, ) -> Option<&'a ScanResponse> { scans.iter().find(|scan| { - if scan.status != "complete" { + if !scan.status.eq_ignore_ascii_case("complete") { return false; } if scan.git_sha.as_deref() != Some(local_sha) { @@ -679,6 +679,23 @@ mod tests { 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", Some("main"), now).map(|s| s.id.as_str()), + Some("s1") + ); + } + #[test] fn does_not_skip_stale_or_exact_24h_scan() { let now = Utc::now(); diff --git a/src/verify_deps/registry.rs b/src/verify_deps/registry.rs index 8168fc5..2339ca1 100644 --- a/src/verify_deps/registry.rs +++ b/src/verify_deps/registry.rs @@ -532,11 +532,10 @@ fn split_pep440_prerelease(v: &str) -> Option<(&str, Option)> { (1, "a", r) } else if let Some(r) = suffix.strip_prefix('b') { (2, "b", r) - } else if let Some(r) = suffix.strip_prefix('c') { + } else { // PEP 440 spells release-candidate `c` and `rc` interchangeably. + let r = suffix.strip_prefix('c')?; (3, "rc", r) - } else { - return None; }; let num_str = rest.trim_start_matches(['.', '-', '_']); // Reject anything we didn't fully consume (combined `a1.dev2`, local From 69b4022c97dafc10f013a74141006642526ad2d4 Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Mon, 13 Jul 2026 12:44:16 +0300 Subject: [PATCH 3/7] Fix cargo audit: bump quick-xml and crossbeam-epoch Address RUSTSEC-2026-0194/0195 (quick-xml >=0.41) and RUSTSEC-2026-0204 (crossbeam-epoch >=0.9.20). Update Fortify parser for the quick-xml 0.41 API. Co-authored-by: Cursor --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- src/scanners/fortify.rs | 9 +++++++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 439bcde..27bf7fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -450,9 +450,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -1579,9 +1579,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.36.2" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index 9c07d83..adbac9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,7 @@ uuid = { version = "1.7.0", features = ["v4"] } which = "6.0.0" zip = "2.3.0" tempfile = "3.12.0" -quick-xml = "0.36.1" +quick-xml = "0.41" ignore = "0.4" globset = "0.4" termcolor = "1.1" diff --git a/src/scanners/fortify.rs b/src/scanners/fortify.rs index 077988e..a426ac9 100644 --- a/src/scanners/fortify.rs +++ b/src/scanners/fortify.rs @@ -2,6 +2,7 @@ use crate::scan::upload_scan; use crate::Config; use quick_xml::events::Event; use quick_xml::reader::Reader; +use quick_xml::XmlVersion; use std::fs::File; use std::io; use std::io::{BufReader, Read}; @@ -92,7 +93,9 @@ fn extract_file_path(scan_file: PathBuf) -> (String, Vec) { Ok(attr) => { let attr_key = attr.key.as_ref(); if attr_key == b"path" { - if let Ok(value) = attr.unescape_value() { + if let Ok(value) = + attr.normalized_value(XmlVersion::Implicit1_0) + { let path_str = value.to_string(); if !paths.contains(&path_str) { paths.push(path_str); @@ -115,7 +118,9 @@ fn extract_file_path(scan_file: PathBuf) -> (String, Vec) { Ok(attr) => { let attr_key = attr.key.as_ref(); if attr_key == b"path" { - if let Ok(value) = attr.unescape_value() { + if let Ok(value) = + attr.normalized_value(XmlVersion::Implicit1_0) + { let path_str = value.to_string(); if !paths.contains(&path_str) { paths.push(path_str); From 84b4e3dd8ade411c193f5d41cb0600ca20d75315 Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Sun, 19 Jul 2026 10:56:03 +0300 Subject: [PATCH 4/7] COR-1639: address comments and other updates --- src/scanners/blast.rs | 159 +++++++++++++++++++++++++++++++++++------- src/utils/generic.rs | 19 ++++- 2 files changed, 150 insertions(+), 28 deletions(-) diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index 686db27..f09c2da 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -49,36 +49,69 @@ 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(); + let mut repo_info = utils::generic::get_repo_info("./").unwrap_or_default(); + // Detached HEAD (common in CI): fill branch from CI metadata so skip and + // upload agree on the same branch name. + if let Some(ref mut info) = repo_info { + if info + .branch + .as_deref() + .map(str::trim) + .filter(|b| !b.is_empty()) + .is_none() + { + info.branch = resolve_skip_branch(None); + } + } if *skip_if_scanned { - if let Some(ref info) = repo_info { - // Require both SHA and branch so detached-HEAD / unknown-branch - // never matches a null-branch scan by accident. - if let (Some(sha), Some(branch)) = (info.sha.as_deref(), info.branch.as_deref()) { - if let Ok(resp) = utils::api::query_scan_list( - &config.get_url(), - Some(&project_name), - Some(1), - None, - Some(branch), - Some(sha), - ) { - let scans = resp.scans.unwrap_or_default(); - let now = Utc::now(); - if let Some(scan) = find_recent_matching_scan(&scans, sha, Some(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; + match &repo_info { + None => { + println!("--skip-if-scanned: not a git repository; scanning normally."); + } + Some(_) if utils::generic::is_working_tree_dirty("./") => { + println!( + "--skip-if-scanned: working tree has uncommitted changes; scanning normally." + ); + } + Some(info) => match (info.sha.as_deref(), info.branch.as_deref()) { + (Some(sha), Some(branch)) => { + if let Ok(resp) = utils::api::query_scan_list( + &config.get_url(), + Some(&project_name), + Some(1), + None, + Some(branch), + Some(sha), + ) { + let scans = resp.scans.unwrap_or_default(); + let now = Utc::now(); + if let Some(scan) = + find_recent_matching_scan(&scans, sha, Some(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; + } } } - } + (None, _) => { + println!( + "--skip-if-scanned: could not determine commit SHA; scanning normally." + ); + } + (_, None) => { + println!( + "--skip-if-scanned: could not determine branch (detached HEAD without CI metadata); scanning normally." + ); + } + }, } } @@ -687,6 +720,30 @@ 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, @@ -697,6 +754,10 @@ fn find_recent_matching_scan<'a>( 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; } @@ -864,6 +925,17 @@ mod tests { 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(), @@ -871,7 +943,7 @@ mod tests { repo: None, branch: branch.map(str::to_string), status: status.to_string(), - engine: "blast".to_string(), + engine: engine.to_string(), created_at: created_at.to_string(), git_sha: sha.map(str::to_string), } @@ -957,6 +1029,39 @@ mod tests { assert!(find_recent_matching_scan(&scans, "abc123", Some("main"), 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", Some("main"), 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(); diff --git a/src/utils/generic.rs b/src/utils/generic.rs index f07d013..1ad0d4c 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; @@ -301,6 +301,23 @@ pub fn get_repo_info(dir: &str) -> Result, git2::Error> { })) } +/// True when the worktree or index differs from HEAD (including untracked files). +/// Returns true on error so callers that skip work fail closed. +pub fn is_working_tree_dirty(dir: &str) -> bool { + let Ok(repo) = Repository::open(Path::new(dir)) else { + return true; + }; + let mut opts = StatusOptions::new(); + opts.include_untracked(true); + opts.exclude_submodules(true); + 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", From 016e6d15bc1d77261167d777ee44bcd92e32d58e Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Mon, 20 Jul 2026 17:00:48 +0300 Subject: [PATCH 5/7] COR-1639: log error instead of print --- src/main.rs | 2 +- src/scanners/blast.rs | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/main.rs b/src/main.rs index a7f6911..45cec9f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,7 +83,7 @@ enum Commands { #[arg( long, - help = "Skip the scan if this commit already has a completed scan for the same project and branch within the last 24h. Only for a default blast scan (not with --fail, --fail-on, --only-uncommitted, --target, --exclude, --scan-type, --policy, or --out-file/--out-format)." + 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, diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index f09c2da..555f3b4 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -67,12 +67,14 @@ pub fn run( if *skip_if_scanned { match &repo_info { None => { - println!("--skip-if-scanned: not a git repository; scanning normally."); + log::error!("--skip-if-scanned can only be used in a git repository."); + std::process::exit(1); } Some(_) if utils::generic::is_working_tree_dirty("./") => { - println!( - "--skip-if-scanned: working tree has uncommitted changes; scanning normally." + log::error!( + "--skip-if-scanned requires a clean working tree (no uncommitted or staged changes)." ); + std::process::exit(1); } Some(info) => match (info.sha.as_deref(), info.branch.as_deref()) { (Some(sha), Some(branch)) => { @@ -102,14 +104,14 @@ pub fn run( } } (None, _) => { - println!( - "--skip-if-scanned: could not determine commit SHA; scanning normally." - ); + log::error!("--skip-if-scanned could not determine the commit SHA."); + std::process::exit(1); } (_, None) => { - println!( - "--skip-if-scanned: could not determine branch (detached HEAD without CI metadata); scanning normally." + log::error!( + "--skip-if-scanned could not determine the branch (detached HEAD without CI metadata)." ); + std::process::exit(1); } }, } From 11b78c90554e98bca1f0106366f1fd607c42e956 Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Tue, 21 Jul 2026 12:14:54 +0300 Subject: [PATCH 6/7] COR-1639: use repository discover and include submodule status in dirty check --- src/utils/generic.rs | 62 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/src/utils/generic.rs b/src/utils/generic.rs index 1ad0d4c..ee3ef1f 100644 --- a/src/utils/generic.rs +++ b/src/utils/generic.rs @@ -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,15 +303,17 @@ pub fn get_repo_info(dir: &str) -> Result, git2::Error> { })) } -/// True when the worktree or index differs from HEAD (including untracked files). -/// Returns true on error so callers that skip work fail closed. +/// 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::open(Path::new(dir)) else { + let Ok(repo) = Repository::discover(Path::new(dir)) else { return true; }; let mut opts = StatusOptions::new(); opts.include_untracked(true); - opts.exclude_submodules(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(), @@ -344,6 +348,52 @@ 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 info = get_repo_info(nested.to_str().unwrap()) + .unwrap() + .expect("should find repo from subdirectory"); + assert!(info.sha.is_some()); + assert!(!is_working_tree_dirty(nested.to_str().unwrap())); + } #[test] fn create_zip_from_target_excludes_default_globs() { From ef6dbdd5c7fe2bf77787817e5e54af93ba8b77f9 Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Tue, 21 Jul 2026 15:51:14 +0300 Subject: [PATCH 7/7] COR-1639: check repo root and other updates --- src/scanners/blast.rs | 116 ++++++++++++++++++++++++++---------------- src/utils/generic.rs | 25 ++++++++- 2 files changed, 94 insertions(+), 47 deletions(-) diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index 555f3b4..7c74501 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -49,20 +49,7 @@ pub fn run( } let project_name = utils::generic::determine_project_name(project_name.as_deref()); - let mut repo_info = utils::generic::get_repo_info("./").unwrap_or_default(); - // Detached HEAD (common in CI): fill branch from CI metadata so skip and - // upload agree on the same branch name. - if let Some(ref mut info) = repo_info { - if info - .branch - .as_deref() - .map(str::trim) - .filter(|b| !b.is_empty()) - .is_none() - { - info.branch = resolve_skip_branch(None); - } - } + let repo_info = utils::generic::get_repo_info("./").unwrap_or_default(); if *skip_if_scanned { match &repo_info { @@ -70,26 +57,55 @@ pub fn run( 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) => match (info.sha.as_deref(), info.branch.as_deref()) { - (Some(sha), Some(branch)) => { - if let Ok(resp) = utils::api::query_scan_list( - &config.get_url(), - Some(&project_name), - Some(1), - None, - Some(branch), - Some(sha), - ) { + 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, Some(branch), now) + 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)) @@ -102,18 +118,13 @@ pub fn run( return; } } + Err(e) => { + log::warn!( + "--skip-if-scanned: failed to query recent scans ({e}); scanning normally." + ); + } } - (None, _) => { - log::error!("--skip-if-scanned could not determine the commit SHA."); - std::process::exit(1); - } - (_, None) => { - log::error!( - "--skip-if-scanned could not determine the branch (detached HEAD without CI metadata)." - ); - std::process::exit(1); - } - }, + } } } @@ -749,7 +760,8 @@ fn resolve_skip_branch(repo_branch: Option<&str>) -> Option { fn find_recent_matching_scan<'a>( scans: &'a [ScanResponse], local_sha: &str, - local_branch: Option<&str>, + local_branch: &str, + allow_null_scan_branch: bool, now: DateTime, ) -> Option<&'a ScanResponse> { scans.iter().find(|scan| { @@ -763,7 +775,9 @@ fn find_recent_matching_scan<'a>( if scan.git_sha.as_deref() != Some(local_sha) { return false; } - if scan.branch.as_deref() != local_branch { + 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) { @@ -962,7 +976,7 @@ mod tests { "complete", &created, )]; - let matched = find_recent_matching_scan(&scans, "abc123", Some("main"), now); + let matched = find_recent_matching_scan(&scans, "abc123", "main", false, now); assert_eq!(matched.map(|s| s.id.as_str()), Some("s1")); } @@ -978,7 +992,7 @@ mod tests { &created, )]; assert_eq!( - find_recent_matching_scan(&scans, "abc123", Some("main"), now).map(|s| s.id.as_str()), + find_recent_matching_scan(&scans, "abc123", "main", false, now).map(|s| s.id.as_str()), Some("s1") ); } @@ -992,7 +1006,7 @@ mod tests { scan("s1", Some("abc123"), Some("main"), "complete", &stale), scan("s2", Some("abc123"), Some("main"), "complete", &exact), ]; - assert!(find_recent_matching_scan(&scans, "abc123", Some("main"), now).is_none()); + assert!(find_recent_matching_scan(&scans, "abc123", "main", false, now).is_none()); } #[test] @@ -1006,7 +1020,7 @@ mod tests { "complete", &created, )]; - assert!(find_recent_matching_scan(&scans, "abc123", Some("main"), now).is_none()); + assert!(find_recent_matching_scan(&scans, "abc123", "main", false, now).is_none()); } #[test] @@ -1017,7 +1031,7 @@ mod tests { scan("s1", Some("abc123"), Some("other"), "complete", &created), scan("s2", Some("ffff"), Some("main"), "complete", &created), ]; - assert!(find_recent_matching_scan(&scans, "abc123", Some("main"), now).is_none()); + assert!(find_recent_matching_scan(&scans, "abc123", "main", false, now).is_none()); } #[test] @@ -1028,7 +1042,19 @@ mod tests { scan("s1", None, Some("main"), "complete", &created), scan("s2", Some("abc123"), Some("main"), "scanning", &created), ]; - assert!(find_recent_matching_scan(&scans, "abc123", Some("main"), now).is_none()); + 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] @@ -1053,7 +1079,7 @@ mod tests { &created, ), ]; - assert!(find_recent_matching_scan(&scans, "abc123", Some("main"), now).is_none()); + assert!(find_recent_matching_scan(&scans, "abc123", "main", false, now).is_none()); } #[test] diff --git a/src/utils/generic.rs b/src/utils/generic.rs index ee3ef1f..e031829 100644 --- a/src/utils/generic.rs +++ b/src/utils/generic.rs @@ -303,6 +303,23 @@ 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 { @@ -388,11 +405,15 @@ mod tests { let nested = root.join("pkg").join("inner"); fs::create_dir_all(&nested).unwrap(); - let info = get_repo_info(nested.to_str().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.to_str().unwrap())); + assert!(!is_working_tree_dirty(nested_s)); + assert!(is_at_repo_root(root_s)); + assert!(!is_at_repo_root(nested_s)); } #[test]