diff --git a/Cargo.lock b/Cargo.lock index 1912510..68d51d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -369,7 +369,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "corgea" -version = "1.10.1" +version = "1.11.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 67b9fdf..5dbc14a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "corgea" -version = "1.10.1" +version = "1.11.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/README.md b/README.md index ac0149f..19c58bd 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,48 @@ evaluated; override with `CORGEA_BLOCKING_RULES_TIMEOUT_SECONDS`. trips: both are written before `--fail`/`--block-on` are evaluated, so a scan that exits 1 on a blocking rule still leaves its report behind to ingest. +### Skipping a re-scan of the same commit + +A pipeline that re-runs on an unchanged commit can reuse the scan it already +has instead of paying for a duplicate: + +```bash +corgea scan --skip-if-commit-scanned-recently --block-on criticals +corgea scan --skip-if-commit-scanned-recently --scanned-within 4h # 90s, 30m, 4h, 7d +``` + +When the project already has a completed scan of the current commit inside the +window (24h by default), that scan takes the new scan's place: the results +table, the `--block-on` gate and its exit code, and any `--out-file` report all +come from it, so the pipeline behaves the same whether or not a scan ran. The +window exists because unchanged code is still exposed to advisories published +since it was last scanned. + +Two lines make the outcome scriptable — `CORGEA_SCAN_SKIPPED=true` plus +`CORGEA_SCAN_ID=` when a scan was reused, `CORGEA_SCAN_SKIPPED=false` when +one ran — so a later step (an ingest, say) can branch on it. + +Only a scan that answers the same question is reused, which is stricter than +"same commit". A candidate has to be a completed BLAST scan of that commit, on a +branch rather than a pull request, from an explicitly clean worktree, with no +scanner problems reported — and this run has to be a default whole-commit scan +itself. Anything else runs a real scan: nothing inside the window, only a failed +or still-running scan, a worktree that does not match the commit (including +files the index hides from `git status`), or a lookup the platform could not +answer. + +Two things are hard errors instead. An unresolvable commit (not a git +repository, or no commits yet) exits 1 rather than silently scanning. And a run +that changes what gets scanned cannot be matched against a candidate — the API +exposes neither a scan's configured scan types and target policies nor whether +it bundled a container image — so the flag is rejected alongside `--scan-type`, +`--policy`, `--include-image`, `--only-uncommitted`, and `--target`. + +`--exclude` is allowed, and warns on a skip. A reused scan is one of the whole +commit (an `--exclude` upload is recorded as not matching the commit exactly, so +it is never itself reusable), which means the results and the gate can cover +files this run would have skipped — over-reporting, never under-reporting. + ## Dependency Inventory (offline) `corgea deps` builds a dependency inventory from npm, Python, and Java manifests diff --git a/skills/corgea/SKILL.md b/skills/corgea/SKILL.md index c53cf98..a7e15f3 100644 --- a/skills/corgea/SKILL.md +++ b/skills/corgea/SKILL.md @@ -44,6 +44,8 @@ corgea scan --sbom sbom.cdx.json # SBOM to a custom file corgea scan --include-image myapp:1.2.3 # Also scan a fully built container image corgea scan --include-image myapp:1.2.3 --include-image ghcr.io/acme/api:latest # Repeatable corgea scan --project-name my-service # Override project name +corgea scan --skip-if-commit-scanned-recently # Reuse a recent scan of this commit instead of scanning again +corgea scan --skip-if-commit-scanned-recently --scanned-within 4h # Window for "recently" (default 24h) ``` Scan types: `blast` (base AI), `policy` (PolicyIQ), `malicious`, `secrets`, `pii`. @@ -62,6 +64,10 @@ An included image is enough on its own: when it is combined with `--only-uncommi `--out-format`/`--out-file` and `--sbom` are honored regardless of the gate: the report and the SBOM are written before `--fail`/`--block-on` are evaluated, so a scan that exits 1 on a blocking rule still leaves the report file behind for the pipeline to ingest. +`--skip-if-commit-scanned-recently` reuses the project's most recent reusable scan of the current commit instead of starting a duplicate, when one ran inside the `--scanned-within` window (default `24h`; accepts `90s`, `30m`, `4h`, `7d`, and a bare number as hours). The reused scan takes the new scan's place for the rest of the command — results table, `--block-on` gate and its exit code, `--out-file` report — so the pipeline behaves the same either way. It prints `CORGEA_SCAN_SKIPPED=true` plus `CORGEA_SCAN_ID=` on a reuse and `CORGEA_SCAN_SKIPPED=false` when a scan runs, so a later step can branch on it. + +Reuse requires a candidate that answers the same question: a completed `corgea-blast` scan of that commit, on a branch rather than a pull request, from an explicitly clean worktree, reporting no scanner problems. Anything else runs a real scan (nothing in the window, a failed or still-running scan, a worktree that does not match the commit including files hidden from `git status`, or a failed lookup). An unresolvable commit is a hard error (exit 1). Because the API exposes neither a scan's configured scan types and target policies nor whether it bundled a container image, a run that changes what gets scanned cannot be matched against a candidate, so the flag cannot be combined with `--scan-type`, `--policy`, `--include-image`, `--only-uncommitted`, or `--target`. `--exclude` is allowed but warns on a skip: what gets reused is a scan of the whole commit, so the results and the gate can cover files the run would have skipped (over-reporting, never under-reporting). + ### Upload — `corgea upload [report]` Upload an existing scan report to Corgea. @@ -387,6 +393,7 @@ corgea inspect --issue --diff ISSUE_ID corgea scan --fail-on CR --out-format sarif --out-file results.sarif corgea scan --fail-on CR,malicious --out-format sarif --out-file results.sarif # also block malicious dependencies corgea scan --block-on criticals --out-format sarif --out-file results.sarif # gate on a CI blocking rule from the web app +corgea scan --block-on criticals --skip-if-commit-scanned-recently # re-runs of an already-scanned commit gate on that scan instead of rescanning ``` The report is written whether or not the gate trips, so a pipeline can both fail diff --git a/src/main.rs b/src/main.rs index 964739b..cebf165 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod log; mod scan; mod setup_hooks; mod skill; +mod skip_scan; mod wait; mod scanners { pub mod blast; @@ -169,6 +170,21 @@ enum Commands { help = "Scan a fully built container image (repeatable), e.g. --include-image myapp:1.2.3 --include-image ghcr.io/acme/api:latest. Each image is exported with docker (or podman), pulled first if it isn't available locally, and uploaded with your project. Corgea scans the images you pass instead of searching your code for base images. Requires container scanning to be enabled for your account." )] include_image: Vec, + + #[arg( + long = "skip-if-commit-scanned-recently", + conflicts_with_all = ["only_uncommitted", "target", "scan_type", "policy", "include_image"], + help = "Do not start a new scan when this commit already has a recent completed scan in the project. That scan then drives the rest of the command — results table, --block-on gate, --out-file report — so the pipeline behaves the same either way. Prints CORGEA_SCAN_SKIPPED=true/false so a pipeline can tell the two apart, and fails if no git commit can be resolved. What can be reused is a scan of the whole commit, and no API tells this run how a past scan was scoped or configured, so the flag is refused with --only-uncommitted, --target, --scan-type, --policy and --include-image; with --exclude it warns instead, since a reused scan covers files this run would have skipped." + )] + skip_if_commit_scanned_recently: bool, + + #[arg( + long = "scanned-within", + value_name = "DURATION", + requires = "skip_if_commit_scanned_recently", + help = "How recent a prior scan of the same commit must be for --skip-if-commit-scanned-recently to reuse it, e.g. 90s, 30m, 24h, 7d (a bare number means hours). Defaults to 24h, because unchanged code is still exposed to advisories published since it was last scanned." + )] + scanned_within: Option, }, /// Wait for the latest in progress scan Wait { @@ -648,6 +664,8 @@ fn main() { project_name, sbom, include_image, + skip_if_commit_scanned_recently, + scanned_within, }) => { verify_token_and_exit_when_fail(&corgea_config); if let Some(level) = fail_on { @@ -784,6 +802,25 @@ fn main() { } }; + if *skip_if_commit_scanned_recently && *scanner != Scanner::Blast { + ::log::error!( + "skip-if-commit-scanned-recently is only supported with blast scanner." + ); + std::process::exit(1); + } + + let skip_recent = if *skip_if_commit_scanned_recently { + match skip_scan::SkipRecentScan::new(scanned_within.as_deref()) { + Ok(skip) => Some(skip), + Err(msg) => { + ::log::error!("{}", msg); + std::process::exit(1); + } + } + } else { + None + }; + match scanner { Scanner::Snyk => scan::run_snyk(&corgea_config, project_name.clone()), Scanner::Semgrep => scan::run_semgrep(&corgea_config, project_name.clone()), @@ -803,6 +840,7 @@ fn main() { project_name.clone(), sbom.clone(), include_images, + skip_recent, ), } } diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index 2e6e5ec..9841ba2 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -63,6 +63,7 @@ pub fn run( project_name: Option, sbom: Option, include_images: Vec, + skip_recent: Option, ) { // Validate that only_uncommitted and target are not used together if *only_uncommitted && target.is_some() { @@ -85,6 +86,201 @@ pub fn run( } } } + + let project_name = utils::generic::determine_project_name(project_name.as_deref()); + + // A reused scan stands in for the new one: everything below this point — + // the results table, the blocking-rule gate, the report file — runs against + // whichever scan id this resolves to. + let reused_scan = skip_recent.as_ref().and_then(|skip| { + crate::skip_scan::resolve_reusable_scan(config, &project_name, skip, exclude.as_deref()) + }); + + let (scan_id, project_id) = match reused_scan { + Some(scan) => (scan.id, None), + None => start_new_scan( + config, + &project_name, + only_uncommitted, + metadata, + scan_type, + policy, + target, + exclude, + include_images, + ), + }; + + let scan_url = build_scan_url( + &config.get_url(), + project_id.as_deref(), + &project_name, + &scan_id, + ); + + let stop_signal = Arc::new(Mutex::new(false)); + let stop_signal_clone = Arc::clone(&stop_signal); + let results_thread = thread::spawn(move || { + utils::terminal::show_loading_message( + "Collecting scan results... ([T]s)", + stop_signal_clone, + ); + }); + + let classifications = match report_scan_status(&config.get_url(), &project_name, &scan_id) { + Ok(issues_classes) => { + *stop_signal.lock().unwrap() = true; + let _ = results_thread.join(); + println!( + "\n\nYou can view the scan results at the following link:\n{}", + utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Green) + ); + issues_classes + } + Err(e) => { + *stop_signal.lock().unwrap() = true; + let _ = results_thread.join(); + log::error!( + "\r{}\n\n{}\n\n\ + However, the scan results may still be accessible at the following link:\n\n\ + {}\n\n\ + \n\nPlease check your network connection, authentication token, and server URL:\n\n\ + - Server URL: {}\n\ + - Error details: {}\n", + utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset), + utils::terminal::set_text_color( + &format!( + "Failed to report the scan status for project: '{}'.", + project_name + ), + utils::terminal::TerminalColor::Red + ), + utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Blue), + config.get_url(), + e + ); + std::process::exit(1); + } + }; + // The report and the SBOM are produced before the blocking-rule gates: a + // tripped gate exits 1, and a pipeline that fails on policy is exactly the + // one that needs the report, to ingest the findings it failed on. + write_scan_report( + config, + &project_name, + &scan_id, + &classifications, + out_format.as_deref(), + out_file.as_deref(), + ); + + if let Some(sbom_file) = sbom { + write_sbom(&sbom_file); + } + + if *fail { + log::warn!( + "\n--fail is deprecated: it evaluates every active blocking rule regardless of whether it applies to pull requests or CI. Use --block-on to name the CI blocking rules this pipeline should enforce." + ); + let blocking_rules = wait_for_blocking_rules(config, &scan_id, None); + if blocking_rules.block { + println!("\nExiting with error code 1 due to some issues violating some blocking rules defined for this project.\nfor more details, please check the scan results at the link: {}\nAlternatively, you can run {} to view the issues list on your local machine.", + utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Green), + utils::terminal::set_text_color( + &format!("corgea ls -i -s={}", scan_id), + utils::terminal::TerminalColor::Green + ) + ); + std::process::exit(1); + } + } + + if let Some(block_on) = &block_on { + let blocking_rules = wait_for_blocking_rules(config, &scan_id, Some(block_on)); + if blocking_rules.block { + // The count comes from the server's pre-pagination total; the slug + // list is drawn from the returned page, which is all the gate needs + // to name the rules at fault. + let triggered = triggered_slug_summary(&blocking_rules.blocking_issues); + println!( + "\nExiting with error code 1: {} issue(s) violated the blocking rule(s) {}.\nFor more details, check the scan results at: {}\nAlternatively, run {} to view the issues list on your local machine.", + blocking_rules.blocked_count(), + utils::terminal::set_text_color(&triggered, utils::terminal::TerminalColor::Red), + utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Green), + utils::terminal::set_text_color( + &format!("corgea ls -i -s={}", scan_id), + utils::terminal::TerminalColor::Green + ) + ); + std::process::exit(1); + } + println!( + "\nNo issues violated the blocking rule(s): {}.", + utils::terminal::set_text_color(block_on, utils::terminal::TerminalColor::Green) + ); + } + + print!("\n\nThank you for using Corgea! 🐕\n\n"); + + if let Some(fail_on) = fail_on { + let tokens = match parse_fail_on_tokens(&fail_on) { + Ok(tokens) => tokens, + Err(msg) => { + log::error!("{}", msg); + std::process::exit(1); + } + }; + + let severity_already_tripped = tokens + .iter() + .filter(|t| t.as_str() != "malicious") + .any(|t| severity_gate_trips(t, &classifications)); + let needs_sca_fetch = !severity_already_tripped && tokens.iter().any(|t| t == "malicious"); + + let sca_issues = if needs_sca_fetch { + match utils::api::get_all_sca_issues( + &config.get_url(), + &project_name, + Some(scan_id.clone()), + ) { + Ok(issues) => issues, + Err(e) => { + log::error!( + "\n\nFailed to fetch SCA issues for --fail-on malicious: {}\n\n", + e + ); + std::process::exit(1); + } + } + } else { + Vec::new() + }; + + if fail_on_gate_trips(&tokens, &classifications, &sca_issues) { + println!( + "\nExiting with error code 1: scan results matched --fail-on {}.", + fail_on + ); + std::process::exit(1); + } + } +} + +/// Package the project, upload it, and wait for the scan to finish. +/// +/// Returns the new scan's id and, when the server reported one, its project id. +#[allow(clippy::too_many_arguments)] +fn start_new_scan( + config: &Config, + project_name: &str, + only_uncommitted: &bool, + metadata: Option, + scan_type: Option, + policy: Option, + target: Option, + exclude: Option, + include_images: Vec, +) -> (String, Option) { println!("\nScanning with BLAST 🚀🚀🚀"); if let Some(scan_type) = &scan_type { @@ -108,7 +304,6 @@ pub fn run( std::process::exit(1); } }; - let project_name = utils::generic::determine_project_name(project_name.as_deref()); let zip_path = format!("{}/{}.zip", temp_dir.display(), project_name); let image_archives = match export_included_images(&include_images, &temp_dir) { @@ -315,7 +510,7 @@ pub fn run( let upload_result = match utils::api::upload_zip( &zip_path, &config.get_url(), - &project_name, + project_name, repo_info, scan_type, policy, @@ -346,7 +541,7 @@ pub fn run( let scan_url = build_scan_url( &config.get_url(), upload_result.project_id.as_deref(), - &project_name, + project_name, &scan_id, ); @@ -363,152 +558,7 @@ pub fn run( ); wait_for_scan(config, &scan_id, WaitBudget::start()); - let stop_signal = Arc::new(Mutex::new(false)); - let stop_signal_clone = Arc::clone(&stop_signal); - let results_thread = thread::spawn(move || { - utils::terminal::show_loading_message( - "Collecting scan results... ([T]s)", - stop_signal_clone, - ); - }); - - let classifications = match report_scan_status(&config.get_url(), &project_name, &scan_id) { - Ok(issues_classes) => { - *stop_signal.lock().unwrap() = true; - let _ = results_thread.join(); - println!( - "\n\nYou can view the scan results at the following link:\n{}", - utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Green) - ); - issues_classes - } - Err(e) => { - *stop_signal.lock().unwrap() = true; - let _ = results_thread.join(); - log::error!( - "\r{}\n\n{}\n\n\ - However, the scan results may still be accessible at the following link:\n\n\ - {}\n\n\ - \n\nPlease check your network connection, authentication token, and server URL:\n\n\ - - Server URL: {}\n\ - - Error details: {}\n", - utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset), - utils::terminal::set_text_color( - &format!( - "Failed to report the scan status for project: '{}'.", - project_name - ), - utils::terminal::TerminalColor::Red - ), - utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Blue), - config.get_url(), - e - ); - std::process::exit(1); - } - }; - // The report and the SBOM are produced before the blocking-rule gates: a - // tripped gate exits 1, and a pipeline that fails on policy is exactly the - // one that needs the report, to ingest the findings it failed on. - write_scan_report( - config, - &project_name, - &scan_id, - &classifications, - out_format.as_deref(), - out_file.as_deref(), - ); - - if let Some(sbom_file) = sbom { - write_sbom(&sbom_file); - } - - if *fail { - log::warn!( - "\n--fail is deprecated: it evaluates every active blocking rule regardless of whether it applies to pull requests or CI. Use --block-on to name the CI blocking rules this pipeline should enforce." - ); - let blocking_rules = wait_for_blocking_rules(config, &scan_id, None); - if blocking_rules.block { - println!("\nExiting with error code 1 due to some issues violating some blocking rules defined for this project.\nfor more details, please check the scan results at the link: {}\nAlternatively, you can run {} to view the issues list on your local machine.", - utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Green), - utils::terminal::set_text_color( - &format!("corgea ls -i -s={}", scan_id), - utils::terminal::TerminalColor::Green - ) - ); - std::process::exit(1); - } - } - - if let Some(block_on) = &block_on { - let blocking_rules = wait_for_blocking_rules(config, &scan_id, Some(block_on)); - if blocking_rules.block { - // The count comes from the server's pre-pagination total; the slug - // list is drawn from the returned page, which is all the gate needs - // to name the rules at fault. - let triggered = triggered_slug_summary(&blocking_rules.blocking_issues); - println!( - "\nExiting with error code 1: {} issue(s) violated the blocking rule(s) {}.\nFor more details, check the scan results at: {}\nAlternatively, run {} to view the issues list on your local machine.", - blocking_rules.blocked_count(), - utils::terminal::set_text_color(&triggered, utils::terminal::TerminalColor::Red), - utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Green), - utils::terminal::set_text_color( - &format!("corgea ls -i -s={}", scan_id), - utils::terminal::TerminalColor::Green - ) - ); - std::process::exit(1); - } - println!( - "\nNo issues violated the blocking rule(s): {}.", - utils::terminal::set_text_color(block_on, utils::terminal::TerminalColor::Green) - ); - } - - print!("\n\nThank you for using Corgea! 🐕\n\n"); - - if let Some(fail_on) = fail_on { - let tokens = match parse_fail_on_tokens(&fail_on) { - Ok(tokens) => tokens, - Err(msg) => { - log::error!("{}", msg); - std::process::exit(1); - } - }; - - let severity_already_tripped = tokens - .iter() - .filter(|t| t.as_str() != "malicious") - .any(|t| severity_gate_trips(t, &classifications)); - let needs_sca_fetch = !severity_already_tripped && tokens.iter().any(|t| t == "malicious"); - - let sca_issues = if needs_sca_fetch { - match utils::api::get_all_sca_issues( - &config.get_url(), - &project_name, - Some(scan_id.clone()), - ) { - Ok(issues) => issues, - Err(e) => { - log::error!( - "\n\nFailed to fetch SCA issues for --fail-on malicious: {}\n\n", - e - ); - std::process::exit(1); - } - } - } else { - Vec::new() - }; - - if fail_on_gate_trips(&tokens, &classifications, &sca_issues) { - println!( - "\nExiting with error code 1: scan results matched --fail-on {}.", - fail_on - ); - std::process::exit(1); - } - } + (scan_id, upload_result.project_id) } /// Write the `--out-format` report for a completed scan to `--out-file`. @@ -1610,6 +1660,8 @@ mod tests { engine: "corgea-blast".to_string(), created_at: "2026-01-01T00:00:00Z".to_string(), git_sha: None, + worktree_dirty: None, + pull_request_id: None, metadata: None, failed_reason: failed_reason.map(|r| r.to_string()), scan_errors, diff --git a/src/skip_scan.rs b/src/skip_scan.rs new file mode 100644 index 0000000..dc13218 --- /dev/null +++ b/src/skip_scan.rs @@ -0,0 +1,690 @@ +//! `--skip-if-commit-scanned-recently`: reuse a recent scan of the current +//! commit instead of starting a duplicate one. +//! +//! Pipelines that re-run on an unchanged commit (a retried stage, a manual +//! re-run, a promotion job) pay for a full scan that can only produce the +//! results the previous run already produced. Skipping the scan itself is only +//! half the job: the run still has to gate on `--block-on` and still has to +//! emit `--out-file`, so the reused scan takes the new scan's place for the +//! rest of the command rather than short-circuiting it. +//! +//! Recency is a policy, not a technicality — the same commit scanned last week +//! predates whatever advisories landed since, so a scan is only reusable +//! inside the window (24h by default). +//! +//! One scan may only stand in for another when it answers the same question, +//! which is a stricter test than "same commit". Doghouse already settled what +//! that means for its own server-side dedupe (`ScanManager._find_reusable_scan`): +//! same commit, not a pull-request scan, an explicitly clean worktree, and +//! matching scan configuration and policies. The checks here are the client-side +//! half of that rule, and where the API cannot yet prove the match — the scan's +//! configured scan types and target policies are not exposed on any read +//! endpoint — the flag refuses the run rather than guessing (see `main.rs`, +//! where `--scan-type`/`--policy` conflict with it). +//! +//! `--exclude` is the one narrowing flag that only warns. It is typically a +//! fixed line in a pipeline template rather than a per-run choice, and reusing a +//! wider scan can only over-report, never miss a finding — so the run continues +//! and says which files the gate may cover after all. + +use crate::config::Config; +use crate::scanners::blast::{classify_scan_status, format_scan_warnings, ScanState}; +use crate::utils; +use crate::utils::api::ScanResponse; +use chrono::{DateTime, Utc}; +use std::time::Duration; + +/// How far back a prior scan may have run and still be reused. +pub const DEFAULT_WINDOW: &str = "24h"; + +/// How many of the commit's scans to read at a time, newest first. +const SCAN_LOOKUP_PAGE_SIZE: u16 = 30; + +/// Backstop on pages walked. The window normally ends the search first (the +/// list is newest first, so an out-of-window page tail means there is nothing +/// left to find); this bounds the remaining case of one commit with more scans +/// inside the window than fit on a page. +const SCAN_LOOKUP_MAX_PAGES: u16 = 3; + +/// The engine every blast scan carries, whoever started it — CLI, platform +/// integration, or scheduled run. An uploaded third-party report carries its +/// own scanner's name and cannot stand in for `corgea scan blast`. +const BLAST_ENGINE: &str = "corgea-blast"; + +/// Grep-able signal for pipelines: printed exactly once per run whenever the +/// flag is on, so a step can branch on whether a scan actually happened. +const SKIPPED_MARKER: &str = "CORGEA_SCAN_SKIPPED"; + +/// The `--skip-if-commit-scanned-recently` request, with the window already +/// validated. +#[derive(Debug, Clone)] +pub struct SkipRecentScan { + window: Duration, + /// The window as the user wrote it, so messages echo their own units. + label: String, +} + +impl SkipRecentScan { + /// Build from the raw `--scanned-within` value, defaulting when absent. + pub fn new(scanned_within: Option<&str>) -> Result { + let label = scanned_within.unwrap_or(DEFAULT_WINDOW).trim().to_string(); + Ok(Self { + window: parse_window(&label)?, + label, + }) + } +} + +/// Parse a `--scanned-within` value: `90s`, `30m`, `24h`, `7d`. A bare number +/// is read as hours, matching the unit the default is expressed in. +pub fn parse_window(raw: &str) -> Result { + let raw = raw.trim(); + let invalid = || { + format!( + "Invalid --scanned-within value '{}'. Expected a positive duration such as 30m, 24h, or 7d.", + raw + ) + }; + let (digits, seconds_per_unit) = match raw.chars().last() { + Some('s') => (&raw[..raw.len() - 1], 1), + Some('m') => (&raw[..raw.len() - 1], 60), + Some('h') => (&raw[..raw.len() - 1], 60 * 60), + Some('d') => (&raw[..raw.len() - 1], 24 * 60 * 60), + Some(c) if c.is_ascii_digit() => (raw, 60 * 60), + _ => return Err(invalid()), + }; + let amount: u64 = digits.parse().map_err(|_| invalid())?; + if amount == 0 { + return Err(invalid()); + } + amount + .checked_mul(seconds_per_unit) + .map(Duration::from_secs) + .ok_or_else(invalid) +} + +/// The scan to report on instead of starting a new one, or `None` to scan. +/// +/// Every `None` is a decision to do the more expensive, more correct thing, so +/// a lookup failure, an unreadable timestamp, or a dirty worktree all land +/// here rather than skipping a scan on incomplete information. The one hard +/// failure is an unresolvable commit: the flag asks a question about the +/// commit, and without one there is no question to answer. +pub fn resolve_reusable_scan( + config: &Config, + project_name: &str, + skip: &SkipRecentScan, + exclude: Option<&str>, +) -> Option { + // `dirty`, not `status_dirty`: this asks whether the run would upload an + // exact snapshot of the commit, and that is the flag the upload itself + // sends. `status_dirty` is narrower — it is the user notice, and it cannot + // see assume-unchanged/skip-worktree files, dirty submodules, or an index + // it failed to read, all of which change what gets packaged. + let commit = utils::generic::get_repo_info_for_scan("./") + .ok() + .flatten() + .and_then(|info| Some((info.sha?, info.dirty))); + let Some((sha, worktree_dirty)) = commit else { + log::error!( + "--skip-if-commit-scanned-recently needs the commit that is being scanned, but no git commit could be resolved here.\n\ + Run it from the root of a git repository with at least one commit, or drop the flag." + ); + std::process::exit(1); + }; + let short = short_sha(&sha); + + if worktree_dirty { + println!( + "Working tree does not match commit {} exactly (uncommitted changes, or files the index hides from git status), so no scan of that commit describes what would be scanned here - running a new scan.", + short + ); + print_skipped_marker(None); + return None; + } + + println!( + "Checking Corgea for a scan of commit {} in project '{}' from the last {}...", + short, project_name, skip.label + ); + + let found = match find_reusable_scan(config, project_name, &sha, skip.window, Utc::now()) { + Ok(found) => found, + Err(e) => { + log::warn!( + "Could not check whether commit {} was already scanned: {}. Running a new scan.", + short, + e + ); + print_skipped_marker(None); + return None; + } + }; + + let Some((scan, age)) = found else { + println!( + "No reusable scan of commit {} in the last {}; running a new scan.", + short, skip.label + ); + print_skipped_marker(None); + return None; + }; + + if let Err(reason) = confirm_reusable_scan(config, &scan.id) { + log::warn!( + "Not reusing scan {}: {}. Running a new scan.", + scan.id, + reason + ); + print_skipped_marker(None); + return None; + } + + println!( + "Skipping scan: commit {} was already scanned {} ago by scan {}.", + short, age, scan.id + ); + println!("Reporting on that scan instead - blocking rules and report output are unchanged."); + // A reusable scan is one of the whole commit, and an `--exclude` upload is + // recorded dirty, so a reused scan was never narrowed the way this run asks. + // The gate below can therefore fail on a file this command line excludes, + // which is worth saying out loud rather than leaving to be discovered. + if let Some(exclude) = exclude { + log::warn!( + "Scan {} covers the whole commit, so it was not narrowed by --exclude '{}'. The results and gate below can include files this run would have skipped.", + scan.id, + exclude + ); + } + print_skipped_marker(Some(&scan.id)); + Some(scan) +} + +/// Walk the commit's scans, newest first, for one that can stand in for a fresh +/// scan. `Err` is a lookup failure, `Ok(None)` a clean miss. +fn find_reusable_scan( + config: &Config, + project_name: &str, + sha: &str, + window: Duration, + now: DateTime, +) -> Result, String> { + let mut page = 1; + loop { + let response = utils::api::query_scans_for_commit( + &config.get_url(), + project_name, + sha, + page, + SCAN_LOOKUP_PAGE_SIZE, + ) + .map_err(|e| e.to_string())?; + let scans = response.scans.unwrap_or_default(); + if scans.is_empty() { + return Ok(None); + } + if let Some(reusable) = select_reusable_scan(&scans, sha, now, window) { + return Ok(Some((reusable.scan.clone(), reusable.age))); + } + // Newest first, so a page that ends outside the window is the end of the + // search: everything after it is older still. + if page_ends_outside_window(&scans, now, window) { + return Ok(None); + } + // A page holding no scan of this commit means the server is not + // filtering on `sha` (a backend predating that parameter answers with + // the whole project), so further pages only read other commits' scans. + if !scans.iter().any(|scan| scan_matches_commit(scan, sha)) { + return Ok(None); + } + if page >= response.total_pages.unwrap_or(1) as u16 || page >= SCAN_LOOKUP_MAX_PAGES { + return Ok(None); + } + page += 1; + } +} + +/// Confirm the scan we intend to reuse against `GET /scan/{id}`. +/// +/// The scan list carries no `scan_errors`, so there a scan that finished with a +/// scanner's results missing is indistinguishable from a clean one. A fresh scan +/// says so out loud and the operator can weigh it; a reused one would gate +/// silently on findings it has no reason to believe are complete. Since the +/// alternative here is simply to scan — which may also clear a transient failure +/// — a degraded scan is not reused at all. +/// +/// One read, on the one scan we mean to reuse: a candidate rejected here sends +/// the run to a real scan rather than to the next-oldest scan, because a commit +/// whose recent scans are all degraded wants a fresh scan anyway. +fn confirm_reusable_scan(config: &Config, scan_id: &str) -> Result<(), String> { + let scan = utils::api::get_scan(&config.get_url(), scan_id, None).map_err(|e| e.to_string())?; + if classify_scan_status(&scan.status) != ScanState::Completed { + return Err(format!("its status is now '{}'", scan.status)); + } + if let Some(warnings) = format_scan_warnings(&scan) { + return Err(format!("it is missing some scanner results.\n{}", warnings)); + } + Ok(()) +} + +/// `CORGEA_SCAN_SKIPPED=true|false`, plus the reused scan id when there is one. +/// Shell-assignment shaped so a pipeline can `eval` or `grep` it. +fn print_skipped_marker(reused_scan_id: Option<&str>) { + match reused_scan_id { + Some(scan_id) => { + println!("{}=true", SKIPPED_MARKER); + println!("CORGEA_SCAN_ID={}", scan_id); + } + None => println!("{}=false", SKIPPED_MARKER), + } +} + +pub struct ReusableScan<'a> { + pub scan: &'a ScanResponse, + /// How long ago it ran, already formatted for the terminal. + pub age: String, +} + +/// The newest scan that can stand in for a fresh scan of `sha`. +/// +/// `scans` arrive newest first and are already filtered server-side, but the +/// checks are repeated here: a backend that predates the `sha` filter answers +/// with the project's scans at every commit, and acting on that would skip a +/// scan of one commit because a different commit was scanned. +pub fn select_reusable_scan<'a>( + scans: &'a [ScanResponse], + sha: &str, + now: DateTime, + window: Duration, +) -> Option> { + for scan in scans { + match scan_age_if_reusable(scan, sha, now, window) { + Ok(age) => { + return Some(ReusableScan { + scan, + age: format_age(age), + }) + } + Err(reason) => log::debug!("Not reusing scan {}: {}", scan.id, reason), + } + } + None +} + +/// True when `scan` records exactly the commit being scanned. +fn scan_matches_commit(scan: &ScanResponse, sha: &str) -> bool { + scan.git_sha + .as_deref() + .is_some_and(|scan_sha| scan_sha.eq_ignore_ascii_case(sha)) +} + +/// How long ago `scan` ran, or why it cannot stand in for a new scan. +fn scan_age_if_reusable( + scan: &ScanResponse, + sha: &str, + now: DateTime, + window: Duration, +) -> Result { + if !scan_matches_commit(scan, sha) { + return match scan.git_sha.as_deref() { + Some(scan_sha) => Err(format!("it scanned commit {}", short_sha(scan_sha))), + None => Err("it records no commit".to_string()), + }; + } + // A scan that failed has no results to gate on, and one still running has + // none yet; both mean this run has to do the scan itself. + if classify_scan_status(&scan.status) != ScanState::Completed { + return Err(format!("its status is '{}'", scan.status)); + } + if !scan.engine.eq_ignore_ascii_case(BLAST_ENGINE) { + return Err(format!("it came from the '{}' engine", scan.engine)); + } + // A pull-request scan answers a question about a proposed merge, and may be + // scoped to the diff; doghouse draws the same line for its own dedupe and + // for "which scan represents full project state". + if let Some(pull_request_id) = scan.pull_request_id.as_deref() { + return Err(format!("it scanned pull request {}", pull_request_id)); + } + // Only an explicit `false` is a clean tree. `None` means the scan never + // reported the flag, and unknown is not clean: doghouse applies the same + // rule to its own dedupe ("only explicit clean may dedupe on SHA/PR"), + // platform and scheduled scans do record `false`, and the scans that do not + // include the partial `--target`/`--exclude` uploads of older CLIs — which + // this run has no way to tell apart from whole-commit ones. + if scan.worktree_dirty != Some(false) { + return match scan.worktree_dirty { + Some(true) => Err("it scanned a worktree with uncommitted changes".to_string()), + _ => Err("it did not report whether its worktree was clean".to_string()), + }; + } + let created_at = parse_timestamp(&scan.created_at) + .ok_or_else(|| format!("its timestamp '{}' could not be read", scan.created_at))?; + let age = age_since(created_at, now); + if age > window { + return Err(format!( + "it ran {} ago, outside the window", + format_age(age) + )); + } + Ok(age) +} + +/// Whether this page's oldest scan already falls outside the window, which — the +/// list being newest first — means no later page can hold a reusable scan. +/// An unreadable timestamp proves nothing, so it does not end the walk. +fn page_ends_outside_window(scans: &[ScanResponse], now: DateTime, window: Duration) -> bool { + scans + .last() + .and_then(|scan| parse_timestamp(&scan.created_at)) + .is_some_and(|created_at| age_since(created_at, now) > window) +} + +/// How long ago `created_at` was. A timestamp in the future is clock skew, not +/// an old scan, so it reads as brand new rather than underflowing. +fn age_since(created_at: DateTime, now: DateTime) -> Duration { + now.signed_duration_since(created_at) + .to_std() + .unwrap_or(Duration::ZERO) +} + +/// Timestamps arrive as RFC 3339 from the scan list, but Django can also +/// serialize a naive datetime, which the RFC 3339 parser rejects. +fn parse_timestamp(raw: &str) -> Option> { + let raw = raw.trim(); + if let Ok(parsed) = DateTime::parse_from_rfc3339(raw) { + return Some(parsed.with_timezone(&Utc)); + } + for format in ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%d %H:%M:%S%.f"] { + if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(raw, format) { + return Some(DateTime::::from_naive_utc_and_offset(naive, Utc)); + } + } + None +} + +/// Two units at most: `3d 4h`, `2h 30m`, `45m`, `12s`. +fn format_age(age: Duration) -> String { + let seconds = age.as_secs(); + let (days, hours, minutes) = ( + seconds / 86_400, + (seconds % 86_400) / 3600, + (seconds % 3600) / 60, + ); + if days > 0 { + return format!("{}d {}h", days, hours); + } + if hours > 0 { + return format!("{}h {}m", hours, minutes); + } + if minutes > 0 { + return format!("{}m", minutes); + } + format!("{}s", seconds) +} + +fn short_sha(sha: &str) -> &str { + &sha[..sha.len().min(7)] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scan(id: &str, status: &str, sha: Option<&str>, created_at: &str) -> ScanResponse { + ScanResponse { + id: id.to_string(), + project: "proj".to_string(), + repo: None, + branch: None, + status: status.to_string(), + engine: "corgea-blast".to_string(), + created_at: created_at.to_string(), + git_sha: sha.map(|s| s.to_string()), + worktree_dirty: Some(false), + pull_request_id: None, + metadata: None, + failed_reason: None, + scan_errors: vec![], + } + } + + fn now() -> DateTime { + DateTime::parse_from_rfc3339("2026-01-02T00:00:00Z") + .unwrap() + .with_timezone(&Utc) + } + + const SHA: &str = "1a2b3c4d5e6f70819293a4b5c6d7e8f901234567"; + const DAY: Duration = Duration::from_secs(24 * 60 * 60); + + #[test] + fn window_accepts_each_unit_and_defaults_a_bare_number_to_hours() { + assert_eq!(parse_window("90s").unwrap(), Duration::from_secs(90)); + assert_eq!(parse_window("30m").unwrap(), Duration::from_secs(1_800)); + assert_eq!(parse_window("24h").unwrap(), DAY); + assert_eq!(parse_window("7d").unwrap(), Duration::from_secs(7 * 86_400)); + assert_eq!( + parse_window(" 12 ").unwrap(), + Duration::from_secs(12 * 3600) + ); + assert_eq!(parse_window(DEFAULT_WINDOW).unwrap(), DAY); + } + + #[test] + fn window_rejects_values_that_would_silently_disable_the_check() { + // A zero or unparseable window must not read as "reuse anything" or + // "reuse nothing"; the user gets told instead. + for raw in ["0h", "0", "", "h", "-1h", "1.5h", "24 hours", "abc"] { + assert!(parse_window(raw).is_err(), "{raw} should be rejected"); + } + } + + #[test] + fn reuses_the_newest_completed_scan_of_this_commit() { + let scans = vec![ + scan("newer", "complete", Some(SHA), "2026-01-01T21:00:00Z"), + scan("older", "complete", Some(SHA), "2026-01-01T12:00:00Z"), + ]; + let reusable = select_reusable_scan(&scans, SHA, now(), DAY).expect("expected a reuse"); + assert_eq!(reusable.scan.id, "newer"); + assert_eq!(reusable.age, "3h 0m"); + } + + #[test] + fn falls_through_to_an_older_scan_when_the_newest_cannot_be_reused() { + // A retried pipeline whose newest attempt failed still has the earlier + // good scan to report on. + let scans = vec![ + scan("failed", "incomplete", Some(SHA), "2026-01-01T23:00:00Z"), + scan("good", "complete", Some(SHA), "2026-01-01T22:00:00Z"), + ]; + let reusable = select_reusable_scan(&scans, SHA, now(), DAY).expect("expected a reuse"); + assert_eq!(reusable.scan.id, "good"); + } + + #[test] + fn running_and_failed_scans_are_not_reusable() { + for status in ["processing", "scanning", "incomplete", "failed", ""] { + let scans = vec![scan("s", status, Some(SHA), "2026-01-01T23:00:00Z")]; + assert!( + select_reusable_scan(&scans, SHA, now(), DAY).is_none(), + "status {status} must not be reused" + ); + } + } + + #[test] + fn scans_of_another_commit_are_never_reused() { + // The guard that matters against a backend that ignores ?sha= and + // answers with every scan of the project. + let other = "ffffffffffffffffffffffffffffffffffffffff"; + let scans = vec![ + scan( + "other-commit", + "complete", + Some(other), + "2026-01-01T23:00:00Z", + ), + scan("no-commit", "complete", None, "2026-01-01T23:00:00Z"), + ]; + assert!(select_reusable_scan(&scans, SHA, now(), DAY).is_none()); + } + + #[test] + fn commit_comparison_ignores_sha_casing() { + let scans = vec![scan( + "s", + "complete", + Some(&SHA.to_uppercase()), + "2026-01-01T23:00:00Z", + )]; + assert!(select_reusable_scan(&scans, SHA, now(), DAY).is_some()); + } + + #[test] + fn scans_outside_the_window_are_not_reused() { + // The point of the window: the code is unchanged, but the advisories + // it is scanned against are not. + let scans = vec![scan("stale", "complete", Some(SHA), "2025-12-30T00:00:00Z")]; + assert!(select_reusable_scan(&scans, SHA, now(), DAY).is_none()); + // A shorter window is what makes a scan from this morning stale. + let scans = vec![scan( + "morning", + "complete", + Some(SHA), + "2026-01-01T20:00:00Z", + )]; + assert!(select_reusable_scan(&scans, SHA, now(), Duration::from_secs(3_600)).is_none()); + } + + #[test] + fn a_scan_exactly_at_the_window_edge_is_still_reusable() { + let scans = vec![scan("edge", "complete", Some(SHA), "2026-01-01T00:00:00Z")]; + assert!(select_reusable_scan(&scans, SHA, now(), DAY).is_some()); + } + + #[test] + fn scans_of_a_dirty_worktree_are_not_reused() { + // Those results describe someone's uncommitted edits, not this commit. + let mut dirty = scan("dirty", "complete", Some(SHA), "2026-01-01T23:00:00Z"); + dirty.worktree_dirty = Some(true); + assert!(select_reusable_scan(&[dirty], SHA, now(), DAY).is_none()); + } + + #[test] + fn scans_that_never_reported_dirtiness_are_not_reused() { + // `None` says the client did not report, not that the tree was clean. + // Platform and scheduled scans do record `false`, so what this rejects + // is mainly the partial `--target` uploads of older CLIs, which are + // indistinguishable from whole-commit ones from here. + let mut unknown = scan("unknown", "complete", Some(SHA), "2026-01-01T23:00:00Z"); + unknown.worktree_dirty = None; + assert!(select_reusable_scan(&[unknown], SHA, now(), DAY).is_none()); + } + + #[test] + fn pull_request_scans_are_not_reused() { + // A PR scan answers a question about a proposed merge and may be scoped + // to the diff, so it cannot stand in for a branch build of the commit. + let mut pr_scan = scan("pr", "complete", Some(SHA), "2026-01-01T23:00:00Z"); + pr_scan.pull_request_id = Some("42".to_string()); + assert!(select_reusable_scan(&[pr_scan], SHA, now(), DAY).is_none()); + } + + #[test] + fn scans_from_another_engine_are_not_reused() { + // An uploaded third-party report covers whatever that scanner found, + // which is not what `corgea scan blast` was asked to produce. + let mut semgrep = scan("semgrep", "complete", Some(SHA), "2026-01-01T23:00:00Z"); + semgrep.engine = "semgrep".to_string(); + assert!(select_reusable_scan(&[semgrep], SHA, now(), DAY).is_none()); + // Every blast scan carries this engine, whoever started it. + let mut blast = scan("blast", "complete", Some(SHA), "2026-01-01T23:00:00Z"); + blast.engine = BLAST_ENGINE.to_uppercase(); + assert!(select_reusable_scan(&[blast], SHA, now(), DAY).is_some()); + } + + #[test] + fn unreadable_timestamps_do_not_skip_the_scan() { + let scans = vec![scan("bad-time", "complete", Some(SHA), "not a timestamp")]; + assert!(select_reusable_scan(&scans, SHA, now(), DAY).is_none()); + } + + #[test] + fn timestamps_parse_in_every_shape_the_api_emits() { + for raw in [ + "2026-01-01T23:00:00Z", + "2026-01-01T23:00:00.123456Z", + "2026-01-01T23:00:00+00:00", + "2026-01-01T18:00:00-05:00", + "2026-01-01T23:00:00", + "2026-01-01 23:00:00", + ] { + let scans = vec![scan("s", "complete", Some(SHA), raw)]; + assert!( + select_reusable_scan(&scans, SHA, now(), DAY).is_some(), + "{raw} should parse" + ); + } + } + + #[test] + fn a_scan_stamped_in_the_future_reads_as_brand_new() { + // Clock skew between the runner and the platform must not underflow + // into an age older than any window. + let scans = vec![scan( + "skewed", + "complete", + Some(SHA), + "2026-01-02T01:00:00Z", + )]; + let reusable = select_reusable_scan(&scans, SHA, now(), DAY).expect("expected a reuse"); + assert_eq!(reusable.age, "0s"); + } + + #[test] + fn empty_scan_list_reuses_nothing() { + assert!(select_reusable_scan(&[], SHA, now(), DAY).is_none()); + } + + #[test] + fn a_page_ending_inside_the_window_leaves_more_to_search() { + // Newest first: while the page's oldest scan is still inside the window, + // a reusable scan can sit on the next page. Anything else would make a + // commit with more than a page of scans quietly unreusable. + let scans = vec![ + scan("a", "incomplete", Some(SHA), "2026-01-01T23:00:00Z"), + scan("b", "incomplete", Some(SHA), "2026-01-01T22:00:00Z"), + ]; + assert!(!page_ends_outside_window(&scans, now(), DAY)); + } + + #[test] + fn a_page_ending_outside_the_window_ends_the_search() { + let scans = vec![ + scan("a", "incomplete", Some(SHA), "2026-01-01T23:00:00Z"), + scan("b", "incomplete", Some(SHA), "2025-12-30T00:00:00Z"), + ]; + assert!(page_ends_outside_window(&scans, now(), DAY)); + } + + #[test] + fn an_unreadable_tail_timestamp_does_not_end_the_search() { + // It proves nothing about what follows, and stopping on it would drop + // reusable scans on later pages. + let scans = vec![scan("a", "incomplete", Some(SHA), "not a timestamp")]; + assert!(!page_ends_outside_window(&scans, now(), DAY)); + assert!(!page_ends_outside_window(&[], now(), DAY)); + } + + #[test] + fn age_formats_to_two_units() { + assert_eq!(format_age(Duration::from_secs(45)), "45s"); + assert_eq!(format_age(Duration::from_secs(90)), "1m"); + assert_eq!( + format_age(Duration::from_secs(3 * 3600 + 12 * 60)), + "3h 12m" + ); + assert_eq!(format_age(Duration::from_secs(2 * 86_400 + 3600)), "2d 1h"); + } +} diff --git a/src/utils/api.rs b/src/utils/api.rs index 880fded..3f8d4f8 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -808,18 +808,45 @@ pub fn query_scan_list( page: Option, page_size: Option, ) -> Result> { - let url = format!("{}{}/scans", url, API_BASE); let page = page.unwrap_or(1); - let mut query_params = vec![("page", page.to_string())]; - if let Some(p_size) = page_size { - query_params.push(("page_size", p_size.to_string())); - } else { - query_params.push(("page_size", "30".to_string())); - } + let mut query_params = vec![ + ("page", page.to_string()), + ("page_size", page_size.unwrap_or(30).to_string()), + ]; if let Some(project) = project { query_params.push(("project", project.to_string())); } + request_scan_list(url, query_params) +} +/// One page of the project's scans at exactly `sha`, newest first. +/// +/// The `sha` filter is server-side, but a backend that predates it ignores the +/// unknown parameter and answers with the project's scans at any commit, so +/// callers must re-check `git_sha` on every scan they act on. +pub fn query_scans_for_commit( + url: &str, + project: &str, + sha: &str, + page: u16, + page_size: u16, +) -> Result> { + request_scan_list( + url, + vec![ + ("page", page.to_string()), + ("page_size", page_size.to_string()), + ("project", project.to_string()), + ("sha", sha.to_string()), + ], + ) +} + +fn request_scan_list( + url: &str, + query_params: Vec<(&str, String)>, +) -> Result> { + let url = format!("{}{}/scans", url, API_BASE); let client = http_client(); debug(&format!("Sending request to URL: {}", url)); let response = match client.get(url).query(&query_params).send() { @@ -1425,6 +1452,15 @@ pub struct ScanResponse { pub created_at: String, #[serde(default)] pub git_sha: Option, + /// Whether the scanned tree carried uncommitted changes. `None` when the + /// scan predates the flag or came from a client that never sent it, which + /// is not the same as a known-clean tree. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree_dirty: Option, + /// Set when the scan belongs to a pull request rather than to a branch + /// build. Carried by the scan list only; `GET /scan/{id}` omits it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pull_request_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub metadata: Option, /// Why a scan ended without finishing. Only set for failed scans. diff --git a/tests/cloud_commands_e2e/main.rs b/tests/cloud_commands_e2e/main.rs index 5b6a98e..eded107 100644 --- a/tests/cloud_commands_e2e/main.rs +++ b/tests/cloud_commands_e2e/main.rs @@ -5,4 +5,5 @@ mod block_on_report; mod common; mod inspect; mod scan_list; +mod scan_skip; mod upload_wait; diff --git a/tests/cloud_commands_e2e/scan_skip.rs b/tests/cloud_commands_e2e/scan_skip.rs new file mode 100644 index 0000000..7549306 --- /dev/null +++ b/tests/cloud_commands_e2e/scan_skip.rs @@ -0,0 +1,565 @@ +//! `--skip-if-commit-scanned-recently`: the CLI reuses a recent scan of the +//! current commit instead of starting a duplicate one, and the rest of the +//! command — results, blocking-rule gate, exit code — runs against that scan. +//! +//! The stub asserts the exact request sequence, so "no new scan was started" +//! is proven by the absence of the upload calls rather than by the output. + +use crate::common::*; +use chrono::{Duration, Utc}; +use hyper::Method; +use serde_json::{json, Value}; +use tempfile::TempDir; + +const PRIOR_SCAN: &str = "prior-scan-123"; +const PROJECT: &str = "cloud-e2e"; + +fn ago(hours: i64) -> String { + (Utc::now() - Duration::hours(hours)).to_rfc3339() +} + +fn prior_scan(sha: &str, created_at: &str) -> Value { + json!({ + "id": PRIOR_SCAN, + "project": PROJECT, + "repo": null, + "branch": "e2e-main", + "status": "complete", + "engine": "corgea-blast", + "created_at": created_at, + "git_sha": sha, + "worktree_dirty": false + }) +} + +fn commit_lookup(sha: &str, scans: Vec) -> ExpectedRequest { + let sha = sha.to_string(); + expected_request( + "look up prior scans of the commit", + move |request| { + assert_authenticated_request(request, Method::GET, "/api/v1/scans")?; + assert_query(request, "project", PROJECT)?; + assert_query(request, "page", "1")?; + assert_query(request, "sha", &sha) + }, + json_response(scans_response(scans)), + ) +} + +/// The confirmation read of the chosen scan. The scan list carries no +/// `scan_errors`, so this is the only place a degraded prior scan can be caught. +fn reused_scan_detail(sha: &str, scan_errors: Value) -> ExpectedRequest { + let mut body = prior_scan(sha, &ago(3)); + body["scan_errors"] = scan_errors; + let path = format!("/api/v1/scan/{PRIOR_SCAN}"); + expected_request( + "confirm the scan being reused", + move |request| assert_authenticated_request(request, Method::GET, &path), + json_response(body), + ) +} + +fn clean_detail(sha: &str) -> ExpectedRequest { + reused_scan_detail(sha, json!([])) +} + +fn reused_scan_issues() -> ExpectedRequest { + let path = format!("/api/v1/scan/{PRIOR_SCAN}/issues"); + expected_request( + "read the reused scan's issues", + move |request| assert_authenticated_request(request, Method::GET, &path), + json_response(regular_issue_page(PRIOR_SCAN, PROJECT)), + ) +} + +fn reused_scan_blocking_rules(block: bool) -> ExpectedRequest { + let path = format!("/api/v1/scan/{PRIOR_SCAN}/check_blocking_rules"); + let body = json!({ + "block": block, + "blocking_issues": if block { + json!([{"id": "issue-cr", "triggered_by_rules": ["1"], "triggered_by_slugs": ["criticals"]}]) + } else { + json!([]) + }, + "total_pages": 1, + "status": "complete" + }); + expected_request( + "evaluate blocking rules against the reused scan", + move |request| { + assert_authenticated_request(request, Method::GET, &path)?; + assert_query(request, "block_on", "criticals") + }, + json_response(body), + ) +} + +fn reused_scan_sarif_report() -> ExpectedRequest { + let path = format!("/api/v1/scan/{PRIOR_SCAN}/report"); + expected_request( + "generate the SARIF report from the reused scan", + move |request| { + assert_authenticated_request(request, Method::GET, &path)?; + assert_query(request, "format", "sarif") + }, + json_response(json!({"version": "2.1.0", "runs": []})), + ) +} + +/// The whole point for CI: the reused scan still decides the exit code, so a +/// re-run of a commit that was blocked stays blocked without scanning again — +/// and, as for a fresh scan, the report is written before the gate exits. +#[test] +fn skipped_scan_still_fails_the_build_on_the_prior_scans_blocking_rules() { + let project = git_project(); + let out_dir = TempDir::new().expect("create output directory"); + let out_file = out_dir.path().join("results.sarif"); + let api = ApiStub::start(vec![ + verify_request(), + commit_lookup(&project.sha, vec![prior_scan(&project.sha, &ago(3))]), + clean_detail(&project.sha), + reused_scan_issues(), + reused_scan_sarif_report(), + reused_scan_blocking_rules(true), + ]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "scan", + "blast", + "--skip-if-commit-scanned-recently", + "--block-on", + "criticals", + "--out-format", + "sarif", + "--out-file", + out_file.to_str().expect("UTF-8 report path"), + "--project-name", + PROJECT, + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert_eq!(output.status.code(), Some(1), "{context}"); + assert!(stdout.contains("Skipping scan"), "{context}"); + assert!(stdout.contains("CORGEA_SCAN_SKIPPED=true"), "{context}"); + assert!( + stdout.contains(&format!("CORGEA_SCAN_ID={PRIOR_SCAN}")), + "{context}" + ); + assert!( + stdout.contains("violated the blocking rule(s)"), + "{context}" + ); + // The upload never happened; the stub would have failed on an extra + // request, and the banner is the user-visible half of the same claim. + assert!(!stdout.contains("Scanning with BLAST"), "{context}"); + let report = std::fs::read_to_string(&out_file) + .unwrap_or_else(|error| panic!("report should exist despite the gate: {error}\n{context}")); + assert!(report.contains("2.1.0"), "{context}"); +} + +/// A clean run of a skipped scan reports the prior findings and exits 0. +#[test] +fn skipped_scan_reports_the_prior_findings() { + let project = git_project(); + let api = ApiStub::start(vec![ + verify_request(), + commit_lookup(&project.sha, vec![prior_scan(&project.sha, &ago(3))]), + clean_detail(&project.sha), + reused_scan_issues(), + reused_scan_blocking_rules(false), + ]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "scan", + "blast", + "--skip-if-commit-scanned-recently", + "--block-on", + "criticals", + "--project-name", + PROJECT, + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert_eq!(output.status.code(), Some(0), "{context}"); + assert_issue_summary(&stdout, &context); + assert!( + stdout.contains("No issues violated the blocking rule(s)"), + "{context}" + ); +} + +/// Outside the window the flag changes nothing: the commit is scanned again, +/// because the advisories it is scanned against have moved on. +#[test] +fn a_scan_older_than_the_window_still_triggers_a_new_scan() { + let project = git_project(); + let mut plan = blast_upload_plan(&project.sha, false, false); + plan.insert( + 1, + commit_lookup(&project.sha, vec![prior_scan(&project.sha, &ago(30))]), + ); + let api = ApiStub::start(plan); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "scan", + "blast", + "--skip-if-commit-scanned-recently", + "--project-name", + PROJECT, + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert_eq!(output.status.code(), Some(0), "{context}"); + assert!(stdout.contains("CORGEA_SCAN_SKIPPED=false"), "{context}"); + assert!(stdout.contains("running a new scan"), "{context}"); + assert!(stdout.contains("Scanning with BLAST"), "{context}"); +} + +/// `--scanned-within` is what makes a scan stale: the same 3h-old scan that +/// the default window reuses is too old for a 1h window. +#[test] +fn a_shorter_window_rejects_a_scan_the_default_would_reuse() { + let project = git_project(); + let mut plan = blast_upload_plan(&project.sha, false, false); + plan.insert( + 1, + commit_lookup(&project.sha, vec![prior_scan(&project.sha, &ago(3))]), + ); + let api = ApiStub::start(plan); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "scan", + "blast", + "--skip-if-commit-scanned-recently", + "--scanned-within", + "1h", + "--project-name", + PROJECT, + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert_eq!(output.status.code(), Some(0), "{context}"); + assert!(stdout.contains("in the last 1h"), "{context}"); + assert!(stdout.contains("CORGEA_SCAN_SKIPPED=false"), "{context}"); +} + +/// Uncommitted changes mean the commit does not describe what would be +/// scanned, so there is nothing a prior scan of it could stand in for — the +/// lookup is not even attempted. +#[test] +fn a_dirty_worktree_scans_instead_of_reusing_the_commits_scan() { + let project = git_project(); + std::fs::write(project.path().join("main.py"), "print('dirty')\n") + .expect("modify tracked file"); + let api = ApiStub::start(blast_upload_plan(&project.sha, true, false)); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "scan", + "blast", + "--skip-if-commit-scanned-recently", + "--project-name", + PROJECT, + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert_eq!(output.status.code(), Some(0), "{context}"); + assert!( + stdout.contains("Working tree does not match commit"), + "{context}" + ); + assert!(stdout.contains("CORGEA_SCAN_SKIPPED=false"), "{context}"); +} + +/// The reuse decision has to read the same dirtiness signal the upload sends. +/// An assume-unchanged modified file is invisible to `git status` — so no +/// worktree notice is printed — but it still changes what gets packaged, and the +/// upload marks it dirty. Reading the narrower status signal here would reuse a +/// clean scan of the commit and gate on files this run does not contain. +#[test] +fn a_file_hidden_from_git_status_scans_instead_of_reusing() { + let project = git_project(); + run_git( + project.path(), + &["update-index", "--assume-unchanged", "main.py"], + ); + std::fs::write(project.path().join("main.py"), "print('hidden change')\n") + .expect("modify assume-unchanged file"); + let api = ApiStub::start(blast_upload_plan(&project.sha, true, false)); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "scan", + "blast", + "--skip-if-commit-scanned-recently", + "--project-name", + PROJECT, + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert_eq!(output.status.code(), Some(0), "{context}"); + assert!( + stdout.contains("Working tree does not match commit"), + "{context}" + ); + assert!(stdout.contains("CORGEA_SCAN_SKIPPED=false"), "{context}"); + // `git status` sees nothing, so the user-facing worktree notice stays quiet; + // only the reuse decision and the upload's dirty flag react. + assert!( + !stdout.contains("Working tree has uncommitted changes"), + "{context}" + ); +} + +/// A prior scan that finished with a scanner's results missing is not reused: a +/// fresh scan says so out loud and may also clear a transient failure, while +/// reusing it would gate silently on findings known to be incomplete. The scan +/// list cannot show this, which is what the confirmation read is for. +#[test] +fn a_degraded_prior_scan_is_not_reused() { + let project = git_project(); + let mut plan = blast_upload_plan(&project.sha, false, false); + plan.insert( + 1, + commit_lookup(&project.sha, vec![prior_scan(&project.sha, &ago(3))]), + ); + plan.insert( + 2, + reused_scan_detail( + &project.sha, + json!([{ + "scan_type": "sca", + "level": "error", + "location": "Project-wide", + "message": "Dependency Analysis did not finish." + }]), + ), + ); + let api = ApiStub::start(plan); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "scan", + "blast", + "--skip-if-commit-scanned-recently", + "--project-name", + PROJECT, + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert_eq!(output.status.code(), Some(0), "{context}"); + assert!(stderr.contains("missing some scanner results"), "{context}"); + assert!(stdout.contains("CORGEA_SCAN_SKIPPED=false"), "{context}"); + assert!(stdout.contains("Scanning with BLAST"), "{context}"); +} + +/// Without a commit the flag has no question to answer, and quietly scanning +/// would hide that the pipeline is not getting the behavior it asked for. +#[test] +fn no_resolvable_commit_fails_before_anything_is_uploaded() { + let project = TempDir::new().expect("create non-git project"); + std::fs::write(project.path().join("main.py"), "print('hi')\n").expect("write source"); + let api = ApiStub::start(vec![verify_request()]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "scan", + "blast", + "--skip-if-commit-scanned-recently", + "--project-name", + PROJECT, + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert_eq!(output.status.code(), Some(1), "{context}"); + assert!( + stderr.contains("no git commit could be resolved"), + "{context}" + ); +} + +#[test] +fn an_unreadable_window_is_rejected_before_the_scan_starts() { + let project = git_project(); + let api = ApiStub::start(vec![verify_request()]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "scan", + "blast", + "--skip-if-commit-scanned-recently", + "--scanned-within", + "yesterday", + "--project-name", + PROJECT, + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert_eq!(output.status.code(), Some(1), "{context}"); + assert!( + stderr.contains("Invalid --scanned-within value 'yesterday'"), + "{context}" + ); +} + +/// Only a default whole-commit scan can stand in for this run, and the API +/// exposes neither a scan's configured scan types and target policies nor +/// whether it bundled a container image, so a run that changes what gets scanned +/// cannot be checked for a match — it is refused at parse time instead of +/// reusing a scan that may have covered less. +#[test] +fn a_custom_scan_configuration_cannot_be_skipped() { + let project = git_project(); + for narrowing_flag in [ + vec!["--scan-type", "secrets"], + vec!["--policy", "1"], + vec!["--include-image", "myapp:1.0.0"], + vec!["--target", "main.py"], + vec!["--only-uncommitted"], + ] { + let api = ApiStub::start(Vec::new()); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args(["scan", "blast", "--skip-if-commit-scanned-recently"]); + command.args(&narrowing_flag); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert_eq!( + output.status.code(), + Some(2), + "{narrowing_flag:?} should conflict\n{context}" + ); + assert!( + stderr.contains("--skip-if-commit-scanned-recently"), + "{narrowing_flag:?}\n{context}" + ); + } +} + +/// `--exclude` is usually a fixed line in a pipeline template, so it does not +/// block the flag. It cannot be matched either: an `--exclude` upload is recorded +/// as not matching the commit exactly, so what gets reused is always a +/// whole-commit scan, and the gate can cover files this run would have skipped. +/// That is over-reporting rather than a missed finding, so the run continues — +/// and says so, because otherwise the extra findings have no explanation. +#[test] +fn excluding_files_warns_but_still_reuses_the_commits_scan() { + let project = git_project(); + let api = ApiStub::start(vec![ + verify_request(), + commit_lookup(&project.sha, vec![prior_scan(&project.sha, &ago(3))]), + clean_detail(&project.sha), + reused_scan_issues(), + ]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "scan", + "blast", + "--skip-if-commit-scanned-recently", + "--exclude", + "tests/**", + "--project-name", + PROJECT, + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert_eq!(output.status.code(), Some(0), "{context}"); + assert!(stdout.contains("CORGEA_SCAN_SKIPPED=true"), "{context}"); + assert!( + stderr.contains("was not narrowed by --exclude 'tests/**'"), + "{context}" + ); +} + +/// The window is meaningless on its own — a pipeline that sets it and forgets +/// the skip flag would silently scan every time. +#[test] +fn the_window_cannot_be_set_without_the_skip_flag() { + let api = ApiStub::start(Vec::new()); + let project = git_project(); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args(["scan", "blast", "--scanned-within", "1h"]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert_eq!(output.status.code(), Some(2), "{context}"); + assert!( + stderr.contains("--skip-if-commit-scanned-recently"), + "{context}" + ); +} + +/// A backend that predates the `sha` filter answers with the project's scans +/// at every commit; acting on that would skip this commit's scan because a +/// different commit was scanned recently. +#[test] +fn a_scan_of_another_commit_is_never_reused() { + let project = git_project(); + let other_commit = "ffffffffffffffffffffffffffffffffffffffff"; + let mut plan = blast_upload_plan(&project.sha, false, false); + plan.insert( + 1, + commit_lookup(&project.sha, vec![prior_scan(other_commit, &ago(1))]), + ); + let api = ApiStub::start(plan); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "scan", + "blast", + "--skip-if-commit-scanned-recently", + "--project-name", + PROJECT, + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert_eq!(output.status.code(), Some(0), "{context}"); + assert!(stdout.contains("CORGEA_SCAN_SKIPPED=false"), "{context}"); + assert!(stdout.contains("Scanning with BLAST"), "{context}"); +}