Skip to main content

rsigma_parser/
ads.rs

1//! ADS (Alerting and Detection Strategy) section vocabulary and reading helpers.
2//!
3//! The Palantir ADS framework describes nine sections every production
4//! detection should carry: a goal, an ATT&CK categorization, a strategy
5//! abstract, technical context, stated blind spots and assumptions,
6//! false-positive notes, a true-positive validation recipe, a priority, and a
7//! response plan. RSigma already homes four of them on standard Sigma fields
8//! (`description`, `tags`, `falsepositives`, `level`) and carries the rest as
9//! plain documentation under a `rsigma.ads.*` custom-attribute namespace.
10//!
11//! [`ads_catalogue`] is the single source of truth for that vocabulary: one
12//! [`AdsSectionInfo`] per section (its stable snake_case id, the field that
13//! carries it, whether it is required by default, and a one-line description).
14//! The linter, the `rsigma rule doc` command, the MCP `rsigma://ads/schema`
15//! resource, and the docs all ground on this list. The list is generated by one
16//! macro so the same source drives both the catalogue and an *exhaustive*
17//! `match`: adding an [`AdsSection`] variant without a catalogue entry is a
18//! compile error.
19//!
20//! These values are pure documentation. The engine never interprets them, so
21//! they carry zero runtime cost.
22//!
23//! # Example
24//!
25//! ```rust
26//! use rsigma_parser::ads::{ads_catalogue, AdsSection};
27//!
28//! let sections = ads_catalogue();
29//! assert_eq!(sections.len(), 9);
30//!
31//! let goal = sections.iter().find(|s| s.id == "goal").unwrap();
32//! assert!(goal.default_required);
33//! assert_eq!(AdsSection::Goal.carrier_field(), "description");
34//! ```
35
36use serde::Serialize;
37
38use crate::ast::SigmaRule;
39
40/// The `rsigma.ads.*` custom-attribute key that opts a rule out of ADS
41/// enforcement (`rsigma.ads.exempt: true`).
42pub const EXEMPT_KEY: &str = "rsigma.ads.exempt";
43
44/// The shared prefix of every `rsigma.ads.*` custom-attribute key.
45pub const ADS_PREFIX: &str = "rsigma.ads.";
46
47/// One ADS section.
48///
49/// Reference: Palantir Alerting and Detection Strategy framework.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
51#[serde(rename_all = "snake_case")]
52pub enum AdsSection {
53    /// What the detection is trying to catch (carried by `description`).
54    Goal,
55    /// The ATT&CK categorization (carried by `attack.*` `tags`).
56    Categorization,
57    /// A one-paragraph abstract of the detection approach.
58    Strategy,
59    /// The data source, fields, and environment knowledge the detection needs.
60    TechnicalContext,
61    /// How an attacker could evade the detection, and what it assumes.
62    BlindSpots,
63    /// Known benign triggers (carried by `falsepositives`).
64    FalsePositives,
65    /// A recipe that produces a true-positive event the detection fires on.
66    Validation,
67    /// Why the detection's `level` is what it is (the priority rationale).
68    Priority,
69    /// What an analyst should do when the detection fires.
70    Response,
71}
72
73/// Where an ADS section's content lives on a rule.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
75#[serde(rename_all = "snake_case", tag = "kind", content = "field")]
76pub enum AdsCarrier {
77    /// A standard Sigma field reused as-is (`description`, `tags`,
78    /// `falsepositives`, `level`).
79    StandardField(&'static str),
80    /// A new `rsigma.ads.*` custom-attribute key.
81    CustomAttribute(&'static str),
82}
83
84impl AdsCarrier {
85    /// The field name or attribute key, regardless of carrier kind.
86    pub fn name(&self) -> &'static str {
87        match self {
88            AdsCarrier::StandardField(name) | AdsCarrier::CustomAttribute(name) => name,
89        }
90    }
91}
92
93/// Metadata describing one ADS section.
94#[derive(Debug, Clone, Copy, Serialize)]
95pub struct AdsSectionInfo {
96    /// The section variant.
97    pub section: AdsSection,
98    /// Stable snake_case identifier (matches `AdsSection`'s serde rename and
99    /// the `ads.<id>` config key).
100    pub id: &'static str,
101    /// Where the section's content is carried on a rule.
102    pub carrier: AdsCarrier,
103    /// Whether the section is required by default (before config overrides).
104    pub default_required: bool,
105    /// One-line, human-readable description of the section.
106    pub description: &'static str,
107}
108
109/// Build the catalogue plus the exhaustive metadata lookup from one list.
110///
111/// Every `AdsSection` variant must appear exactly once. The generated
112/// `describe` match has no wildcard arm, so a new variant fails to compile
113/// until it is added here.
114macro_rules! ads_catalogue {
115    ($($variant:ident => ($id:expr, $carrier:expr, $required:expr, $desc:expr)),+ $(,)?) => {
116        /// All ADS sections, in canonical (framework) order.
117        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
156/// Return metadata for every [`AdsSection`], in canonical order.
157pub fn ads_catalogue() -> Vec<AdsSectionInfo> {
158    ALL_ADS_SECTIONS.iter().map(|&s| describe(s)).collect()
159}
160
161impl AdsSection {
162    /// All sections, in canonical order.
163    pub fn all() -> &'static [AdsSection] {
164        ALL_ADS_SECTIONS
165    }
166
167    /// Look up a section by its stable snake_case id.
168    pub fn from_id(id: &str) -> Option<AdsSection> {
169        ALL_ADS_SECTIONS.iter().copied().find(|s| s.info().id == id)
170    }
171
172    /// This section's catalogue metadata.
173    pub fn info(&self) -> AdsSectionInfo {
174        describe(*self)
175    }
176
177    /// The stable snake_case id (e.g. `blind_spots`).
178    pub fn id(&self) -> &'static str {
179        self.info().id
180    }
181
182    /// The carrier of this section's content.
183    pub fn carrier(&self) -> AdsCarrier {
184        self.info().carrier
185    }
186
187    /// The field name or attribute key that carries this section.
188    pub fn carrier_field(&self) -> &'static str {
189        self.info().carrier.name()
190    }
191
192    /// Whether this section is required by default.
193    pub fn default_required(&self) -> bool {
194        self.info().default_required
195    }
196
197    /// Extract this section's content from a rule, or `None` when the section
198    /// is absent or blank.
199    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    /// Whether this section's content is present and non-blank on the rule.
234    pub fn is_present(&self, rule: &SigmaRule) -> bool {
235        self.content(rule).is_some()
236    }
237}
238
239/// Rendered content of an ADS section.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
241#[serde(untagged)]
242pub enum AdsContent {
243    /// A single prose value (block scalar).
244    Text(String),
245    /// A list of values (blind spots, response steps, tags).
246    List(Vec<String>),
247}
248
249impl AdsContent {
250    /// Render as plain text, one list item per line.
251    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    /// The list items, treating a single text value as a one-element list.
259    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
267/// Whether a rule is exempt from ADS enforcement (`rsigma.ads.exempt: true`).
268pub 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
275/// The `attack.*` tags on a rule (the ATT&CK categorization carrier).
276pub 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
283/// Whether the rule carries an ATT&CK categorization: an `attack.*` tag, or a
284/// tag in any of the `extra_namespaces` (a private ATT&CK-adjacent taxonomy a
285/// team recognises via the linter's `tag_namespaces` setting).
286///
287/// [`AdsSection::Categorization`]'s own [`content`](AdsSection::content) and
288/// [`is_present`](AdsSection::is_present) consider only `attack.*`; this is the
289/// config-aware variant the linter, `rule doc`, and the `author_ads` tool use so
290/// the three agree on whether a rule is categorized.
291pub 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/// The status of one ADS section on a rule: which section, whether it is
299/// present, and its content when present.
300#[derive(Debug, Clone, Serialize)]
301pub struct AdsSectionStatus {
302    /// The section id (e.g. `validation`).
303    pub id: &'static str,
304    /// Whether the section is required (by default; callers may override).
305    pub required: bool,
306    /// Whether the section's content is present on the rule.
307    pub present: bool,
308    /// The carrier field or attribute key.
309    pub carrier: &'static str,
310    /// The rendered content when present.
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub content: Option<AdsContent>,
313}
314
315/// The assembled ADS document for one rule: every section, its presence, and
316/// its content.
317#[derive(Debug, Clone, Serialize)]
318pub struct AdsDocument {
319    /// One entry per ADS section, in canonical order.
320    pub sections: Vec<AdsSectionStatus>,
321}
322
323impl AdsDocument {
324    /// Assemble the ADS document for a rule from its reused fields and
325    /// `rsigma.ads.*` sections.
326    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    /// The ids of required sections missing from the rule.
344    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/// One entry of a generated ADS scaffold: a `rsigma.ads.*` key and a
354/// placeholder value for an author or agent to complete.
355#[derive(Debug, Clone, Serialize)]
356pub struct AdsScaffoldEntry {
357    /// The `rsigma.ads.*` custom-attribute key.
358    pub key: &'static str,
359    /// The placeholder content.
360    pub placeholder: AdsContent,
361}
362
363/// Build placeholder `rsigma.ads.*` entries for the sections a rule is missing.
364///
365/// Only the custom-attribute sections are scaffolded; the reused fields
366/// (`description`, `tags`, `falsepositives`) already live on the rule, so the
367/// scaffold leaves them in place and fills the gaps under `rsigma.ads.*`.
368pub 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        // The reused-field sections are never scaffolded under rsigma.ads.*.
409        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        // description satisfies goal, attack.* tags satisfy categorization,
532        // falsepositives satisfies false_positives.
533        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        // Every section is missing: no description, no tags, no falsepositives,
565        // no rsigma.ads.* keys.
566        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        // strategy is present, so it is not scaffolded; the other five custom
589        // sections are.
590        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        // attack.* alone does not satisfy it.
613        assert!(!AdsSection::Categorization.is_present(&rule));
614        assert!(!has_categorization(&rule, &[]));
615        // A configured namespace does.
616        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}