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        self.content_of(rule)
201    }
202
203    /// Extract this section's content from any [`AdsCarriers`] source.
204    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    /// Whether this section's content is present and non-blank on the rule.
241    pub fn is_present(&self, rule: &SigmaRule) -> bool {
242        self.content(rule).is_some()
243    }
244}
245
246/// A source of the rule fields ADS sections are carried on.
247///
248/// The section vocabulary is the same wherever a rule is read from, but the
249/// representations are not: a parsed [`SigmaRule`] holds YAML custom attributes
250/// while a compiled rule holds JSON ones. Implementing this trait lets both go
251/// through [`AdsSection::content_of`] and [`AdsDocument::from_carriers`]
252/// instead of reimplementing the carrier mapping.
253pub trait AdsCarriers {
254    /// The `description` field (the goal carrier).
255    fn ads_description(&self) -> Option<&str>;
256    /// The `tags` field (the categorization carrier, filtered to `attack.*`).
257    fn ads_tags(&self) -> &[String];
258    /// The `falsepositives` field (the false-positives carrier).
259    fn ads_falsepositives(&self) -> &[String];
260    /// One `rsigma.ads.*` custom attribute, already rendered to content.
261    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/// Rendered content of an ADS section.
285#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
286#[serde(untagged)]
287pub enum AdsContent {
288    /// A single prose value (block scalar).
289    Text(String),
290    /// A list of values (blind spots, response steps, tags).
291    List(Vec<String>),
292}
293
294impl AdsContent {
295    /// Render as plain text, one list item per line.
296    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    /// The list items, treating a single text value as a one-element list.
304    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    /// Render a YAML custom-attribute value as section content, or `None` when
312    /// it is blank or not a scalar (or sequence of scalars).
313    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    /// Render a JSON custom-attribute value as section content, matching
322    /// [`AdsContent::from_yaml`]'s scalar and sequence handling.
323    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
332/// Whether a rule is exempt from ADS enforcement (`rsigma.ads.exempt: true`).
333pub 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
340/// The `attack.*` tags on a rule (the ATT&CK categorization carrier).
341pub 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
348/// Whether the rule carries an ATT&CK categorization: an `attack.*` tag, or a
349/// tag in any of the `extra_namespaces` (a private ATT&CK-adjacent taxonomy a
350/// team recognises via the linter's `tag_namespaces` setting).
351///
352/// [`AdsSection::Categorization`]'s own [`content`](AdsSection::content) and
353/// [`is_present`](AdsSection::is_present) consider only `attack.*`; this is the
354/// config-aware variant the linter, `rule doc`, and the `author_ads` tool use so
355/// the three agree on whether a rule is categorized.
356pub 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/// The status of one ADS section on a rule: which section, whether it is
364/// present, and its content when present.
365#[derive(Debug, Clone, Serialize)]
366pub struct AdsSectionStatus {
367    /// The section id (e.g. `validation`).
368    pub id: &'static str,
369    /// Whether the section is required (by default; callers may override).
370    pub required: bool,
371    /// Whether the section's content is present on the rule.
372    pub present: bool,
373    /// The carrier field or attribute key.
374    pub carrier: &'static str,
375    /// The rendered content when present.
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub content: Option<AdsContent>,
378}
379
380/// The assembled ADS document for one rule: every section, its presence, and
381/// its content.
382#[derive(Debug, Clone, Serialize)]
383pub struct AdsDocument {
384    /// One entry per ADS section, in canonical order.
385    pub sections: Vec<AdsSectionStatus>,
386}
387
388impl AdsDocument {
389    /// Assemble the ADS document for a rule from its reused fields and
390    /// `rsigma.ads.*` sections.
391    pub fn from_rule(rule: &SigmaRule) -> Self {
392        Self::from_carriers(rule)
393    }
394
395    /// Assemble the ADS document from any [`AdsCarriers`] source, so a compiled
396    /// rule produces the same nine-section document as a parsed one.
397    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    /// Whether any section carries content.
415    pub fn is_empty(&self) -> bool {
416        self.sections.iter().all(|s| !s.present)
417    }
418
419    /// The ids of required sections missing from the rule.
420    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/// One entry of a generated ADS scaffold: a `rsigma.ads.*` key and a
430/// placeholder value for an author or agent to complete.
431#[derive(Debug, Clone, Serialize)]
432pub struct AdsScaffoldEntry {
433    /// The `rsigma.ads.*` custom-attribute key.
434    pub key: &'static str,
435    /// The placeholder content.
436    pub placeholder: AdsContent,
437}
438
439/// Build placeholder `rsigma.ads.*` entries for the sections a rule is missing.
440///
441/// Only the custom-attribute sections are scaffolded; the reused fields
442/// (`description`, `tags`, `falsepositives`) already live on the rule, so the
443/// scaffold leaves them in place and fills the gaps under `rsigma.ads.*`.
444pub 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        // The reused-field sections are never scaffolded under rsigma.ads.*.
485        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        // description satisfies goal, attack.* tags satisfy categorization,
612        // falsepositives satisfies false_positives.
613        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        // Every section is missing: no description, no tags, no falsepositives,
645        // no rsigma.ads.* keys.
646        assert_eq!(missing.len(), 9);
647    }
648
649    /// A JSON-backed carrier source, standing in for a compiled rule.
650    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        // strategy is present, so it is not scaffolded; the other five custom
729        // sections are.
730        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        // attack.* alone does not satisfy it.
753        assert!(!AdsSection::Categorization.is_present(&rule));
754        assert!(!has_categorization(&rule, &[]));
755        // A configured namespace does.
756        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}