1use serde::Serialize;
37
38use crate::ast::SigmaRule;
39
40pub const EXEMPT_KEY: &str = "rsigma.ads.exempt";
43
44pub const ADS_PREFIX: &str = "rsigma.ads.";
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
51#[serde(rename_all = "snake_case")]
52pub enum AdsSection {
53 Goal,
55 Categorization,
57 Strategy,
59 TechnicalContext,
61 BlindSpots,
63 FalsePositives,
65 Validation,
67 Priority,
69 Response,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
75#[serde(rename_all = "snake_case", tag = "kind", content = "field")]
76pub enum AdsCarrier {
77 StandardField(&'static str),
80 CustomAttribute(&'static str),
82}
83
84impl AdsCarrier {
85 pub fn name(&self) -> &'static str {
87 match self {
88 AdsCarrier::StandardField(name) | AdsCarrier::CustomAttribute(name) => name,
89 }
90 }
91}
92
93#[derive(Debug, Clone, Copy, Serialize)]
95pub struct AdsSectionInfo {
96 pub section: AdsSection,
98 pub id: &'static str,
101 pub carrier: AdsCarrier,
103 pub default_required: bool,
105 pub description: &'static str,
107}
108
109macro_rules! ads_catalogue {
115 ($($variant:ident => ($id:expr, $carrier:expr, $required:expr, $desc:expr)),+ $(,)?) => {
116 const ALL_ADS_SECTIONS: &[AdsSection] = &[$(AdsSection::$variant),+];
118
119 fn describe(section: AdsSection) -> AdsSectionInfo {
120 match section {
121 $(AdsSection::$variant => AdsSectionInfo {
122 section: AdsSection::$variant,
123 id: $id,
124 carrier: $carrier,
125 default_required: $required,
126 description: $desc,
127 }),+
128 }
129 }
130 };
131}
132
133use AdsCarrier::{CustomAttribute, StandardField};
134
135ads_catalogue! {
136 Goal => ("goal", StandardField("description"), true,
137 "What the detection is trying to catch."),
138 Categorization => ("categorization", StandardField("tags"), true,
139 "The ATT&CK categorization, carried by attack.* tags."),
140 Strategy => ("strategy", CustomAttribute("rsigma.ads.strategy"), true,
141 "A one-paragraph abstract of the detection approach."),
142 TechnicalContext => ("technical_context", CustomAttribute("rsigma.ads.technical_context"), true,
143 "The data source, fields, and environment knowledge the detection needs."),
144 BlindSpots => ("blind_spots", CustomAttribute("rsigma.ads.blind_spots"), true,
145 "How an attacker could evade the detection, and what it assumes."),
146 FalsePositives => ("false_positives", StandardField("falsepositives"), true,
147 "Known benign triggers, carried by falsepositives."),
148 Validation => ("validation", CustomAttribute("rsigma.ads.validation"), true,
149 "A recipe that produces a true-positive event the detection fires on."),
150 Priority => ("priority", CustomAttribute("rsigma.ads.priority"), true,
151 "Why the detection's level is what it is (the priority rationale)."),
152 Response => ("response", CustomAttribute("rsigma.ads.response"), true,
153 "What an analyst should do when the detection fires."),
154}
155
156pub fn ads_catalogue() -> Vec<AdsSectionInfo> {
158 ALL_ADS_SECTIONS.iter().map(|&s| describe(s)).collect()
159}
160
161impl AdsSection {
162 pub fn all() -> &'static [AdsSection] {
164 ALL_ADS_SECTIONS
165 }
166
167 pub fn from_id(id: &str) -> Option<AdsSection> {
169 ALL_ADS_SECTIONS.iter().copied().find(|s| s.info().id == id)
170 }
171
172 pub fn info(&self) -> AdsSectionInfo {
174 describe(*self)
175 }
176
177 pub fn id(&self) -> &'static str {
179 self.info().id
180 }
181
182 pub fn carrier(&self) -> AdsCarrier {
184 self.info().carrier
185 }
186
187 pub fn carrier_field(&self) -> &'static str {
189 self.info().carrier.name()
190 }
191
192 pub fn default_required(&self) -> bool {
194 self.info().default_required
195 }
196
197 pub fn content(&self, rule: &SigmaRule) -> Option<AdsContent> {
200 self.content_of(rule)
201 }
202
203 pub fn content_of<C: AdsCarriers + ?Sized>(&self, carriers: &C) -> Option<AdsContent> {
205 match self {
206 AdsSection::Goal => carriers
207 .ads_description()
208 .and_then(non_blank)
209 .map(|s| AdsContent::Text(s.to_string())),
210 AdsSection::Categorization => {
211 let tags: Vec<String> = carriers
212 .ads_tags()
213 .iter()
214 .map(String::as_str)
215 .filter(|t| t.starts_with("attack."))
216 .map(str::to_string)
217 .collect();
218 if tags.is_empty() {
219 None
220 } else {
221 Some(AdsContent::List(tags))
222 }
223 }
224 AdsSection::FalsePositives => {
225 let items: Vec<String> = carriers
226 .ads_falsepositives()
227 .iter()
228 .filter_map(|s| non_blank(s).map(str::to_string))
229 .collect();
230 if items.is_empty() {
231 None
232 } else {
233 Some(AdsContent::List(items))
234 }
235 }
236 other => carriers.ads_custom_attribute(other.carrier_field()),
237 }
238 }
239
240 pub fn is_present(&self, rule: &SigmaRule) -> bool {
242 self.content(rule).is_some()
243 }
244}
245
246pub trait AdsCarriers {
254 fn ads_description(&self) -> Option<&str>;
256 fn ads_tags(&self) -> &[String];
258 fn ads_falsepositives(&self) -> &[String];
260 fn ads_custom_attribute(&self, key: &str) -> Option<AdsContent>;
262}
263
264impl AdsCarriers for SigmaRule {
265 fn ads_description(&self) -> Option<&str> {
266 self.description.as_deref()
267 }
268
269 fn ads_tags(&self) -> &[String] {
270 &self.tags
271 }
272
273 fn ads_falsepositives(&self) -> &[String] {
274 &self.falsepositives
275 }
276
277 fn ads_custom_attribute(&self, key: &str) -> Option<AdsContent> {
278 self.custom_attributes
279 .get(key)
280 .and_then(AdsContent::from_yaml)
281 }
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
286#[serde(untagged)]
287pub enum AdsContent {
288 Text(String),
290 List(Vec<String>),
292}
293
294impl AdsContent {
295 pub fn as_text(&self) -> String {
297 match self {
298 AdsContent::Text(s) => s.clone(),
299 AdsContent::List(items) => items.join("\n"),
300 }
301 }
302
303 pub fn items(&self) -> Vec<String> {
305 match self {
306 AdsContent::Text(s) => vec![s.clone()],
307 AdsContent::List(items) => items.clone(),
308 }
309 }
310
311 pub fn from_yaml(v: &yaml_serde::Value) -> Option<AdsContent> {
314 use yaml_serde::Value;
315 match v {
316 Value::Sequence(seq) => list_content(seq.iter().filter_map(yaml_scalar_text)),
317 other => yaml_scalar_text(other).map(AdsContent::Text),
318 }
319 }
320
321 pub fn from_json(v: &serde_json::Value) -> Option<AdsContent> {
324 use serde_json::Value;
325 match v {
326 Value::Array(items) => list_content(items.iter().filter_map(json_scalar_text)),
327 other => json_scalar_text(other).map(AdsContent::Text),
328 }
329 }
330}
331
332pub fn is_exempt(rule: &SigmaRule) -> bool {
334 rule.custom_attributes
335 .get(EXEMPT_KEY)
336 .and_then(|v| v.as_bool())
337 .unwrap_or(false)
338}
339
340pub fn attack_tags(rule: &SigmaRule) -> impl Iterator<Item = &str> {
342 rule.tags
343 .iter()
344 .map(String::as_str)
345 .filter(|t| t.starts_with("attack."))
346}
347
348pub fn has_categorization(rule: &SigmaRule, extra_namespaces: &[String]) -> bool {
357 rule.tags
358 .iter()
359 .filter_map(|t| t.split('.').next())
360 .any(|ns| ns == "attack" || extra_namespaces.iter().any(|e| e == ns))
361}
362
363#[derive(Debug, Clone, Serialize)]
366pub struct AdsSectionStatus {
367 pub id: &'static str,
369 pub required: bool,
371 pub present: bool,
373 pub carrier: &'static str,
375 #[serde(skip_serializing_if = "Option::is_none")]
377 pub content: Option<AdsContent>,
378}
379
380#[derive(Debug, Clone, Serialize)]
383pub struct AdsDocument {
384 pub sections: Vec<AdsSectionStatus>,
386}
387
388impl AdsDocument {
389 pub fn from_rule(rule: &SigmaRule) -> Self {
392 Self::from_carriers(rule)
393 }
394
395 pub fn from_carriers<C: AdsCarriers + ?Sized>(carriers: &C) -> Self {
398 let sections = AdsSection::all()
399 .iter()
400 .map(|s| {
401 let content = s.content_of(carriers);
402 AdsSectionStatus {
403 id: s.id(),
404 required: s.default_required(),
405 present: content.is_some(),
406 carrier: s.carrier_field(),
407 content,
408 }
409 })
410 .collect();
411 AdsDocument { sections }
412 }
413
414 pub fn is_empty(&self) -> bool {
416 self.sections.iter().all(|s| !s.present)
417 }
418
419 pub fn missing_required(&self) -> Vec<&'static str> {
421 self.sections
422 .iter()
423 .filter(|s| s.required && !s.present)
424 .map(|s| s.id)
425 .collect()
426 }
427}
428
429#[derive(Debug, Clone, Serialize)]
432pub struct AdsScaffoldEntry {
433 pub key: &'static str,
435 pub placeholder: AdsContent,
437}
438
439pub fn scaffold_missing(rule: &SigmaRule) -> Vec<AdsScaffoldEntry> {
445 AdsSection::all()
446 .iter()
447 .filter(|s| matches!(s.carrier(), AdsCarrier::CustomAttribute(_)))
448 .filter(|s| !s.is_present(rule))
449 .map(|s| AdsScaffoldEntry {
450 key: s.carrier_field(),
451 placeholder: placeholder_for(*s),
452 })
453 .collect()
454}
455
456fn placeholder_for(section: AdsSection) -> AdsContent {
457 match section {
458 AdsSection::Strategy => AdsContent::Text(
459 "TODO: a one-paragraph abstract of what this detection does and the approach it takes."
460 .to_string(),
461 ),
462 AdsSection::TechnicalContext => AdsContent::Text(
463 "TODO: the data source, fields, and environment knowledge needed to understand this \
464 detection."
465 .to_string(),
466 ),
467 AdsSection::BlindSpots => AdsContent::List(vec![
468 "TODO: a way an attacker could evade this detection.".to_string(),
469 "TODO: an assumption this detection relies on.".to_string(),
470 ]),
471 AdsSection::Validation => AdsContent::Text(
472 "TODO: the steps to generate a true-positive event that triggers this detection."
473 .to_string(),
474 ),
475 AdsSection::Priority => AdsContent::Text(
476 "TODO: why this detection's level is set as it is, and what it implies for response \
477 urgency."
478 .to_string(),
479 ),
480 AdsSection::Response => AdsContent::List(vec![
481 "TODO: the first triage step when this detection fires.".to_string(),
482 "TODO: the escalation or containment action.".to_string(),
483 ]),
484 AdsSection::Goal | AdsSection::Categorization | AdsSection::FalsePositives => {
486 AdsContent::Text(String::new())
487 }
488 }
489}
490
491fn non_blank(s: &str) -> Option<&str> {
492 let t = s.trim();
493 if t.is_empty() { None } else { Some(t) }
494}
495
496fn list_content(items: impl Iterator<Item = String>) -> Option<AdsContent> {
497 let items: Vec<String> = items.collect();
498 if items.is_empty() {
499 None
500 } else {
501 Some(AdsContent::List(items))
502 }
503}
504
505fn yaml_scalar_text(v: &yaml_serde::Value) -> Option<String> {
506 use yaml_serde::Value;
507 match v {
508 Value::String(s) => non_blank(s).map(str::to_string),
509 Value::Bool(b) => Some(b.to_string()),
510 Value::Number(n) => Some(n.to_string()),
511 _ => None,
512 }
513}
514
515fn json_scalar_text(v: &serde_json::Value) -> Option<String> {
516 use serde_json::Value;
517 match v {
518 Value::String(s) => non_blank(s).map(str::to_string),
519 Value::Bool(b) => Some(b.to_string()),
520 Value::Number(n) => Some(n.to_string()),
521 _ => None,
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use crate::parse_sigma_yaml;
529
530 fn rule(yaml: &str) -> SigmaRule {
531 parse_sigma_yaml(yaml).unwrap().rules.pop().unwrap()
532 }
533
534 const FULL_RULE: &str = r#"
535title: Whoami execution
536description: Detects whoami execution, a common discovery step.
537status: stable
538logsource:
539 category: process_creation
540 product: windows
541detection:
542 selection:
543 CommandLine|contains: whoami
544 condition: selection
545level: medium
546falsepositives:
547 - Legitimate administrators enumerating their own privileges
548tags:
549 - attack.execution
550 - attack.t1059
551custom_attributes:
552 rsigma.ads.strategy: Watch for the whoami binary in process creation events.
553 rsigma.ads.technical_context: Requires process_creation telemetry with CommandLine.
554 rsigma.ads.blind_spots:
555 - Renamed whoami binaries evade the image match.
556 - Assumes CommandLine logging is enabled.
557 rsigma.ads.validation: Run `whoami` in a lab and confirm the rule fires.
558 rsigma.ads.priority: Medium because discovery is mid-kill-chain.
559 rsigma.ads.response:
560 - Confirm the user and host.
561 - Correlate with other discovery activity.
562"#;
563
564 #[test]
565 fn catalogue_has_nine_sections() {
566 let cat = ads_catalogue();
567 assert_eq!(cat.len(), 9);
568 assert_eq!(ALL_ADS_SECTIONS.len(), 9);
569 }
570
571 #[test]
572 fn ids_are_unique_and_round_trip() {
573 use std::collections::HashSet;
574 let mut seen = HashSet::new();
575 for &s in AdsSection::all() {
576 let id = s.id();
577 assert!(seen.insert(id), "duplicate ADS section id: {id}");
578 assert_eq!(AdsSection::from_id(id), Some(s));
579 }
580 assert_eq!(AdsSection::from_id("nope"), None);
581 }
582
583 #[test]
584 fn carriers_match_the_schema() {
585 assert_eq!(AdsSection::Goal.carrier_field(), "description");
586 assert_eq!(AdsSection::Categorization.carrier_field(), "tags");
587 assert_eq!(AdsSection::FalsePositives.carrier_field(), "falsepositives");
588 assert_eq!(AdsSection::Strategy.carrier_field(), "rsigma.ads.strategy");
589 assert!(matches!(
590 AdsSection::Goal.carrier(),
591 AdsCarrier::StandardField(_)
592 ));
593 assert!(matches!(
594 AdsSection::Response.carrier(),
595 AdsCarrier::CustomAttribute(_)
596 ));
597 }
598
599 #[test]
600 fn full_rule_has_every_section_present() {
601 let rule = rule(FULL_RULE);
602 let doc = AdsDocument::from_rule(&rule);
603 assert!(doc.missing_required().is_empty(), "{doc:?}");
604 for s in AdsSection::all() {
605 assert!(s.is_present(&rule), "{} should be present", s.id());
606 }
607 }
608
609 #[test]
610 fn reused_fields_satisfy_their_sections() {
611 let rule = rule(FULL_RULE);
614 assert!(AdsSection::Goal.is_present(&rule));
615 assert!(AdsSection::Categorization.is_present(&rule));
616 assert!(AdsSection::FalsePositives.is_present(&rule));
617 }
618
619 #[test]
620 fn list_content_preserves_items() {
621 let rule = rule(FULL_RULE);
622 match AdsSection::BlindSpots.content(&rule).unwrap() {
623 AdsContent::List(items) => assert_eq!(items.len(), 2),
624 other => panic!("expected list, got {other:?}"),
625 }
626 }
627
628 #[test]
629 fn bare_rule_is_missing_custom_sections() {
630 let rule = rule(
631 r#"
632title: Bare
633status: stable
634logsource:
635 category: test
636detection:
637 selection:
638 field: value
639 condition: selection
640"#,
641 );
642 let doc = AdsDocument::from_rule(&rule);
643 let missing = doc.missing_required();
644 assert_eq!(missing.len(), 9);
647 }
648
649 struct JsonCarriers {
651 description: Option<String>,
652 tags: Vec<String>,
653 falsepositives: Vec<String>,
654 custom_attributes: std::collections::HashMap<String, serde_json::Value>,
655 }
656
657 impl AdsCarriers for JsonCarriers {
658 fn ads_description(&self) -> Option<&str> {
659 self.description.as_deref()
660 }
661 fn ads_tags(&self) -> &[String] {
662 &self.tags
663 }
664 fn ads_falsepositives(&self) -> &[String] {
665 &self.falsepositives
666 }
667 fn ads_custom_attribute(&self, key: &str) -> Option<AdsContent> {
668 self.custom_attributes
669 .get(key)
670 .and_then(AdsContent::from_json)
671 }
672 }
673
674 #[test]
675 fn json_carriers_produce_the_same_document_as_the_parsed_rule() {
676 let parsed = rule(FULL_RULE);
677 let json = JsonCarriers {
678 description: parsed.description.clone(),
679 tags: parsed.tags.clone(),
680 falsepositives: parsed.falsepositives.clone(),
681 custom_attributes: parsed
682 .custom_attributes
683 .iter()
684 .map(|(k, v)| (k.clone(), serde_json::to_value(v).unwrap()))
685 .collect(),
686 };
687
688 let from_yaml = AdsDocument::from_rule(&parsed);
689 let from_json = AdsDocument::from_carriers(&json);
690
691 assert!(from_yaml.missing_required().is_empty());
692 assert_eq!(
693 serde_json::to_value(&from_yaml).unwrap(),
694 serde_json::to_value(&from_json).unwrap()
695 );
696 }
697
698 #[test]
699 fn an_undocumented_rule_yields_an_empty_document() {
700 let carriers = JsonCarriers {
701 description: Some(" ".to_string()),
702 tags: vec!["tlp.clear".to_string()],
703 falsepositives: Vec::new(),
704 custom_attributes: std::collections::HashMap::new(),
705 };
706 assert!(AdsDocument::from_carriers(&carriers).is_empty());
707 }
708
709 #[test]
710 fn scaffold_fills_only_missing_custom_sections() {
711 let rule = rule(
712 r#"
713title: Partly documented
714description: Has a goal already.
715status: stable
716logsource:
717 category: test
718detection:
719 selection:
720 field: value
721 condition: selection
722custom_attributes:
723 rsigma.ads.strategy: Already written.
724"#,
725 );
726 let entries = scaffold_missing(&rule);
727 let keys: Vec<&str> = entries.iter().map(|e| e.key).collect();
728 assert!(!keys.contains(&"rsigma.ads.strategy"));
731 assert!(keys.contains(&"rsigma.ads.validation"));
732 assert!(keys.contains(&"rsigma.ads.response"));
733 assert_eq!(entries.len(), 5);
734 }
735
736 #[test]
737 fn categorization_honours_extra_namespaces() {
738 let rule = rule(
739 r#"
740title: Private taxonomy
741status: stable
742logsource:
743 category: test
744detection:
745 selection:
746 field: value
747 condition: selection
748tags:
749 - myorg.technique
750"#,
751 );
752 assert!(!AdsSection::Categorization.is_present(&rule));
754 assert!(!has_categorization(&rule, &[]));
755 assert!(has_categorization(&rule, &["myorg".to_string()]));
757 }
758
759 #[test]
760 fn exempt_flag_is_read() {
761 let rule = rule(
762 r#"
763title: Vendor import
764status: stable
765logsource:
766 category: test
767detection:
768 selection:
769 field: value
770 condition: selection
771custom_attributes:
772 rsigma.ads.exempt: true
773"#,
774 );
775 assert!(is_exempt(&rule));
776 }
777}