use std::collections::HashSet;
use crate::kb::Platform;
use crate::model::{KbEntry, KnowledgeBase, Severity};
pub fn entries_by_ids<'a>(kb: &'a KnowledgeBase, ids: &[&str]) -> Vec<&'a KbEntry> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for &id in ids {
if seen.insert(id)
&& let Some(entry) = kb.entries.iter().find(|e| e.id == id)
{
out.push(entry);
}
}
out
}
pub fn rules_for(entries: &[&KbEntry], platform: Platform, date: &str) -> String {
entries
.iter()
.map(|e| rule_for(e, platform, date))
.collect::<Vec<_>>()
.join("---\n")
}
pub fn rule_for(entry: &KbEntry, platform: Platform, date: &str) -> String {
let mut out = String::new();
out.push_str("# opseclint scaffold — a starter rule mirroring how opseclint matches this\n");
out.push_str("# action. Refine the TODO fields (and tighten the detection) before\n");
out.push_str("# submitting upstream to SigmaHQ.\n");
out.push_str(&format!(
"title: '{}'\n",
yaml_sq(&scaffold_title(&entry.description))
));
out.push_str(&format!(
"id: {} # generated placeholder — regenerate with uuidgen\n",
placeholder_uuid(&entry.id)
));
out.push_str("status: experimental\n");
out.push_str("description: |\n");
out.push_str(&format!(" {}\n", entry.description));
out.push_str("references:\n");
for t in &entry.techniques {
out.push_str(&format!(
" - https://attack.mitre.org/techniques/{}/\n",
t.id.replace('.', "/")
));
}
out.push_str("author: 'TODO: your name'\n");
out.push_str(&format!("date: {date}\n"));
out.push_str("tags:\n");
for t in &entry.techniques {
out.push_str(&format!(" - attack.{}\n", t.id.to_lowercase()));
}
out.push_str(" # TODO: add the ATT&CK tactic tag, e.g. attack.defense-evasion\n");
out.push_str("logsource:\n");
out.push_str(" category: process_creation\n");
out.push_str(&format!(" product: {}\n", platform.sigma_product()));
out.push_str("detection:\n");
out.push_str(" selection:\n");
out.push_str(&build_selection(entry, platform));
out.push_str(" condition: selection\n");
out.push_str("falsepositives:\n");
out.push_str(" - Unknown\n");
out.push_str(&format!("level: {}\n", level_for(entry.noise)));
out
}
fn build_selection(entry: &KbEntry, platform: Platform) -> String {
let sel = entry.matcher.sigma_selection();
let mut s = String::new();
if sel.simplified {
s.push_str(
" # NOTE: this matcher uses alternation/nesting the scaffold can't fully\n\
\x20 # mirror; review the selection (some alternatives may be missing).\n",
);
}
let image = |p: &str| match platform {
Platform::WindowsSysmon => format!("\\{}.exe", yaml_sq(p)),
_ => format!("/{}", yaml_sq(p)),
};
push_field(&mut s, "Image|endswith", &sel.image_endswith, false, image);
let ident = |v: &str| yaml_sq(v);
if sel.contains_all.is_empty() && !sel.contains_any.is_empty() {
push_field(
&mut s,
"CommandLine|contains",
&sel.contains_any,
false,
ident,
);
} else {
push_field(
&mut s,
"CommandLine|contains",
&sel.contains_all,
true,
ident,
);
}
push_field(&mut s, "CommandLine|re", &sel.regexes, false, ident);
if s.is_empty() {
s.push_str(" # TODO: no matchable field on this entry; define the selection\n");
}
s
}
fn push_field(
out: &mut String,
key: &str,
values: &[String],
and_list: bool,
fmt: impl Fn(&str) -> String,
) {
match values {
[] => {}
[only] => out.push_str(&format!(" {key}: '{}'\n", fmt(only))),
many => {
let key = if and_list {
format!("{key}|all")
} else {
key.to_string()
};
out.push_str(&format!(" {key}:\n"));
for v in many {
out.push_str(&format!(" - '{}'\n", fmt(v)));
}
}
}
}
fn yaml_sq(s: &str) -> String {
s.replace('\'', "''")
}
fn scaffold_title(desc: &str) -> String {
let base = desc.split('—').next().unwrap_or(desc).trim();
let mut chars = base.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => "Opseclint scaffold".to_string(),
}
}
fn level_for(noise: u8) -> &'static str {
match Severity::from_noise(noise) {
Severity::Low => "low",
Severity::Medium => "medium",
Severity::High => "high",
Severity::Critical => "critical",
}
}
fn placeholder_uuid(seed: &str) -> String {
let mut b = [0u8; 16];
b[..8].copy_from_slice(&fnv1a(seed.as_bytes()).to_be_bytes());
let mut salted = seed.as_bytes().to_vec();
salted.extend_from_slice(b"::opseclint-scaffold");
b[8..].copy_from_slice(&fnv1a(&salted).to_be_bytes());
b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80; format!(
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
b[0],
b[1],
b[2],
b[3],
b[4],
b[5],
b[6],
b[7],
b[8],
b[9],
b[10],
b[11],
b[12],
b[13],
b[14],
b[15]
)
}
fn fnv1a(data: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &byte in data {
h ^= byte as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
pub fn today() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let (y, m, d) = civil_from_days(secs.div_euclid(86_400));
format!("{y:04}-{m:02}-{d:02}")
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::kb;
use crate::matcher::{LinePred, Matcher};
use crate::model::Technique;
fn linux_kb() -> KnowledgeBase {
kb::load(kb::Platform::LinuxAuditd).unwrap()
}
fn entry<'a>(kb: &'a KnowledgeBase, id: &str) -> &'a KbEntry {
kb.entries.iter().find(|e| e.id == id).unwrap()
}
#[test]
fn scaffold_is_valid_sigma_yaml_for_a_command_entry() {
let kb = linux_kb();
let e = entry(&kb, "clear-syslog-rm");
let yaml = rule_for(e, kb::Platform::LinuxAuditd, "2026-07-29");
let v: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(v["status"].as_str(), Some("experimental"));
assert_eq!(
v["logsource"]["category"].as_str(),
Some("process_creation")
);
assert_eq!(v["logsource"]["product"].as_str(), Some("linux"));
assert_eq!(v["detection"]["condition"].as_str(), Some("selection"));
let sel = &v["detection"]["selection"];
assert_eq!(sel["Image|endswith"].as_str(), Some("/rm"));
assert_eq!(sel["CommandLine|contains"].as_str(), Some("/var/log"));
let id = v["id"].as_str().unwrap();
assert_eq!(id.len(), 36);
assert_eq!(id.as_bytes()[14], b'4');
assert!(yaml.contains("https://attack.mitre.org/techniques/T1070/002/"));
assert!(yaml.contains("attack.t1070.002"));
}
#[test]
fn scaffold_handles_raw_only_entry() {
let kb = linux_kb();
let e = entry(&kb, "reverse-shell-devtcp"); let yaml = rule_for(e, kb::Platform::LinuxAuditd, "2026-07-29");
let v: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
let sel = &v["detection"]["selection"];
assert!(sel.get("Image|endswith").is_none());
assert_eq!(sel["CommandLine|contains"].as_str(), Some("/dev/tcp"));
}
#[test]
fn scaffold_id_is_deterministic_and_multidoc_joins() {
let kb = linux_kb();
let e = entry(&kb, "clear-syslog-rm");
let a = rule_for(e, kb::Platform::LinuxAuditd, "2026-07-29");
let b = rule_for(e, kb::Platform::LinuxAuditd, "2026-07-29");
assert_eq!(a, b, "same entry must scaffold identically");
let stream = rules_for(
&[
entry(&kb, "clear-syslog-rm"),
entry(&kb, "reverse-shell-devtcp"),
],
kb::Platform::LinuxAuditd,
"2026-07-29",
);
assert!(stream.contains("\n---\n"));
assert_eq!(stream.matches("status: experimental").count(), 2);
}
#[test]
fn civil_date_is_correct() {
assert_eq!(civil_from_days(20_663), (2026, 7, 29));
assert_eq!(civil_from_days(0), (1970, 1, 1));
}
#[test]
fn scaffold_windows_uses_backslash_exe_image() {
let kb = kb::load(kb::Platform::WindowsSysmon).unwrap();
let e = entry(&kb, "certutil-download");
let yaml = rule_for(e, kb::Platform::WindowsSysmon, "2026-07-29");
let v: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(v["logsource"]["product"].as_str(), Some("windows"));
assert_eq!(
v["detection"]["selection"]["Image|endswith"].as_str(),
Some("\\certutil.exe")
);
}
#[test]
fn scaffold_title_with_colon_stays_valid_yaml() {
let e = KbEntry {
id: "synthetic".into(),
matcher: Matcher {
program: None,
args: None,
line: Some(LinePred::Contains("lsass".into())),
event: None,
},
example: None,
description: "Dump credentials: full LSASS memory — credential access".into(),
techniques: vec![Technique {
id: "T1003.001".into(),
name: "LSASS Memory".into(),
}],
telemetry: vec![],
detections: vec![],
noise: 80,
};
let yaml = rule_for(&e, kb::Platform::WindowsSysmon, "2026-07-29");
let v: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(
v["title"].as_str(),
Some("Dump credentials: full LSASS memory")
);
assert_eq!(v["level"].as_str(), Some("critical")); }
#[test]
fn scaffold_maps_a_regex_leaf_to_commandline_re() {
let kb = kb::load(kb::Platform::WindowsSysmon).unwrap();
let e = entry(&kb, "powershell-hidden");
let yaml = rule_for(e, kb::Platform::WindowsSysmon, "2026-07-29");
let v: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
let sel = &v["detection"]["selection"];
assert!(
sel["CommandLine|re"].as_str().is_some(),
"expected a CommandLine|re selection, got:\n{yaml}"
);
let contains: Vec<&str> = sel["CommandLine|contains"]
.as_sequence()
.unwrap()
.iter()
.map(|x| x.as_str().unwrap())
.collect();
assert!(
contains.contains(&"powershell") && contains.contains(&"pwsh"),
"{yaml}"
);
}
#[test]
fn scaffold_lowers_program_any_of_to_an_image_list() {
let kb = kb::load(kb::Platform::WindowsSysmon).unwrap();
let yaml = rule_for(
entry(&kb, "net-user"),
kb::Platform::WindowsSysmon,
"2026-07-29",
);
let v: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
let imgs: Vec<&str> = v["detection"]["selection"]["Image|endswith"]
.as_sequence()
.unwrap()
.iter()
.map(|x| x.as_str().unwrap())
.collect();
assert_eq!(imgs, vec!["\\net.exe", "\\net1.exe"]);
}
#[test]
fn scaffold_lowers_line_any_to_a_contains_or_list() {
let kb = kb::load(kb::Platform::LinuxAuditd).unwrap();
let yaml = rule_for(
entry(&kb, "sudo-l"),
kb::Platform::LinuxAuditd,
"2026-07-29",
);
let v: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
let contains: Vec<&str> = v["detection"]["selection"]["CommandLine|contains"]
.as_sequence()
.unwrap()
.iter()
.map(|x| x.as_str().unwrap())
.collect();
assert_eq!(contains, vec!["sudo -l", "sudo --list"]);
}
#[test]
fn scaffold_flags_a_dropped_negation_with_a_note() {
let kb = kb::load(kb::Platform::LinuxAuditd).unwrap();
let yaml = rule_for(
entry(&kb, "private-key-rsa"),
kb::Platform::LinuxAuditd,
"2026-07-29",
);
assert!(
yaml.contains("# NOTE:"),
"expected a review NOTE, got:\n{yaml}"
);
serde_yaml::from_str::<serde_yaml::Value>(&yaml).unwrap();
}
#[test]
fn scaffold_lists_multiple_regexes_as_a_yaml_sequence() {
let matcher: Matcher = serde_json::from_str(
r#"{ "line": { "all": [{ "regex": "aa" }, { "regex": "bb" }] } }"#,
)
.unwrap();
let e = KbEntry {
id: "multi".into(),
matcher,
example: Some("aa bb".into()),
description: "two regexes".into(),
techniques: vec![Technique {
id: "T1059".into(),
name: "n".into(),
}],
telemetry: vec![],
detections: vec![],
noise: 50,
};
let yaml = rule_for(&e, kb::Platform::LinuxAuditd, "2026-07-29");
let v: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
let re = &v["detection"]["selection"]["CommandLine|re"];
assert_eq!(re.as_sequence().map(|s| s.len()), Some(2), "got:\n{yaml}");
}
}