use serde::{Deserialize, Serialize};
use crate::finding::{downgrade_severity, Finding};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SuppressionMode {
#[default]
Downgrade,
Suppress,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Suppression {
pub fingerprint: String,
pub rule_id: String,
pub reason: String,
pub accepted_by: String,
pub accepted_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SuppressionConfig {
#[serde(default)]
pub suppressions: Vec<Suppression>,
}
#[derive(Debug, thiserror::Error)]
pub enum SuppressionError {
#[error("failed to read suppression file {path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("failed to parse suppression file {path}: {source}")]
Parse {
path: String,
#[source]
source: serde_yaml::Error,
},
#[error(
"suppression for fingerprint {fingerprint} (rule {rule_id}) waives a critical finding but has no expires_at — critical waivers must expire"
)]
MissingExpiryForCritical {
fingerprint: String,
rule_id: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SuppressionStatus {
Active,
ExpiringSoon,
Expired,
StaleForReview,
}
impl SuppressionConfig {
pub fn load_from_path(path: &std::path::Path) -> Result<Self, SuppressionError> {
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(path).map_err(|e| SuppressionError::Io {
path: path.display().to_string(),
source: e,
})?;
let cfg: SuppressionConfig =
serde_yaml::from_str(&content).map_err(|e| SuppressionError::Parse {
path: path.display().to_string(),
source: e,
})?;
Ok(cfg)
}
pub fn discover(repo_root: &std::path::Path) -> Option<std::path::PathBuf> {
let primary = repo_root.join(".taudit-suppressions.yml");
if primary.exists() {
return Some(primary);
}
let fallback = repo_root.join(".taudit/suppressions.yml");
if fallback.exists() {
return Some(fallback);
}
None
}
pub fn validate_critical_waivers<'a, I>(
&self,
critical_fingerprints: I,
) -> Result<(), Vec<SuppressionError>>
where
I: IntoIterator<Item = &'a str>,
{
let critical_set: std::collections::HashSet<&str> =
critical_fingerprints.into_iter().collect();
let mut errors = Vec::new();
for entry in &self.suppressions {
if entry.expires_at.is_none() && critical_set.contains(entry.fingerprint.as_str()) {
errors.push(SuppressionError::MissingExpiryForCritical {
fingerprint: entry.fingerprint.clone(),
rule_id: entry.rule_id.clone(),
});
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
pub fn apply(
&self,
findings: Vec<Finding>,
mode: SuppressionMode,
fingerprints: &[String],
today: chrono::NaiveDate,
) -> (Vec<Finding>, Vec<String>) {
let mut by_fp: std::collections::HashMap<&str, &Suppression> =
std::collections::HashMap::with_capacity(self.suppressions.len());
for entry in &self.suppressions {
by_fp.entry(entry.fingerprint.as_str()).or_insert(entry);
}
let mut warnings = Vec::new();
let mut out: Vec<Finding> = Vec::with_capacity(findings.len());
for (i, mut finding) in findings.into_iter().enumerate() {
let fp = fingerprints.get(i).map(String::as_str);
let Some(entry) = fp.and_then(|f| by_fp.get(f)) else {
out.push(finding);
continue;
};
if let Some(ref expiry) = entry.expires_at {
if let Ok(expiry_date) = chrono::NaiveDate::parse_from_str(expiry, "%Y-%m-%d") {
if expiry_date < today {
warnings.push(format!(
"WARNING: suppression for fingerprint {} expired on {}; finding restored to original severity",
entry.fingerprint, expiry,
));
out.push(finding);
continue;
}
} else {
warnings.push(format!(
"WARNING: suppression for fingerprint {} has unparseable expires_at '{}' (expected YYYY-MM-DD); ignoring entry",
entry.fingerprint, expiry,
));
out.push(finding);
continue;
}
}
let original = finding.severity;
match mode {
SuppressionMode::Downgrade => {
finding.severity = downgrade_severity(finding.severity);
}
SuppressionMode::Suppress => {
finding.extras.suppressed = true;
}
}
if finding.extras.original_severity.is_none()
&& (finding.severity != original || mode == SuppressionMode::Suppress)
{
finding.extras.original_severity = Some(original);
}
finding.extras.suppression_reason = Some(entry.reason.clone());
out.push(finding);
}
(out, warnings)
}
pub fn status_of(entry: &Suppression, today: chrono::NaiveDate) -> SuppressionStatus {
if let Some(ref expiry) = entry.expires_at {
if let Ok(expiry_date) = chrono::NaiveDate::parse_from_str(expiry, "%Y-%m-%d") {
if expiry_date < today {
return SuppressionStatus::Expired;
}
let days_left = (expiry_date - today).num_days();
if days_left <= 30 {
return SuppressionStatus::ExpiringSoon;
}
return SuppressionStatus::Active;
}
}
if let Ok(accepted_date) = chrono::NaiveDate::parse_from_str(&entry.accepted_at, "%Y-%m-%d")
{
let age_days = (today - accepted_date).num_days();
if age_days >= 90 {
return SuppressionStatus::StaleForReview;
}
}
SuppressionStatus::Active
}
}
impl SuppressionStatus {
pub fn label(self) -> &'static str {
match self {
SuppressionStatus::Active => "active",
SuppressionStatus::ExpiringSoon => "expiring-soon",
SuppressionStatus::Expired => "expired",
SuppressionStatus::StaleForReview => "stale-for-review",
}
}
}
pub fn render_entry_yaml(entry: &Suppression) -> String {
let mut out = String::new();
out.push_str(&format!(" - fingerprint: \"{}\"\n", entry.fingerprint));
out.push_str(&format!(" rule_id: \"{}\"\n", entry.rule_id));
out.push_str(&format!(
" reason: \"{}\"\n",
entry.reason.replace('"', "\\\"")
));
out.push_str(&format!(" accepted_by: \"{}\"\n", entry.accepted_by));
out.push_str(&format!(" accepted_at: \"{}\"\n", entry.accepted_at));
if let Some(ref expiry) = entry.expires_at {
out.push_str(&format!(" expires_at: \"{}\"\n", expiry));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::finding::{
Finding, FindingCategory, FindingExtras, FindingSource, Recommendation, Severity,
};
use chrono::NaiveDate;
fn finding(severity: Severity, message: &str) -> Finding {
Finding {
severity,
category: FindingCategory::UnpinnedAction,
path: None,
nodes_involved: vec![],
message: message.into(),
recommendation: Recommendation::Manual {
action: "fix".into(),
},
source: FindingSource::BuiltIn,
extras: FindingExtras::default(),
}
}
fn today() -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 4, 26).unwrap()
}
#[test]
fn loader_returns_empty_when_file_missing() {
let cfg = SuppressionConfig::load_from_path(std::path::Path::new(
"/nonexistent/path/to/.taudit-suppressions.yml",
))
.expect("missing file should be Ok(empty)");
assert!(cfg.suppressions.is_empty());
}
#[test]
fn loader_parses_canonical_yaml() {
let yaml = r#"
suppressions:
- fingerprint: "5edb30f4db3b5fa3"
rule_id: "untrusted_with_authority"
reason: "Internal-only action; threat-modeled and accepted by security team."
accepted_by: "ryan@example.com"
accepted_at: "2026-04-26"
expires_at: "2026-07-26"
- fingerprint: "a3c8d9e1f2b4c5d6"
rule_id: "long_lived_credential"
reason: "External SaaS does not support OIDC yet; rotation policy in place."
accepted_by: "ryan@example.com"
accepted_at: "2026-04-26"
"#;
let dir = tempdir();
let path = dir.join(".taudit-suppressions.yml");
std::fs::write(&path, yaml).unwrap();
let cfg = SuppressionConfig::load_from_path(&path).expect("parse OK");
assert_eq!(cfg.suppressions.len(), 2);
assert_eq!(cfg.suppressions[0].fingerprint, "5edb30f4db3b5fa3");
assert_eq!(
cfg.suppressions[0].expires_at.as_deref(),
Some("2026-07-26")
);
assert!(cfg.suppressions[1].expires_at.is_none());
}
#[test]
fn discover_finds_root_then_dot_taudit() {
let dir = tempdir();
assert!(SuppressionConfig::discover(&dir).is_none());
std::fs::create_dir_all(dir.join(".taudit")).unwrap();
std::fs::write(dir.join(".taudit/suppressions.yml"), "suppressions: []").unwrap();
assert_eq!(
SuppressionConfig::discover(&dir).unwrap(),
dir.join(".taudit/suppressions.yml")
);
std::fs::write(dir.join(".taudit-suppressions.yml"), "suppressions: []").unwrap();
assert_eq!(
SuppressionConfig::discover(&dir).unwrap(),
dir.join(".taudit-suppressions.yml")
);
}
#[test]
fn downgrade_mode_drops_severity_one_tier_and_records_original() {
let entry = Suppression {
fingerprint: "deadbeef00000000".into(),
rule_id: "unpinned_action".into(),
reason: "internal action; risk owned by platform team".into(),
accepted_by: "alice@example.com".into(),
accepted_at: "2026-04-26".into(),
expires_at: None,
};
let cfg = SuppressionConfig {
suppressions: vec![entry],
};
let f = finding(Severity::High, "msg");
let fingerprints = vec!["deadbeef00000000".to_string()];
let (out, warnings) =
cfg.apply(vec![f], SuppressionMode::Downgrade, &fingerprints, today());
assert!(warnings.is_empty());
assert_eq!(out[0].severity, Severity::Medium);
assert_eq!(out[0].extras.original_severity, Some(Severity::High));
assert_eq!(
out[0].extras.suppression_reason.as_deref(),
Some("internal action; risk owned by platform team")
);
assert!(!out[0].extras.suppressed);
}
#[test]
fn suppress_mode_sets_flag_and_does_not_change_severity() {
let cfg = SuppressionConfig {
suppressions: vec![Suppression {
fingerprint: "deadbeef00000000".into(),
rule_id: "unpinned_action".into(),
reason: "fork-only build; never publishes".into(),
accepted_by: "alice@example.com".into(),
accepted_at: "2026-04-26".into(),
expires_at: Some("2027-04-26".into()),
}],
};
let f = finding(Severity::High, "msg");
let fingerprints = vec!["deadbeef00000000".to_string()];
let (out, _w) = cfg.apply(vec![f], SuppressionMode::Suppress, &fingerprints, today());
assert_eq!(out[0].severity, Severity::High);
assert!(out[0].extras.suppressed);
assert_eq!(out[0].extras.original_severity, Some(Severity::High));
}
#[test]
fn expired_waiver_does_not_apply_and_emits_warning() {
let cfg = SuppressionConfig {
suppressions: vec![Suppression {
fingerprint: "deadbeef00000000".into(),
rule_id: "unpinned_action".into(),
reason: "needs to rotate".into(),
accepted_by: "alice@example.com".into(),
accepted_at: "2026-01-01".into(),
expires_at: Some("2026-03-01".into()),
}],
};
let f = finding(Severity::High, "msg");
let fingerprints = vec!["deadbeef00000000".to_string()];
let (out, warnings) =
cfg.apply(vec![f], SuppressionMode::Downgrade, &fingerprints, today());
assert_eq!(out[0].severity, Severity::High);
assert_eq!(warnings.len(), 1);
assert!(
warnings[0].contains("expired on 2026-03-01"),
"unexpected warning: {}",
warnings[0]
);
}
#[test]
fn critical_without_expiry_is_rejected_at_validation() {
let cfg = SuppressionConfig {
suppressions: vec![Suppression {
fingerprint: "cafebabecafebabe".into(),
rule_id: "untrusted_with_authority".into(),
reason: "no expiry on critical — should be rejected".into(),
accepted_by: "alice@example.com".into(),
accepted_at: "2026-04-26".into(),
expires_at: None,
}],
};
let critical = ["cafebabecafebabe"];
let result = cfg.validate_critical_waivers(critical.iter().copied());
assert!(result.is_err());
let errors = result.unwrap_err();
assert_eq!(errors.len(), 1);
match &errors[0] {
SuppressionError::MissingExpiryForCritical { fingerprint, .. } => {
assert_eq!(fingerprint, "cafebabecafebabe");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn critical_with_expiry_passes_validation() {
let cfg = SuppressionConfig {
suppressions: vec![Suppression {
fingerprint: "cafebabecafebabe".into(),
rule_id: "untrusted_with_authority".into(),
reason: "approved by security; rotates with quarterly review".into(),
accepted_by: "alice@example.com".into(),
accepted_at: "2026-04-26".into(),
expires_at: Some("2026-07-26".into()),
}],
};
let critical = ["cafebabecafebabe"];
cfg.validate_critical_waivers(critical.iter().copied())
.expect("expiring waiver should pass");
}
#[test]
fn status_active_for_recent_no_expiry() {
let entry = Suppression {
fingerprint: "x".into(),
rule_id: "y".into(),
reason: "z".into(),
accepted_by: "a".into(),
accepted_at: "2026-04-01".into(),
expires_at: None,
};
assert_eq!(
SuppressionConfig::status_of(&entry, today()),
SuppressionStatus::Active
);
}
#[test]
fn status_stale_for_review_after_90_days_no_expiry() {
let entry = Suppression {
fingerprint: "x".into(),
rule_id: "y".into(),
reason: "z".into(),
accepted_by: "a".into(),
accepted_at: "2025-12-01".into(),
expires_at: None,
};
assert_eq!(
SuppressionConfig::status_of(&entry, today()),
SuppressionStatus::StaleForReview
);
}
#[test]
fn status_expiring_soon_within_30_days() {
let entry = Suppression {
fingerprint: "x".into(),
rule_id: "y".into(),
reason: "z".into(),
accepted_by: "a".into(),
accepted_at: "2026-04-01".into(),
expires_at: Some("2026-05-15".into()), };
assert_eq!(
SuppressionConfig::status_of(&entry, today()),
SuppressionStatus::ExpiringSoon
);
}
#[test]
fn status_expired_after_expiry_date() {
let entry = Suppression {
fingerprint: "x".into(),
rule_id: "y".into(),
reason: "z".into(),
accepted_by: "a".into(),
accepted_at: "2025-01-01".into(),
expires_at: Some("2026-01-01".into()),
};
assert_eq!(
SuppressionConfig::status_of(&entry, today()),
SuppressionStatus::Expired
);
}
#[test]
fn render_entry_yaml_round_trips() {
let entry = Suppression {
fingerprint: "5edb30f4db3b5fa3".into(),
rule_id: "untrusted_with_authority".into(),
reason: "internal action; risk accepted".into(),
accepted_by: "alice@example.com".into(),
accepted_at: "2026-04-26".into(),
expires_at: Some("2026-07-26".into()),
};
let body = render_entry_yaml(&entry);
let wrapped = format!("suppressions:\n{body}");
let cfg: SuppressionConfig = serde_yaml::from_str(&wrapped).expect("round-trip parse");
assert_eq!(cfg.suppressions.len(), 1);
assert_eq!(cfg.suppressions[0].fingerprint, entry.fingerprint);
assert_eq!(cfg.suppressions[0].rule_id, entry.rule_id);
assert_eq!(cfg.suppressions[0].reason, entry.reason);
assert_eq!(cfg.suppressions[0].expires_at, entry.expires_at);
}
fn tempdir() -> std::path::PathBuf {
let unique = format!(
"suppressions-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let dir = std::env::temp_dir().join(unique);
std::fs::create_dir_all(&dir).unwrap();
dir
}
}