From 35521b6459c727c47589dbeeed3a4351c248327e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 09:45:41 +0000 Subject: [PATCH 1/2] Write the scan report and SBOM before the blocking-rule gates exit --out-format/--out-file and --sbom ran after the --fail and --block-on gates, and a tripped gate calls exit(1), so a scan that violated a blocking rule produced no report at all. That is backwards: a pipeline that gates on policy is precisely the one that needs the report file, to ingest the findings it just failed on. Only a passing gate left a report behind, so the flags appeared to work until a rule actually tripped. Both now run immediately after the scan results are collected, ahead of every gate. Rather than reorder the bodies in place, they moved into write_scan_report and write_sbom, which also collapses the four near-identical out-format branches into one server-rendered path plus the JSON case, and stops the spinner thread on the error exits that previously left it running. The e2e plans are ordered, so they pin the sequence: a reorder back puts the report request after check_blocking_rules and fails the plan. Covered for --block-on (with an SBOM), for the deprecated --fail, and for a gate that passes, which must still write the report and exit 0. corgea deps scan already emits its out-file before its --fail-on gate can return non-zero, and no other command pairs an output file with a gate. Co-authored-by: ibrahim --- src/scanners/blast.rs | 235 +++++++++++--------- tests/cloud_commands_e2e/block_on_report.rs | 204 +++++++++++++++++ tests/cloud_commands_e2e/main.rs | 1 + 3 files changed, 329 insertions(+), 111 deletions(-) create mode 100644 tests/cloud_commands_e2e/block_on_report.rs diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index dd7f0a0..05997dd 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 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." @@ -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,114 @@ 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) => { + stop_spinner(); + 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) => { + stop_spinner(); + 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/block_on_report.rs b/tests/cloud_commands_e2e/block_on_report.rs new file mode 100644 index 0000000..61bbf30 --- /dev/null +++ b/tests/cloud_commands_e2e/block_on_report.rs @@ -0,0 +1,204 @@ +//! `--out-format`/`--out-file` and `--sbom` under a CI blocking-rule gate: a +//! pipeline that fails on policy is the one that needs the report, so a tripped +//! gate must not take the report file down with it. +//! +//! The stub's plan is ordered, so these tests are also what pins the report +//! ahead of the gate: reordering them back puts the report request after the +//! `check_blocking_rules` request and fails the plan. + +use crate::common::*; +use hyper::Method; +use serde_json::json; +use tempfile::TempDir; + +const PROJECT: &str = "cloud-e2e"; +const SCAN_ID: &str = "blast-scan-123"; + +/// `check_blocking_rules` answering "blocked", 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" + }) +} + +/// The SARIF report request the CLI makes for `--out-format sarif`. +fn sarif_report_request() -> ExpectedRequest { + expected_request( + "generate the SARIF report", + |request| { + assert_authenticated_request( + request, + Method::GET, + &format!("/api/v1/scan/{SCAN_ID}/report"), + )?; + assert_query(request, "format", "sarif") + }, + json_response(json!({"version": "2.1.0", "runs": []})), + ) +} + +/// A blocking-rules check that reports the scan as blocked. `block_on` is the +/// expected query value, or `None` for the rule-wide `--fail` form. +fn blocked_check_request(block_on: Option<&'static str>) -> ExpectedRequest { + expected_request( + "evaluate the blocking rules", + move |request| { + assert_authenticated_request( + request, + Method::GET, + &format!("/api/v1/scan/{SCAN_ID}/check_blocking_rules"), + )?; + match block_on { + Some(slugs) => assert_query(request, "block_on", slugs), + // --fail evaluates every active rule, so it must not narrow the + // check to slugs. + None => match query_value(request, "block_on") { + Ok(value) => Err(format!("unexpected block_on={value} for --fail")), + Err(_) => Ok(()), + }, + } + }, + json_response(blocked_response()), + ) +} + +#[test] +fn block_on_writes_the_report_and_sbom_before_failing_the_gate() { + let project = git_project(); + let out_dir = TempDir::new().expect("create output directory"); + let out_file = out_dir.path().join("results.sarif"); + let sbom_file = out_dir.path().join("bom.json"); + let mut plan = blast_upload_plan(&project.sha, false, false); + plan.push(sarif_report_request()); + plan.push(blocked_check_request(Some("criticals"))); + 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"), + "--sbom", + sbom_file.to_str().expect("UTF-8 SBOM 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 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}"); + let sbom = std::fs::read_to_string(&sbom_file) + .unwrap_or_else(|error| panic!("SBOM should exist despite the gate: {error}\n{context}")); + assert!(sbom.contains("bomFormat"), "SBOM body: {sbom}\n{context}"); +} + +/// `--fail` is deprecated but still supported, and it exits through the same +/// gate, so it must not drop the report either. +#[test] +fn fail_writes_the_report_before_failing_the_gate() { + let project = git_project(); + let out_dir = TempDir::new().expect("create output directory"); + let out_file = out_dir.path().join("results.sarif"); + let mut plan = blast_upload_plan(&project.sha, false, false); + plan.push(sarif_report_request()); + plan.push(blocked_check_request(None)); + let api = ApiStub::start(plan); + let (mut command, _home) = cloud_command(&api, project.path()); + command.args([ + "scan", + "blast", + "--fail", + "--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 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 gate that passes has always written the report; the reorder must not have +/// changed that, and the scan still exits 0. +#[test] +fn block_on_still_writes_the_report_when_the_gate_passes() { + let project = git_project(); + let out_dir = TempDir::new().expect("create output directory"); + let out_file = out_dir.path().join("results.sarif"); + let mut plan = blast_upload_plan(&project.sha, false, false); + plan.push(sarif_report_request()); + plan.push(expected_request( + "evaluate the blocking rules", + |request| { + assert_authenticated_request( + request, + Method::GET, + &format!("/api/v1/scan/{SCAN_ID}/check_blocking_rules"), + ) + }, + json_response(json!({ + "block": false, + "blocking_issues": [], + "total_pages": 1, + "stats": {"blocked_issues": 0}, + "status": "complete" + })), + )); + 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(0), "{context}"); + let stdout = String::from_utf8_lossy(&output.stdout); + // The slug itself is color-coded, so the literal stops at the colon. + assert!( + stdout.contains("No issues violated the blocking rule(s):"), + "{context}" + ); + let report = std::fs::read_to_string(&out_file) + .unwrap_or_else(|error| panic!("report should exist: {error}\n{context}")); + assert!(report.contains("2.1.0"), "{context}"); +} diff --git a/tests/cloud_commands_e2e/main.rs b/tests/cloud_commands_e2e/main.rs index eb2352d..5b6a98e 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 block_on_report; mod common; mod inspect; mod scan_list; From 92fdeb8b0270e464945403abe719af2a084a1e03 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 09:45:48 +0000 Subject: [PATCH 2/2] Document the report guarantee and bump to 1.10.1 A report that was silently dropped when a gate tripped is a bug fix, not a new flag, so SemVer puts this at a patch 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 | 4 ++++ skills/corgea/SKILL.md | 5 +++++ 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1b96394..1912510 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -369,7 +369,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "corgea" -version = "1.10.0" +version = "1.10.1" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 86d0c45..6edcb0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "corgea" -version = "1.10.0" +version = "1.10.1" 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..ac0149f 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,10 @@ 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 a 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. + ## 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..1d6fac4 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. @@ -374,6 +376,9 @@ 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. + ### Upload third-party reports ```bash