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 40789b7..4c98462 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. @@ -359,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 @@ -371,22 +408,29 @@ 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 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 +443,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 +661,149 @@ 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 dd7f0a0..bf8a623 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. @@ -643,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 { @@ -659,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 new file mode 100644 index 0000000..ca2dbd7 --- /dev/null +++ b/tests/cloud_commands_e2e/blocking_verdict.rs @@ -0,0 +1,328 @@ +//! 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, StatusCode}; +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" + }) +} + +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. +#[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}"); +} + +/// 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}" + ); +} 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; 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