Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 9 additions & 2 deletions skills/corgea/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
corgea scan --sbom sbom.cdx.json # SBOM to a custom file
Expand All @@ -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. 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.

`--only-uncommitted` and `--target` are mutually exclusive. `--fail-on`, `--fail`, and `--block-on` are mutually exclusive.

### Upload — `corgea upload [report]`

Expand Down Expand Up @@ -358,6 +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 criticals --out-format sarif --out-file results.sarif # gate on a CI blocking rule from the web app
```

### Upload third-party reports
Expand Down
2 changes: 1 addition & 1 deletion src/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ pub fn run(config: &Config, args: ListArgs) {
if let Some(id) = scan_id.as_ref().filter(|_| !code_quality) {
let mut page: u32 = 1;
loop {
match utils::api::check_blocking_rules(&config.get_url(), id, Some(page)) {
match utils::api::check_blocking_rules(&config.get_url(), id, Some(page), None) {
Ok(rules) => {
if rules.block {
render_blocking_rules = true;
Expand Down
29 changes: 28 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 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<String>,

#[arg(
short,
long,
Expand Down Expand Up @@ -621,6 +628,7 @@ fn main() {
scanner,
fail_on,
fail,
block_on,
only_uncommitted,
metadata,
scan_type,
Expand Down Expand Up @@ -649,6 +657,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);
Expand Down Expand Up @@ -696,6 +709,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.");
Expand Down Expand Up @@ -743,6 +769,7 @@ fn main() {
&corgea_config,
fail_on.clone(),
fail,
block_on,
only_uncommitted,
metadata_json,
scan_type.clone(),
Expand Down
214 changes: 212 additions & 2 deletions src/scanners/blast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub fn run(
config: &Config,
fail_on: Option<String>,
fail: &bool,
block_on: Option<String>,
only_uncommitted: &bool,
metadata: Option<String>,
scan_type: Option<String>,
Expand Down Expand Up @@ -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 <slug> 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);
Expand All @@ -324,6 +328,42 @@ 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 {
// The count comes from the server's pre-pagination total; the slug
// list is drawn from the returned page, which is all the gate needs
// to name the rules at fault.
let triggered = triggered_slug_summary(&blocking_rules.blocking_issues);
println!(
"\nExiting with error code 1: {} issue(s) violated the blocking rule(s) {}.\nFor more details, check the scan results at: {}\nAlternatively, run {} to view the issues list on your local machine.",
blocking_rules.blocked_count(),
utils::terminal::set_text_color(&triggered, utils::terminal::TerminalColor::Red),
utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Green),
utils::terminal::set_text_color(
&format!("corgea ls -i -s={}", scan_id),
utils::terminal::TerminalColor::Green
)
);
std::process::exit(1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Generate requested artifacts before exiting the gate

A blocking response exits here, but report generation starts at line 364 and SBOM generation at line 458. As a result, the newly documented command corgea scan --block-on no-criticals --out-format sarif --out-file results.sarif never writes results.sarif when the rule actually trips; --sbom is skipped too. Those failing CI runs are exactly where the diagnostic artifacts are needed, and --fail-on already avoids this by evaluating its exit after artifact generation. Store the blocking result, generate requested outputs, then exit 1 (while still failing immediately on API/configuration errors), and add a stubbed CLI test combining a blocking response with --out-file or --sbom.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this finding and think it should be addressed.

high: Generate requested artifacts before exiting the gate

The blocking branch exits before report and SBOM generation later in the function. Consequently, the newly documented combination of --block-on with SARIF produces no SARIF precisely when the gate detects violations. Preserve the blocking result, generate requested artifacts, and exit afterward; API and configuration errors can still fail immediately.

Proof or reproduction:

`corgea scan --block-on no-criticals --out-format sarif --out-file results.sarif` calls `std::process::exit(1)` in this branch, so execution never reaches the later out_file handling.

}
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));
Expand Down Expand Up @@ -556,6 +596,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<Option<String>, 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 criticals,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(issues: &[utils::api::BlockingIssue]) -> String {
let mut names: Vec<String> = Vec::new();
for issue in 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));
Expand Down Expand Up @@ -700,7 +789,10 @@ pub fn metadata_json_from_pairs(pairs: &[String]) -> Result<Option<String>, Stri
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::api::{SCAIssue, SCALocation, SCAPackage};
use crate::utils::api::{
BlockOnError, BlockingIssue, BlockingRuleResponse, BlockingRuleStats, SCAIssue,
SCALocation, SCAPackage,
};

fn counts(pairs: &[(&str, usize)]) -> HashMap<String, usize> {
pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect()
Expand Down Expand Up @@ -865,4 +957,122 @@ 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("criticals")).unwrap(),
Some("criticals".to_string())
);
assert_eq!(
normalize_block_on(Some(" criticals , malicious-deps ")).unwrap(),
Some("criticals,malicious-deps".to_string())
);
assert_eq!(
normalize_block_on(Some("criticals,criticals")).unwrap(),
Some("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("criticals,")).is_err());
assert!(normalize_block_on(Some("criticals,,other")).is_err());
}

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 issues = vec![
issue("issue-1", &["1"], Some(&["criticals"])),
issue(
"issue-2",
&["1", "2"],
Some(&["criticals", "malicious-deps"]),
),
];
assert_eq!(triggered_slug_summary(&issues), "criticals, malicious-deps");
}

#[test]
fn triggered_slug_summary_falls_back_to_rule_ids() {
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(&[]), "");
}

/// 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 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]
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."
);
}
}
Loading
Loading