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 diff --git a/skills/corgea/SKILL.md b/skills/corgea/SKILL.md index ebc45d0..bbf8d9e 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 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 @@ -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]` @@ -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 diff --git a/src/list.rs b/src/list.rs index 486beab..40789b7 100644 --- a/src/list.rs +++ b/src/list.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index 8832fec..57cac78 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 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, + #[arg( short, long, @@ -621,6 +628,7 @@ fn main() { scanner, fail_on, fail, + block_on, only_uncommitted, metadata, scan_type, @@ -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); @@ -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."); @@ -743,6 +769,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..9795e9e 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,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); + } + 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 +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, 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 = 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)); @@ -700,7 +789,10 @@ 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, BlockingRuleStats, SCAIssue, + SCALocation, SCAPackage, + }; fn counts(pairs: &[(&str, usize)]) -> HashMap { pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect() @@ -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." + ); + } } diff --git a/src/utils/api.rs b/src/utils/api.rs index e4c4f74..7a0846b 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -1181,17 +1181,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)); @@ -1222,6 +1231,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()) } } @@ -1474,12 +1488,90 @@ 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)] 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)] 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();