From 8cdc94ea51f9c36740abee5c5415d194523387e8 Mon Sep 17 00:00:00 2001 From: Ibrahim Rahhal Date: Tue, 4 Aug 2026 14:37:45 +0300 Subject: [PATCH 1/6] Add --block-on to select which blocking rules gate a CI scan --fail evaluated every active blocking rule, so a pipeline could not opt into a specific gate and any rule meant for CI also blocked pull requests. --block-on takes a comma-separated list of rule slugs and fails the scan only on those. It is blast-only and mutually exclusive with --fail and --fail-on. Unknown, inactive, and PR-scoped slugs are reported by name and exit 1 rather than passing silently, so a typo cannot quietly disable the gate. --fail still works but now warns that it is deprecated. Co-authored-by: Cursor --- skills/corgea/SKILL.md | 11 ++- src/list.rs | 1 + src/main.rs | 29 ++++++- src/scanners/blast.rs | 189 ++++++++++++++++++++++++++++++++++++++++- src/utils/api.rs | 70 ++++++++++++++- 5 files changed, 294 insertions(+), 6 deletions(-) diff --git a/skills/corgea/SKILL.md b/skills/corgea/SKILL.md index df32d48..e103972 100644 --- a/skills/corgea/SKILL.md +++ b/skills/corgea/SKILL.md @@ -35,7 +35,9 @@ corgea scan --scan-type policy --policy 1 # Specific policy ID corgea scan --fail-on CR # Exit 1 on critical issues (CR, HI, ME, LO) corgea scan --fail-on malicious # Exit 1 if any dependency is classified malicious corgea scan --fail-on HI,malicious # Comma-separated conditions combine -corgea scan --fail # Exit 1 based on project blocking rules +corgea scan --block-on no-criticals # Exit 1 if the scan violates the named CI blocking rules +corgea scan --block-on no-criticals,no-malicious-deps # Comma-separated rule slugs +corgea scan --fail # Deprecated: exit 1 based on every active blocking rule corgea scan --out-format json --out-file r.json # Export (json, html, sarif, markdown) corgea scan --sbom # Also write a CycloneDX SBOM to bom.json corgea scan --sbom sbom.cdx.json # SBOM to a custom file @@ -46,7 +48,11 @@ Scan types: `blast` (base AI), `policy` (PolicyIQ), `malicious`, `secrets`, `pii `--fail-on` takes comma-separated conditions: severity thresholds `CR`, `HI`, `ME`, `LO` (trip at or above the level) and/or `malicious` (trips when any dependency in the scan is classified malicious). Use `malicious` to block supply-chain findings in CI across every ecosystem the scan covers, including those the `corgea npm`/`corgea pip` install gate does not. -`--only-uncommitted` and `--target` are mutually exclusive. `--fail-on` and `--fail` are mutually exclusive. +`--block-on` takes comma-separated blocking-rule slugs. Blocking rules are configured in the web app and each one applies either to pull requests or to CI. Only CI rules can be named by `--block-on`; a slug that is unknown, inactive, or scoped to pull requests is a hard error (exit 1) rather than a silently skipped gate. The slug is shown next to each rule in the web app and is derived from the rule name, so renaming a rule changes its slug. + +`--fail` is deprecated. It evaluates every active blocking rule regardless of what it applies to; use `--block-on` to name the CI rules a pipeline should enforce. + +`--only-uncommitted` and `--target` are mutually exclusive. `--fail-on`, `--fail`, and `--block-on` are mutually exclusive. ### Upload — `corgea upload [report]` @@ -356,6 +362,7 @@ corgea inspect --issue --diff ISSUE_ID ```bash corgea scan --fail-on CR --out-format sarif --out-file results.sarif corgea scan --fail-on CR,malicious --out-format sarif --out-file results.sarif # also block malicious dependencies +corgea scan --block-on no-criticals --out-format sarif --out-file results.sarif # gate on a CI blocking rule from the web app ``` ### Upload third-party reports diff --git a/src/list.rs b/src/list.rs index f0b66c0..52ec977 100644 --- a/src/list.rs +++ b/src/list.rs @@ -147,6 +147,7 @@ pub fn run( &config.get_url(), scan_id.as_ref().unwrap(), Some(page), + None, ) { Ok(rules) => { if rules.block { diff --git a/src/main.rs b/src/main.rs index d18ab82..15959d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -97,10 +97,17 @@ enum Commands { #[arg( short, long, - help = "Fail on (exits with error code 1) based on blocking rules defined in the web app." + help = "Deprecated: use --block-on instead. Fail on (exits with error code 1) based on every active blocking rule defined in the web app, regardless of what it applies to." )] fail: bool, + #[arg( + long = "block-on", + value_name = "SLUG", + help = "Fail (exit code 1) if the scan violates the named CI blocking rules. Comma-separated rule slugs, e.g. --block-on no-criticals,no-malicious-deps. Slugs are shown next to each rule in the web app. Rules must exist, be active, and have 'Applies To' set to CI." + )] + block_on: Option, + #[arg( short, long, @@ -570,6 +577,7 @@ fn main() { scanner, fail_on, fail, + block_on, only_uncommitted, metadata, scan_type, @@ -598,6 +606,11 @@ fn main() { std::process::exit(1); } + if block_on.is_some() && *scanner != Scanner::Blast { + ::log::error!("block-on is only supported with blast scanner."); + std::process::exit(1); + } + if *only_uncommitted && *scanner != Scanner::Blast { ::log::error!("only_uncommitted is only supported with blast scanner."); std::process::exit(1); @@ -645,6 +658,19 @@ fn main() { std::process::exit(1); } + if block_on.is_some() && (*fail || fail_on.is_some()) { + ::log::error!("block-on cannot be used together with fail or fail_on."); + 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); + } + }; + if let Some(scan_type) = scan_type { if scan_type.is_empty() { ::log::error!("scan_type cannot be empty."); @@ -692,6 +718,7 @@ fn main() { &corgea_config, fail_on.clone(), fail, + block_on, only_uncommitted, metadata_json, scan_type.clone(), diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index 193d408..dbe1e16 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -15,6 +15,7 @@ pub fn run( config: &Config, fail_on: Option, fail: &bool, + block_on: Option, only_uncommitted: &bool, metadata: Option, scan_type: Option, @@ -304,8 +305,11 @@ pub fn run( } }; if *fail { + log::warn!( + "\n--fail is deprecated: it evaluates every active blocking rule regardless of whether it applies to pull requests or CI. Use --block-on to name the CI blocking rules this pipeline should enforce." + ); let blocking_rules = - match utils::api::check_blocking_rules(&config.get_url(), &scan_id, None) { + match utils::api::check_blocking_rules(&config.get_url(), &scan_id, None, None) { Ok(rules) => rules, Err(e) => { log::error!("Failed to check blocking rules: {}", e); @@ -324,6 +328,39 @@ pub fn run( } } + if let Some(block_on) = &block_on { + let blocking_rules = match utils::api::check_blocking_rules( + &config.get_url(), + &scan_id, + None, + Some(block_on), + ) { + Ok(rules) => rules, + Err(e) => { + log::error!("{}", e); + std::process::exit(1); + } + }; + if blocking_rules.block { + let triggered = triggered_slug_summary(&blocking_rules); + println!( + "\nExiting with error code 1: {} issue(s) violated the blocking rule(s) {}.\nFor more details, check the scan results at: {}\nAlternatively, run {} to view the issues list on your local machine.", + blocking_rules.blocking_issues.len(), + utils::terminal::set_text_color(&triggered, utils::terminal::TerminalColor::Red), + utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Green), + utils::terminal::set_text_color( + &format!("corgea ls -i -s={}", scan_id), + utils::terminal::TerminalColor::Green + ) + ); + std::process::exit(1); + } + println!( + "\nNo issues violated the blocking rule(s): {}.", + utils::terminal::set_text_color(block_on, utils::terminal::TerminalColor::Green) + ); + } + if let Some(out_file) = out_file { if let Some(out_format) = out_format { let stop_signal = Arc::new(Mutex::new(false)); @@ -556,6 +593,55 @@ pub fn fail_on_gate_trips( }) } +/// Trim and de-duplicate the comma-separated `--block-on` slugs. +/// +/// Returns the canonical comma-joined value to send to the API, or `None` +/// when the flag was not supplied. Empty entries are rejected here so a stray +/// comma is reported locally rather than as an opaque server error. +pub fn normalize_block_on(block_on: Option<&str>) -> Result, String> { + let Some(raw) = block_on else { + return Ok(None); + }; + + let mut slugs: Vec<&str> = Vec::new(); + for part in raw.split(',') { + let slug = part.trim(); + if slug.is_empty() { + return Err( + "block-on contains an empty rule slug. Expected a comma-separated list of rule slugs, e.g. --block-on no-criticals,no-malicious-deps." + .to_string(), + ); + } + if !slugs.contains(&slug) { + slugs.push(slug); + } + } + + if slugs.is_empty() { + return Err("block-on cannot be empty.".to_string()); + } + Ok(Some(slugs.join(","))) +} + +/// The distinct rule slugs that blocked the scan, for the failure message. +/// +/// Falls back to rule ids against backends that do not send slugs yet. +pub fn triggered_slug_summary(response: &utils::api::BlockingRuleResponse) -> String { + let mut names: Vec = Vec::new(); + for issue in &response.blocking_issues { + let identifiers = match &issue.triggered_by_slugs { + Some(slugs) if !slugs.is_empty() => slugs.clone(), + _ => issue.triggered_by_rules.clone(), + }; + for identifier in identifiers { + if !names.contains(&identifier) { + names.push(identifier); + } + } + } + names.join(", ") +} + pub fn wait_for_scan(config: &Config, scan_id: &str) { // Create loading animation let stop_signal = Arc::new(Mutex::new(false)); @@ -700,7 +786,9 @@ pub fn metadata_json_from_pairs(pairs: &[String]) -> Result, Stri #[cfg(test)] mod tests { use super::*; - use crate::utils::api::{SCAIssue, SCALocation, SCAPackage}; + use crate::utils::api::{ + BlockOnError, BlockingIssue, BlockingRuleResponse, SCAIssue, SCALocation, SCAPackage, + }; fn counts(pairs: &[(&str, usize)]) -> HashMap { pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect() @@ -865,4 +953,101 @@ mod tests { assert_eq!(map.get("k").and_then(|v| v.as_str()), Some("")); assert!(metadata_json_from_pairs(&[]).unwrap().is_none()); } + + #[test] + fn normalize_block_on_returns_none_when_flag_absent() { + assert_eq!(normalize_block_on(None).unwrap(), None); + } + + #[test] + fn normalize_block_on_trims_and_dedupes_slugs() { + assert_eq!( + normalize_block_on(Some("no-criticals")).unwrap(), + Some("no-criticals".to_string()) + ); + assert_eq!( + normalize_block_on(Some(" no-criticals , no-malicious-deps ")).unwrap(), + Some("no-criticals,no-malicious-deps".to_string()) + ); + assert_eq!( + normalize_block_on(Some("no-criticals,no-criticals")).unwrap(), + Some("no-criticals".to_string()) + ); + } + + #[test] + fn normalize_block_on_rejects_empty_entries() { + assert!(normalize_block_on(Some("")).is_err()); + assert!(normalize_block_on(Some(" ")).is_err()); + assert!(normalize_block_on(Some(",")).is_err()); + assert!(normalize_block_on(Some("no-criticals,")).is_err()); + assert!(normalize_block_on(Some("no-criticals,,other")).is_err()); + } + + fn blocking_response(issues: Vec) -> BlockingRuleResponse { + BlockingRuleResponse { + block: !issues.is_empty(), + blocking_issues: issues, + total_pages: 1, + } + } + + #[test] + fn triggered_slug_summary_dedupes_across_issues() { + let response = blocking_response(vec![ + BlockingIssue { + id: "issue-1".to_string(), + triggered_by_rules: vec!["1".to_string()], + triggered_by_slugs: Some(vec!["no-criticals".to_string()]), + }, + BlockingIssue { + id: "issue-2".to_string(), + triggered_by_rules: vec!["1".to_string(), "2".to_string()], + triggered_by_slugs: Some(vec![ + "no-criticals".to_string(), + "no-malicious-deps".to_string(), + ]), + }, + ]); + assert_eq!( + triggered_slug_summary(&response), + "no-criticals, no-malicious-deps" + ); + } + + #[test] + fn triggered_slug_summary_falls_back_to_rule_ids() { + let response = blocking_response(vec![BlockingIssue { + id: "issue-1".to_string(), + triggered_by_rules: vec!["7".to_string()], + triggered_by_slugs: None, + }]); + assert_eq!(triggered_slug_summary(&response), "7"); + } + + #[test] + fn block_on_error_names_each_failure_category() { + let error = BlockOnError { + message: Some("Invalid block_on rule(s).".to_string()), + unknown_slugs: vec!["typo-rule".to_string()], + inactive_slugs: vec!["old-rule".to_string()], + non_ci_slugs: vec!["pr-only-rule".to_string()], + }; + let described = error.describe(); + assert!(described.contains("Unknown blocking rule(s): typo-rule")); + assert!(described.contains("Rule(s) not scoped to CI: pr-only-rule")); + assert!(described.contains("Inactive rule(s): old-rule")); + } + + #[test] + fn block_on_error_falls_back_to_the_server_message() { + let error = BlockOnError { + message: Some("block_on was provided but contained no rule slugs.".to_string()), + ..Default::default() + }; + assert_eq!( + error.describe(), + "block_on was provided but contained no rule slugs." + ); + } } diff --git a/src/utils/api.rs b/src/utils/api.rs index 2356b6a..b871f84 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -792,17 +792,26 @@ pub fn verify_token(corgea_url: &str) -> Result> { } } +/// Evaluate a scan against blocking rules. +/// +/// `block_on` is a comma-separated list of CI rule slugs. When omitted the +/// backend falls back to evaluating every active rule, which is the legacy +/// `--fail` behavior. pub fn check_blocking_rules( url: &str, sast_scan_id: &str, page: Option, + block_on: Option<&str>, ) -> Result> { let url = format!( "{}{}/scan/{}/check_blocking_rules", url, API_BASE, sast_scan_id ); let page = page.unwrap_or(1); - let query_params = vec![("page", page.to_string())]; + let mut query_params = vec![("page", page.to_string())]; + if let Some(block_on) = block_on { + query_params.push(("block_on", block_on.to_string())); + } let client = http_client(); debug(&format!("Sending request to URL: {}", url)); @@ -833,6 +842,11 @@ pub fn check_blocking_rules( let status = response.status(); let response_text = response.text()?; debug(&format!("Response body: {}", response_text)); + if status == reqwest::StatusCode::BAD_REQUEST { + if let Ok(block_on_error) = serde_json::from_str::(&response_text) { + return Err(block_on_error.describe().into()); + } + } Err(format!("API request failed with status: {}", status).into()) } } @@ -1078,6 +1092,60 @@ pub struct BlockingRuleResponse { pub struct BlockingIssue { pub id: String, pub triggered_by_rules: Vec, + // Optional so the CLI keeps working against backends predating rule slugs. + #[serde(default)] + pub triggered_by_slugs: Option>, +} + +/// Structured 400 body returned when `--block-on` names unusable rules. +#[derive(Deserialize, Debug, Clone, Default)] +pub struct BlockOnError { + #[serde(default)] + pub message: Option, + #[serde(default)] + pub unknown_slugs: Vec, + #[serde(default)] + pub inactive_slugs: Vec, + #[serde(default)] + pub non_ci_slugs: Vec, +} + +impl BlockOnError { + fn is_empty(&self) -> bool { + self.unknown_slugs.is_empty() + && self.inactive_slugs.is_empty() + && self.non_ci_slugs.is_empty() + } + + /// One line per failure category, naming the offending slugs. + pub fn describe(&self) -> String { + if self.is_empty() { + return self + .message + .clone() + .unwrap_or_else(|| "Invalid --block-on value.".to_string()); + } + let mut lines = Vec::new(); + if !self.unknown_slugs.is_empty() { + lines.push(format!( + "Unknown blocking rule(s): {}", + self.unknown_slugs.join(", ") + )); + } + if !self.non_ci_slugs.is_empty() { + lines.push(format!( + "Rule(s) not scoped to CI: {}. Change 'Applies To' to CI in the web app, or remove them from --block-on.", + self.non_ci_slugs.join(", ") + )); + } + if !self.inactive_slugs.is_empty() { + lines.push(format!( + "Inactive rule(s): {}. Activate them in the web app, or remove them from --block-on.", + self.inactive_slugs.join(", ") + )); + } + lines.join("\n") + } } #[derive(Deserialize, Serialize, Debug)] From c8f6ddb5d8a6e0f14892ba859700cfd234e44f40 Mon Sep 17 00:00:00 2001 From: Ibrahim Rahhal Date: Wed, 5 Aug 2026 17:47:46 +0300 Subject: [PATCH 2/6] Aggregate every page of blocking issues before reporting the gate failure The --block-on message printed blocking_issues.len() and the triggered rule slugs from page 1 only, but the endpoint pages at 20 issues. A blocked scan with more than 20 issues reported "20 issue(s)" whatever the real total, and a rule that only tripped on a later page was left out of the list of rules blamed for the failure. The exit code was right; the explanation was not. collect_blocking_issues walks pages 2..=total_pages and dedupes by issue id, following the loop corgea ls already uses against the same endpoint. A page that fails to load is warned about and skipped rather than aborting, so a transient pagination error cannot downgrade a blocked scan to a pass. Co-authored-by: Cursor --- src/scanners/blast.rs | 130 ++++++++++++++++++++++++++++++------------ 1 file changed, 95 insertions(+), 35 deletions(-) diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index dbe1e16..e8382f0 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -2,7 +2,7 @@ use crate::config::Config; use crate::targets; use crate::utils; use crate::utils::api::SCAIssue; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::env; use std::error::Error; use std::fs; @@ -342,10 +342,11 @@ pub fn run( } }; if blocking_rules.block { - let triggered = triggered_slug_summary(&blocking_rules); + let issues = collect_blocking_issues(config, &scan_id, block_on, blocking_rules); + let triggered = triggered_slug_summary(&issues); println!( "\nExiting with error code 1: {} issue(s) violated the blocking rule(s) {}.\nFor more details, check the scan results at: {}\nAlternatively, run {} to view the issues list on your local machine.", - blocking_rules.blocking_issues.len(), + issues.len(), utils::terminal::set_text_color(&triggered, utils::terminal::TerminalColor::Red), utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Green), utils::terminal::set_text_color( @@ -623,12 +624,63 @@ pub fn normalize_block_on(block_on: Option<&str>) -> Result, Stri Ok(Some(slugs.join(","))) } +/// Every distinct blocking issue across all pages of the response. +/// +/// The endpoint pages at 20 issues by default, so page 1 alone under-reports +/// the count and can omit a rule that only trips on a later page. `first` is +/// the already-fetched page 1. +/// +/// A later page that fails to load is warned about and skipped rather than +/// aborting: the scan is already known to be blocked, so turning a transient +/// pagination failure into a lost exit code would be worse than an incomplete +/// summary. +fn collect_blocking_issues( + config: &Config, + scan_id: &str, + block_on: &str, + first: utils::api::BlockingRuleResponse, +) -> Vec { + let total_pages = first.total_pages; + let mut seen: HashSet = HashSet::new(); + let mut issues: Vec = Vec::new(); + let mut push_unique = |issues: &mut Vec, + incoming: Vec| { + for issue in incoming { + if seen.insert(issue.id.clone()) { + issues.push(issue); + } + } + }; + + push_unique(&mut issues, first.blocking_issues); + for page in 2..=total_pages { + match utils::api::check_blocking_rules( + &config.get_url(), + scan_id, + Some(page), + Some(block_on), + ) { + Ok(rules) => push_unique(&mut issues, rules.blocking_issues), + Err(e) => { + log::warn!( + "Could not load page {} of {} of the blocking issues, so the summary below may be incomplete: {}", + page, + total_pages, + e + ); + break; + } + } + } + issues +} + /// The distinct rule slugs that blocked the scan, for the failure message. /// /// Falls back to rule ids against backends that do not send slugs yet. -pub fn triggered_slug_summary(response: &utils::api::BlockingRuleResponse) -> String { +pub fn triggered_slug_summary(issues: &[utils::api::BlockingIssue]) -> String { let mut names: Vec = Vec::new(); - for issue in &response.blocking_issues { + for issue in issues { let identifiers = match &issue.triggered_by_slugs { Some(slugs) if !slugs.is_empty() => slugs.clone(), _ => issue.triggered_by_rules.clone(), @@ -786,9 +838,7 @@ pub fn metadata_json_from_pairs(pairs: &[String]) -> Result, Stri #[cfg(test)] mod tests { use super::*; - use crate::utils::api::{ - BlockOnError, BlockingIssue, BlockingRuleResponse, SCAIssue, SCALocation, SCAPackage, - }; + use crate::utils::api::{BlockOnError, BlockingIssue, SCAIssue, SCALocation, SCAPackage}; fn counts(pairs: &[(&str, usize)]) -> HashMap { pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect() @@ -984,45 +1034,55 @@ mod tests { assert!(normalize_block_on(Some("no-criticals,,other")).is_err()); } - fn blocking_response(issues: Vec) -> BlockingRuleResponse { - BlockingRuleResponse { - block: !issues.is_empty(), - blocking_issues: issues, - total_pages: 1, + fn issue(id: &str, rules: &[&str], slugs: Option<&[&str]>) -> BlockingIssue { + BlockingIssue { + id: id.to_string(), + triggered_by_rules: rules.iter().map(|r| r.to_string()).collect(), + triggered_by_slugs: slugs.map(|s| s.iter().map(|s| s.to_string()).collect()), } } #[test] fn triggered_slug_summary_dedupes_across_issues() { - let response = blocking_response(vec![ - BlockingIssue { - id: "issue-1".to_string(), - triggered_by_rules: vec!["1".to_string()], - triggered_by_slugs: Some(vec!["no-criticals".to_string()]), - }, - BlockingIssue { - id: "issue-2".to_string(), - triggered_by_rules: vec!["1".to_string(), "2".to_string()], - triggered_by_slugs: Some(vec![ - "no-criticals".to_string(), - "no-malicious-deps".to_string(), - ]), - }, - ]); + let issues = vec![ + issue("issue-1", &["1"], Some(&["no-criticals"])), + issue( + "issue-2", + &["1", "2"], + Some(&["no-criticals", "no-malicious-deps"]), + ), + ]; assert_eq!( - triggered_slug_summary(&response), + triggered_slug_summary(&issues), "no-criticals, no-malicious-deps" ); } #[test] fn triggered_slug_summary_falls_back_to_rule_ids() { - let response = blocking_response(vec![BlockingIssue { - id: "issue-1".to_string(), - triggered_by_rules: vec!["7".to_string()], - triggered_by_slugs: None, - }]); - assert_eq!(triggered_slug_summary(&response), "7"); + let issues = vec![issue("issue-1", &["7"], None)]; + assert_eq!(triggered_slug_summary(&issues), "7"); + } + + #[test] + fn triggered_slug_summary_is_empty_without_issues() { + assert_eq!(triggered_slug_summary(&[]), ""); + } + + /// A rule that only trips on a later page must still be named, which is the + /// whole reason the gate aggregates pages before building its message. + #[test] + fn triggered_slug_summary_names_rules_from_every_page() { + let aggregated = vec![ + issue("issue-1", &["1"], Some(&["no-criticals"])), + issue("issue-2", &["2"], Some(&["no-malicious-deps"])), + ]; + assert_eq!( + triggered_slug_summary(&aggregated), + "no-criticals, no-malicious-deps" + ); + // Page 1 in isolation would have blamed only the first rule. + assert_eq!(triggered_slug_summary(&aggregated[..1]), "no-criticals"); } #[test] From 3988e4493418f2595303fb05b372e72b514a80f6 Mon Sep 17 00:00:00 2001 From: Ibrahim Rahhal Date: Wed, 5 Aug 2026 18:00:21 +0300 Subject: [PATCH 3/6] Use direct rule-slug examples instead of double negatives "--block-on no-criticals" reads as "block when there are no criticals", the opposite of what it does. Name the example rules for the condition that trips the gate: --block-on criticals,malicious-deps. Applied to the --help text and the empty-slug error alongside the skill docs, since all three carried the same examples, and recorded the naming guidance next to the note that slugs derive from the rule name. Co-authored-by: Cursor --- skills/corgea/SKILL.md | 8 ++++---- src/main.rs | 2 +- src/scanners/blast.rs | 35 ++++++++++++++++------------------- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/skills/corgea/SKILL.md b/skills/corgea/SKILL.md index 4eab2b3..bbf8d9e 100644 --- a/skills/corgea/SKILL.md +++ b/skills/corgea/SKILL.md @@ -35,8 +35,8 @@ corgea scan --scan-type policy --policy 1 # Specific policy ID corgea scan --fail-on CR # Exit 1 on critical issues (CR, HI, ME, LO) corgea scan --fail-on malicious # Exit 1 if any dependency is classified malicious corgea scan --fail-on HI,malicious # Comma-separated conditions combine -corgea scan --block-on no-criticals # Exit 1 if the scan violates the named CI blocking rules -corgea scan --block-on no-criticals,no-malicious-deps # Comma-separated rule slugs +corgea scan --block-on criticals # Exit 1 if the scan violates the named CI blocking rules +corgea scan --block-on criticals,malicious-deps # Comma-separated rule slugs corgea scan --fail # Deprecated: exit 1 based on every active blocking rule corgea scan --out-format json --out-file r.json # Export (json, html, sarif, markdown) corgea scan --sbom # Also write a CycloneDX SBOM to bom.json @@ -48,7 +48,7 @@ Scan types: `blast` (base AI), `policy` (PolicyIQ), `malicious`, `secrets`, `pii `--fail-on` takes comma-separated conditions: severity thresholds `CR`, `HI`, `ME`, `LO` (trip at or above the level) and/or `malicious` (trips when any dependency in the scan is classified malicious). Use `malicious` to block supply-chain findings in CI across every ecosystem the scan covers, including those the `corgea npm`/`corgea pip` install gate does not. -`--block-on` takes comma-separated blocking-rule slugs. Blocking rules are configured in the web app and each one applies either to pull requests or to CI. Only CI rules can be named by `--block-on`; a slug that is unknown, inactive, or scoped to pull requests is a hard error (exit 1) rather than a silently skipped gate. The slug is shown next to each rule in the web app and is derived from the rule name, so renaming a rule changes its slug. +`--block-on` takes comma-separated blocking-rule slugs. Blocking rules are configured in the web app and each one applies either to pull requests or to CI. Only CI rules can be named by `--block-on`; a slug that is unknown, inactive, or scoped to pull requests is a hard error (exit 1) rather than a silently skipped gate. The slug is shown next to each rule in the web app and is derived from the rule name, so renaming a rule changes its slug. Name rules for the condition that trips them — `criticals`, not `no-criticals` — so that `--block-on` reads as a direct assertion rather than a double negative. `--fail` is deprecated. It evaluates every active blocking rule regardless of what it applies to; use `--block-on` to name the CI rules a pipeline should enforce. @@ -364,7 +364,7 @@ corgea inspect --issue --diff ISSUE_ID ```bash corgea scan --fail-on CR --out-format sarif --out-file results.sarif corgea scan --fail-on CR,malicious --out-format sarif --out-file results.sarif # also block malicious dependencies -corgea scan --block-on no-criticals --out-format sarif --out-file results.sarif # gate on a CI blocking rule from the web app +corgea scan --block-on criticals --out-format sarif --out-file results.sarif # gate on a CI blocking rule from the web app ``` ### Upload third-party reports diff --git a/src/main.rs b/src/main.rs index b77fe55..57cac78 100644 --- a/src/main.rs +++ b/src/main.rs @@ -104,7 +104,7 @@ enum Commands { #[arg( long = "block-on", value_name = "SLUG", - help = "Fail (exit code 1) if the scan violates the named CI blocking rules. Comma-separated rule slugs, e.g. --block-on no-criticals,no-malicious-deps. Slugs are shown next to each rule in the web app. Rules must exist, be active, and have 'Applies To' set to CI." + help = "Fail (exit code 1) if the scan violates the named CI blocking rules. Comma-separated rule slugs, e.g. --block-on criticals,malicious-deps. Slugs are shown next to each rule in the web app. Rules must exist, be active, and have 'Applies To' set to CI." )] block_on: Option, diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index e8382f0..b1b03c1 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -609,7 +609,7 @@ pub fn normalize_block_on(block_on: Option<&str>) -> Result, Stri let slug = part.trim(); if slug.is_empty() { return Err( - "block-on contains an empty rule slug. Expected a comma-separated list of rule slugs, e.g. --block-on no-criticals,no-malicious-deps." + "block-on contains an empty rule slug. Expected a comma-separated list of rule slugs, e.g. --block-on criticals,malicious-deps." .to_string(), ); } @@ -1012,16 +1012,16 @@ mod tests { #[test] fn normalize_block_on_trims_and_dedupes_slugs() { assert_eq!( - normalize_block_on(Some("no-criticals")).unwrap(), - Some("no-criticals".to_string()) + normalize_block_on(Some("criticals")).unwrap(), + Some("criticals".to_string()) ); assert_eq!( - normalize_block_on(Some(" no-criticals , no-malicious-deps ")).unwrap(), - Some("no-criticals,no-malicious-deps".to_string()) + normalize_block_on(Some(" criticals , malicious-deps ")).unwrap(), + Some("criticals,malicious-deps".to_string()) ); assert_eq!( - normalize_block_on(Some("no-criticals,no-criticals")).unwrap(), - Some("no-criticals".to_string()) + normalize_block_on(Some("criticals,criticals")).unwrap(), + Some("criticals".to_string()) ); } @@ -1030,8 +1030,8 @@ mod tests { assert!(normalize_block_on(Some("")).is_err()); assert!(normalize_block_on(Some(" ")).is_err()); assert!(normalize_block_on(Some(",")).is_err()); - assert!(normalize_block_on(Some("no-criticals,")).is_err()); - assert!(normalize_block_on(Some("no-criticals,,other")).is_err()); + assert!(normalize_block_on(Some("criticals,")).is_err()); + assert!(normalize_block_on(Some("criticals,,other")).is_err()); } fn issue(id: &str, rules: &[&str], slugs: Option<&[&str]>) -> BlockingIssue { @@ -1045,17 +1045,14 @@ mod tests { #[test] fn triggered_slug_summary_dedupes_across_issues() { let issues = vec![ - issue("issue-1", &["1"], Some(&["no-criticals"])), + issue("issue-1", &["1"], Some(&["criticals"])), issue( "issue-2", &["1", "2"], - Some(&["no-criticals", "no-malicious-deps"]), + Some(&["criticals", "malicious-deps"]), ), ]; - assert_eq!( - triggered_slug_summary(&issues), - "no-criticals, no-malicious-deps" - ); + assert_eq!(triggered_slug_summary(&issues), "criticals, malicious-deps"); } #[test] @@ -1074,15 +1071,15 @@ mod tests { #[test] fn triggered_slug_summary_names_rules_from_every_page() { let aggregated = vec![ - issue("issue-1", &["1"], Some(&["no-criticals"])), - issue("issue-2", &["2"], Some(&["no-malicious-deps"])), + issue("issue-1", &["1"], Some(&["criticals"])), + issue("issue-2", &["2"], Some(&["malicious-deps"])), ]; assert_eq!( triggered_slug_summary(&aggregated), - "no-criticals, no-malicious-deps" + "criticals, malicious-deps" ); // Page 1 in isolation would have blamed only the first rule. - assert_eq!(triggered_slug_summary(&aggregated[..1]), "no-criticals"); + assert_eq!(triggered_slug_summary(&aggregated[..1]), "criticals"); } #[test] From 60a4a2ace92af7c775ff735f06ce1f96c13af21d Mon Sep 17 00:00:00 2001 From: Ibrahim Rahhal Date: Wed, 5 Aug 2026 18:03:07 +0300 Subject: [PATCH 4/6] Bump version to 1.10.0 for the --block-on flag --block-on is a new backward-compatible flag and --fail is deprecated rather than removed, 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, so only the manifest and lockfile change. Co-authored-by: Cursor --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e35738f..1b96394 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -369,7 +369,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "corgea" -version = "1.9.3" +version = "1.10.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 2543dc4..86d0c45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "corgea" -version = "1.9.3" +version = "1.10.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html From 72f683b2c05ea60121bb03806e725339bace5722 Mon Sep 17 00:00:00 2001 From: Ibrahim Rahhal Date: Wed, 5 Aug 2026 18:46:18 +0300 Subject: [PATCH 5/6] Report the blocked-issue count from the server total, not extra page fetches The endpoint already returns the pre-pagination total in stats.blocked_issues, which the CLI simply was not deserializing. Reading it gives an exact count from the single request the gate already makes, so walking pages 2..=total_pages was both unnecessary and unsound: each request re-paginates a list rebuilt from querysets with no ORDER BY, so pages from separate requests need not line up. The CLI never uses the issue records themselves, only their count and the rule slugs, so the returned page remains sufficient to name the rules at fault. Co-authored-by: Cursor --- src/scanners/blast.rs | 106 +++++++++++++++--------------------------- src/utils/api.rs | 24 ++++++++++ 2 files changed, 61 insertions(+), 69 deletions(-) diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index b1b03c1..9795e9e 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -2,7 +2,7 @@ use crate::config::Config; use crate::targets; use crate::utils; use crate::utils::api::SCAIssue; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::env; use std::error::Error; use std::fs; @@ -342,11 +342,13 @@ pub fn run( } }; if blocking_rules.block { - let issues = collect_blocking_issues(config, &scan_id, block_on, blocking_rules); - let triggered = triggered_slug_summary(&issues); + // The count comes from the server's pre-pagination total; the slug + // list is drawn from the returned page, which is all the gate needs + // to name the rules at fault. + let triggered = triggered_slug_summary(&blocking_rules.blocking_issues); println!( "\nExiting with error code 1: {} issue(s) violated the blocking rule(s) {}.\nFor more details, check the scan results at: {}\nAlternatively, run {} to view the issues list on your local machine.", - issues.len(), + blocking_rules.blocked_count(), utils::terminal::set_text_color(&triggered, utils::terminal::TerminalColor::Red), utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Green), utils::terminal::set_text_color( @@ -624,57 +626,6 @@ pub fn normalize_block_on(block_on: Option<&str>) -> Result, Stri Ok(Some(slugs.join(","))) } -/// Every distinct blocking issue across all pages of the response. -/// -/// The endpoint pages at 20 issues by default, so page 1 alone under-reports -/// the count and can omit a rule that only trips on a later page. `first` is -/// the already-fetched page 1. -/// -/// A later page that fails to load is warned about and skipped rather than -/// aborting: the scan is already known to be blocked, so turning a transient -/// pagination failure into a lost exit code would be worse than an incomplete -/// summary. -fn collect_blocking_issues( - config: &Config, - scan_id: &str, - block_on: &str, - first: utils::api::BlockingRuleResponse, -) -> Vec { - let total_pages = first.total_pages; - let mut seen: HashSet = HashSet::new(); - let mut issues: Vec = Vec::new(); - let mut push_unique = |issues: &mut Vec, - incoming: Vec| { - for issue in incoming { - if seen.insert(issue.id.clone()) { - issues.push(issue); - } - } - }; - - push_unique(&mut issues, first.blocking_issues); - for page in 2..=total_pages { - match utils::api::check_blocking_rules( - &config.get_url(), - scan_id, - Some(page), - Some(block_on), - ) { - Ok(rules) => push_unique(&mut issues, rules.blocking_issues), - Err(e) => { - log::warn!( - "Could not load page {} of {} of the blocking issues, so the summary below may be incomplete: {}", - page, - total_pages, - e - ); - break; - } - } - } - issues -} - /// The distinct rule slugs that blocked the scan, for the failure message. /// /// Falls back to rule ids against backends that do not send slugs yet. @@ -838,7 +789,10 @@ pub fn metadata_json_from_pairs(pairs: &[String]) -> Result, Stri #[cfg(test)] mod tests { use super::*; - use crate::utils::api::{BlockOnError, BlockingIssue, SCAIssue, SCALocation, SCAPackage}; + use crate::utils::api::{ + BlockOnError, BlockingIssue, BlockingRuleResponse, BlockingRuleStats, SCAIssue, + SCALocation, SCAPackage, + }; fn counts(pairs: &[(&str, usize)]) -> HashMap { pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect() @@ -1066,20 +1020,34 @@ mod tests { assert_eq!(triggered_slug_summary(&[]), ""); } - /// A rule that only trips on a later page must still be named, which is the - /// whole reason the gate aggregates pages before building its message. + /// The gate reports the server's pre-pagination total, not the page length, + /// so a blocked scan with more issues than fit on one page still reports the + /// real count from a single request. #[test] - fn triggered_slug_summary_names_rules_from_every_page() { - let aggregated = vec![ - issue("issue-1", &["1"], Some(&["criticals"])), - issue("issue-2", &["2"], Some(&["malicious-deps"])), - ]; - assert_eq!( - triggered_slug_summary(&aggregated), - "criticals, malicious-deps" - ); - // Page 1 in isolation would have blamed only the first rule. - assert_eq!(triggered_slug_summary(&aggregated[..1]), "criticals"); + fn blocked_count_prefers_the_server_total_over_the_page_length() { + let response = BlockingRuleResponse { + block: true, + blocking_issues: vec![issue("issue-1", &["1"], Some(&["criticals"]))], + total_pages: 7, + stats: Some(BlockingRuleStats { + blocked_issues: 133, + }), + }; + assert_eq!(response.blocked_count(), 133); + } + + #[test] + fn blocked_count_falls_back_to_the_page_length_without_stats() { + let response = BlockingRuleResponse { + block: true, + blocking_issues: vec![ + issue("issue-1", &["1"], Some(&["criticals"])), + issue("issue-2", &["2"], Some(&["malicious-deps"])), + ], + total_pages: 1, + stats: None, + }; + assert_eq!(response.blocked_count(), 2); } #[test] diff --git a/src/utils/api.rs b/src/utils/api.rs index d377c96..7a0846b 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -1488,6 +1488,30 @@ pub struct BlockingRuleResponse { pub block: bool, pub blocking_issues: Vec, pub total_pages: u32, + // Totals the server computes over the whole result set before paginating. + // Optional so the CLI keeps working against backends predating the field. + #[serde(default)] + pub stats: Option, +} + +#[derive(Deserialize, Debug, Clone, Default)] +pub struct BlockingRuleStats { + #[serde(default)] + pub blocked_issues: u32, +} + +impl BlockingRuleResponse { + /// How many issues violated the evaluated rules, across every page. + /// + /// The server counts this before paginating, so one request is enough. + /// `blocking_issues` only holds the requested page, so it is the fallback + /// for backends that do not send `stats` and can under-report. + pub fn blocked_count(&self) -> usize { + self.stats + .as_ref() + .map(|stats| stats.blocked_issues as usize) + .unwrap_or_else(|| self.blocking_issues.len()) + } } #[derive(Deserialize, Debug, Clone)] From 9d4ecc276365440e40844aec7d15a16002548910 Mon Sep 17 00:00:00 2001 From: Ibrahim Rahhal Date: Thu, 6 Aug 2026 10:38:53 +0300 Subject: [PATCH 6/6] Drain the request body in the test HTTP stub before answering The stub stopped reading at the header terminator, then replied with Connection: close while the client was still writing its body. Small bodies survived on socket buffering alone, but the streamed multipart uploads (POST /start-scan and the chunk PATCH) did not, so the client got a request-body error instead of a response. That is what made scan_sbom_unwritable_path_errors_cleanly fail in CI while passing locally: it asserts on the SBOM write error, and the scan died at upload instead. Coverage instrumentation slows the client enough to widen the window. read_http_request now follows Content-Length, or reads to the zero-length chunk for chunked bodies, and carries a read timeout so a client that never finishes cannot park the single-threaded stub. Adds unit tests for all three cases, since the e2e symptom only reproduces under CI timing. Co-authored-by: Cursor --- src/vuln_api_stub/mod.rs | 143 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 135 insertions(+), 8 deletions(-) diff --git a/src/vuln_api_stub/mod.rs b/src/vuln_api_stub/mod.rs index 2b554f0..88cf113 100644 --- a/src/vuln_api_stub/mod.rs +++ b/src/vuln_api_stub/mod.rs @@ -235,20 +235,65 @@ pub fn header_value(request: &str, name: &str) -> Option { .map(|(_, value)| value.trim().to_string()) } -/// Read one HTTP request's bytes (through the header terminator) off `stream`. +/// Read one HTTP request off `stream`: the headers, then whatever body they +/// declare. +/// +/// The body is drained even though no stub inspects it. Responses carry +/// `Connection: close`, so answering while the client is still writing its +/// body resets the connection, and the client reports a request-body error +/// instead of reading the reply. Small bodies survive on socket buffering +/// alone; the streamed multipart uploads (`POST /start-scan` and the chunk +/// `PATCH`) do not, which made the scan e2e tests fail intermittently. +/// +/// The read timeout keeps a client that never finishes its body from parking +/// the single-threaded stub thread forever; on expiry the request is served +/// with whatever arrived. pub fn read_http_request(stream: &mut std::net::TcpStream) -> Vec { + let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(10))); + let mut buf = Vec::with_capacity(4096); let mut chunk = [0u8; 1024]; - while let Ok(n) = stream.read(&mut chunk) { - if n == 0 { - break; + let mut searched = 0; + + let header_end = loop { + // Rescan only the seam plus what just arrived, so a multi-megabyte + // upload is not re-walked on every read. + if let Some(offset) = buf[searched..].windows(4).position(|w| w == b"\r\n\r\n") { + break searched + offset + 4; + } + searched = buf.len().saturating_sub(3); + match stream.read(&mut chunk) { + Ok(0) | Err(_) => return buf, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + } + }; + + let headers = String::from_utf8_lossy(&buf[..header_end]).into_owned(); + let chunked = header_value(&headers, "transfer-encoding") + .is_some_and(|value| value.to_ascii_lowercase().contains("chunked")); + // A chunked body ends with a zero-length chunk rather than at a known + // offset. Anything else is bounded by Content-Length, absent for bodyless + // requests and therefore zero. + let body_end = (!chunked).then(|| { + header_end + + header_value(&headers, "content-length") + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0) + }); + + loop { + let complete = match body_end { + Some(end) => buf.len() >= end, + None => buf[header_end..].ends_with(b"0\r\n\r\n"), + }; + if complete { + return buf; } - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; + match stream.read(&mut chunk) { + Ok(0) | Err(_) => return buf, + Ok(n) => buf.extend_from_slice(&chunk[..n]), } } - buf } #[allow(clippy::too_many_arguments)] @@ -381,6 +426,88 @@ mod tests { resp } + /// Accept one connection, hand it to `read_http_request`, and return what + /// that pulled off the wire while `send` writes the request. + fn read_one_request(send: impl FnOnce(&mut TcpStream) + Send + 'static) -> Vec { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().unwrap(); + let client = thread::spawn(move || { + let mut stream = TcpStream::connect(addr).expect("connect"); + send(&mut stream); + }); + let (mut stream, _) = listener.accept().expect("accept"); + let buf = read_http_request(&mut stream); + client.join().expect("client thread"); + buf + } + + /// A body that lands after the headers is still drained. Stopping at the + /// header terminator would let the stub answer and close mid-upload, + /// which the client sees as a request-body error rather than a response. + #[test] + fn reads_content_length_body_arriving_after_the_headers() { + let body = "x".repeat(5000); + let expected = body.clone(); + let buf = read_one_request(move |stream| { + let headers = format!( + "POST /upload HTTP/1.1\r\nContent-Length: {}\r\n\r\n", + body.len() + ); + stream.write_all(headers.as_bytes()).unwrap(); + thread::sleep(std::time::Duration::from_millis(50)); + stream.write_all(body.as_bytes()).unwrap(); + }); + + let request = String::from_utf8_lossy(&buf); + assert!( + request.ends_with(&expected), + "body truncated at {} bytes", + buf.len() + ); + } + + /// Streamed multipart uploads send no Content-Length, so the drain has to + /// run to the zero-length chunk instead. + #[test] + fn reads_chunked_body_to_its_terminator() { + let buf = read_one_request(|stream| { + stream + .write_all(b"POST /upload HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n") + .unwrap(); + thread::sleep(std::time::Duration::from_millis(50)); + stream.write_all(b"5\r\nhello\r\n").unwrap(); + thread::sleep(std::time::Duration::from_millis(50)); + stream.write_all(b"0\r\n\r\n").unwrap(); + }); + + let request = String::from_utf8_lossy(&buf); + assert!(request.contains("hello"), "chunk missing from: {request}"); + assert!( + request.ends_with("0\r\n\r\n"), + "did not stop at the terminator: {request}" + ); + } + + /// A bodyless request returns as soon as the headers are in, rather than + /// blocking on EOF or the read timeout. + #[test] + fn returns_on_the_headers_when_there_is_no_body() { + let started = std::time::Instant::now(); + let buf = read_one_request(|stream| { + stream + .write_all(b"GET /thing HTTP/1.1\r\nHost: localhost\r\n\r\n") + .unwrap(); + // Held open so a read that waits for EOF would stall here. + thread::sleep(std::time::Duration::from_millis(200)); + }); + + assert!( + started.elapsed() < std::time::Duration::from_secs(5), + "waited on the timeout instead of returning at the headers" + ); + assert!(String::from_utf8_lossy(&buf).starts_with("GET /thing")); + } + #[test] fn scripted_package_check_and_status_override() { let mut checks = HashMap::new();