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
242 changes: 221 additions & 21 deletions src/scanners/blast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,14 +308,7 @@ pub fn run(
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, None) {
Ok(rules) => rules,
Err(e) => {
log::error!("Failed to check blocking rules: {}", e);
std::process::exit(1);
}
};
let blocking_rules = wait_for_blocking_rules(config, &scan_id, None);
if blocking_rules.block {
println!("\nExiting with error code 1 due to some issues violating some blocking rules defined for this project.\nfor more details, please check the scan results at the link: {}\nAlternatively, you can run {} to view the issues list on your local machine.",
utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Green),
Expand All @@ -329,18 +322,7 @@ 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);
}
};
let blocking_rules = wait_for_blocking_rules(config, &scan_id, Some(block_on));
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
Expand Down Expand Up @@ -699,6 +681,119 @@ pub fn wait_for_scan(config: &Config, scan_id: &str) {
);
}

/// Match doghouse `LICENSE_DEPS_WAIT_TIMEOUT` (15 minutes).
const BLOCKING_RULES_WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15 * 60);
const BLOCKING_RULES_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);

fn stop_blocking_rules_spinner(stop_signal: &Arc<Mutex<bool>>, spinner: thread::JoinHandle<()>) {
*stop_signal.lock().unwrap() = true;
let _ = spinner.join();
print!(
"{}",
utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset)
);
}

#[derive(Debug)]
enum BlockingRulesPollDecision {
Complete(utils::api::BlockingRuleResponse),
KeepWaiting,
FailClosed { message: String },
}

const BLOCKING_RULES_TIMEOUT_MESSAGE: &str =
"Timed out waiting for blocking rules to finish. Failing closed.";

/// Only explicit `complete` is terminal. Pending/unknown wait; transient errors
/// retry until timeout; permanent errors fail immediately.
fn decide_blocking_rules_poll(
result: Result<utils::api::BlockingRuleResponse, String>,
elapsed: std::time::Duration,
timeout: std::time::Duration,
) -> BlockingRulesPollDecision {
match result {
Ok(rules) if rules.is_complete() => BlockingRulesPollDecision::Complete(rules),
Ok(rules) => {
if elapsed >= timeout {
log::debug!(
"Blocking-rules wait timed out; last status was '{}'.",
rules.status
);
BlockingRulesPollDecision::FailClosed {
message: BLOCKING_RULES_TIMEOUT_MESSAGE.to_string(),
}
} else {
if rules.status == utils::api::BLOCKING_RULES_STATUS_PENDING {
log::debug!("Blocking rules still pending; waiting.");
} else {
log::debug!(
"Unexpected blocking-rules status '{}'; waiting for complete.",
rules.status
);
}
BlockingRulesPollDecision::KeepWaiting
}
}
Err(e) => {
if !utils::api::is_retryable_blocking_rules_error_message(&e) || elapsed >= timeout {
BlockingRulesPollDecision::FailClosed { message: e }
} else {
log::debug!("Transient blocking-rules error; will retry: {}", e);
BlockingRulesPollDecision::KeepWaiting
}
}
}
}

/// Poll until blocking-rules status is `complete` (or 15m timeout).
/// Older backends omit status (serde defaults to complete: one-shot).
/// `block_on` is forwarded as the CI rule-slug filter (`--block-on`); `None`
/// keeps legacy `--fail` "all active rules" behavior.
fn wait_for_blocking_rules(
config: &Config,
scan_id: &str,
block_on: Option<&str>,
) -> utils::api::BlockingRuleResponse {
let stop_signal = Arc::new(Mutex::new(false));
let stop_signal_clone = Arc::clone(&stop_signal);
let spinner = thread::spawn(move || {
utils::terminal::show_loading_message(
"Checking blocking rules... ([T]s)",
stop_signal_clone,
);
});

let started = std::time::Instant::now();
loop {
// Do not start another request after the deadline.
if started.elapsed() >= BLOCKING_RULES_WAIT_TIMEOUT {
stop_blocking_rules_spinner(&stop_signal, spinner);
log::error!("\n{} (scan '{}')", BLOCKING_RULES_TIMEOUT_MESSAGE, scan_id);
std::process::exit(1);
}

let result = utils::api::check_blocking_rules(&config.get_url(), scan_id, None, block_on)
.map_err(|e| e.to_string());
match decide_blocking_rules_poll(result, started.elapsed(), BLOCKING_RULES_WAIT_TIMEOUT) {
BlockingRulesPollDecision::Complete(rules) => {
stop_blocking_rules_spinner(&stop_signal, spinner);
return rules;
}
BlockingRulesPollDecision::KeepWaiting => {}
BlockingRulesPollDecision::FailClosed { message } => {
stop_blocking_rules_spinner(&stop_signal, spinner);
if message == BLOCKING_RULES_TIMEOUT_MESSAGE {
log::error!("\n{} (scan '{}')", message, scan_id);
} else {
log::error!("Failed to check blocking rules: {}", message);
}
std::process::exit(1);
}
}
std::thread::sleep(BLOCKING_RULES_POLL_INTERVAL);
}
}

pub fn check_scan_status(scan_id: &str, url: &str) -> Result<bool, Box<dyn Error>> {
match utils::api::get_scan(url, scan_id) {
Ok(scan) => Ok(scan.status == "complete"),
Expand Down Expand Up @@ -791,13 +886,116 @@ mod tests {
use super::*;
use crate::utils::api::{
BlockOnError, BlockingIssue, BlockingRuleResponse, BlockingRuleStats, SCAIssue,
SCALocation, SCAPackage,
SCALocation, SCAPackage, BLOCKING_RULES_STATUS_COMPLETE, BLOCKING_RULES_STATUS_PENDING,
};
use std::time::Duration;

fn counts(pairs: &[(&str, usize)]) -> HashMap<String, usize> {
pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect()
}

fn sample_rules(status: &str, block: bool) -> BlockingRuleResponse {
BlockingRuleResponse {
block,
blocking_issues: vec![],
total_pages: 1,
stats: None,
status: status.to_string(),
}
}

#[test]
fn poll_only_complete_is_terminal_unknown_keeps_waiting() {
let timeout = Duration::from_secs(60);
match decide_blocking_rules_poll(
Ok(sample_rules(BLOCKING_RULES_STATUS_COMPLETE, true)),
Duration::ZERO,
timeout,
) {
BlockingRulesPollDecision::Complete(r) => assert!(r.block),
other => panic!("expected Complete, got {other:?}"),
}
assert!(matches!(
decide_blocking_rules_poll(
Ok(sample_rules(BLOCKING_RULES_STATUS_PENDING, false)),
Duration::ZERO,
timeout
),
BlockingRulesPollDecision::KeepWaiting
));
assert!(matches!(
decide_blocking_rules_poll(
Ok(sample_rules("processing", false)),
Duration::ZERO,
timeout
),
BlockingRulesPollDecision::KeepWaiting
));
assert!(matches!(
decide_blocking_rules_poll(Ok(sample_rules("", false)), Duration::ZERO, timeout),
BlockingRulesPollDecision::KeepWaiting
));
assert!(matches!(
decide_blocking_rules_poll(Ok(sample_rules("processing", false)), timeout, timeout),
BlockingRulesPollDecision::FailClosed { .. }
));
}

#[test]
fn poll_retries_transient_error_then_accepts_complete() {
let timeout = Duration::from_secs(60);
assert!(matches!(
decide_blocking_rules_poll(
Err("API request failed with status: 503 Service Unavailable".into()),
Duration::from_secs(1),
timeout,
),
BlockingRulesPollDecision::KeepWaiting
));
// Still fail closed once the overall deadline is hit.
assert!(matches!(
decide_blocking_rules_poll(
Err("API request failed with status: 503 Service Unavailable".into()),
timeout,
timeout,
),
BlockingRulesPollDecision::FailClosed { .. }
));
match decide_blocking_rules_poll(
Ok(sample_rules(BLOCKING_RULES_STATUS_COMPLETE, false)),
Duration::from_secs(2),
timeout,
) {
BlockingRulesPollDecision::Complete(r) => assert!(!r.block),
other => panic!("expected Complete after retry, got {other:?}"),
}
}

#[test]
fn poll_fails_fast_on_permanent_auth_error() {
assert!(matches!(
decide_blocking_rules_poll(
Err("API request failed with status: 401 Unauthorized".into()),
Duration::ZERO,
Duration::from_secs(60),
),
BlockingRulesPollDecision::FailClosed { .. }
));
}

#[test]
fn poll_accepts_complete_even_after_deadline_overrun() {
// Request started under budget; late response still honored.
match decide_blocking_rules_poll(
Ok(sample_rules(BLOCKING_RULES_STATUS_COMPLETE, true)),
Duration::from_secs(16 * 60),
Duration::from_secs(15 * 60),
) {
BlockingRulesPollDecision::Complete(r) => assert!(r.block),
other => panic!("expected late Complete to be accepted, got {other:?}"),
}
}

fn sca_issue(classification: Option<&str>) -> SCAIssue {
SCAIssue {
id: "issue-1".to_string(),
Expand Down Expand Up @@ -1032,6 +1230,7 @@ mod tests {
stats: Some(BlockingRuleStats {
blocked_issues: 133,
}),
status: BLOCKING_RULES_STATUS_COMPLETE.to_string(),
};
assert_eq!(response.blocked_count(), 133);
}
Expand All @@ -1046,6 +1245,7 @@ mod tests {
],
total_pages: 1,
stats: None,
status: BLOCKING_RULES_STATUS_COMPLETE.to_string(),
};
assert_eq!(response.blocked_count(), 2);
}
Expand Down
Loading
Loading