use crate::engine::Engine;
use crate::suggest::audit::AuditRecord;
use std::collections::{BTreeMap, HashSet};
#[derive(Debug, Clone)]
pub enum Suggestion {
RuleNeverFires { rule_id: String, window_days: Option<u32> },
ConsistentlyDemoted {
rule_id: String,
observed_fires: usize,
raw_severity: String,
observed_final: String,
},
NoisyWarn { rule_id: String, observed_fires: usize },
}
impl Suggestion {
pub fn rule_id(&self) -> &str {
match self {
Suggestion::RuleNeverFires { rule_id, .. } => rule_id,
Suggestion::ConsistentlyDemoted { rule_id, .. } => rule_id,
Suggestion::NoisyWarn { rule_id, .. } => rule_id,
}
}
pub fn kind(&self) -> &'static str {
match self {
Suggestion::RuleNeverFires { .. } => "RULE_NEVER_FIRES",
Suggestion::ConsistentlyDemoted { .. } => "CONSISTENTLY_DEMOTED",
Suggestion::NoisyWarn { .. } => "NOISY_WARN",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct AnalyzeOptions {
pub window_days: Option<u32>,
pub min_occurrences: usize,
}
impl Default for AnalyzeOptions {
fn default() -> Self {
Self {
window_days: Some(30),
min_occurrences: 5,
}
}
}
#[derive(Debug, Default, Clone)]
pub struct RuleStats {
pub fires: usize,
pub decisions: BTreeMap<String, usize>,
pub raw_severities: HashSet<String>,
pub final_severities: HashSet<String>,
pub demotions: usize,
}
pub fn aggregate(records: &[AuditRecord]) -> BTreeMap<String, RuleStats> {
let mut out: BTreeMap<String, RuleStats> = BTreeMap::new();
for r in records {
let entry = out.entry(r.primary_rule_id.clone()).or_default();
entry.fires += 1;
*entry.decisions.entry(r.decision.clone()).or_insert(0) += 1;
entry.raw_severities.insert(r.raw_severity.clone());
entry.final_severities.insert(r.final_severity.clone());
if severity_rank(&r.final_severity) < severity_rank(&r.raw_severity) {
entry.demotions += 1;
}
}
out
}
pub fn analyze(
engine: &Engine,
records: &[AuditRecord],
opts: AnalyzeOptions,
) -> Vec<Suggestion> {
let stats = aggregate(records);
let mut out: Vec<Suggestion> = Vec::new();
for rule in &engine.rules {
if !stats.contains_key(&rule.id) {
out.push(Suggestion::RuleNeverFires {
rule_id: rule.id.clone(),
window_days: opts.window_days,
});
}
}
for (rule_id, s) in &stats {
if s.fires >= opts.min_occurrences && s.demotions == s.fires {
let lowest_final = lowest_severity(&s.final_severities)
.unwrap_or_else(|| "Low".to_string());
let raw_one = s
.raw_severities
.iter()
.next()
.cloned()
.unwrap_or_else(|| "Unknown".to_string());
out.push(Suggestion::ConsistentlyDemoted {
rule_id: rule_id.clone(),
observed_fires: s.fires,
raw_severity: raw_one,
observed_final: lowest_final,
});
continue; }
if s.fires >= opts.min_occurrences {
let only_warn = s.decisions.len() == 1
&& s.decisions.contains_key("warn");
if only_warn {
out.push(Suggestion::NoisyWarn {
rule_id: rule_id.clone(),
observed_fires: s.fires,
});
}
}
}
out
}
fn severity_rank(s: &str) -> u8 {
match s {
"Low" => 1,
"Medium" => 2,
"High" => 3,
"Critical" => 4,
_ => 0,
}
}
fn lowest_severity(set: &HashSet<String>) -> Option<String> {
set.iter()
.filter(|s| severity_rank(s) > 0)
.min_by_key(|s| severity_rank(s))
.cloned()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn rec(rule: &str, decision: &str, raw: &str, fin: &str) -> AuditRecord {
AuditRecord {
ts: Utc::now(),
kind: "shield_eval".into(),
tool: "execute_sql".into(),
primary_rule_id: rule.into(),
fingerprint: "fp".into(),
matched_rules: vec![rule.into()],
raw_severity: raw.into(),
composite_points: 1,
composite_severity: raw.into(),
final_severity: fin.into(),
decision: decision.into(),
}
}
#[test]
fn aggregate_counts_fires_and_demotions() {
let records = vec![
rec("sql.grant_all", "warn", "Medium", "Low"),
rec("sql.grant_all", "warn", "Medium", "Low"),
rec("sql.grant_all", "warn", "Medium", "Low"),
];
let stats = aggregate(&records);
let s = stats.get("sql.grant_all").unwrap();
assert_eq!(s.fires, 3);
assert_eq!(s.demotions, 3);
assert_eq!(s.decisions.get("warn").copied(), Some(3));
}
#[test]
fn analyze_emits_rule_never_fires_for_loaded_unfired_rules() {
let engine = crate::engine::Engine::builtin_default();
let records: Vec<AuditRecord> = vec![]; let suggestions = analyze(&engine, &records, AnalyzeOptions::default());
let never_fires_count = suggestions
.iter()
.filter(|s| matches!(s, Suggestion::RuleNeverFires { .. }))
.count();
assert_eq!(never_fires_count, engine.rules.len());
}
#[test]
fn analyze_emits_consistently_demoted_for_always_demoted_rule() {
let engine = crate::engine::Engine::builtin_default();
let rule_id = engine.rules[0].id.clone(); let records: Vec<AuditRecord> = (0..6)
.map(|_| rec(&rule_id, "warn", "Critical", "Low"))
.collect();
let suggestions = analyze(
&engine,
&records,
AnalyzeOptions {
window_days: None,
min_occurrences: 5,
},
);
let demoted: Vec<_> = suggestions
.iter()
.filter_map(|s| match s {
Suggestion::ConsistentlyDemoted { rule_id: r, .. } if r == &rule_id => Some(s),
_ => None,
})
.collect();
assert_eq!(demoted.len(), 1, "expected one CONSISTENTLY_DEMOTED for {}", rule_id);
}
#[test]
fn analyze_emits_noisy_warn_for_always_warn_rule() {
let engine = crate::engine::Engine::builtin_default();
let rule_id = engine.rules[0].id.clone();
let records: Vec<AuditRecord> = (0..6)
.map(|_| rec(&rule_id, "warn", "Medium", "Medium"))
.collect();
let suggestions = analyze(
&engine,
&records,
AnalyzeOptions {
window_days: None,
min_occurrences: 5,
},
);
let noisy: Vec<_> = suggestions
.iter()
.filter(|s| matches!(s, Suggestion::NoisyWarn { rule_id: r, .. } if r == &rule_id))
.collect();
assert_eq!(noisy.len(), 1);
}
#[test]
fn analyze_does_not_double_emit_demoted_and_noisy_for_same_rule() {
let engine = crate::engine::Engine::builtin_default();
let rule_id = engine.rules[0].id.clone();
let records: Vec<AuditRecord> = (0..6)
.map(|_| rec(&rule_id, "warn", "Critical", "Low"))
.collect();
let suggestions = analyze(
&engine,
&records,
AnalyzeOptions {
window_days: None,
min_occurrences: 5,
},
);
let for_this_rule: Vec<_> = suggestions
.iter()
.filter(|s| s.rule_id() == rule_id)
.collect();
assert_eq!(for_this_rule.len(), 1, "expected one suggestion, got {:#?}", for_this_rule);
assert!(matches!(for_this_rule[0], Suggestion::ConsistentlyDemoted { .. }));
}
}