use serde::Deserialize;
use crate::finding::{Finding, FindingCategory};
#[derive(Debug, Clone, Deserialize)]
pub struct IgnoreRule {
pub category: FindingCategory,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct IgnoreConfig {
#[serde(default)]
pub ignore: Vec<IgnoreRule>,
}
pub struct IgnoreResult {
pub findings: Vec<Finding>,
pub suppressed_count: usize,
}
impl IgnoreConfig {
pub fn apply(&self, findings: Vec<Finding>, source_file: &str) -> IgnoreResult {
if self.ignore.is_empty() {
return IgnoreResult {
findings,
suppressed_count: 0,
};
}
let mut kept = Vec::new();
let mut suppressed = 0;
for finding in findings {
if self.matches(&finding, source_file) {
suppressed += 1;
} else {
kept.push(finding);
}
}
IgnoreResult {
findings: kept,
suppressed_count: suppressed,
}
}
fn matches(&self, finding: &Finding, source_file: &str) -> bool {
self.ignore
.iter()
.any(|rule| rule.matches(finding, source_file))
}
}
impl IgnoreRule {
fn matches(&self, finding: &Finding, source_file: &str) -> bool {
if self.category != finding.category {
return false;
}
if let Some(ref pattern) = self.path {
return glob_match(pattern, source_file);
}
true
}
}
pub fn glob_match(pattern: &str, text: &str) -> bool {
if pattern == "*" {
return true;
}
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 1 {
return pattern == text;
}
let mut pos = 0;
if !parts[0].is_empty() {
if !text.starts_with(parts[0]) {
return false;
}
pos = parts[0].len();
}
let last = parts[parts.len() - 1];
let end_bound = if !last.is_empty() {
if !text.ends_with(last) {
return false;
}
text.len() - last.len()
} else {
text.len()
};
for part in &parts[1..parts.len() - 1] {
if part.is_empty() {
continue;
}
if let Some(found) = text[pos..end_bound].find(part) {
pos += found + part.len();
} else {
return false;
}
}
pos <= end_bound
}
#[cfg(test)]
mod tests {
use super::*;
use crate::finding::{FindingExtras, FindingSource, Recommendation, Severity};
fn finding(category: FindingCategory) -> Finding {
Finding {
severity: Severity::High,
category,
path: None,
nodes_involved: vec![0],
message: "test".into(),
recommendation: Recommendation::Manual {
action: "fix".into(),
},
source: FindingSource::BuiltIn,
extras: FindingExtras::default(),
}
}
#[test]
fn category_only_rule_matches_all_files() {
let config = IgnoreConfig {
ignore: vec![IgnoreRule {
category: FindingCategory::UnpinnedAction,
path: None,
reason: Some("accepted".into()),
}],
};
let findings = vec![
finding(FindingCategory::UnpinnedAction),
finding(FindingCategory::AuthorityPropagation),
];
let result = config.apply(findings, ".github/workflows/ci.yml");
assert_eq!(result.findings.len(), 1);
assert_eq!(result.suppressed_count, 1);
assert_eq!(
result.findings[0].category,
FindingCategory::AuthorityPropagation
);
}
#[test]
fn path_glob_filters_to_specific_file() {
let config = IgnoreConfig {
ignore: vec![IgnoreRule {
category: FindingCategory::UnpinnedAction,
path: Some(".github/workflows/legacy.yml".into()),
reason: None,
}],
};
let result_legacy = config.apply(
vec![finding(FindingCategory::UnpinnedAction)],
".github/workflows/legacy.yml",
);
assert_eq!(result_legacy.findings.len(), 0);
assert_eq!(result_legacy.suppressed_count, 1);
let result_ci = config.apply(
vec![finding(FindingCategory::UnpinnedAction)],
".github/workflows/ci.yml",
);
assert_eq!(result_ci.findings.len(), 1);
assert_eq!(result_ci.suppressed_count, 0);
}
#[test]
fn path_glob_with_wildcard() {
let config = IgnoreConfig {
ignore: vec![IgnoreRule {
category: FindingCategory::OverPrivilegedIdentity,
path: Some("*.yml".into()),
reason: None,
}],
};
let result = config.apply(
vec![finding(FindingCategory::OverPrivilegedIdentity)],
".github/workflows/ci.yml",
);
assert_eq!(result.findings.len(), 0);
assert_eq!(result.suppressed_count, 1);
}
#[test]
fn unmatched_findings_pass_through() {
let config = IgnoreConfig {
ignore: vec![IgnoreRule {
category: FindingCategory::FloatingImage,
path: None,
reason: None,
}],
};
let findings = vec![
finding(FindingCategory::UnpinnedAction),
finding(FindingCategory::AuthorityPropagation),
finding(FindingCategory::OverPrivilegedIdentity),
];
let result = config.apply(findings, "ci.yml");
assert_eq!(result.findings.len(), 3, "no findings should be suppressed");
assert_eq!(result.suppressed_count, 0);
}
#[test]
fn empty_config_passes_everything() {
let config = IgnoreConfig::default();
let findings = vec![
finding(FindingCategory::UnpinnedAction),
finding(FindingCategory::AuthorityPropagation),
];
let result = config.apply(findings, "ci.yml");
assert_eq!(result.findings.len(), 2);
assert_eq!(result.suppressed_count, 0);
}
#[test]
fn multiple_rules_compose() {
let config = IgnoreConfig {
ignore: vec![
IgnoreRule {
category: FindingCategory::UnpinnedAction,
path: None,
reason: None,
},
IgnoreRule {
category: FindingCategory::LongLivedCredential,
path: Some("*legacy*".into()),
reason: Some("migrating".into()),
},
],
};
let findings = vec![
finding(FindingCategory::UnpinnedAction),
finding(FindingCategory::LongLivedCredential),
finding(FindingCategory::AuthorityPropagation),
];
let result = config.apply(findings, ".github/workflows/legacy-deploy.yml");
assert_eq!(result.findings.len(), 1);
assert_eq!(result.suppressed_count, 2);
assert_eq!(
result.findings[0].category,
FindingCategory::AuthorityPropagation
);
}
#[test]
fn glob_exact_match() {
assert!(glob_match("foo.yml", "foo.yml"));
assert!(!glob_match("foo.yml", "bar.yml"));
}
#[test]
fn glob_star_suffix() {
assert!(glob_match("*.yml", "ci.yml"));
assert!(glob_match("*.yml", ".github/workflows/ci.yml"));
assert!(!glob_match("*.yml", "ci.yaml"));
}
#[test]
fn glob_star_prefix() {
assert!(glob_match("ci.*", "ci.yml"));
assert!(glob_match("ci.*", "ci.yaml"));
assert!(!glob_match("ci.*", "deploy.yml"));
}
#[test]
fn glob_star_middle() {
assert!(glob_match(".github/*/ci.yml", ".github/workflows/ci.yml"));
assert!(!glob_match(".github/*/ci.yml", ".github/ci.yml"));
}
#[test]
fn glob_wildcard_all() {
assert!(glob_match("*", "anything"));
assert!(glob_match("*", ""));
}
}