From fc219b44a41dc16abc6ab30ba56e07f5b90c438f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 09:06:35 +0000 Subject: [PATCH 1/4] Write the scan report before the blocking-rule gate exits --out-format/--out-file ran after the --fail and --block-on gates, so a scan that violated a blocking rule exited 1 without ever writing the report. A pipeline that gates on policy is exactly the one that needs the report file, to ingest the findings it just failed on. The report and the SBOM now run before the gates. Both bodies moved into write_scan_report and write_sbom rather than being reordered in place, which also collapses the four near-identical out-format branches into one server-rendered path plus the JSON case. Co-authored-by: ibrahim --- src/scanners/blast.rs | 233 ++++++++++--------- tests/cloud_commands_e2e/blocking_verdict.rs | 87 +++++++ tests/cloud_commands_e2e/main.rs | 1 + 3 files changed, 210 insertions(+), 111 deletions(-) create mode 100644 tests/cloud_commands_e2e/blocking_verdict.rs diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index dd7f0a0..40807c8 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -339,6 +339,22 @@ pub fn run( 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 still needs the + // report it asked for 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." @@ -381,117 +397,6 @@ pub fn run( ); } - if let Some(out_file) = out_file { - if let Some(out_format) = out_format { - 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( - "Generating scan report... ([T]s)", - stop_signal_clone, - ); - }); - - if out_format == "json" { - let issues = match utils::api::get_all_issues( - &config.get_url(), - &project_name, - Some(scan_id.clone()), - ) { - Ok(issues) => issues, - Err(e) => { - log::error!("\n\nFailed to fetch issues: {}\n\n", e); - std::process::exit(1); - } - }; - let sca_issues = 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: {}\n\n", e); - std::process::exit(1); - } - }; - let json = serde_json::to_string_pretty(&issues).unwrap(); - let sca_json = serde_json::to_string_pretty(&sca_issues).unwrap(); - let report_json = serde_json::to_string_pretty(&classifications).unwrap(); - let results_json = format!( - "{{\"issues\": {}, \"sca_issues\": {}, \"report\": {}}}", - json, sca_json, report_json - ); - *stop_signal.lock().unwrap() = true; - let _ = results_thread.join(); - fs::write(out_file.clone(), results_json).expect("Failed to write JSON file, check if the file path is valid and you have the necessary permissions to write to it."); - utils::terminal::clear_previous_line(); - println!("\n\nScan results written to: {}\n\n", out_file.clone()); - } else if out_format == "html" { - let report = match utils::api::get_scan_report(&config.get_url(), &scan_id, None) { - Ok(html) => html, - Err(e) => { - log::error!("\n\nFailed to fetch scan report: {}\n\n", e); - std::process::exit(1); - } - }; - *stop_signal.lock().unwrap() = true; - let _ = results_thread.join(); - fs::write(out_file.clone(), report).expect("\n\nFailed to write HTML file, check if the file path is valid and you have the necessary permissions to write to it."); - utils::terminal::clear_previous_line(); - println!("\n\nScan report written to: {}\n\n", out_file.clone()); - } else if out_format == "sarif" { - let report = - match utils::api::get_scan_report(&config.get_url(), &scan_id, Some("sarif")) { - Ok(sarif) => sarif, - Err(e) => { - log::error!("\n\nFailed to fetch SARIF report: {}\n\n", e); - std::process::exit(1); - } - }; - *stop_signal.lock().unwrap() = true; - let _ = results_thread.join(); - fs::write(out_file.clone(), report).expect("\n\nFailed to write SARIF file, check if the file path is valid and you have the necessary permissions to write to it."); - utils::terminal::clear_previous_line(); - println!("\n\nScan report written to: {}\n\n", out_file.clone()); - } else if out_format == "markdown" { - let report = match utils::api::get_scan_report( - &config.get_url(), - &scan_id, - Some("markdown"), - ) { - Ok(markdown) => markdown, - Err(e) => { - log::error!("\n\nFailed to fetch Markdown report: {}\n\n", e); - std::process::exit(1); - } - }; - *stop_signal.lock().unwrap() = true; - let _ = results_thread.join(); - fs::write(out_file.clone(), report).expect("\n\nFailed to write Markdown file, check if the file path is valid and you have the necessary permissions to write to it."); - utils::terminal::clear_previous_line(); - println!("\n\nScan report written to: {}\n\n", out_file.clone()); - } - } - } - - if let Some(sbom_file) = sbom { - match corgea::deps::report::sbom(std::path::Path::new(".")) { - Ok(doc) => { - let json = serde_json::to_string_pretty(&doc).expect("serialize SBOM"); - if let Err(e) = fs::write(&sbom_file, json) { - log::error!("\n\nFailed to write SBOM to '{}': {}\n\n", sbom_file, e); - std::process::exit(1); - } - println!("CycloneDX SBOM written to: {}\n", sbom_file); - } - Err(e) => { - log::error!("\n\nFailed to generate SBOM: {}\n\n", e); - std::process::exit(1); - } - } - } - print!("\n\nThank you for using Corgea! 🐕\n\n"); if let Some(fail_on) = fail_on { @@ -538,6 +443,112 @@ pub fn run( } } +/// Write the `--out-format` report for a completed scan to `--out-file`. +/// +/// Does nothing unless both are set; `main` rejects one without the other. +fn write_scan_report( + config: &Config, + project_name: &str, + scan_id: &str, + classifications: &HashMap, + out_format: Option<&str>, + out_file: Option<&str>, +) { + let (Some(out_format), Some(out_file)) = (out_format, out_file) else { + return; + }; + + 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( + "Generating scan report... ([T]s)", + stop_signal_clone, + ); + }); + let stop_spinner = move || { + *stop_signal.lock().unwrap() = true; + let _ = results_thread.join(); + }; + + if out_format == "json" { + let issues = + match utils::api::get_all_issues(&config.get_url(), project_name, Some(scan_id.into())) + { + Ok(issues) => issues, + Err(e) => { + log::error!("\n\nFailed to fetch issues: {}\n\n", e); + std::process::exit(1); + } + }; + let sca_issues = match utils::api::get_all_sca_issues( + &config.get_url(), + project_name, + Some(scan_id.into()), + ) { + Ok(issues) => issues, + Err(e) => { + log::error!("\n\nFailed to fetch SCA issues: {}\n\n", e); + std::process::exit(1); + } + }; + let json = serde_json::to_string_pretty(&issues).unwrap(); + let sca_json = serde_json::to_string_pretty(&sca_issues).unwrap(); + let report_json = serde_json::to_string_pretty(classifications).unwrap(); + let results_json = format!( + "{{\"issues\": {}, \"sca_issues\": {}, \"report\": {}}}", + json, sca_json, report_json + ); + stop_spinner(); + fs::write(out_file, results_json).expect("Failed to write JSON file, check if the file path is valid and you have the necessary permissions to write to it."); + utils::terminal::clear_previous_line(); + println!("\n\nScan results written to: {}\n\n", out_file); + return; + } + + // The server renders these; `None` is its HTML default. + let (report_format, label) = match out_format { + "html" => (None, "HTML"), + "sarif" => (Some("sarif"), "SARIF"), + "markdown" => (Some("markdown"), "Markdown"), + _ => { + stop_spinner(); + log::error!("\n\nUnsupported out_format: {}\n\n", out_format); + std::process::exit(1); + } + }; + let report = match utils::api::get_scan_report(&config.get_url(), scan_id, report_format) { + Ok(report) => report, + Err(e) => { + stop_spinner(); + log::error!("\n\nFailed to fetch {} report: {}\n\n", label, e); + std::process::exit(1); + } + }; + stop_spinner(); + fs::write(out_file, report).unwrap_or_else(|_| panic!("\n\nFailed to write {label} file, check if the file path is valid and you have the necessary permissions to write to it.")); + utils::terminal::clear_previous_line(); + println!("\n\nScan report written to: {}\n\n", out_file); +} + +/// Write a CycloneDX SBOM of the working directory to `sbom_file`. +fn write_sbom(sbom_file: &str) { + match corgea::deps::report::sbom(std::path::Path::new(".")) { + Ok(doc) => { + let json = serde_json::to_string_pretty(&doc).expect("serialize SBOM"); + if let Err(e) = fs::write(sbom_file, json) { + log::error!("\n\nFailed to write SBOM to '{}': {}\n\n", sbom_file, e); + std::process::exit(1); + } + println!("CycloneDX SBOM written to: {}\n", sbom_file); + } + Err(e) => { + log::error!("\n\nFailed to generate SBOM: {}\n\n", e); + std::process::exit(1); + } + } +} + pub const VALID_FAIL_ON_TOKENS: [&str; 5] = ["CR", "HI", "ME", "LO", "malicious"]; /// Parse and validate a comma-separated --fail-on value. diff --git a/tests/cloud_commands_e2e/blocking_verdict.rs b/tests/cloud_commands_e2e/blocking_verdict.rs new file mode 100644 index 0000000..1cc6edd --- /dev/null +++ b/tests/cloud_commands_e2e/blocking_verdict.rs @@ -0,0 +1,87 @@ +//! Contracts for the CI blocking-rule gate: that a tripped `--block-on` still +//! produces the report the pipeline asked for. + +use crate::common::*; +use hyper::Method; +use serde_json::json; +use tempfile::TempDir; + +const PROJECT: &str = "cloud-e2e"; + +/// `check_blocking_rules` answering "blocked by `criticals`", with the server's +/// pre-pagination total. +fn blocked_response() -> serde_json::Value { + json!({ + "block": true, + "blocking_issues": [{ + "id": "issue-1", + "triggered_by_rules": ["7"], + "triggered_by_slugs": ["criticals"] + }], + "total_pages": 1, + "stats": {"blocked_issues": 3}, + "status": "complete" + }) +} + +/// A tripped `--block-on` gate exits 1, but the pipeline still needs the report +/// to ingest the findings it failed on. The stub's plan is ordered, so it is +/// also what proves the report is fetched before the gate is evaluated. +#[test] +fn scan_block_on_writes_the_report_before_failing_the_gate() { + let project = git_project(); + let out_dir = TempDir::new().expect("create report directory"); + let out_file = out_dir.path().join("results.sarif"); + let mut plan = blast_upload_plan(&project.sha, false, false); + plan.push(expected_request( + "generate the SARIF report", + |request| { + assert_authenticated_request( + request, + Method::GET, + "/api/v1/scan/blast-scan-123/report", + )?; + assert_query(request, "format", "sarif") + }, + json_response(json!({"version": "2.1.0", "runs": []})), + )); + plan.push(expected_request( + "evaluate the blocking rules", + |request| { + assert_authenticated_request( + request, + Method::GET, + "/api/v1/scan/blast-scan-123/check_blocking_rules", + )?; + assert_query(request, "block_on", "criticals") + }, + json_response(blocked_response()), + )); + let api = ApiStub::start(plan); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "scan", + "blast", + "--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); + assert_eq!(output.status.code(), Some(1), "{context}"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("3 issue(s) violated the blocking rule(s)"), + "{context}" + ); + let written = std::fs::read_to_string(&out_file) + .unwrap_or_else(|error| panic!("report should exist despite the gate: {error}\n{context}")); + assert!(written.contains("2.1.0"), "{context}"); +} diff --git a/tests/cloud_commands_e2e/main.rs b/tests/cloud_commands_e2e/main.rs index eb2352d..bf57391 100644 --- a/tests/cloud_commands_e2e/main.rs +++ b/tests/cloud_commands_e2e/main.rs @@ -1,6 +1,7 @@ #[path = "../common/mod.rs"] mod repo_common; +mod blocking_verdict; mod common; mod inspect; mod scan_list; From f8dd4e40cfd577e1654a37e7aeecffdb7cf22056 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 09:07:33 +0000 Subject: [PATCH 2/4] Report a scan's CI blocking-rule verdict from corgea list A pipeline that skips a duplicate scan for a commit it has already scanned has no verdict to gate on: --block-on lives on the scan, so the skip path fell back to counting vulnerabilities from the previous scan, which is a different question from the one the CI rules answer. corgea list --block-on attaches a blocking_verdict to every listed scan: the rules asked for, whether they blocked, how many issues did, and which rules tripped. Only 'complete' is a final answer; a scan still running, one that failed, or a server still resolving license dependencies reports 'unavailable'/'pending' with block null rather than a field a consumer would read as a pass. An evaluation error exits 1 for the same reason. --sha narrows the listing to one commit, which is how the duplicate-skip path reaches its scan in a single request. It takes the full SHA because the server matches exactly, and the returned scans are re-checked against it so a backend that ignores the filter cannot answer with another commit's verdict. A verdict costs one request per scan and the endpoint re-evaluates every finding, so the pass is capped and the default page shrinks to the ten scans it evaluates. Co-authored-by: ibrahim --- src/list.rs | 384 ++++++++++++++++++- src/main.rs | 47 +++ src/scanners/blast.rs | 11 +- src/utils/api.rs | 9 + src/wait.rs | 1 + tests/cloud_commands_e2e/blocking_verdict.rs | 239 +++++++++++- 6 files changed, 677 insertions(+), 14 deletions(-) diff --git a/src/list.rs b/src/list.rs index 40789b7..39711d6 100644 --- a/src/list.rs +++ b/src/list.rs @@ -1,10 +1,26 @@ use crate::config::Config; use crate::log::debug; +use crate::scanners::blast::{classify_scan_status, triggered_slugs, ScanState}; use crate::utils; -use crate::utils::api::ProjectSelector; -use serde_json::json; +use crate::utils::api::{ProjectSelector, ScanResponse}; +use serde_json::{json, Value}; use std::path::Path; +/// `blocking_verdict.status` values. Only `complete` is a final answer: +/// `pending` means the server is still resolving the scan's dependencies and +/// `unavailable` means no verdict was produced at all, so a pipeline gating on +/// either must retry or fail closed rather than read `block`. +const VERDICT_STATUS_COMPLETE: &str = "complete"; +const VERDICT_STATUS_PENDING: &str = "pending"; +const VERDICT_STATUS_UNAVAILABLE: &str = "unavailable"; + +/// How many scans on a page `--block-on` evaluates. +/// +/// A verdict costs one request per scan, and the endpoint re-evaluates every +/// finding in that scan, so the pass is bounded and the default page shrinks to +/// what it can evaluate. `--sha` narrows a duplicate-scan lookup to one scan. +const BLOCKING_VERDICT_MAX_SCANS: usize = 10; + #[derive(Default)] pub struct ListArgs { pub issues: bool, @@ -15,6 +31,11 @@ pub struct ListArgs { pub page_size: Option, pub scan_id: Option, pub selector: ProjectSelector, + /// Normalized `--block-on` slugs: attach each listed scan's verdict against + /// these CI blocking rules. + pub block_on: Option, + /// Normalized `--sha`: list only the scans of one commit. + pub sha: Option, } pub fn run(config: &Config, args: ListArgs) { @@ -27,6 +48,8 @@ pub fn run(config: &Config, args: ListArgs) { page_size, scan_id, selector, + block_on, + sha, } = args; println!(); if sca_issues { @@ -315,11 +338,16 @@ pub fn run(config: &Config, args: ListArgs) { } else { let resolved = utils::api::resolve_project_or_exit(&config.get_url(), &selector); let project_name = &resolved.query_name; + // A verdict pass is the expensive part of the listing, so with + // --block-on the default page is the number of scans it will evaluate. + let page_size = + page_size.or_else(|| block_on.as_ref().map(|_| BLOCKING_VERDICT_MAX_SCANS as u16)); let (scans, page, total_pages) = match utils::api::query_scan_list( &config.get_url(), Some(project_name), page, page_size, + sha.as_deref(), ) { Ok(scans) => { let page = scans.page; @@ -346,11 +374,18 @@ pub fn run(config: &Config, args: ListArgs) { std::process::exit(1); } }; + let scans = match sha.as_deref() { + Some(sha) => retain_scans_at_sha(scans, sha), + None => scans, + }; + let verdicts = block_on + .as_deref() + .map(|block_on| blocking_verdicts(config, &scans, block_on)); if json { let output = json!({ "page": page, "total_pages": total_pages, - "results": scans + "results": scan_results_json(&scans, verdicts.as_deref()) }); // The envelope prints first so JSON consumers get valid stdout even // when the miss below exits 1. @@ -377,16 +412,20 @@ pub fn run(config: &Config, args: ListArgs) { ); return; } - let mut table = vec![vec![ + let mut header = vec![ "Scan ID".to_string(), "Project".to_string(), "Status".to_string(), "Repo".to_string(), "Branch".to_string(), "SHA".to_string(), - ]]; + ]; + if verdicts.is_some() { + header.push("Blocking".to_string()); + } + let mut table = vec![header]; - for scan in &scans { + for (index, scan) in scans.iter().enumerate() { let formatted_repo = scan.repo.clone().unwrap_or("N/A".to_string()); let formatted_repo = if formatted_repo != "N/A" { if let Some(repo_name) = formatted_repo.split('/').next_back() { @@ -399,20 +438,211 @@ pub fn run(config: &Config, args: ListArgs) { } else { formatted_repo }; - table.push(vec![ + let mut row = vec![ scan.id.clone(), scan.project.clone(), scan.status.clone(), formatted_repo, scan.branch.clone().unwrap_or("N/A".to_string()), format_short_sha(scan.git_sha.as_deref()), - ]); + ]; + if let Some(verdicts) = &verdicts { + row.push(verdict_cell(verdicts.get(index))); + } + table.push(row); } utils::terminal::print_table(table, page, total_pages); } } +/// The listed scans as JSON, each carrying its `blocking_verdict` when +/// `--block-on` asked for one. +fn scan_results_json(scans: &[ScanResponse], verdicts: Option<&[Value]>) -> Vec { + scans + .iter() + .enumerate() + .map(|(index, scan)| { + let mut value = serde_json::to_value(scan).expect("serialize scan"); + if let (Some(verdict), Value::Object(object)) = ( + verdicts.and_then(|verdicts| verdicts.get(index)), + &mut value, + ) { + object.insert("blocking_verdict".to_string(), verdict.clone()); + } + value + }) + .collect() +} + +/// Drop the scans that are not at `sha`. +/// +/// The server-side `sha` filter does the narrowing; this re-check is what keeps +/// a backend that ignores the parameter from answering with another commit's +/// scans, whose verdict would then be read as this commit's. +fn retain_scans_at_sha(scans: Vec, sha: &str) -> Vec { + let listed = scans.len(); + let matching: Vec = scans + .into_iter() + .filter(|scan| { + scan.git_sha + .as_deref() + .is_some_and(|value| value.trim().eq_ignore_ascii_case(sha)) + }) + .collect(); + if matching.len() != listed { + log::warn!( + "Ignored {} scan(s) not at commit {}. This Corgea instance may not filter scans by commit, so scans of {} may be on a later page.", + listed - matching.len(), + sha, + sha + ); + } + matching +} + +/// Each scan's verdict against the `--block-on` rules, positionally aligned +/// with `scans`. +/// +/// One request per scan, without the wait `corgea scan --block-on` does: a +/// listing reports a `pending` verdict for the caller to retry rather than +/// blocking on it. +fn blocking_verdicts(config: &Config, scans: &[ScanResponse], block_on: &str) -> Vec { + if scans.len() > BLOCKING_VERDICT_MAX_SCANS { + log::warn!( + "Only the first {} scans of this page are evaluated against --block-on. Narrow the listing with --sha or --page-size.", + BLOCKING_VERDICT_MAX_SCANS + ); + } + scans + .iter() + .enumerate() + .map(|(index, scan)| { + if index >= BLOCKING_VERDICT_MAX_SCANS { + return unavailable_verdict( + block_on, + &format!( + "only the first {BLOCKING_VERDICT_MAX_SCANS} scans of a page are evaluated; narrow the listing with --sha or --page-size" + ), + ); + } + // Blocking rules are evaluated against the findings recorded so + // far, so a verdict for a scan that has not finished would read as + // a pass on findings that are still coming. + match classify_scan_status(&scan.status) { + ScanState::Completed => {} + ScanState::Failed => { + return unavailable_verdict( + block_on, + &format!("scan did not complete (status '{}')", scan.status), + ) + } + ScanState::Running => { + return unavailable_verdict( + block_on, + &format!("scan has not completed yet (status '{}')", scan.status), + ) + } + } + match utils::api::check_blocking_rules( + &config.get_url(), + &scan.id, + None, + Some(block_on), + ) { + Ok(response) => verdict_from_response(block_on, &response), + // Fail loud: a verdict a pipeline gates on must not degrade + // into a missing field that reads as "not blocked". + Err(e) => { + log::error!( + "Failed to check blocking rules for scan {}: {}", + scan.id, + e + ); + std::process::exit(1); + } + } + }) + .collect() +} + +/// One scan's verdict as reported by `check_blocking_rules`. +fn verdict_from_response(block_on: &str, response: &utils::api::BlockingRuleResponse) -> Value { + json!({ + "block_on": slug_list(block_on), + "status": if response.is_complete() { + VERDICT_STATUS_COMPLETE + } else { + VERDICT_STATUS_PENDING + }, + "block": response.block, + "blocked_issues": response.blocked_count(), + "triggered_rules": triggered_slugs(&response.blocking_issues), + }) +} + +/// A verdict that could not be produced. `block` is null rather than false so +/// that a consumer reading it as a boolean cannot mistake it for a pass. +fn unavailable_verdict(block_on: &str, reason: &str) -> Value { + json!({ + "block_on": slug_list(block_on), + "status": VERDICT_STATUS_UNAVAILABLE, + "block": Value::Null, + "reason": reason, + }) +} + +/// The rule slugs behind an already-normalized `--block-on` value. +fn slug_list(block_on: &str) -> Vec<&str> { + block_on.split(',').collect() +} + +/// The Blocking column for one scan. +fn verdict_cell(verdict: Option<&Value>) -> String { + let Some(verdict) = verdict else { + return "N/A".to_string(); + }; + match verdict["status"].as_str() { + Some(VERDICT_STATUS_COMPLETE) => { + if verdict["block"].as_bool() != Some(true) { + return "pass".to_string(); + } + let rules = verdict["triggered_rules"] + .as_array() + .map(|rules| { + rules + .iter() + .filter_map(|rule| rule.as_str()) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + if rules.is_empty() { + "BLOCKED".to_string() + } else { + format!("BLOCKED: {rules}") + } + } + Some(VERDICT_STATUS_PENDING) => "pending".to_string(), + _ => "N/A".to_string(), + } +} + +/// Canonicalize `--sha`. +/// +/// The server matches a commit exactly, so a short SHA would answer "no scans" +/// instead of narrowing — a silent miss a duplicate-scan check would read as +/// "never scanned". +pub fn normalize_sha(raw: &str) -> Result { + let sha = raw.trim(); + if !(40..=64).contains(&sha.len()) || !sha.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!( + "--sha expects a full commit SHA, as printed by `git rev-parse HEAD`, got '{sha}'." + )); + } + Ok(sha.to_ascii_lowercase()) +} + /// Format a git SHA for the list table. Missing/blank → "N/A"; otherwise first 8 chars. fn format_short_sha(git_sha: Option<&str>) -> String { git_sha @@ -426,6 +656,144 @@ fn format_short_sha(git_sha: Option<&str>) -> String { mod tests { use super::*; + fn scan(id: &str, status: &str, git_sha: Option<&str>) -> ScanResponse { + ScanResponse { + id: id.to_string(), + project: "proj".to_string(), + repo: None, + branch: None, + status: status.to_string(), + engine: "blast".to_string(), + created_at: "2026-07-30T12:00:00Z".to_string(), + git_sha: git_sha.map(str::to_string), + metadata: None, + failed_reason: None, + scan_errors: Vec::new(), + } + } + + fn blocking_response(body: Value) -> utils::api::BlockingRuleResponse { + serde_json::from_value(body).expect("blocking rules response") + } + + #[test] + fn normalize_sha_lowercases_a_full_sha() { + assert_eq!( + normalize_sha(" 0123456789ABCDEF0123456789abcdef01234567 "), + Ok("0123456789abcdef0123456789abcdef01234567".to_string()) + ); + } + + #[test] + fn normalize_sha_rejects_a_prefix_or_a_non_sha() { + // The server matches the commit exactly, so a prefix would answer "no + // scans" — a miss a duplicate-scan check reads as "never scanned". + for raw in ["", "0123456", "main", "0123456789abcdef0123456789abcdef0123456z"] { + assert!(normalize_sha(raw).is_err(), "{raw} should be rejected"); + } + } + + #[test] + fn verdict_from_response_reports_the_blocked_count_and_rules() { + let verdict = verdict_from_response( + "criticals,malicious-deps", + &blocking_response(json!({ + "block": true, + "blocking_issues": [{ + "id": "issue-1", + "triggered_by_rules": ["7"], + "triggered_by_slugs": ["criticals"] + }], + "total_pages": 1, + "stats": {"blocked_issues": 12}, + "status": "complete" + })), + ); + assert_eq!(verdict["status"], VERDICT_STATUS_COMPLETE); + assert_eq!(verdict["block"], true); + // The server's pre-pagination total, not the returned page length. + assert_eq!(verdict["blocked_issues"], 12); + assert_eq!(verdict["triggered_rules"], json!(["criticals"])); + assert_eq!(verdict["block_on"], json!(["criticals", "malicious-deps"])); + } + + #[test] + fn verdict_from_response_marks_an_unfinished_evaluation_pending() { + let verdict = verdict_from_response( + "criticals", + &blocking_response(json!({ + "block": false, + "blocking_issues": [], + "total_pages": 1, + "status": "pending" + })), + ); + assert_eq!(verdict["status"], VERDICT_STATUS_PENDING); + } + + #[test] + fn unavailable_verdict_leaves_block_null() { + // A consumer reading `block` as a boolean must not see a pass. + let verdict = unavailable_verdict("criticals", "scan has not completed yet"); + assert_eq!(verdict["status"], VERDICT_STATUS_UNAVAILABLE); + assert!(verdict["block"].is_null()); + assert_eq!(verdict["reason"], "scan has not completed yet"); + } + + #[test] + fn verdict_cell_names_the_rules_that_blocked() { + let blocked = json!({ + "status": VERDICT_STATUS_COMPLETE, + "block": true, + "triggered_rules": ["criticals", "malicious-deps"] + }); + assert_eq!( + verdict_cell(Some(&blocked)), + "BLOCKED: criticals, malicious-deps" + ); + } + + #[test] + fn verdict_cell_renders_every_other_state() { + let pass = json!({"status": VERDICT_STATUS_COMPLETE, "block": false}); + assert_eq!(verdict_cell(Some(&pass)), "pass"); + let pending = json!({"status": VERDICT_STATUS_PENDING, "block": false}); + assert_eq!(verdict_cell(Some(&pending)), "pending"); + let unavailable = json!({"status": VERDICT_STATUS_UNAVAILABLE, "block": null}); + assert_eq!(verdict_cell(Some(&unavailable)), "N/A"); + assert_eq!(verdict_cell(None), "N/A"); + } + + #[test] + fn scan_results_json_attaches_the_verdict_only_when_asked() { + let scans = vec![scan("scan-1", "complete", None)]; + let plain = scan_results_json(&scans, None); + assert!(plain[0].get("blocking_verdict").is_none()); + + let verdicts = vec![unavailable_verdict("criticals", "nope")]; + let annotated = scan_results_json(&scans, Some(&verdicts)); + assert_eq!(annotated[0]["id"], "scan-1"); + assert_eq!( + annotated[0]["blocking_verdict"]["status"], + VERDICT_STATUS_UNAVAILABLE + ); + } + + #[test] + fn retain_scans_at_sha_drops_other_commits() { + let sha = "0123456789abcdef0123456789abcdef01234567"; + let scans = vec![ + scan("scan-1", "complete", Some(&sha.to_ascii_uppercase())), + scan("scan-2", "complete", Some("f00dcafe")), + scan("scan-3", "complete", None), + ]; + let kept = retain_scans_at_sha(scans, sha); + assert_eq!( + kept.iter().map(|scan| scan.id.as_str()).collect::>(), + vec!["scan-1"] + ); + } + #[test] fn format_short_sha_missing_or_blank_is_na() { assert_eq!(format_short_sha(None), "N/A"); diff --git a/src/main.rs b/src/main.rs index 57cac78..11a1d7a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -208,6 +208,20 @@ enum Commands { #[arg(short, long, help = "Specify the scan id to list issues for.")] scan_id: Option, + #[arg( + long = "block-on", + value_name = "SLUG", + help = "Report each listed scan's verdict against the named CI blocking rules, as 'blocking_verdict' in --json output and a Blocking column otherwise. Comma-separated rule slugs, e.g. --block-on criticals,malicious-deps. Only 'complete' verdicts are final. Only for the scan listing; not for issue listings." + )] + block_on: Option, + + #[arg( + long, + value_name = "SHA", + help = "List only the scans of one commit. Takes the full commit SHA, as printed by `git rev-parse HEAD`. Only for the scan listing; not for issue listings." + )] + sha: Option, + #[arg(short, long, value_parser = clap::value_parser!(u16))] page: Option, @@ -812,6 +826,8 @@ fn main() { code_quality, project_name, repo, + block_on, + sha, }) => { verify_token_and_exit_when_fail(&corgea_config); if [*issues, *sca_issues, *code_quality] @@ -829,6 +845,35 @@ fn main() { println!("scan_id option is only supported for issues list command."); std::process::exit(1); } + let lists_issues = *issues || *sca_issues || *code_quality; + // Both narrow the scan listing itself. On an issue listing they + // would silently do nothing, and a pipeline reading a missing + // verdict as a pass is exactly what --block-on is there to prevent. + for (flag, value) in [("block-on", block_on), ("sha", sha)] { + if value.is_some() && lists_issues { + ::log::error!( + "{} is only supported for the scan listing, not with --issues, --sca-issues, or --code-quality.", + flag + ); + std::process::exit(1); + } + } + + let block_on = match scanners::blast::normalize_block_on(block_on.as_deref()) { + Ok(slugs) => slugs, + Err(msg) => { + ::log::error!("{}", msg); + std::process::exit(1); + } + }; + let sha = match sha.as_deref().map(list::normalize_sha).transpose() { + Ok(sha) => sha, + Err(msg) => { + ::log::error!("{}", msg); + std::process::exit(1); + } + }; + list::run( &corgea_config, list::ListArgs { @@ -843,6 +888,8 @@ fn main() { name: project_name.clone(), repo: repo.clone(), }, + block_on, + sha, }, ); } diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index 40807c8..bf8a623 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -654,10 +654,10 @@ pub fn normalize_block_on(block_on: Option<&str>) -> Result, Stri Ok(Some(slugs.join(","))) } -/// The distinct rule slugs that blocked the scan, for the failure message. +/// The distinct rule slugs that blocked the scan. /// /// Falls back to rule ids against backends that do not send slugs yet. -pub fn triggered_slug_summary(issues: &[utils::api::BlockingIssue]) -> String { +pub fn triggered_slugs(issues: &[utils::api::BlockingIssue]) -> Vec { let mut names: Vec = Vec::new(); for issue in issues { let identifiers = match &issue.triggered_by_slugs { @@ -670,7 +670,12 @@ pub fn triggered_slug_summary(issues: &[utils::api::BlockingIssue]) -> String { } } } - names.join(", ") + names +} + +/// The distinct rule slugs that blocked the scan, for the failure message. +pub fn triggered_slug_summary(issues: &[utils::api::BlockingIssue]) -> String { + triggered_slugs(issues).join(", ") } /// Whether a scan status means the scan has stopped, and if so, how it ended. diff --git a/src/utils/api.rs b/src/utils/api.rs index 880fded..cd5bbbc 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -802,11 +802,17 @@ pub fn get_skill( Ok(Some(skill_response)) } +/// One page of a project's scans. +/// +/// `sha` asks the server for the scans of one commit. A backend that does not +/// support the filter answers the unfiltered page, so callers that rely on the +/// narrowing must re-check `git_sha` on the results. pub fn query_scan_list( url: &str, project: Option<&str>, page: Option, page_size: Option, + sha: Option<&str>, ) -> Result> { let url = format!("{}{}/scans", url, API_BASE); let page = page.unwrap_or(1); @@ -819,6 +825,9 @@ pub fn query_scan_list( if let Some(project) = project { query_params.push(("project", project.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)); diff --git a/src/wait.rs b/src/wait.rs index c179eb0..bc53967 100644 --- a/src/wait.rs +++ b/src/wait.rs @@ -21,6 +21,7 @@ fn latest_scan_id(config: &Config, resolved: &utils::api::ResolvedProject) -> St Some(&resolved.query_name), Some(1), None, + None, ) { Ok(result) => result.scans.unwrap_or_default(), Err(e) => { diff --git a/tests/cloud_commands_e2e/blocking_verdict.rs b/tests/cloud_commands_e2e/blocking_verdict.rs index 1cc6edd..ac51e3b 100644 --- a/tests/cloud_commands_e2e/blocking_verdict.rs +++ b/tests/cloud_commands_e2e/blocking_verdict.rs @@ -1,8 +1,9 @@ -//! Contracts for the CI blocking-rule gate: that a tripped `--block-on` still -//! produces the report the pipeline asked for. +//! Contracts for the CI blocking-rule verdict: that a tripped `--block-on` +//! gate still produces the report the pipeline asked for, and that +//! `corgea list` can report a past scan's verdict without rescanning. use crate::common::*; -use hyper::Method; +use hyper::{Method, StatusCode}; use serde_json::json; use tempfile::TempDir; @@ -24,6 +25,19 @@ fn blocked_response() -> serde_json::Value { }) } +fn scan_at(id: &str, status: &str, git_sha: &str) -> serde_json::Value { + json!({ + "id": id, + "project": PROJECT, + "repo": "https://github.com/corgea/cloud-e2e.git", + "branch": "e2e-main", + "status": status, + "engine": "blast", + "created_at": "2026-07-30T12:00:00Z", + "git_sha": git_sha + }) +} + /// A tripped `--block-on` gate exits 1, but the pipeline still needs the report /// to ingest the findings it failed on. The stub's plan is ordered, so it is /// also what proves the report is fetched before the gate is evaluated. @@ -85,3 +99,222 @@ fn scan_block_on_writes_the_report_before_failing_the_gate() { .unwrap_or_else(|error| panic!("report should exist despite the gate: {error}\n{context}")); assert!(written.contains("2.1.0"), "{context}"); } + +/// The duplicate-scan path: read a past scan's verdict against the same CI +/// rules the pipeline gates on, without running a scan. +#[test] +fn list_block_on_attaches_a_verdict_to_every_scan() { + let project = TempDir::new().expect("create list project"); + let api = ApiStub::start(vec![ + verify_request(), + expected_request( + "list scans to evaluate", + |request| { + assert_authenticated_request(request, Method::GET, "/api/v1/scans")?; + assert_query(request, "project", PROJECT)?; + // A verdict costs a request per scan, so the default page is + // the number of scans the pass will evaluate. + assert_query(request, "page_size", "10") + }, + json_response(scans_response(vec![ + scan_response("scan-blocked", PROJECT, "complete"), + scan_response("scan-running", PROJECT, "scanning"), + ])), + ), + expected_request( + "evaluate the completed scan", + |request| { + assert_authenticated_request( + request, + Method::GET, + "/api/v1/scan/scan-blocked/check_blocking_rules", + )?; + assert_query(request, "block_on", "criticals,malicious-deps") + }, + json_response(blocked_response()), + ), + ]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "list", + "--json", + "--project-name", + PROJECT, + "--block-on", + "criticals,malicious-deps", + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + assert_eq!(output.status.code(), Some(0), "{context}"); + let body = parse_output_json(&output, &transcript); + let results = body["results"].as_array().expect("list JSON results"); + assert_eq!(results.len(), 2, "{context}"); + + let blocked = &results[0]["blocking_verdict"]; + assert_eq!(blocked["status"], "complete", "{context}"); + assert_eq!(blocked["block"], true, "{context}"); + assert_eq!(blocked["blocked_issues"], 3, "{context}"); + assert_eq!(blocked["triggered_rules"], json!(["criticals"]), "{context}"); + assert_eq!( + blocked["block_on"], + json!(["criticals", "malicious-deps"]), + "{context}" + ); + + // A scan that has not finished is never evaluated — the plan above would + // have flagged the request — and reports a null verdict rather than a pass. + let running = &results[1]["blocking_verdict"]; + assert_eq!(running["status"], "unavailable", "{context}"); + assert!(running["block"].is_null(), "{context}"); +} + +/// `--sha` narrows the lookup to the commit the pipeline skipped scanning. +#[test] +fn list_sha_asks_the_server_for_one_commit_and_re_checks_it() { + let sha = "0123456789abcdef0123456789abcdef01234567"; + let project = TempDir::new().expect("create list project"); + let api = ApiStub::start(vec![ + verify_request(), + expected_request( + "list the scans of one commit", + move |request| { + assert_authenticated_request(request, Method::GET, "/api/v1/scans")?; + assert_query(request, "sha", "0123456789abcdef0123456789abcdef01234567") + }, + // A backend that does not support the filter answers the + // unfiltered page, whose other commits must not be reported as + // this one's. + json_response(scans_response(vec![ + scan_at("scan-at-head", "complete", sha), + scan_at("scan-earlier", "complete", "f00dcafef00dcafef00dcafef00dcafef00dcafe"), + ])), + ), + ]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "list", + "--json", + "--project-name", + PROJECT, + "--sha", + &sha.to_ascii_uppercase(), + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + assert_eq!(output.status.code(), Some(0), "{context}"); + let body = parse_output_json(&output, &transcript); + let results = body["results"].as_array().expect("list JSON results"); + assert_eq!(results.len(), 1, "{context}"); + assert_eq!(results[0]["id"], "scan-at-head", "{context}"); +} + +/// A short SHA would match nothing server-side, and a duplicate-scan check +/// reads a miss as "never scanned", so it is rejected rather than sent. +#[test] +fn list_sha_rejects_a_short_sha_without_dialing_the_api() { + let project = TempDir::new().expect("create list project"); + let api = ApiStub::start(vec![verify_request()]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "list", + "--json", + "--project-name", + PROJECT, + "--sha", + "0123456", + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + assert_eq!(output.status.code(), Some(1), "{context}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("expects a full commit SHA"), "{context}"); +} + +/// On an issue listing the flag would silently do nothing, and a pipeline +/// reading a missing verdict as a pass is what `--block-on` exists to prevent. +#[test] +fn list_block_on_is_rejected_on_an_issue_listing() { + let project = TempDir::new().expect("create list project"); + let api = ApiStub::start(vec![verify_request()]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "list", + "--issues", + "--project-name", + PROJECT, + "--block-on", + "criticals", + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + assert_eq!(output.status.code(), Some(1), "{context}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("only supported for the scan listing"), + "{context}" + ); +} + +/// A verdict a pipeline gates on must not degrade into a missing field that +/// reads as "not blocked". +#[test] +fn list_block_on_exits_one_when_the_evaluation_fails() { + let project = TempDir::new().expect("create list project"); + let api = ApiStub::start(vec![ + verify_request(), + expected_request( + "list scans to evaluate", + |request| assert_authenticated_request(request, Method::GET, "/api/v1/scans"), + json_response(scans_response(vec![scan_response( + "scan-blocked", + PROJECT, + "complete", + )])), + ), + expected_request( + "reject the unknown rule slug", + |request| { + assert_authenticated_request( + request, + Method::GET, + "/api/v1/scan/scan-blocked/check_blocking_rules", + ) + }, + json_response_with_status( + StatusCode::BAD_REQUEST, + json!({ + "status": "error", + "message": "Invalid block_on rule(s)", + "unknown_slugs": ["criticalz"] + }), + ), + ), + ]); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "list", + "--json", + "--project-name", + PROJECT, + "--block-on", + "criticalz", + ]); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + assert_eq!(output.status.code(), Some(1), "{context}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Unknown blocking rule(s): criticalz"), + "{context}" + ); +} From 2f63811ae0dee5358f3cbe652510f8ae45c69381 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 09:10:06 +0000 Subject: [PATCH 3/4] Document the list verdict and the version bump to 1.11.0 corgea ls --block-on/--sha are new backward-compatible flags and the report ordering only adds a guarantee, so SemVer puts this at a minor bump. Cargo.toml is the single source of truth: PyPI reads it via maturin and npm takes its version from the release tag. Co-authored-by: ibrahim --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 40 ++++++++++++ skills/corgea/SKILL.md | 69 ++++++++++++++++++++ src/list.rs | 7 +- tests/cloud_commands_e2e/blocking_verdict.rs | 12 +++- 6 files changed, 127 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1b96394..68d51d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -369,7 +369,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "corgea" -version = "1.10.0" +version = "1.11.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 86d0c45..71416b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "corgea" -version = "1.10.0" +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 20021f3..67a3418 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,46 @@ Waiting gives up after 10 hours; override with `CORGEA_SCAN_TIMEOUT_SECONDS`. `--fail`/`--block-on` then wait up to 15 minutes for blocking rules to be evaluated; override with `CORGEA_BLOCKING_RULES_TIMEOUT_SECONDS`. +`--out-format`/`--out-file` and `--sbom` are honored whether or not the gate +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. + +## CI Blocking Rules + +`corgea scan --block-on ` fails a pipeline on the CI blocking rules +configured in the web app, named by their comma-separated slugs. + +`corgea list --block-on ` answers the same question for a scan that +already ran, which is what a pipeline needs when it skips a duplicate scan for a +commit it has already scanned: + +```bash +corgea ls --sha "$(git rev-parse HEAD)" --block-on criticals,malicious-deps --json +``` + +Each listed scan gains a `blocking_verdict` (`--json`) or a Blocking column: + +```json +"blocking_verdict": { + "block_on": ["criticals", "malicious-deps"], + "status": "complete", + "block": true, + "blocked_issues": 3, + "triggered_rules": ["criticals"] +} +``` + +Only `status: "complete"` is a final answer. `pending` means the server is still +resolving the scan's dependencies, and `unavailable` means no verdict was +produced — for a scan that never completed, or one past the per-page evaluation +cap, with the cause in `reason`. Both leave `block` null, so read `status` first +and fail closed on anything else. An evaluation that errors exits 1. + +A verdict costs one request per scan, and the server re-evaluates every finding +in that scan, so at most the first 10 scans of a page are evaluated and +`--block-on` shrinks the default page to 10. `--sha` narrows to one commit and +takes the full SHA, since the server matches exactly. + ## 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 21712e1..f91f64a 100644 --- a/skills/corgea/SKILL.md +++ b/skills/corgea/SKILL.md @@ -54,6 +54,8 @@ Scan types: `blast` (base AI), `policy` (PolicyIQ), `malicious`, `secrets`, `pii `--only-uncommitted` and `--target` are mutually exclusive. `--fail-on`, `--fail`, and `--block-on` are mutually exclusive. +`--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. + ### Upload — `corgea upload [report]` Upload an existing scan report to Corgea. @@ -93,6 +95,8 @@ corgea ls --sca-issues # SCA (dependency) issues corgea ls --code-quality # Code quality issues corgea ls --issues --page 2 --page-size 10 # Pagination corgea ls --issues --scan-id SCAN_ID --json # JSON output +corgea ls --sha $(git rev-parse HEAD) # Scans of one commit +corgea ls --sha $(git rev-parse HEAD) --block-on criticals --json # ...and their CI blocking-rule verdict ``` | Flag | Short | Description | @@ -101,10 +105,55 @@ corgea ls --issues --scan-id SCAN_ID --json # JSON output | `--sca-issues` | `-c` | List SCA issues | | `--code-quality` | `-q` | List code quality issues (alias `--quality`) | | `--scan-id` | `-s` | Filter to a scan | +| `--sha` | | List only the scans of one commit (scan listing only) | +| `--block-on` | | Report each scan's verdict against the named CI blocking rules (scan listing only) | | `--page` | `-p` | Page number | | `--page-size` | | Items per page | | `--json` | | JSON output | +#### Blocking-rule verdicts on a listed scan + +`corgea ls --block-on ` answers, for a scan that already ran, the same +question `corgea scan --block-on ` gates on. It takes the same +comma-separated CI rule slugs and adds a `blocking_verdict` to each scan in +`--json` output (a Blocking column otherwise): + +```json +{ + "id": "…", + "git_sha": "…", + "blocking_verdict": { + "block_on": ["criticals", "malicious-deps"], + "status": "complete", + "block": true, + "blocked_issues": 3, + "triggered_rules": ["criticals"] + } +} +``` + +`status` says whether the verdict can be trusted, and **only `complete` is a +final answer**: + +| `status` | Meaning | `block` | +|----------|---------|---------| +| `complete` | Verdict is final | `true`/`false` | +| `pending` | The server is still resolving the scan's dependencies; retry | `false`, not yet final | +| `unavailable` | No verdict: the scan never completed, or it was past the per-page evaluation cap (see `reason`) | `null` | + +Read `status` before `block`, and treat anything other than `complete` as +fail-closed. An evaluation that errors — including an unknown, inactive, or +pull-request-scoped slug — exits 1 rather than reporting a verdict. + +A verdict costs one request per scan and the server re-evaluates every finding +in that scan, so the pass covers at most the first 10 scans of a page and +`--block-on` shrinks the default page to 10. Narrow with `--sha` (one commit) or +`--page-size`. + +`--sha` takes the **full** commit SHA (`git rev-parse HEAD`); a prefix is +rejected rather than sent, since the server matches exactly and an empty answer +reads as "never scanned". + ### Inspect — `corgea inspect ` ```bash @@ -374,6 +423,26 @@ corgea scan --fail-on CR,malicious --out-format sarif --out-file results.sarif corgea scan --block-on criticals --out-format sarif --out-file results.sarif # gate on a CI blocking rule from the web app ``` +The report is written whether or not the gate trips, so a pipeline can both fail +on policy and ingest the results file. + +### Skip a duplicate scan but keep the gate + +When a pipeline skips scanning a commit it has already scanned, read the +previous scan's verdict against the same CI rules instead of re-deriving one +from vulnerability counts: + +```bash +verdict=$(corgea ls --sha "$(git rev-parse HEAD)" --block-on criticals,malicious-deps --json) +echo "$verdict" | jq -e '.results[0].blocking_verdict.status == "complete"' > /dev/null \ + || { echo "no final verdict for this commit; run a scan"; exit 1; } +echo "$verdict" | jq -e '.results[0].blocking_verdict.block == false' > /dev/null \ + || { echo "blocked by $(echo "$verdict" | jq -r '.results[0].blocking_verdict.triggered_rules | join(", ")')"; exit 1; } +``` + +An empty `.results` means the commit has never been scanned: scan it rather than +treating the absent verdict as a pass. + ### Upload third-party reports ```bash diff --git a/src/list.rs b/src/list.rs index 39711d6..eb55414 100644 --- a/src/list.rs +++ b/src/list.rs @@ -688,7 +688,12 @@ mod tests { fn normalize_sha_rejects_a_prefix_or_a_non_sha() { // The server matches the commit exactly, so a prefix would answer "no // scans" — a miss a duplicate-scan check reads as "never scanned". - for raw in ["", "0123456", "main", "0123456789abcdef0123456789abcdef0123456z"] { + for raw in [ + "", + "0123456", + "main", + "0123456789abcdef0123456789abcdef0123456z", + ] { assert!(normalize_sha(raw).is_err(), "{raw} should be rejected"); } } diff --git a/tests/cloud_commands_e2e/blocking_verdict.rs b/tests/cloud_commands_e2e/blocking_verdict.rs index ac51e3b..ca2dbd7 100644 --- a/tests/cloud_commands_e2e/blocking_verdict.rs +++ b/tests/cloud_commands_e2e/blocking_verdict.rs @@ -156,7 +156,11 @@ fn list_block_on_attaches_a_verdict_to_every_scan() { assert_eq!(blocked["status"], "complete", "{context}"); assert_eq!(blocked["block"], true, "{context}"); assert_eq!(blocked["blocked_issues"], 3, "{context}"); - assert_eq!(blocked["triggered_rules"], json!(["criticals"]), "{context}"); + assert_eq!( + blocked["triggered_rules"], + json!(["criticals"]), + "{context}" + ); assert_eq!( blocked["block_on"], json!(["criticals", "malicious-deps"]), @@ -188,7 +192,11 @@ fn list_sha_asks_the_server_for_one_commit_and_re_checks_it() { // this one's. json_response(scans_response(vec![ scan_at("scan-at-head", "complete", sha), - scan_at("scan-earlier", "complete", "f00dcafef00dcafef00dcafef00dcafef00dcafe"), + scan_at( + "scan-earlier", + "complete", + "f00dcafef00dcafef00dcafef00dcafef00dcafe", + ), ])), ), ]); From 9968ae1020c35a851be089da82b008b04acbd6a4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 09:13:07 +0000 Subject: [PATCH 4/4] Read an empty --sha page as an unscanned commit, not a missing project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan listing exits 1 with "No Corgea project found" when an unconfirmed project returns no scans, which is the right answer for a listing of every scan. With --sha an empty page is the expected answer for a commit that has not been scanned yet — the case a duplicate-skip path handles by scanning — so it now reports the empty result and says which commit had no scan. Co-authored-by: ibrahim --- src/list.rs | 17 +++++++++++------ tests/list_resolution.rs | 25 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/list.rs b/src/list.rs index eb55414..4c98462 100644 --- a/src/list.rs +++ b/src/list.rs @@ -394,8 +394,10 @@ pub fn run(config: &Config, args: ListArgs) { // An unresolved project is a miss (exit 1, as --issues and `wait`); a // confirmed project with no scans is a valid empty result. So is an // explicit --project-name: /scans answers 200-empty either way, so the - // caller's own exact name is the better authority. - if scans.is_empty() && !resolved.confirmed && selector.name.is_none() { + // caller's own exact name is the better authority. So is --sha, whose + // empty page means "this commit has not been scanned" — the answer a + // duplicate-skip path acts on by scanning. + if scans.is_empty() && !resolved.confirmed && selector.name.is_none() && sha.is_none() { log::error!( "No Corgea project found for {}. Run 'corgea scan' to create one, or pass --project-name .", resolved.tried_label @@ -406,10 +408,13 @@ pub fn run(config: &Config, args: ListArgs) { return; } if scans.is_empty() { - println!( - "Project '{}' has no scans yet. Run 'corgea scan' to create one.", - project_name - ); + match sha.as_deref() { + Some(sha) => println!("No scan of commit {} in project '{}'.", sha, project_name), + None => println!( + "Project '{}' has no scans yet. Run 'corgea scan' to create one.", + project_name + ), + } return; } let mut header = vec![ diff --git a/tests/list_resolution.rs b/tests/list_resolution.rs index 0f36a35..cc4f90b 100644 --- a/tests/list_resolution.rs +++ b/tests/list_resolution.rs @@ -339,6 +339,31 @@ fn list_json_miss_is_valid_empty_envelope() { ); } +#[test] +fn list_sha_with_no_matching_scan_is_an_empty_result_not_a_project_miss() { + // An unconfirmed project (old or not-yet-onboarded backend) plus --sha: an + // empty page means "this commit has not been scanned", which is what a + // duplicate-skip path acts on by scanning. Reporting a project miss would + // send it looking for the wrong problem. + let (url, _hits) = spawn_stub(projects_empty(), scans_empty(), issues_miss()); + let (_tmp, repo) = temp_git_repo("dotnet-azure-web-tsb", REMOTE); + let out = run_list(&["--json", "--sha", &"a".repeat(40)], &url, &repo); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let v: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("stdout not JSON ({e}): {stdout}")); + assert_eq!( + v["results"].as_array().map(|a| a.len()), + Some(0), + "stdout: {stdout}" + ); +} + #[test] fn list_issues_with_scan_id_skips_project_resolution() { // The scan-id issue route ignores the project, so no /projects call should