const TACTICS: &[&str] = &[
"reconnaissance",
"resource-development",
"initial-access",
"execution",
"persistence",
"privilege-escalation",
"defense-evasion",
"credential-access",
"discovery",
"lateral-movement",
"collection",
"command-and-control",
"exfiltration",
"impact",
];
pub(crate) fn normalize_technique(raw: &str) -> Option<String> {
let up = raw.trim().to_ascii_uppercase();
let body = up.strip_prefix('T')?;
let (num, sub) = match body.split_once('.') {
Some((n, s)) => (n, Some(s)),
None => (body, None),
};
if num.len() < 4 || !num.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
if let Some(s) = sub
&& (s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()))
{
return None;
}
Some(up)
}
pub(crate) fn parent_technique(id: &str) -> Option<&str> {
id.split_once('.').map(|(parent, _)| parent)
}
fn tactic_slug(short: &str) -> Option<&'static str> {
let normalized = short.replace('_', "-");
TACTICS.iter().copied().find(|slug| *slug == normalized)
}
pub(crate) fn has_attack_tag(tags: &[String]) -> bool {
classify_tags(tags).2
}
pub(crate) fn status_str(status: rsigma_parser::Status) -> &'static str {
use rsigma_parser::Status;
match status {
Status::Stable => "stable",
Status::Test => "test",
Status::Experimental => "experimental",
Status::Deprecated => "deprecated",
Status::Unsupported => "unsupported",
}
}
pub(crate) fn is_retired_status(status: rsigma_parser::Status) -> bool {
use rsigma_parser::Status;
matches!(status, Status::Deprecated | Status::Unsupported)
}
pub(crate) fn resolve_owner(
author: Option<&str>,
custom: &std::collections::HashMap<String, yaml_serde::Value>,
) -> Option<String> {
if let Some(v) = custom.get("owner").and_then(|v| v.as_str()) {
let trimmed = v.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
author
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
pub(crate) fn parse_rule_date(raw: &str) -> Option<i64> {
let normalized = raw.trim().replace('/', "-");
let mut parts = normalized.split('-');
let y: i64 = parts.next()?.parse().ok()?;
let m: u32 = parts.next()?.parse().ok()?;
let d: u32 = parts.next()?.parse().ok()?;
if parts.next().is_some() || !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
Some(days_from_civil(y, m, d))
}
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y.rem_euclid(400);
let m = m as i64;
let d = d as i64;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
pub(crate) fn classify_tags(tags: &[String]) -> (Vec<String>, Vec<String>, bool) {
let mut techniques = Vec::new();
let mut tactics = Vec::new();
let mut has_attack = false;
for tag in tags {
let lower = tag.to_ascii_lowercase();
let Some(rest) = lower.strip_prefix("attack.") else {
continue;
};
has_attack = true;
if let Some(after_t) = rest.strip_prefix('t')
&& after_t.bytes().next().is_some_and(|b| b.is_ascii_digit())
{
if let Some(id) = normalize_technique(&format!("t{after_t}")) {
techniques.push(id);
}
continue;
}
if let Some(slug) = tactic_slug(rest) {
tactics.push(slug.to_string());
}
}
(techniques, tactics, has_attack)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_technique_accepts_and_rejects() {
assert_eq!(normalize_technique("t1059").as_deref(), Some("T1059"));
assert_eq!(
normalize_technique("T1059.001").as_deref(),
Some("T1059.001")
);
assert_eq!(normalize_technique(" t1003 ").as_deref(), Some("T1003"));
assert_eq!(normalize_technique("TA0001"), None); assert_eq!(normalize_technique("1059"), None); assert_eq!(normalize_technique("T10"), None); assert_eq!(normalize_technique("T1059.xy"), None); }
#[test]
fn classify_tags_splits_techniques_tactics_and_other() {
let tags = vec![
"attack.t1059".to_string(),
"attack.t1059.001".to_string(),
"attack.execution".to_string(),
"attack.g0016".to_string(), "cve.2023.1234".to_string(),
];
let (techs, tactics, has_attack) = classify_tags(&tags);
assert_eq!(techs, vec!["T1059".to_string(), "T1059.001".to_string()]);
assert_eq!(tactics, vec!["execution".to_string()]);
assert!(has_attack);
}
#[test]
fn classify_tags_accepts_hyphen_and_underscore_tactics() {
let (_, hyphen, _) = classify_tags(&["attack.privilege-escalation".to_string()]);
let (_, underscore, _) = classify_tags(&["attack.privilege_escalation".to_string()]);
assert_eq!(hyphen, vec!["privilege-escalation".to_string()]);
assert_eq!(underscore, vec!["privilege-escalation".to_string()]);
let (_, custom, has_attack) = classify_tags(&["attack.stealth".to_string()]);
assert!(custom.is_empty());
assert!(has_attack);
}
#[test]
fn no_attack_tag_is_untagged() {
let (techs, tactics, has_attack) = classify_tags(&["cve.2023.1".to_string()]);
assert!(techs.is_empty());
assert!(tactics.is_empty());
assert!(!has_attack);
assert!(!has_attack_tag(&["cve.2023.1".to_string()]));
assert!(has_attack_tag(&["attack.t1059".to_string()]));
}
#[test]
fn parse_rule_date_handles_both_separators() {
assert_eq!(parse_rule_date("2021-01-01"), Some(18_628));
assert_eq!(parse_rule_date("2021/01/01"), Some(18_628));
assert_eq!(parse_rule_date("1970-01-01"), Some(0));
assert_eq!(parse_rule_date(" 2021-01-01 "), Some(18_628));
assert_eq!(parse_rule_date("not-a-date"), None);
assert_eq!(parse_rule_date("2021-13-01"), None);
assert_eq!(parse_rule_date("2021-01"), None);
}
#[test]
fn resolve_owner_prefers_custom_then_author() {
use std::collections::HashMap;
let mut custom: HashMap<String, yaml_serde::Value> = HashMap::new();
assert_eq!(
resolve_owner(Some("Alice"), &custom).as_deref(),
Some("Alice")
);
assert_eq!(resolve_owner(Some(" "), &custom), None);
assert_eq!(resolve_owner(None, &custom), None);
custom.insert(
"owner".to_string(),
yaml_serde::Value::String("Blue Team".to_string()),
);
assert_eq!(
resolve_owner(Some("Alice"), &custom).as_deref(),
Some("Blue Team")
);
}
#[test]
fn retired_status_is_deprecated_or_unsupported() {
use rsigma_parser::Status;
assert!(is_retired_status(Status::Deprecated));
assert!(is_retired_status(Status::Unsupported));
assert!(!is_retired_status(Status::Stable));
assert_eq!(status_str(Status::Test), "test");
}
}