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 AdsSection::Validation => {
237 if let Some(content) = carriers.ads_custom_attribute(self.carrier_field()) {
238 Some(content)
239 } else {
240 let n = carriers.ads_match_exemplar_count();
241 if n == 0 {
242 None
243 } else {
244 Some(AdsContent::Text(format!("{n} executable exemplar(s)")))
245 }
246 }
247 }
248 other => carriers.ads_custom_attribute(other.carrier_field()),
249 }
250 }
251
252 pub fn is_present(&self, rule: &SigmaRule) -> bool {
254 self.content(rule).is_some()
255 }
256}
257
258pub trait AdsCarriers {
266 fn ads_description(&self) -> Option<&str>;
268 fn ads_tags(&self) -> &[String];
270 fn ads_falsepositives(&self) -> &[String];
272 fn ads_custom_attribute(&self, key: &str) -> Option<AdsContent>;
274 fn ads_match_exemplar_count(&self) -> usize {
277 0
278 }
279}
280
281impl AdsCarriers for SigmaRule {
282 fn ads_description(&self) -> Option<&str> {
283 self.description.as_deref()
284 }
285
286 fn ads_tags(&self) -> &[String] {
287 &self.tags
288 }
289
290 fn ads_falsepositives(&self) -> &[String] {
291 &self.falsepositives
292 }
293
294 fn ads_custom_attribute(&self, key: &str) -> Option<AdsContent> {
295 self.custom_attributes
296 .get(key)
297 .and_then(AdsContent::from_yaml)
298 }
299
300 fn ads_match_exemplar_count(&self) -> usize {
301 crate::exemplar::match_exemplar_count(&self.custom_attributes)
302 }
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
307#[serde(untagged)]
308pub enum AdsContent {
309 Text(String),
311 List(Vec<String>),
313}
314
315impl AdsContent {
316 pub fn as_text(&self) -> String {
318 match self {
319 AdsContent::Text(s) => s.clone(),
320 AdsContent::List(items) => items.join("\n"),
321 }
322 }
323
324 pub fn items(&self) -> Vec<String> {
326 match self {
327 AdsContent::Text(s) => vec![s.clone()],
328 AdsContent::List(items) => items.clone(),
329 }
330 }
331
332 pub fn from_yaml(v: &yaml_serde::Value) -> Option<AdsContent> {
335 use yaml_serde::Value;
336 match v {
337 Value::Sequence(seq) => list_content(seq.iter().filter_map(yaml_scalar_text)),
338 other => yaml_scalar_text(other).map(AdsContent::Text),
339 }
340 }
341
342 pub fn from_json(v: &serde_json::Value) -> Option<AdsContent> {
345 use serde_json::Value;
346 match v {
347 Value::Array(items) => list_content(items.iter().filter_map(json_scalar_text)),
348 other => json_scalar_text(other).map(AdsContent::Text),
349 }
350 }
351}
352
353pub fn is_exempt(rule: &SigmaRule) -> bool {
355 rule.custom_attributes
356 .get(EXEMPT_KEY)
357 .and_then(|v| v.as_bool())
358 .unwrap_or(false)
359}
360
361pub fn attack_tags(rule: &SigmaRule) -> impl Iterator<Item = &str> {
363 rule.tags
364 .iter()
365 .map(String::as_str)
366 .filter(|t| t.starts_with("attack."))
367}
368
369pub fn has_categorization(rule: &SigmaRule, extra_namespaces: &[String]) -> bool {
378 rule.tags
379 .iter()
380 .filter_map(|t| t.split('.').next())
381 .any(|ns| ns == "attack" || extra_namespaces.iter().any(|e| e == ns))
382}
383
384#[derive(Debug, Clone, Serialize)]
387pub struct AdsSectionStatus {
388 pub id: &'static str,
390 pub required: bool,
392 pub present: bool,
394 pub carrier: &'static str,
396 #[serde(skip_serializing_if = "Option::is_none")]
398 pub content: Option<AdsContent>,
399}
400
401#[derive(Debug, Clone, Serialize)]
404pub struct AdsDocument {
405 pub sections: Vec<AdsSectionStatus>,
407}
408
409impl AdsDocument {
410 pub fn from_rule(rule: &SigmaRule) -> Self {
413 Self::from_carriers(rule)
414 }
415
416 pub fn from_carriers<C: AdsCarriers + ?Sized>(carriers: &C) -> Self {
419 let sections = AdsSection::all()
420 .iter()
421 .map(|s| {
422 let content = s.content_of(carriers);
423 AdsSectionStatus {
424 id: s.id(),
425 required: s.default_required(),
426 present: content.is_some(),
427 carrier: s.carrier_field(),
428 content,
429 }
430 })
431 .collect();
432 AdsDocument { sections }
433 }
434
435 pub fn is_empty(&self) -> bool {
437 self.sections.iter().all(|s| !s.present)
438 }
439
440 pub fn missing_required(&self) -> Vec<&'static str> {
442 self.sections
443 .iter()
444 .filter(|s| s.required && !s.present)
445 .map(|s| s.id)
446 .collect()
447 }
448}
449
450#[derive(Debug, Clone, Serialize)]
453pub struct AdsScaffoldEntry {
454 pub key: &'static str,
456 pub placeholder: AdsContent,
458}
459
460pub fn scaffold_missing(rule: &SigmaRule) -> Vec<AdsScaffoldEntry> {
466 AdsSection::all()
467 .iter()
468 .filter(|s| matches!(s.carrier(), AdsCarrier::CustomAttribute(_)))
469 .filter(|s| !s.is_present(rule))
470 .map(|s| AdsScaffoldEntry {
471 key: s.carrier_field(),
472 placeholder: placeholder_for(*s),
473 })
474 .collect()
475}
476
477fn placeholder_for(section: AdsSection) -> AdsContent {
478 match section {
479 AdsSection::Strategy => AdsContent::Text(
480 "TODO: a one-paragraph abstract of what this detection does and the approach it takes."
481 .to_string(),
482 ),
483 AdsSection::TechnicalContext => AdsContent::Text(
484 "TODO: the data source, fields, and environment knowledge needed to understand this \
485 detection."
486 .to_string(),
487 ),
488 AdsSection::BlindSpots => AdsContent::List(vec![
489 "TODO: a way an attacker could evade this detection.".to_string(),
490 "TODO: an assumption this detection relies on.".to_string(),
491 ]),
492 AdsSection::Validation => AdsContent::Text(
493 "TODO: the steps to generate a true-positive event that triggers this detection."
494 .to_string(),
495 ),
496 AdsSection::Priority => AdsContent::Text(
497 "TODO: why this detection's level is set as it is, and what it implies for response \
498 urgency."
499 .to_string(),
500 ),
501 AdsSection::Response => AdsContent::List(vec![
502 "TODO: the first triage step when this detection fires.".to_string(),
503 "TODO: the escalation or containment action.".to_string(),
504 ]),
505 AdsSection::Goal | AdsSection::Categorization | AdsSection::FalsePositives => {
507 AdsContent::Text(String::new())
508 }
509 }
510}
511
512fn non_blank(s: &str) -> Option<&str> {
513 let t = s.trim();
514 if t.is_empty() { None } else { Some(t) }
515}
516
517fn list_content(items: impl Iterator<Item = String>) -> Option<AdsContent> {
518 let items: Vec<String> = items.collect();
519 if items.is_empty() {
520 None
521 } else {
522 Some(AdsContent::List(items))
523 }
524}
525
526fn yaml_scalar_text(v: &yaml_serde::Value) -> Option<String> {
527 use yaml_serde::Value;
528 match v {
529 Value::String(s) => non_blank(s).map(str::to_string),
530 Value::Bool(b) => Some(b.to_string()),
531 Value::Number(n) => Some(n.to_string()),
532 _ => None,
533 }
534}
535
536fn json_scalar_text(v: &serde_json::Value) -> Option<String> {
537 use serde_json::Value;
538 match v {
539 Value::String(s) => non_blank(s).map(str::to_string),
540 Value::Bool(b) => Some(b.to_string()),
541 Value::Number(n) => Some(n.to_string()),
542 _ => None,
543 }
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549 use crate::parse_sigma_yaml;
550
551 fn rule(yaml: &str) -> SigmaRule {
552 parse_sigma_yaml(yaml).unwrap().rules.pop().unwrap()
553 }
554
555 const FULL_RULE: &str = r#"
556title: Whoami execution
557description: Detects whoami execution, a common discovery step.
558status: stable
559logsource:
560 category: process_creation
561 product: windows
562detection:
563 selection:
564 CommandLine|contains: whoami
565 condition: selection
566level: medium
567falsepositives:
568 - Legitimate administrators enumerating their own privileges
569tags:
570 - attack.execution
571 - attack.t1059
572custom_attributes:
573 rsigma.ads.strategy: Watch for the whoami binary in process creation events.
574 rsigma.ads.technical_context: Requires process_creation telemetry with CommandLine.
575 rsigma.ads.blind_spots:
576 - Renamed whoami binaries evade the image match.
577 - Assumes CommandLine logging is enabled.
578 rsigma.ads.validation: Run `whoami` in a lab and confirm the rule fires.
579 rsigma.ads.priority: Medium because discovery is mid-kill-chain.
580 rsigma.ads.response:
581 - Confirm the user and host.
582 - Correlate with other discovery activity.
583"#;
584
585 #[test]
586 fn catalogue_has_nine_sections() {
587 let cat = ads_catalogue();
588 assert_eq!(cat.len(), 9);
589 assert_eq!(ALL_ADS_SECTIONS.len(), 9);
590 }
591
592 #[test]
593 fn ids_are_unique_and_round_trip() {
594 use std::collections::HashSet;
595 let mut seen = HashSet::new();
596 for &s in AdsSection::all() {
597 let id = s.id();
598 assert!(seen.insert(id), "duplicate ADS section id: {id}");
599 assert_eq!(AdsSection::from_id(id), Some(s));
600 }
601 assert_eq!(AdsSection::from_id("nope"), None);
602 }
603
604 #[test]
605 fn carriers_match_the_schema() {
606 assert_eq!(AdsSection::Goal.carrier_field(), "description");
607 assert_eq!(AdsSection::Categorization.carrier_field(), "tags");
608 assert_eq!(AdsSection::FalsePositives.carrier_field(), "falsepositives");
609 assert_eq!(AdsSection::Strategy.carrier_field(), "rsigma.ads.strategy");
610 assert!(matches!(
611 AdsSection::Goal.carrier(),
612 AdsCarrier::StandardField(_)
613 ));
614 assert!(matches!(
615 AdsSection::Response.carrier(),
616 AdsCarrier::CustomAttribute(_)
617 ));
618 }
619
620 #[test]
621 fn full_rule_has_every_section_present() {
622 let rule = rule(FULL_RULE);
623 let doc = AdsDocument::from_rule(&rule);
624 assert!(doc.missing_required().is_empty(), "{doc:?}");
625 for s in AdsSection::all() {
626 assert!(s.is_present(&rule), "{} should be present", s.id());
627 }
628 }
629
630 #[test]
631 fn reused_fields_satisfy_their_sections() {
632 let rule = rule(FULL_RULE);
635 assert!(AdsSection::Goal.is_present(&rule));
636 assert!(AdsSection::Categorization.is_present(&rule));
637 assert!(AdsSection::FalsePositives.is_present(&rule));
638 }
639
640 #[test]
641 fn list_content_preserves_items() {
642 let rule = rule(FULL_RULE);
643 match AdsSection::BlindSpots.content(&rule).unwrap() {
644 AdsContent::List(items) => assert_eq!(items.len(), 2),
645 other => panic!("expected list, got {other:?}"),
646 }
647 }
648
649 #[test]
650 fn bare_rule_is_missing_custom_sections() {
651 let rule = rule(
652 r#"
653title: Bare
654status: stable
655logsource:
656 category: test
657detection:
658 selection:
659 field: value
660 condition: selection
661"#,
662 );
663 let doc = AdsDocument::from_rule(&rule);
664 let missing = doc.missing_required();
665 assert_eq!(missing.len(), 9);
668 }
669
670 struct JsonCarriers {
672 description: Option<String>,
673 tags: Vec<String>,
674 falsepositives: Vec<String>,
675 custom_attributes: std::collections::HashMap<String, serde_json::Value>,
676 }
677
678 impl AdsCarriers for JsonCarriers {
679 fn ads_description(&self) -> Option<&str> {
680 self.description.as_deref()
681 }
682 fn ads_tags(&self) -> &[String] {
683 &self.tags
684 }
685 fn ads_falsepositives(&self) -> &[String] {
686 &self.falsepositives
687 }
688 fn ads_custom_attribute(&self, key: &str) -> Option<AdsContent> {
689 self.custom_attributes
690 .get(key)
691 .and_then(AdsContent::from_json)
692 }
693 }
694
695 #[test]
696 fn json_carriers_produce_the_same_document_as_the_parsed_rule() {
697 let parsed = rule(FULL_RULE);
698 let json = JsonCarriers {
699 description: parsed.description.clone(),
700 tags: parsed.tags.clone(),
701 falsepositives: parsed.falsepositives.clone(),
702 custom_attributes: parsed
703 .custom_attributes
704 .iter()
705 .map(|(k, v)| (k.clone(), serde_json::to_value(v).unwrap()))
706 .collect(),
707 };
708
709 let from_yaml = AdsDocument::from_rule(&parsed);
710 let from_json = AdsDocument::from_carriers(&json);
711
712 assert!(from_yaml.missing_required().is_empty());
713 assert_eq!(
714 serde_json::to_value(&from_yaml).unwrap(),
715 serde_json::to_value(&from_json).unwrap()
716 );
717 }
718
719 #[test]
720 fn an_undocumented_rule_yields_an_empty_document() {
721 let carriers = JsonCarriers {
722 description: Some(" ".to_string()),
723 tags: vec!["tlp.clear".to_string()],
724 falsepositives: Vec::new(),
725 custom_attributes: std::collections::HashMap::new(),
726 };
727 assert!(AdsDocument::from_carriers(&carriers).is_empty());
728 }
729
730 #[test]
731 fn scaffold_fills_only_missing_custom_sections() {
732 let rule = rule(
733 r#"
734title: Partly documented
735description: Has a goal already.
736status: stable
737logsource:
738 category: test
739detection:
740 selection:
741 field: value
742 condition: selection
743custom_attributes:
744 rsigma.ads.strategy: Already written.
745"#,
746 );
747 let entries = scaffold_missing(&rule);
748 let keys: Vec<&str> = entries.iter().map(|e| e.key).collect();
749 assert!(!keys.contains(&"rsigma.ads.strategy"));
752 assert!(keys.contains(&"rsigma.ads.validation"));
753 assert!(keys.contains(&"rsigma.ads.response"));
754 assert_eq!(entries.len(), 5);
755 }
756
757 #[test]
758 fn categorization_honours_extra_namespaces() {
759 let rule = rule(
760 r#"
761title: Private taxonomy
762status: stable
763logsource:
764 category: test
765detection:
766 selection:
767 field: value
768 condition: selection
769tags:
770 - myorg.technique
771"#,
772 );
773 assert!(!AdsSection::Categorization.is_present(&rule));
775 assert!(!has_categorization(&rule, &[]));
776 assert!(has_categorization(&rule, &["myorg".to_string()]));
778 }
779
780 #[test]
781 fn exempt_flag_is_read() {
782 let rule = rule(
783 r#"
784title: Vendor import
785status: stable
786logsource:
787 category: test
788detection:
789 selection:
790 field: value
791 condition: selection
792custom_attributes:
793 rsigma.ads.exempt: true
794"#,
795 );
796 assert!(is_exempt(&rule));
797 }
798
799 #[test]
800 fn match_exemplars_satisfy_validation_when_prose_is_absent() {
801 let rule = rule(
802 r#"
803title: Whoami
804status: stable
805logsource:
806 category: test
807detection:
808 selection:
809 field: value
810 condition: selection
811custom_attributes:
812 rsigma.exemplars:
813 - expect: match
814 event:
815 field: value
816 - expect: no-match
817 event:
818 field: other
819"#,
820 );
821 assert!(AdsSection::Validation.is_present(&rule));
822 assert_eq!(
823 AdsSection::Validation.content(&rule),
824 Some(AdsContent::Text("1 executable exemplar(s)".to_string()))
825 );
826 }
827
828 #[test]
829 fn validation_prose_wins_over_exemplars() {
830 let rule = rule(
831 r#"
832title: Whoami
833status: stable
834logsource:
835 category: test
836detection:
837 selection:
838 field: value
839 condition: selection
840custom_attributes:
841 rsigma.ads.validation: Run whoami in a lab.
842 rsigma.exemplars:
843 - expect: match
844 event:
845 field: value
846"#,
847 );
848 assert_eq!(
849 AdsSection::Validation.content(&rule),
850 Some(AdsContent::Text("Run whoami in a lab.".to_string()))
851 );
852 }
853}