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 match self {
201 AdsSection::Goal => rule
202 .description
203 .as_deref()
204 .and_then(non_blank)
205 .map(|s| AdsContent::Text(s.to_string())),
206 AdsSection::Categorization => {
207 let tags: Vec<String> = attack_tags(rule).map(str::to_string).collect();
208 if tags.is_empty() {
209 None
210 } else {
211 Some(AdsContent::List(tags))
212 }
213 }
214 AdsSection::FalsePositives => {
215 let items: Vec<String> = rule
216 .falsepositives
217 .iter()
218 .filter_map(|s| non_blank(s).map(str::to_string))
219 .collect();
220 if items.is_empty() {
221 None
222 } else {
223 Some(AdsContent::List(items))
224 }
225 }
226 other => {
227 let key = other.carrier_field();
228 rule.custom_attributes.get(key).and_then(content_from_value)
229 }
230 }
231 }
232
233 pub fn is_present(&self, rule: &SigmaRule) -> bool {
235 self.content(rule).is_some()
236 }
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
241#[serde(untagged)]
242pub enum AdsContent {
243 Text(String),
245 List(Vec<String>),
247}
248
249impl AdsContent {
250 pub fn as_text(&self) -> String {
252 match self {
253 AdsContent::Text(s) => s.clone(),
254 AdsContent::List(items) => items.join("\n"),
255 }
256 }
257
258 pub fn items(&self) -> Vec<String> {
260 match self {
261 AdsContent::Text(s) => vec![s.clone()],
262 AdsContent::List(items) => items.clone(),
263 }
264 }
265}
266
267pub fn is_exempt(rule: &SigmaRule) -> bool {
269 rule.custom_attributes
270 .get(EXEMPT_KEY)
271 .and_then(|v| v.as_bool())
272 .unwrap_or(false)
273}
274
275pub fn attack_tags(rule: &SigmaRule) -> impl Iterator<Item = &str> {
277 rule.tags
278 .iter()
279 .map(String::as_str)
280 .filter(|t| t.starts_with("attack."))
281}
282
283pub fn has_categorization(rule: &SigmaRule, extra_namespaces: &[String]) -> bool {
292 rule.tags
293 .iter()
294 .filter_map(|t| t.split('.').next())
295 .any(|ns| ns == "attack" || extra_namespaces.iter().any(|e| e == ns))
296}
297
298#[derive(Debug, Clone, Serialize)]
301pub struct AdsSectionStatus {
302 pub id: &'static str,
304 pub required: bool,
306 pub present: bool,
308 pub carrier: &'static str,
310 #[serde(skip_serializing_if = "Option::is_none")]
312 pub content: Option<AdsContent>,
313}
314
315#[derive(Debug, Clone, Serialize)]
318pub struct AdsDocument {
319 pub sections: Vec<AdsSectionStatus>,
321}
322
323impl AdsDocument {
324 pub fn from_rule(rule: &SigmaRule) -> Self {
327 let sections = AdsSection::all()
328 .iter()
329 .map(|s| {
330 let content = s.content(rule);
331 AdsSectionStatus {
332 id: s.id(),
333 required: s.default_required(),
334 present: content.is_some(),
335 carrier: s.carrier_field(),
336 content,
337 }
338 })
339 .collect();
340 AdsDocument { sections }
341 }
342
343 pub fn missing_required(&self) -> Vec<&'static str> {
345 self.sections
346 .iter()
347 .filter(|s| s.required && !s.present)
348 .map(|s| s.id)
349 .collect()
350 }
351}
352
353#[derive(Debug, Clone, Serialize)]
356pub struct AdsScaffoldEntry {
357 pub key: &'static str,
359 pub placeholder: AdsContent,
361}
362
363pub fn scaffold_missing(rule: &SigmaRule) -> Vec<AdsScaffoldEntry> {
369 AdsSection::all()
370 .iter()
371 .filter(|s| matches!(s.carrier(), AdsCarrier::CustomAttribute(_)))
372 .filter(|s| !s.is_present(rule))
373 .map(|s| AdsScaffoldEntry {
374 key: s.carrier_field(),
375 placeholder: placeholder_for(*s),
376 })
377 .collect()
378}
379
380fn placeholder_for(section: AdsSection) -> AdsContent {
381 match section {
382 AdsSection::Strategy => AdsContent::Text(
383 "TODO: a one-paragraph abstract of what this detection does and the approach it takes."
384 .to_string(),
385 ),
386 AdsSection::TechnicalContext => AdsContent::Text(
387 "TODO: the data source, fields, and environment knowledge needed to understand this \
388 detection."
389 .to_string(),
390 ),
391 AdsSection::BlindSpots => AdsContent::List(vec![
392 "TODO: a way an attacker could evade this detection.".to_string(),
393 "TODO: an assumption this detection relies on.".to_string(),
394 ]),
395 AdsSection::Validation => AdsContent::Text(
396 "TODO: the steps to generate a true-positive event that triggers this detection."
397 .to_string(),
398 ),
399 AdsSection::Priority => AdsContent::Text(
400 "TODO: why this detection's level is set as it is, and what it implies for response \
401 urgency."
402 .to_string(),
403 ),
404 AdsSection::Response => AdsContent::List(vec![
405 "TODO: the first triage step when this detection fires.".to_string(),
406 "TODO: the escalation or containment action.".to_string(),
407 ]),
408 AdsSection::Goal | AdsSection::Categorization | AdsSection::FalsePositives => {
410 AdsContent::Text(String::new())
411 }
412 }
413}
414
415fn non_blank(s: &str) -> Option<&str> {
416 let t = s.trim();
417 if t.is_empty() { None } else { Some(t) }
418}
419
420fn content_from_value(v: &yaml_serde::Value) -> Option<AdsContent> {
421 use yaml_serde::Value;
422 match v {
423 Value::Sequence(seq) => {
424 let items: Vec<String> = seq.iter().filter_map(scalar_text).collect();
425 if items.is_empty() {
426 None
427 } else {
428 Some(AdsContent::List(items))
429 }
430 }
431 other => scalar_text(other).map(AdsContent::Text),
432 }
433}
434
435fn scalar_text(v: &yaml_serde::Value) -> Option<String> {
436 use yaml_serde::Value;
437 match v {
438 Value::String(s) => non_blank(s).map(str::to_string),
439 Value::Bool(b) => Some(b.to_string()),
440 Value::Number(n) => Some(n.to_string()),
441 _ => None,
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448 use crate::parse_sigma_yaml;
449
450 fn rule(yaml: &str) -> SigmaRule {
451 parse_sigma_yaml(yaml).unwrap().rules.pop().unwrap()
452 }
453
454 const FULL_RULE: &str = r#"
455title: Whoami execution
456description: Detects whoami execution, a common discovery step.
457status: stable
458logsource:
459 category: process_creation
460 product: windows
461detection:
462 selection:
463 CommandLine|contains: whoami
464 condition: selection
465level: medium
466falsepositives:
467 - Legitimate administrators enumerating their own privileges
468tags:
469 - attack.execution
470 - attack.t1059
471custom_attributes:
472 rsigma.ads.strategy: Watch for the whoami binary in process creation events.
473 rsigma.ads.technical_context: Requires process_creation telemetry with CommandLine.
474 rsigma.ads.blind_spots:
475 - Renamed whoami binaries evade the image match.
476 - Assumes CommandLine logging is enabled.
477 rsigma.ads.validation: Run `whoami` in a lab and confirm the rule fires.
478 rsigma.ads.priority: Medium because discovery is mid-kill-chain.
479 rsigma.ads.response:
480 - Confirm the user and host.
481 - Correlate with other discovery activity.
482"#;
483
484 #[test]
485 fn catalogue_has_nine_sections() {
486 let cat = ads_catalogue();
487 assert_eq!(cat.len(), 9);
488 assert_eq!(ALL_ADS_SECTIONS.len(), 9);
489 }
490
491 #[test]
492 fn ids_are_unique_and_round_trip() {
493 use std::collections::HashSet;
494 let mut seen = HashSet::new();
495 for &s in AdsSection::all() {
496 let id = s.id();
497 assert!(seen.insert(id), "duplicate ADS section id: {id}");
498 assert_eq!(AdsSection::from_id(id), Some(s));
499 }
500 assert_eq!(AdsSection::from_id("nope"), None);
501 }
502
503 #[test]
504 fn carriers_match_the_schema() {
505 assert_eq!(AdsSection::Goal.carrier_field(), "description");
506 assert_eq!(AdsSection::Categorization.carrier_field(), "tags");
507 assert_eq!(AdsSection::FalsePositives.carrier_field(), "falsepositives");
508 assert_eq!(AdsSection::Strategy.carrier_field(), "rsigma.ads.strategy");
509 assert!(matches!(
510 AdsSection::Goal.carrier(),
511 AdsCarrier::StandardField(_)
512 ));
513 assert!(matches!(
514 AdsSection::Response.carrier(),
515 AdsCarrier::CustomAttribute(_)
516 ));
517 }
518
519 #[test]
520 fn full_rule_has_every_section_present() {
521 let rule = rule(FULL_RULE);
522 let doc = AdsDocument::from_rule(&rule);
523 assert!(doc.missing_required().is_empty(), "{doc:?}");
524 for s in AdsSection::all() {
525 assert!(s.is_present(&rule), "{} should be present", s.id());
526 }
527 }
528
529 #[test]
530 fn reused_fields_satisfy_their_sections() {
531 let rule = rule(FULL_RULE);
534 assert!(AdsSection::Goal.is_present(&rule));
535 assert!(AdsSection::Categorization.is_present(&rule));
536 assert!(AdsSection::FalsePositives.is_present(&rule));
537 }
538
539 #[test]
540 fn list_content_preserves_items() {
541 let rule = rule(FULL_RULE);
542 match AdsSection::BlindSpots.content(&rule).unwrap() {
543 AdsContent::List(items) => assert_eq!(items.len(), 2),
544 other => panic!("expected list, got {other:?}"),
545 }
546 }
547
548 #[test]
549 fn bare_rule_is_missing_custom_sections() {
550 let rule = rule(
551 r#"
552title: Bare
553status: stable
554logsource:
555 category: test
556detection:
557 selection:
558 field: value
559 condition: selection
560"#,
561 );
562 let doc = AdsDocument::from_rule(&rule);
563 let missing = doc.missing_required();
564 assert_eq!(missing.len(), 9);
567 }
568
569 #[test]
570 fn scaffold_fills_only_missing_custom_sections() {
571 let rule = rule(
572 r#"
573title: Partly documented
574description: Has a goal already.
575status: stable
576logsource:
577 category: test
578detection:
579 selection:
580 field: value
581 condition: selection
582custom_attributes:
583 rsigma.ads.strategy: Already written.
584"#,
585 );
586 let entries = scaffold_missing(&rule);
587 let keys: Vec<&str> = entries.iter().map(|e| e.key).collect();
588 assert!(!keys.contains(&"rsigma.ads.strategy"));
591 assert!(keys.contains(&"rsigma.ads.validation"));
592 assert!(keys.contains(&"rsigma.ads.response"));
593 assert_eq!(entries.len(), 5);
594 }
595
596 #[test]
597 fn categorization_honours_extra_namespaces() {
598 let rule = rule(
599 r#"
600title: Private taxonomy
601status: stable
602logsource:
603 category: test
604detection:
605 selection:
606 field: value
607 condition: selection
608tags:
609 - myorg.technique
610"#,
611 );
612 assert!(!AdsSection::Categorization.is_present(&rule));
614 assert!(!has_categorization(&rule, &[]));
615 assert!(has_categorization(&rule, &["myorg".to_string()]));
617 }
618
619 #[test]
620 fn exempt_flag_is_read() {
621 let rule = rule(
622 r#"
623title: Vendor import
624status: stable
625logsource:
626 category: test
627detection:
628 selection:
629 field: value
630 condition: selection
631custom_attributes:
632 rsigma.ads.exempt: true
633"#,
634 );
635 assert!(is_exempt(&rule));
636 }
637}