Skip to main content

rsigma_parser/lint/
mod.rs

1//! Built-in linter for Sigma rules, correlations, and filters.
2//!
3//! Validates raw `yaml_serde::Value` documents against the Sigma specification
4//! v2.1.0 constraints — catching metadata issues that the parser silently
5//! ignores (invalid enums, date formats, tag patterns, etc.).
6//!
7//! # Usage
8//!
9//! ```rust
10//! use rsigma_parser::lint::{lint_yaml_value, Severity};
11//!
12//! let yaml = "title: Test\nlogsource:\n  category: test\ndetection:\n  sel:\n    field: value\n  condition: sel\n";
13//! let value: yaml_serde::Value = yaml_serde::from_str(yaml).unwrap();
14//! let warnings = lint_yaml_value(&value);
15//! for w in &warnings {
16//!     if w.severity == Severity::Error {
17//!         eprintln!("{}", w.message);
18//!     }
19//! }
20//! ```
21
22pub mod catalogue;
23#[cfg(feature = "fix")]
24pub mod fix;
25mod rules;
26
27use std::collections::{HashMap, HashSet};
28use std::fmt;
29use std::path::Path;
30use std::sync::LazyLock;
31
32use serde::{Deserialize, Serialize};
33use yaml_serde::Value;
34
35use crate::ads::AdsSection;
36
37// =============================================================================
38// Public types
39// =============================================================================
40
41/// Severity of a lint finding.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
43pub enum Severity {
44    /// Spec violation — the rule is invalid.
45    Error,
46    /// Best-practice issue — the rule works but is not spec-ideal.
47    Warning,
48    /// Informational suggestion — soft best-practice hint (e.g. missing author).
49    Info,
50    /// Subtle hint — lowest severity, for stylistic suggestions.
51    Hint,
52}
53
54impl fmt::Display for Severity {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            Severity::Error => write!(f, "error"),
58            Severity::Warning => write!(f, "warning"),
59            Severity::Info => write!(f, "info"),
60            Severity::Hint => write!(f, "hint"),
61        }
62    }
63}
64
65/// Identifies which lint rule fired.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
67pub enum LintRule {
68    // ── Infrastructure / parse errors ────────────────────────────────────
69    YamlParseError,
70    NotAMapping,
71    FileReadError,
72    SchemaViolation,
73
74    // ── Shared (all document types) ──────────────────────────────────────
75    MissingTitle,
76    EmptyTitle,
77    TitleTooLong,
78    MissingDescription,
79    MissingAuthor,
80    InvalidId,
81    InvalidStatus,
82    MissingLevel,
83    InvalidLevel,
84    InvalidDate,
85    InvalidModified,
86    ModifiedBeforeDate,
87    DescriptionTooLong,
88    NameTooLong,
89    TaxonomyTooLong,
90    NonLowercaseKey,
91
92    // ── Detection rules ──────────────────────────────────────────────────
93    MissingLogsource,
94    MissingDetection,
95    MissingCondition,
96    EmptyDetection,
97    InvalidRelatedType,
98    InvalidRelatedId,
99    RelatedMissingRequired,
100    DeprecatedWithoutRelated,
101    InvalidTag,
102    UnknownTagNamespace,
103    DuplicateTags,
104    DuplicateReferences,
105    DuplicateFields,
106    FalsepositiveTooShort,
107    ScopeTooShort,
108    LogsourceValueNotLowercase,
109    ConditionReferencesUnknown,
110    DeprecatedAggregationSyntax,
111    DeprecatedDetectionTimeframe,
112
113    // ── Correlation rules ────────────────────────────────────────────────
114    MissingCorrelation,
115    MissingCorrelationType,
116    InvalidCorrelationType,
117    MissingCorrelationRules,
118    EmptyCorrelationRules,
119    MissingCorrelationTimespan,
120    InvalidTimespanFormat,
121    InvalidWindowMode,
122    MissingSessionGap,
123    GapWithoutSession,
124    InvalidGapFormat,
125    MissingGroupBy,
126    MissingCorrelationCondition,
127    MissingConditionField,
128    InvalidConditionOperator,
129    ConditionValueNotNumeric,
130    GenerateNotBoolean,
131    CorrelationOnlyReferences,
132
133    // ── Filter rules ─────────────────────────────────────────────────────
134    MissingFilter,
135    MissingFilterRules,
136    EmptyFilterRules,
137    MissingFilterSelection,
138    MissingFilterCondition,
139    FilterHasLevel,
140    FilterHasStatus,
141    MissingFilterLogsource,
142    FilterReferenceByTitle,
143
144    // ── Detection logic (cross-cutting) ──────────────────────────────────
145    NullInValueList,
146    SingleValueAllModifier,
147    AllWithRe,
148    IncompatibleModifiers,
149    EmptyValueList,
150    WildcardOnlyValue,
151    FlattenedArrayCorrelation,
152    UnsupportedSigmaVersion,
153    ArrayMatchingWithoutVersion,
154    SigmaVersionMismatch,
155    UnknownRuleReference,
156    UnknownKey,
157
158    // ── ADS detection-strategy metadata ──────────────────────────────────
159    AdsMissingGoal,
160    AdsMissingCategorization,
161    AdsMissingStrategy,
162    AdsMissingTechnicalContext,
163    AdsMissingBlindSpots,
164    AdsMissingFalsePositives,
165    AdsMissingValidation,
166    AdsMissingPriority,
167    AdsMissingResponse,
168    AdsEmptySection,
169    AdsUnknownSection,
170
171    // ── Embedded exemplars ───────────────────────────────────────────────
172    ExemplarShape,
173    ExemplarWrongRuleKind,
174}
175
176impl fmt::Display for LintRule {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        let s = match self {
179            LintRule::YamlParseError => "yaml_parse_error",
180            LintRule::NotAMapping => "not_a_mapping",
181            LintRule::FileReadError => "file_read_error",
182            LintRule::SchemaViolation => "schema_violation",
183            LintRule::MissingTitle => "missing_title",
184            LintRule::EmptyTitle => "empty_title",
185            LintRule::TitleTooLong => "title_too_long",
186            LintRule::MissingDescription => "missing_description",
187            LintRule::MissingAuthor => "missing_author",
188            LintRule::InvalidId => "invalid_id",
189            LintRule::InvalidStatus => "invalid_status",
190            LintRule::MissingLevel => "missing_level",
191            LintRule::InvalidLevel => "invalid_level",
192            LintRule::InvalidDate => "invalid_date",
193            LintRule::InvalidModified => "invalid_modified",
194            LintRule::ModifiedBeforeDate => "modified_before_date",
195            LintRule::DescriptionTooLong => "description_too_long",
196            LintRule::NameTooLong => "name_too_long",
197            LintRule::TaxonomyTooLong => "taxonomy_too_long",
198            LintRule::NonLowercaseKey => "non_lowercase_key",
199            LintRule::MissingLogsource => "missing_logsource",
200            LintRule::MissingDetection => "missing_detection",
201            LintRule::MissingCondition => "missing_condition",
202            LintRule::EmptyDetection => "empty_detection",
203            LintRule::InvalidRelatedType => "invalid_related_type",
204            LintRule::InvalidRelatedId => "invalid_related_id",
205            LintRule::RelatedMissingRequired => "related_missing_required",
206            LintRule::DeprecatedWithoutRelated => "deprecated_without_related",
207            LintRule::InvalidTag => "invalid_tag",
208            LintRule::UnknownTagNamespace => "unknown_tag_namespace",
209            LintRule::DuplicateTags => "duplicate_tags",
210            LintRule::DuplicateReferences => "duplicate_references",
211            LintRule::DuplicateFields => "duplicate_fields",
212            LintRule::FalsepositiveTooShort => "falsepositive_too_short",
213            LintRule::ScopeTooShort => "scope_too_short",
214            LintRule::LogsourceValueNotLowercase => "logsource_value_not_lowercase",
215            LintRule::ConditionReferencesUnknown => "condition_references_unknown",
216            LintRule::DeprecatedAggregationSyntax => "deprecated_aggregation_syntax",
217            LintRule::DeprecatedDetectionTimeframe => "deprecated_detection_timeframe",
218            LintRule::MissingCorrelation => "missing_correlation",
219            LintRule::MissingCorrelationType => "missing_correlation_type",
220            LintRule::InvalidCorrelationType => "invalid_correlation_type",
221            LintRule::MissingCorrelationRules => "missing_correlation_rules",
222            LintRule::EmptyCorrelationRules => "empty_correlation_rules",
223            LintRule::MissingCorrelationTimespan => "missing_correlation_timespan",
224            LintRule::InvalidTimespanFormat => "invalid_timespan_format",
225            LintRule::InvalidWindowMode => "invalid_window_mode",
226            LintRule::MissingSessionGap => "missing_session_gap",
227            LintRule::GapWithoutSession => "gap_without_session",
228            LintRule::InvalidGapFormat => "invalid_gap_format",
229            LintRule::MissingGroupBy => "missing_group_by",
230            LintRule::MissingCorrelationCondition => "missing_correlation_condition",
231            LintRule::MissingConditionField => "missing_condition_field",
232            LintRule::InvalidConditionOperator => "invalid_condition_operator",
233            LintRule::ConditionValueNotNumeric => "condition_value_not_numeric",
234            LintRule::GenerateNotBoolean => "generate_not_boolean",
235            LintRule::CorrelationOnlyReferences => "correlation_only_references",
236            LintRule::MissingFilter => "missing_filter",
237            LintRule::MissingFilterRules => "missing_filter_rules",
238            LintRule::EmptyFilterRules => "empty_filter_rules",
239            LintRule::MissingFilterSelection => "missing_filter_selection",
240            LintRule::MissingFilterCondition => "missing_filter_condition",
241            LintRule::FilterHasLevel => "filter_has_level",
242            LintRule::FilterHasStatus => "filter_has_status",
243            LintRule::MissingFilterLogsource => "missing_filter_logsource",
244            LintRule::FilterReferenceByTitle => "filter_reference_by_title",
245            LintRule::NullInValueList => "null_in_value_list",
246            LintRule::SingleValueAllModifier => "single_value_all_modifier",
247            LintRule::AllWithRe => "all_with_re",
248            LintRule::IncompatibleModifiers => "incompatible_modifiers",
249            LintRule::EmptyValueList => "empty_value_list",
250            LintRule::WildcardOnlyValue => "wildcard_only_value",
251            LintRule::FlattenedArrayCorrelation => "flattened_array_correlation",
252            LintRule::UnsupportedSigmaVersion => "unsupported_sigma_version",
253            LintRule::ArrayMatchingWithoutVersion => "array_matching_without_version",
254            LintRule::SigmaVersionMismatch => "sigma_version_mismatch",
255            LintRule::UnknownRuleReference => "unknown_rule_reference",
256            LintRule::UnknownKey => "unknown_key",
257            LintRule::AdsMissingGoal => "ads_missing_goal",
258            LintRule::AdsMissingCategorization => "ads_missing_categorization",
259            LintRule::AdsMissingStrategy => "ads_missing_strategy",
260            LintRule::AdsMissingTechnicalContext => "ads_missing_technical_context",
261            LintRule::AdsMissingBlindSpots => "ads_missing_blind_spots",
262            LintRule::AdsMissingFalsePositives => "ads_missing_false_positives",
263            LintRule::AdsMissingValidation => "ads_missing_validation",
264            LintRule::AdsMissingPriority => "ads_missing_priority",
265            LintRule::AdsMissingResponse => "ads_missing_response",
266            LintRule::AdsEmptySection => "ads_empty_section",
267            LintRule::AdsUnknownSection => "ads_unknown_section",
268            LintRule::ExemplarShape => "exemplar_shape",
269            LintRule::ExemplarWrongRuleKind => "exemplar_wrong_rule_kind",
270        };
271        write!(f, "{s}")
272    }
273}
274
275/// A source span (line/column, both 0-indexed).
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
277pub struct Span {
278    pub start_line: u32,
279    pub start_col: u32,
280    pub end_line: u32,
281    pub end_col: u32,
282}
283
284// =============================================================================
285// Auto-fix types
286// =============================================================================
287
288/// Whether a fix is safe to apply automatically or needs manual review.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
290pub enum FixDisposition {
291    Safe,
292    Unsafe,
293}
294
295/// A single patch operation within a [`Fix`].
296#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
297pub enum FixPatch {
298    ReplaceValue { path: String, new_value: String },
299    ReplaceKey { path: String, new_key: String },
300    Remove { path: String },
301}
302
303/// A suggested fix for a lint finding.
304#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
305pub struct Fix {
306    pub title: String,
307    pub disposition: FixDisposition,
308    pub patches: Vec<FixPatch>,
309}
310
311/// A single lint finding.
312#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
313pub struct LintWarning {
314    pub rule: LintRule,
315    pub severity: Severity,
316    pub message: String,
317    pub path: String,
318    pub span: Option<Span>,
319    pub fix: Option<Fix>,
320}
321
322impl fmt::Display for LintWarning {
323    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
324        write!(
325            f,
326            "{}[{}]: {}\n    --> {}",
327            self.severity, self.rule, self.message, self.path
328        )
329    }
330}
331
332/// Result of linting a single file (may contain multiple YAML documents).
333#[derive(Debug, Clone, Serialize)]
334pub struct FileLintResult {
335    pub path: std::path::PathBuf,
336    pub warnings: Vec<LintWarning>,
337}
338
339impl FileLintResult {
340    pub fn has_errors(&self) -> bool {
341        self.warnings.iter().any(|w| w.severity == Severity::Error)
342    }
343
344    pub fn error_count(&self) -> usize {
345        self.warnings
346            .iter()
347            .filter(|w| w.severity == Severity::Error)
348            .count()
349    }
350
351    pub fn warning_count(&self) -> usize {
352        self.warnings
353            .iter()
354            .filter(|w| w.severity == Severity::Warning)
355            .count()
356    }
357
358    pub fn info_count(&self) -> usize {
359        self.warnings
360            .iter()
361            .filter(|w| w.severity == Severity::Info)
362            .count()
363    }
364
365    pub fn hint_count(&self) -> usize {
366        self.warnings
367            .iter()
368            .filter(|w| w.severity == Severity::Hint)
369            .count()
370    }
371}
372
373// =============================================================================
374// Helpers (shared with rule submodules)
375// =============================================================================
376
377static KEY_CACHE: LazyLock<HashMap<&'static str, Value>> = LazyLock::new(|| {
378    [
379        "action",
380        "author",
381        "category",
382        "condition",
383        "correlation",
384        "custom_attributes",
385        "date",
386        "description",
387        "detection",
388        "falsepositives",
389        "field",
390        "fields",
391        "filter",
392        "gap",
393        "generate",
394        "group-by",
395        "id",
396        "level",
397        "logsource",
398        "modified",
399        "name",
400        "product",
401        "references",
402        "related",
403        "rsigma.gap",
404        "rsigma.window",
405        "rules",
406        "scope",
407        "selection",
408        "service",
409        "sigma-version",
410        "status",
411        "tags",
412        "taxonomy",
413        "timeframe",
414        "timespan",
415        "title",
416        "type",
417        "window",
418    ]
419    .into_iter()
420    .map(|n| (n, Value::String(n.into())))
421    .collect()
422});
423
424pub(crate) fn key(s: &str) -> &'static Value {
425    KEY_CACHE
426        .get(s)
427        .unwrap_or_else(|| panic!("lint key not pre-cached: \"{s}\" — add it to KEY_CACHE"))
428}
429
430pub(crate) fn get_str<'a>(m: &'a yaml_serde::Mapping, k: &str) -> Option<&'a str> {
431    m.get(key(k)).and_then(|v| v.as_str())
432}
433
434pub(crate) fn get_mapping<'a>(
435    m: &'a yaml_serde::Mapping,
436    k: &str,
437) -> Option<&'a yaml_serde::Mapping> {
438    m.get(key(k)).and_then(|v| v.as_mapping())
439}
440
441pub(crate) fn get_seq<'a>(m: &'a yaml_serde::Mapping, k: &str) -> Option<&'a yaml_serde::Sequence> {
442    m.get(key(k)).and_then(|v| v.as_sequence())
443}
444
445pub(crate) fn warn(
446    rule: LintRule,
447    severity: Severity,
448    message: impl Into<String>,
449    path: impl Into<String>,
450) -> LintWarning {
451    LintWarning {
452        rule,
453        severity,
454        message: message.into(),
455        path: path.into(),
456        span: None,
457        fix: None,
458    }
459}
460
461pub(crate) fn err(
462    rule: LintRule,
463    message: impl Into<String>,
464    path: impl Into<String>,
465) -> LintWarning {
466    warn(rule, Severity::Error, message, path)
467}
468
469pub(crate) fn warning(
470    rule: LintRule,
471    message: impl Into<String>,
472    path: impl Into<String>,
473) -> LintWarning {
474    warn(rule, Severity::Warning, message, path)
475}
476
477pub(crate) fn info(
478    rule: LintRule,
479    message: impl Into<String>,
480    path: impl Into<String>,
481) -> LintWarning {
482    warn(rule, Severity::Info, message, path)
483}
484
485pub(crate) fn safe_fix(title: impl Into<String>, patches: Vec<FixPatch>) -> Option<Fix> {
486    Some(Fix {
487        title: title.into(),
488        disposition: FixDisposition::Safe,
489        patches,
490    })
491}
492
493/// Find the closest match for `input` among `candidates` using edit distance.
494pub(crate) fn closest_match<'a>(
495    input: &str,
496    candidates: &[&'a str],
497    max_distance: usize,
498) -> Option<&'a str> {
499    candidates
500        .iter()
501        .filter(|c| edit_distance(input, c) <= max_distance)
502        .min_by_key(|c| edit_distance(input, c))
503        .copied()
504}
505
506/// Levenshtein edit distance between two strings.
507pub(crate) fn edit_distance(a: &str, b: &str) -> usize {
508    let (a_len, b_len) = (a.len(), b.len());
509    if a_len == 0 {
510        return b_len;
511    }
512    if b_len == 0 {
513        return a_len;
514    }
515    let mut prev: Vec<usize> = (0..=b_len).collect();
516    let mut curr = vec![0; b_len + 1];
517    for (i, ca) in a.bytes().enumerate() {
518        curr[0] = i + 1;
519        for (j, cb) in b.bytes().enumerate() {
520            let cost = if ca == cb { 0 } else { 1 };
521            curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1);
522        }
523        std::mem::swap(&mut prev, &mut curr);
524    }
525    prev[b_len]
526}
527
528pub(crate) const TYPO_MAX_EDIT_DISTANCE: usize = 2;
529
530// =============================================================================
531// Document type detection
532// =============================================================================
533
534#[derive(Debug, Clone, Copy, PartialEq, Eq)]
535pub(crate) enum DocType {
536    Detection,
537    Correlation,
538    Filter,
539}
540
541impl DocType {
542    pub(crate) fn known_keys(&self) -> &'static [&'static str] {
543        match self {
544            DocType::Detection => rules::shared::KNOWN_KEYS_DETECTION,
545            DocType::Correlation => rules::shared::KNOWN_KEYS_CORRELATION,
546            DocType::Filter => rules::shared::KNOWN_KEYS_FILTER,
547        }
548    }
549}
550
551fn detect_doc_type(m: &yaml_serde::Mapping) -> DocType {
552    if m.contains_key(key("correlation")) {
553        DocType::Correlation
554    } else if m.contains_key(key("filter")) {
555        DocType::Filter
556    } else {
557        DocType::Detection
558    }
559}
560
561fn is_action_fragment(m: &yaml_serde::Mapping) -> bool {
562    matches!(get_str(m, "action"), Some("global" | "reset" | "repeat"))
563}
564
565// =============================================================================
566// Cross-document reference resolution
567// =============================================================================
568
569/// An index of referenceable rules (detection rules and correlation rules) by
570/// their identifiers (`id` and `name`), each mapped to its resolved
571/// specification major. Built file-local for single-text linting and
572/// directory-global for directory linting.
573struct RuleIndex {
574    majors: HashMap<String, u32>,
575    /// Detection-rule ids and names: the only stable identities a filter can
576    /// target, since filters never apply to correlation rules.
577    detection_identities: HashMap<String, u32>,
578    detection_titles: HashMap<String, u32>,
579    /// Whether the index covers the whole set being linted. Only then is an
580    /// unresolved reference genuinely missing rather than living in a file
581    /// outside the linted scope.
582    complete: bool,
583}
584
585impl RuleIndex {
586    fn new(complete: bool) -> Self {
587        Self {
588            majors: HashMap::new(),
589            detection_identities: HashMap::new(),
590            detection_titles: HashMap::new(),
591            complete,
592        }
593    }
594
595    /// Index every referenceable document in one multi-document YAML text.
596    fn add_text(&mut self, text: &str) {
597        for doc in yaml_serde::Deserializer::from_str(text) {
598            let Ok(value) = Value::deserialize(doc) else {
599                break;
600            };
601            self.add_value(&value);
602        }
603    }
604
605    fn add_value(&mut self, value: &Value) {
606        let Some(m) = value.as_mapping() else {
607            return;
608        };
609        if is_action_fragment(m) {
610            return;
611        }
612        // Only detection rules and correlation rules can be referenced.
613        let doc_type = detect_doc_type(m);
614        if matches!(doc_type, DocType::Detection | DocType::Correlation) {
615            let major = crate::version::resolve_major(
616                m.get(key("sigma-version"))
617                    .and_then(crate::version::major_from_value),
618            );
619            for id_key in ["id", "name"] {
620                if let Some(v) = get_str(m, id_key) {
621                    self.majors.insert(v.to_string(), major);
622                    if doc_type == DocType::Detection {
623                        self.detection_identities.insert(v.to_string(), major);
624                    }
625                }
626            }
627            if doc_type == DocType::Detection
628                && let Some(title) = get_str(m, "title")
629            {
630                self.detection_titles.insert(title.to_string(), major);
631            }
632        }
633    }
634}
635
636/// Extract a `rules:` reference list (a single string or a sequence of strings).
637fn reference_list(v: Option<&Value>) -> Vec<String> {
638    match v {
639        Some(Value::String(s)) => vec![s.clone()],
640        Some(Value::Sequence(seq)) => seq
641            .iter()
642            .filter_map(|x| x.as_str().map(str::to_string))
643            .collect(),
644        _ => Vec::new(),
645    }
646}
647
648/// References declared by a correlation rule (`correlation.rules`).
649fn correlation_rule_refs(m: &yaml_serde::Mapping) -> Vec<String> {
650    m.get(key("correlation"))
651        .and_then(|c| c.as_mapping())
652        .map(|c| reference_list(c.get(key("rules"))))
653        .unwrap_or_default()
654}
655
656/// References declared by a filter rule (`filter.rules`). Returns `None` when the
657/// filter targets every rule (`rules: any`), which is not resolvable.
658fn filter_rule_refs(m: &yaml_serde::Mapping) -> Option<Vec<String>> {
659    let f = m.get(key("filter"))?.as_mapping()?;
660    let rules = f.get(key("rules"))?;
661    if let Some(s) = rules.as_str()
662        && s.eq_ignore_ascii_case("any")
663    {
664        return None;
665    }
666    Some(reference_list(Some(rules)))
667}
668
669/// Cross-document lints over the documents in one YAML text, resolving each
670/// correlation/filter reference against `index`:
671///
672/// - `sigma_version_mismatch` (warning): a referencing document and a resolved
673///   referenced rule declare different specification majors.
674/// - `unknown_rule_reference` (warning): a reference resolves to no rule and the
675///   index is complete (so it is genuinely missing, not out of the linted scope).
676fn lint_cross_references(docs: &[Value], index: &RuleIndex, warnings: &mut Vec<LintWarning>) {
677    for value in docs {
678        let Some(m) = value.as_mapping() else {
679            continue;
680        };
681        if is_action_fragment(m) {
682            continue;
683        }
684        let doc_type = detect_doc_type(m);
685        let (refs, path) = match doc_type {
686            DocType::Correlation => (correlation_rule_refs(m), "/correlation/rules"),
687            DocType::Filter => match filter_rule_refs(m) {
688                Some(refs) => (refs, "/filter/rules"),
689                None => continue,
690            },
691            DocType::Detection => continue,
692        };
693        if refs.is_empty() {
694            continue;
695        }
696        let self_major = crate::version::resolve_major(
697            m.get(key("sigma-version"))
698                .and_then(crate::version::major_from_value),
699        );
700        let label = get_str(m, "title")
701            .or_else(|| get_str(m, "name"))
702            .unwrap_or("<rule>");
703        for r in refs {
704            let identities = if doc_type == DocType::Filter {
705                &index.detection_identities
706            } else {
707                &index.majors
708            };
709            let mut target = identities.get(&r).copied();
710            if target.is_none()
711                && doc_type == DocType::Filter
712                && let Some(title_target) = index.detection_titles.get(&r).copied()
713            {
714                warnings.push(warning(
715                    LintRule::FilterReferenceByTitle,
716                    format!(
717                        "'{label}' references rule title '{r}'; title references are deprecated, \
718                         use the rule id or name"
719                    ),
720                    path,
721                ));
722                target = Some(title_target);
723            }
724            match target {
725                Some(target) if target != self_major => warnings.push(warning(
726                    LintRule::SigmaVersionMismatch,
727                    format!(
728                        "'{label}' targets sigma-version major {self_major} but references rule \
729                         '{r}' which targets major {target}; cross-referencing rules must share a \
730                         specification major"
731                    ),
732                    path,
733                )),
734                Some(_) => {}
735                None if index.complete => warnings.push(warning(
736                    LintRule::UnknownRuleReference,
737                    format!(
738                        "'{label}' references rule '{r}', which was not found among the linted \
739                         rules (matched by id or name)"
740                    ),
741                    path,
742                )),
743                None => {}
744            }
745        }
746    }
747}
748
749// =============================================================================
750// Public API
751// =============================================================================
752
753fn lint_yaml_value_ext(
754    value: &Value,
755    extra_ns: &[String],
756    ads: Option<&AdsConfig>,
757) -> Vec<LintWarning> {
758    let Some(m) = value.as_mapping() else {
759        return vec![err(
760            LintRule::NotAMapping,
761            "document is not a YAML mapping",
762            "/",
763        )];
764    };
765
766    if is_action_fragment(m) {
767        let mut warnings = Vec::new();
768        rules::exemplar::lint_exemplars(m, None, &mut warnings);
769        return warnings;
770    }
771
772    let mut warnings = Vec::new();
773
774    rules::metadata::lint_shared(m, &mut warnings);
775
776    let doc_type = detect_doc_type(m);
777    match doc_type {
778        DocType::Detection => rules::detection::lint_detection_rule(m, &mut warnings, extra_ns),
779        DocType::Correlation => rules::correlation::lint_correlation_rule(m, &mut warnings),
780        DocType::Filter => rules::filter::lint_filter_rule(m, &mut warnings),
781    }
782
783    rules::version::lint_sigma_version(m, doc_type, &mut warnings);
784    rules::shared::lint_unknown_keys(m, doc_type, &mut warnings);
785    rules::exemplar::lint_exemplars(m, Some(doc_type), &mut warnings);
786
787    // ADS enforcement applies to detection rules only and only when an `ads:`
788    // block is configured.
789    if let Some(ads_cfg) = ads
790        && doc_type == DocType::Detection
791    {
792        rules::ads::lint_ads(m, ads_cfg, extra_ns, &mut warnings);
793    }
794
795    warnings
796}
797
798/// Lint a single YAML document value.
799pub fn lint_yaml_value(value: &Value) -> Vec<LintWarning> {
800    lint_yaml_value_ext(value, &[], None)
801}
802
803fn lint_yaml_str_ext(text: &str, extra_ns: &[String], ads: Option<&AdsConfig>) -> Vec<LintWarning> {
804    lint_yaml_str_indexed(text, extra_ns, ads, None)
805}
806
807/// Lint one YAML text. When `external_index` is `Some` (directory linting) it is
808/// the directory-global rule index used for cross-reference checks; when `None`,
809/// a file-local index is built from this text, so cross-file references are out
810/// of scope and `unknown_rule_reference` does not fire.
811fn lint_yaml_str_indexed(
812    text: &str,
813    extra_ns: &[String],
814    ads: Option<&AdsConfig>,
815    external_index: Option<&RuleIndex>,
816) -> Vec<LintWarning> {
817    let mut all_warnings = Vec::new();
818    let mut docs: Vec<Value> = Vec::new();
819
820    for doc in yaml_serde::Deserializer::from_str(text) {
821        let value: Value = match Value::deserialize(doc) {
822            Ok(v) => v,
823            Err(e) => {
824                let mut w = err(
825                    LintRule::YamlParseError,
826                    format!("YAML parse error: {e}"),
827                    "/",
828                );
829                if let Some(loc) = e.location() {
830                    w.span = Some(Span {
831                        start_line: loc.line().saturating_sub(1) as u32,
832                        start_col: loc.column() as u32,
833                        end_line: loc.line().saturating_sub(1) as u32,
834                        end_col: loc.column() as u32 + 1,
835                    });
836                }
837                all_warnings.push(w);
838                break;
839            }
840        };
841
842        for mut w in lint_yaml_value_ext(&value, extra_ns, ads) {
843            w.span = resolve_path_to_span(text, &w.path);
844            all_warnings.push(w);
845        }
846        docs.push(value);
847    }
848
849    // Cross-document checks resolve references against the directory-global index
850    // when given, otherwise a file-local index built from this text's documents.
851    let local_index;
852    let index = match external_index {
853        Some(idx) => idx,
854        None => {
855            let mut idx = RuleIndex::new(false);
856            for v in &docs {
857                idx.add_value(v);
858            }
859            local_index = idx;
860            &local_index
861        }
862    };
863    let mut xref = Vec::new();
864    lint_cross_references(&docs, index, &mut xref);
865    for mut w in xref {
866        w.span = resolve_path_to_span(text, &w.path);
867        all_warnings.push(w);
868    }
869
870    all_warnings
871}
872
873/// Lint a raw YAML string, returning warnings with resolved source spans.
874pub fn lint_yaml_str(text: &str) -> Vec<LintWarning> {
875    lint_yaml_str_ext(text, &[], None)
876}
877
878fn resolve_path_to_span(text: &str, path: &str) -> Option<Span> {
879    if path == "/" || path.is_empty() {
880        for (i, line) in text.lines().enumerate() {
881            let trimmed = line.trim();
882            if !trimmed.is_empty() && !trimmed.starts_with('#') && trimmed != "---" {
883                return Some(Span {
884                    start_line: i as u32,
885                    start_col: 0,
886                    end_line: i as u32,
887                    end_col: line.len() as u32,
888                });
889            }
890        }
891        return None;
892    }
893
894    let segments: Vec<&str> = path.strip_prefix('/').unwrap_or(path).split('/').collect();
895
896    if segments.is_empty() {
897        return None;
898    }
899
900    let lines: Vec<&str> = text.lines().collect();
901    let mut current_indent: i32 = -1;
902    let mut search_start = 0usize;
903    let mut last_matched_line: Option<usize> = None;
904
905    for segment in &segments {
906        let array_index: Option<usize> = segment.parse().ok();
907        let mut found = false;
908
909        let mut line_num = search_start;
910        while line_num < lines.len() {
911            let line = lines[line_num];
912            let trimmed = line.trim();
913            if trimmed.is_empty() || trimmed.starts_with('#') {
914                line_num += 1;
915                continue;
916            }
917
918            let indent = (line.len() - trimmed.len()) as i32;
919
920            if indent <= current_indent && found {
921                break;
922            }
923            if indent <= current_indent {
924                line_num += 1;
925                continue;
926            }
927
928            if let Some(idx) = array_index {
929                if trimmed.starts_with("- ") && indent > current_indent {
930                    let mut count = 0usize;
931                    for (offset, sl) in lines[search_start..].iter().enumerate() {
932                        let scan = search_start + offset;
933                        let st = sl.trim();
934                        if st.is_empty() || st.starts_with('#') {
935                            continue;
936                        }
937                        let si = (sl.len() - st.len()) as i32;
938                        if si == indent && st.starts_with("- ") {
939                            if count == idx {
940                                last_matched_line = Some(scan);
941                                search_start = scan + 1;
942                                current_indent = indent;
943                                found = true;
944                                break;
945                            }
946                            count += 1;
947                        }
948                        if si < indent && count > 0 {
949                            break;
950                        }
951                    }
952                    break;
953                }
954            } else {
955                let key_pattern = format!("{segment}:");
956                if trimmed.starts_with(&key_pattern) || trimmed == *segment {
957                    last_matched_line = Some(line_num);
958                    search_start = line_num + 1;
959                    current_indent = indent;
960                    found = true;
961                    break;
962                }
963            }
964
965            line_num += 1;
966        }
967
968        if !found && last_matched_line.is_none() {
969            break;
970        }
971    }
972
973    last_matched_line.map(|line_num| {
974        let line = lines[line_num];
975        Span {
976            start_line: line_num as u32,
977            start_col: 0,
978            end_line: line_num as u32,
979            end_col: line.len() as u32,
980        }
981    })
982}
983
984/// Lint all YAML documents in a file.
985pub fn lint_yaml_file(path: &Path) -> crate::error::Result<FileLintResult> {
986    let content = std::fs::read_to_string(path)?;
987    let warnings = lint_yaml_str(&content);
988    Ok(FileLintResult {
989        path: path.to_path_buf(),
990        warnings,
991    })
992}
993
994/// Recursively collect `.yml`/`.yaml` file paths under `dir`, in sorted
995/// depth-first order, skipping hidden directories and any path matching the
996/// exclude set (relative to `base`). Symlink loops are guarded by `visited`.
997fn collect_yaml_files(
998    dir: &Path,
999    base: &Path,
1000    exclude_set: Option<&globset::GlobSet>,
1001    files: &mut Vec<std::path::PathBuf>,
1002    visited: &mut HashSet<std::path::PathBuf>,
1003) -> crate::error::Result<()> {
1004    let canonical = match dir.canonicalize() {
1005        Ok(p) => p,
1006        Err(_) => return Ok(()),
1007    };
1008    if !visited.insert(canonical) {
1009        return Ok(());
1010    }
1011
1012    let mut entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
1013    entries.sort_by_key(|e| e.path());
1014
1015    for entry in entries {
1016        let path = entry.path();
1017
1018        if let Some(gs) = exclude_set
1019            && let Ok(rel) = path.strip_prefix(base)
1020            && gs.is_match(rel)
1021        {
1022            continue;
1023        }
1024
1025        if path.is_dir() {
1026            if path
1027                .file_name()
1028                .and_then(|n| n.to_str())
1029                .is_some_and(|n| n.starts_with('.'))
1030            {
1031                continue;
1032            }
1033            collect_yaml_files(&path, base, exclude_set, files, visited)?;
1034        } else if matches!(
1035            path.extension().and_then(|e| e.to_str()),
1036            Some("yml" | "yaml")
1037        ) {
1038            files.push(path);
1039        }
1040    }
1041    Ok(())
1042}
1043
1044/// Two-pass directory lint: collect and read every file once to build a
1045/// directory-global rule index, then lint each file against it so
1046/// cross-reference checks see rules defined in sibling files.
1047fn lint_directory_impl(
1048    dir: &Path,
1049    config: Option<&LintConfig>,
1050) -> crate::error::Result<Vec<FileLintResult>> {
1051    let exclude_set = config.and_then(LintConfig::build_exclude_set);
1052    let mut files = Vec::new();
1053    let mut visited = HashSet::new();
1054    collect_yaml_files(dir, dir, exclude_set.as_ref(), &mut files, &mut visited)?;
1055
1056    // Read each file once and index every referenceable rule across the tree.
1057    let mut index = RuleIndex::new(true);
1058    let mut contents: Vec<(std::path::PathBuf, std::result::Result<String, String>)> =
1059        Vec::with_capacity(files.len());
1060    for path in files {
1061        match std::fs::read_to_string(&path) {
1062            Ok(text) => {
1063                index.add_text(&text);
1064                contents.push((path, Ok(text)));
1065            }
1066            Err(e) => contents.push((path, Err(format!("error reading file: {e}")))),
1067        }
1068    }
1069
1070    let mut results = Vec::with_capacity(contents.len());
1071    for (path, content) in contents {
1072        match content {
1073            Ok(text) => {
1074                let warnings = match config {
1075                    Some(cfg) => {
1076                        let w = lint_yaml_str_indexed(
1077                            &text,
1078                            &cfg.tag_namespaces,
1079                            cfg.ads.as_ref(),
1080                            Some(&index),
1081                        );
1082                        apply_suppressions(w, cfg, &parse_inline_suppressions(&text))
1083                    }
1084                    None => lint_yaml_str_indexed(&text, &[], None, Some(&index)),
1085                };
1086                results.push(FileLintResult { path, warnings });
1087            }
1088            Err(msg) => results.push(FileLintResult {
1089                path,
1090                warnings: vec![err(LintRule::FileReadError, msg, "/")],
1091            }),
1092        }
1093    }
1094    Ok(results)
1095}
1096
1097/// Lint all `.yml`/`.yaml` files in a directory recursively.
1098pub fn lint_yaml_directory(dir: &Path) -> crate::error::Result<Vec<FileLintResult>> {
1099    lint_directory_impl(dir, None)
1100}
1101
1102// =============================================================================
1103// Lint configuration & suppression
1104// =============================================================================
1105
1106/// Configuration for lint rule suppression and severity overrides.
1107#[derive(Debug, Clone, Default, Serialize)]
1108pub struct LintConfig {
1109    pub disabled_rules: HashSet<String>,
1110    pub severity_overrides: HashMap<String, Severity>,
1111    pub exclude_patterns: Vec<String>,
1112    /// Extra tag namespaces recognised in addition to the built-in set.
1113    pub tag_namespaces: Vec<String>,
1114    /// ADS enforcement configuration. `None` (the default) leaves the ADS
1115    /// presence checks off; an `ads:` block in the config enables them.
1116    #[serde(skip_serializing_if = "Option::is_none")]
1117    pub ads: Option<AdsConfig>,
1118}
1119
1120/// ADS (Alerting and Detection Strategy) enforcement configuration.
1121///
1122/// Present (`Some`) only when an `ads:` block appears in the layered lint
1123/// config; the ADS presence checks are off otherwise. When enabled, the checks
1124/// fire on detection rules whose `status` is in [`enforce_status`](Self::enforce_status)
1125/// and flag each missing [`required`](Self::required) section.
1126#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1127pub struct AdsConfig {
1128    /// Rule statuses that require ADS sections (lowercased).
1129    pub enforce_status: Vec<String>,
1130    /// The ADS section ids that are mandatory.
1131    pub required: Vec<String>,
1132    /// A single severity applied to every ADS finding, overriding the
1133    /// per-section default. `None` keeps the catalogue defaults.
1134    #[serde(skip_serializing_if = "Option::is_none")]
1135    pub severity: Option<Severity>,
1136}
1137
1138impl Default for AdsConfig {
1139    fn default() -> Self {
1140        AdsConfig {
1141            enforce_status: vec!["stable".to_string()],
1142            required: AdsSection::all()
1143                .iter()
1144                .map(|s| s.id().to_string())
1145                .collect(),
1146            severity: None,
1147        }
1148    }
1149}
1150
1151impl AdsConfig {
1152    /// Whether a rule with the given `status` string is in scope for ADS
1153    /// enforcement.
1154    pub fn enforces_status(&self, status: Option<&str>) -> bool {
1155        match status {
1156            Some(s) => self.enforce_status.iter().any(|e| e == s),
1157            None => false,
1158        }
1159    }
1160
1161    /// Whether the section id is required.
1162    pub fn requires(&self, section_id: &str) -> bool {
1163        self.required.iter().any(|r| r == section_id)
1164    }
1165}
1166
1167#[derive(Debug, Deserialize)]
1168struct RawLintConfig {
1169    #[serde(default)]
1170    disabled_rules: Vec<String>,
1171    #[serde(default)]
1172    severity_overrides: HashMap<String, String>,
1173    #[serde(default)]
1174    exclude: Vec<String>,
1175    #[serde(default)]
1176    tag_namespaces: Vec<String>,
1177    #[serde(default)]
1178    ads: Option<RawAdsConfig>,
1179}
1180
1181#[derive(Debug, Deserialize)]
1182struct RawAdsConfig {
1183    #[serde(default)]
1184    enforce_status: Option<Vec<String>>,
1185    #[serde(default)]
1186    required: Option<Vec<String>>,
1187    #[serde(default)]
1188    severity: Option<String>,
1189}
1190
1191/// Parse a lint severity wire string.
1192fn parse_severity(s: &str) -> Option<Severity> {
1193    match s {
1194        "error" => Some(Severity::Error),
1195        "warning" => Some(Severity::Warning),
1196        "info" => Some(Severity::Info),
1197        "hint" => Some(Severity::Hint),
1198        _ => None,
1199    }
1200}
1201
1202/// Build a validated [`AdsConfig`] from its raw, deserialized form, layering
1203/// any provided fields over the defaults.
1204fn ads_config_from_raw(raw: RawAdsConfig) -> crate::error::Result<AdsConfig> {
1205    let mut config = AdsConfig::default();
1206
1207    if let Some(statuses) = raw.enforce_status {
1208        const VALID_STATUSES: &[&str] = &[
1209            "stable",
1210            "test",
1211            "experimental",
1212            "deprecated",
1213            "unsupported",
1214        ];
1215        let mut normalised = Vec::with_capacity(statuses.len());
1216        for s in statuses {
1217            let lower = s.to_lowercase();
1218            if !VALID_STATUSES.contains(&lower.as_str()) {
1219                return Err(crate::error::SigmaParserError::InvalidRule(format!(
1220                    "invalid ads.enforce_status '{s}'; expected one of: {}",
1221                    VALID_STATUSES.join(", ")
1222                )));
1223            }
1224            normalised.push(lower);
1225        }
1226        dedup_preserving_order(&mut normalised);
1227        config.enforce_status = normalised;
1228    }
1229
1230    if let Some(required) = raw.required {
1231        let mut ids = Vec::with_capacity(required.len());
1232        for id in required {
1233            let lower = id.to_lowercase();
1234            if AdsSection::from_id(&lower).is_none() {
1235                return Err(crate::error::SigmaParserError::InvalidRule(format!(
1236                    "invalid ads.required section '{id}'; expected one of: {}",
1237                    AdsSection::all()
1238                        .iter()
1239                        .map(|s| s.id())
1240                        .collect::<Vec<_>>()
1241                        .join(", ")
1242                )));
1243            }
1244            ids.push(lower);
1245        }
1246        dedup_preserving_order(&mut ids);
1247        config.required = ids;
1248    }
1249
1250    if let Some(sev) = raw.severity {
1251        config.severity = Some(parse_severity(&sev).ok_or_else(|| {
1252            crate::error::SigmaParserError::InvalidRule(format!(
1253                "invalid ads.severity '{sev}'; expected error, warning, info, or hint"
1254            ))
1255        })?);
1256    }
1257
1258    Ok(config)
1259}
1260
1261/// Remove duplicate entries from a list while keeping the first occurrence of
1262/// each, so merged `exclude_patterns` / `tag_namespaces` stay stable and don't
1263/// repeat a value that appears in both the config file and a CLI flag.
1264fn dedup_preserving_order(items: &mut Vec<String>) {
1265    let mut seen = HashSet::new();
1266    items.retain(|item| seen.insert(item.clone()));
1267}
1268
1269impl LintConfig {
1270    pub fn load(path: &Path) -> crate::error::Result<Self> {
1271        let content = std::fs::read_to_string(path)?;
1272        let raw: RawLintConfig = yaml_serde::from_str(&content)?;
1273
1274        let disabled_rules: HashSet<String> = raw.disabled_rules.into_iter().collect();
1275        let mut severity_overrides = HashMap::new();
1276        for (rule, sev_str) in &raw.severity_overrides {
1277            let sev = parse_severity(sev_str).ok_or_else(|| {
1278                crate::error::SigmaParserError::InvalidRule(format!(
1279                    "invalid severity '{sev_str}' for rule '{rule}' in lint config"
1280                ))
1281            })?;
1282            severity_overrides.insert(rule.clone(), sev);
1283        }
1284
1285        let mut exclude_patterns = raw.exclude;
1286        dedup_preserving_order(&mut exclude_patterns);
1287
1288        let mut tag_namespaces: Vec<String> = raw
1289            .tag_namespaces
1290            .into_iter()
1291            .map(|s| s.to_lowercase())
1292            .collect();
1293        dedup_preserving_order(&mut tag_namespaces);
1294
1295        let ads = raw.ads.map(ads_config_from_raw).transpose()?;
1296
1297        Ok(LintConfig {
1298            disabled_rules,
1299            severity_overrides,
1300            exclude_patterns,
1301            tag_namespaces,
1302            ads,
1303        })
1304    }
1305
1306    pub fn find_in_ancestors(start_path: &Path) -> Option<std::path::PathBuf> {
1307        let dir = if start_path.is_file() {
1308            start_path.parent()?
1309        } else {
1310            start_path
1311        };
1312
1313        let mut current = dir;
1314        loop {
1315            let candidate = current.join(".rsigma-lint.yml");
1316            if candidate.is_file() {
1317                return Some(candidate);
1318            }
1319            let candidate_yaml = current.join(".rsigma-lint.yaml");
1320            if candidate_yaml.is_file() {
1321                return Some(candidate_yaml);
1322            }
1323            current = current.parent()?;
1324        }
1325    }
1326
1327    pub fn merge(&mut self, other: &LintConfig) {
1328        self.disabled_rules
1329            .extend(other.disabled_rules.iter().cloned());
1330        for (rule, sev) in &other.severity_overrides {
1331            self.severity_overrides.insert(rule.clone(), *sev);
1332        }
1333        self.exclude_patterns
1334            .extend(other.exclude_patterns.iter().cloned());
1335        dedup_preserving_order(&mut self.exclude_patterns);
1336        self.tag_namespaces
1337            .extend(other.tag_namespaces.iter().cloned());
1338        dedup_preserving_order(&mut self.tag_namespaces);
1339        // A nearer-layer `ads:` block replaces the inherited one wholesale, so
1340        // a project can set its own ADS bar without merging stale section lists.
1341        if other.ads.is_some() {
1342            self.ads = other.ads.clone();
1343        }
1344    }
1345
1346    pub fn is_disabled(&self, rule: &LintRule) -> bool {
1347        self.disabled_rules.contains(&rule.to_string())
1348    }
1349
1350    pub fn build_exclude_set(&self) -> Option<globset::GlobSet> {
1351        if self.exclude_patterns.is_empty() {
1352            return None;
1353        }
1354        let mut builder = globset::GlobSetBuilder::new();
1355        for pat in &self.exclude_patterns {
1356            if let Ok(glob) = globset::GlobBuilder::new(pat)
1357                .literal_separator(false)
1358                .build()
1359            {
1360                builder.add(glob);
1361            }
1362        }
1363        builder.build().ok()
1364    }
1365}
1366
1367// =============================================================================
1368// Inline suppression comments
1369// =============================================================================
1370
1371#[derive(Debug, Clone, Default)]
1372pub struct InlineSuppressions {
1373    pub disable_all: bool,
1374    pub file_disabled: HashSet<String>,
1375    pub line_disabled: HashMap<u32, Option<HashSet<String>>>,
1376}
1377
1378pub fn parse_inline_suppressions(text: &str) -> InlineSuppressions {
1379    let mut result = InlineSuppressions::default();
1380
1381    for (i, line) in text.lines().enumerate() {
1382        let trimmed = line.trim();
1383
1384        let comment = if let Some(pos) = find_yaml_comment(trimmed) {
1385            trimmed[pos + 1..].trim()
1386        } else {
1387            continue;
1388        };
1389
1390        if let Some(rest) = comment.strip_prefix("rsigma-disable-next-line") {
1391            let rest = rest.trim();
1392            let next_line = (i + 1) as u32;
1393            if rest.is_empty() {
1394                result.line_disabled.insert(next_line, None);
1395            } else {
1396                let rules: HashSet<String> = rest
1397                    .split(',')
1398                    .map(|s| s.trim().to_string())
1399                    .filter(|s| !s.is_empty())
1400                    .collect();
1401                if !rules.is_empty() {
1402                    result
1403                        .line_disabled
1404                        .entry(next_line)
1405                        .and_modify(|existing| {
1406                            if let Some(existing_set) = existing {
1407                                existing_set.extend(rules.iter().cloned());
1408                            }
1409                        })
1410                        .or_insert(Some(rules));
1411                }
1412            }
1413        } else if let Some(rest) = comment.strip_prefix("rsigma-disable") {
1414            let rest = rest.trim();
1415            if rest.is_empty() {
1416                result.disable_all = true;
1417            } else {
1418                for rule in rest.split(',') {
1419                    let rule = rule.trim();
1420                    if !rule.is_empty() {
1421                        result.file_disabled.insert(rule.to_string());
1422                    }
1423                }
1424            }
1425        }
1426    }
1427
1428    result
1429}
1430
1431fn find_yaml_comment(line: &str) -> Option<usize> {
1432    let mut in_single = false;
1433    let mut in_double = false;
1434    for (i, c) in line.char_indices() {
1435        match c {
1436            '\'' if !in_double => in_single = !in_single,
1437            '"' if !in_single => in_double = !in_double,
1438            '#' if !in_single && !in_double => return Some(i),
1439            _ => {}
1440        }
1441    }
1442    None
1443}
1444
1445impl InlineSuppressions {
1446    pub fn is_suppressed(&self, warning: &LintWarning) -> bool {
1447        if self.disable_all {
1448            return true;
1449        }
1450
1451        let rule_name = warning.rule.to_string();
1452        if self.file_disabled.contains(&rule_name) {
1453            return true;
1454        }
1455
1456        if let Some(span) = &warning.span
1457            && let Some(line_rules) = self.line_disabled.get(&span.start_line)
1458        {
1459            return match line_rules {
1460                None => true,
1461                Some(rules) => rules.contains(&rule_name),
1462            };
1463        }
1464
1465        false
1466    }
1467}
1468
1469// =============================================================================
1470// Suppression filtering
1471// =============================================================================
1472
1473pub fn apply_suppressions(
1474    warnings: Vec<LintWarning>,
1475    config: &LintConfig,
1476    inline: &InlineSuppressions,
1477) -> Vec<LintWarning> {
1478    warnings
1479        .into_iter()
1480        .filter(|w| !config.is_disabled(&w.rule))
1481        .filter(|w| !inline.is_suppressed(w))
1482        .map(|mut w| {
1483            let rule_name = w.rule.to_string();
1484            if let Some(sev) = config.severity_overrides.get(&rule_name) {
1485                w.severity = *sev;
1486            }
1487            w
1488        })
1489        .collect()
1490}
1491
1492pub fn lint_yaml_str_with_config(text: &str, config: &LintConfig) -> Vec<LintWarning> {
1493    let warnings = lint_yaml_str_ext(text, &config.tag_namespaces, config.ads.as_ref());
1494    let inline = parse_inline_suppressions(text);
1495    apply_suppressions(warnings, config, &inline)
1496}
1497
1498pub fn lint_yaml_file_with_config(
1499    path: &Path,
1500    config: &LintConfig,
1501) -> crate::error::Result<FileLintResult> {
1502    let content = std::fs::read_to_string(path)?;
1503    let warnings = lint_yaml_str_with_config(&content, config);
1504    Ok(FileLintResult {
1505        path: path.to_path_buf(),
1506        warnings,
1507    })
1508}
1509
1510pub fn lint_yaml_directory_with_config(
1511    dir: &Path,
1512    config: &LintConfig,
1513) -> crate::error::Result<Vec<FileLintResult>> {
1514    lint_directory_impl(dir, Some(config))
1515}
1516
1517// =============================================================================
1518// Tests
1519// =============================================================================
1520
1521#[cfg(test)]
1522mod tests {
1523    use super::*;
1524
1525    fn yaml_value(yaml: &str) -> Value {
1526        yaml_serde::from_str(yaml).unwrap()
1527    }
1528
1529    fn lint(yaml: &str) -> Vec<LintWarning> {
1530        lint_yaml_value(&yaml_value(yaml))
1531    }
1532
1533    fn has_rule(warnings: &[LintWarning], rule: LintRule) -> bool {
1534        warnings.iter().any(|w| w.rule == rule)
1535    }
1536
1537    fn has_no_rule(warnings: &[LintWarning], rule: LintRule) -> bool {
1538        !has_rule(warnings, rule)
1539    }
1540
1541    #[test]
1542    fn valid_detection_rule_no_errors() {
1543        let w = lint(
1544            r#"
1545title: Test Rule
1546id: 929a690e-bef0-4204-a928-ef5e620d6fcc
1547status: test
1548logsource:
1549    category: process_creation
1550    product: windows
1551detection:
1552    selection:
1553        CommandLine|contains: 'whoami'
1554    condition: selection
1555level: medium
1556tags:
1557    - attack.execution
1558    - attack.t1059
1559"#,
1560        );
1561        let errors: Vec<_> = w.iter().filter(|w| w.severity == Severity::Error).collect();
1562        assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1563    }
1564
1565    #[test]
1566    fn not_a_mapping() {
1567        let v: yaml_serde::Value = yaml_serde::from_str("- item1\n- item2").unwrap();
1568        let w = lint_yaml_value(&v);
1569        assert!(has_rule(&w, LintRule::NotAMapping));
1570    }
1571
1572    #[test]
1573    fn lint_yaml_str_produces_spans() {
1574        let text = r#"title: Test
1575status: invalid_status
1576logsource:
1577    category: test
1578detection:
1579    selection:
1580        field: value
1581    condition: selection
1582level: medium
1583"#;
1584        let warnings = lint_yaml_str(text);
1585        let invalid_status = warnings.iter().find(|w| w.rule == LintRule::InvalidStatus);
1586        assert!(invalid_status.is_some(), "expected InvalidStatus warning");
1587        let span = invalid_status.unwrap().span;
1588        assert!(span.is_some(), "expected span to be resolved");
1589        assert_eq!(span.unwrap().start_line, 1);
1590    }
1591
1592    #[test]
1593    fn yaml_parse_error_uses_correct_rule() {
1594        let text = "title: [unclosed";
1595        let warnings = lint_yaml_str(text);
1596        assert!(has_rule(&warnings, LintRule::YamlParseError));
1597        assert!(has_no_rule(&warnings, LintRule::MissingTitle));
1598    }
1599
1600    #[test]
1601    fn action_global_skipped() {
1602        let w = lint(
1603            r#"
1604action: global
1605title: Global Template
1606logsource:
1607    product: windows
1608"#,
1609        );
1610        assert!(w.is_empty());
1611    }
1612
1613    #[test]
1614    fn action_reset_skipped() {
1615        let w = lint(
1616            r#"
1617action: reset
1618"#,
1619        );
1620        assert!(w.is_empty());
1621    }
1622
1623    #[test]
1624    fn resolve_path_to_span_root() {
1625        let text = "title: Test\nstatus: test\n";
1626        let span = resolve_path_to_span(text, "/");
1627        assert!(span.is_some());
1628        assert_eq!(span.unwrap().start_line, 0);
1629    }
1630
1631    #[test]
1632    fn resolve_path_to_span_top_level_key() {
1633        let text = "title: Test\nstatus: test\nlevel: high\n";
1634        let span = resolve_path_to_span(text, "/status");
1635        assert!(span.is_some());
1636        assert_eq!(span.unwrap().start_line, 1);
1637    }
1638
1639    #[test]
1640    fn resolve_path_to_span_nested_key() {
1641        let text = "title: Test\nlogsource:\n    category: test\n    product: windows\n";
1642        let span = resolve_path_to_span(text, "/logsource/product");
1643        assert!(span.is_some());
1644        assert_eq!(span.unwrap().start_line, 3);
1645    }
1646
1647    #[test]
1648    fn resolve_path_to_span_missing_key() {
1649        let text = "title: Test\nstatus: test\n";
1650        let span = resolve_path_to_span(text, "/nonexistent");
1651        assert!(span.is_none());
1652    }
1653
1654    #[test]
1655    fn multi_doc_yaml_lints_all_documents() {
1656        let text = r#"title: Rule 1
1657logsource:
1658    category: test
1659detection:
1660    selection:
1661        field: value
1662    condition: selection
1663level: medium
1664---
1665title: Rule 2
1666status: bad_status
1667logsource:
1668    category: test
1669detection:
1670    selection:
1671        field: value
1672    condition: selection
1673level: medium
1674"#;
1675        let warnings = lint_yaml_str(text);
1676        assert!(has_rule(&warnings, LintRule::InvalidStatus));
1677    }
1678
1679    #[test]
1680    fn severity_display() {
1681        assert_eq!(format!("{}", Severity::Error), "error");
1682        assert_eq!(format!("{}", Severity::Warning), "warning");
1683        assert_eq!(format!("{}", Severity::Info), "info");
1684        assert_eq!(format!("{}", Severity::Hint), "hint");
1685    }
1686
1687    #[test]
1688    fn file_lint_result_has_errors() {
1689        let result = FileLintResult {
1690            path: std::path::PathBuf::from("test.yml"),
1691            warnings: vec![
1692                warning(LintRule::TitleTooLong, "too long", "/title"),
1693                err(
1694                    LintRule::MissingCondition,
1695                    "missing",
1696                    "/detection/condition",
1697                ),
1698            ],
1699        };
1700        assert!(result.has_errors());
1701        assert_eq!(result.error_count(), 1);
1702        assert_eq!(result.warning_count(), 1);
1703    }
1704
1705    #[test]
1706    fn file_lint_result_no_errors() {
1707        let result = FileLintResult {
1708            path: std::path::PathBuf::from("test.yml"),
1709            warnings: vec![warning(LintRule::TitleTooLong, "too long", "/title")],
1710        };
1711        assert!(!result.has_errors());
1712        assert_eq!(result.error_count(), 0);
1713        assert_eq!(result.warning_count(), 1);
1714    }
1715
1716    #[test]
1717    fn file_lint_result_empty() {
1718        let result = FileLintResult {
1719            path: std::path::PathBuf::from("test.yml"),
1720            warnings: vec![],
1721        };
1722        assert!(!result.has_errors());
1723        assert_eq!(result.error_count(), 0);
1724        assert_eq!(result.warning_count(), 0);
1725    }
1726
1727    #[test]
1728    fn lint_warning_display() {
1729        let w = err(
1730            LintRule::MissingTitle,
1731            "missing required field 'title'",
1732            "/title",
1733        );
1734        let display = format!("{w}");
1735        assert!(display.contains("error"));
1736        assert!(display.contains("missing_title"));
1737        assert!(display.contains("/title"));
1738    }
1739
1740    #[test]
1741    fn file_lint_result_info_count() {
1742        let result = FileLintResult {
1743            path: std::path::PathBuf::from("test.yml"),
1744            warnings: vec![
1745                info(LintRule::MissingDescription, "missing desc", "/description"),
1746                info(LintRule::MissingAuthor, "missing author", "/author"),
1747                warning(LintRule::TitleTooLong, "too long", "/title"),
1748            ],
1749        };
1750        assert_eq!(result.info_count(), 2);
1751        assert_eq!(result.warning_count(), 1);
1752        assert_eq!(result.error_count(), 0);
1753        assert!(!result.has_errors());
1754    }
1755
1756    #[test]
1757    fn parse_inline_disable_all() {
1758        let text = "# rsigma-disable\ntitle: Test\n";
1759        let sup = parse_inline_suppressions(text);
1760        assert!(sup.disable_all);
1761    }
1762
1763    #[test]
1764    fn parse_inline_disable_specific_rules() {
1765        let text = "# rsigma-disable missing_description, missing_author\ntitle: Test\n";
1766        let sup = parse_inline_suppressions(text);
1767        assert!(!sup.disable_all);
1768        assert!(sup.file_disabled.contains("missing_description"));
1769        assert!(sup.file_disabled.contains("missing_author"));
1770    }
1771
1772    #[test]
1773    fn parse_inline_disable_next_line_all() {
1774        let text = "# rsigma-disable-next-line\ntitle: Test\n";
1775        let sup = parse_inline_suppressions(text);
1776        assert!(!sup.disable_all);
1777        assert!(sup.line_disabled.contains_key(&1));
1778        assert!(sup.line_disabled[&1].is_none());
1779    }
1780
1781    #[test]
1782    fn parse_inline_disable_next_line_specific() {
1783        let text = "title: Test\n# rsigma-disable-next-line missing_level\nlevel: medium\n";
1784        let sup = parse_inline_suppressions(text);
1785        assert!(sup.line_disabled.contains_key(&2));
1786        let rules = sup.line_disabled[&2].as_ref().unwrap();
1787        assert!(rules.contains("missing_level"));
1788    }
1789
1790    #[test]
1791    fn parse_inline_no_comments() {
1792        let text = "title: Test\nstatus: test\n";
1793        let sup = parse_inline_suppressions(text);
1794        assert!(!sup.disable_all);
1795        assert!(sup.file_disabled.is_empty());
1796        assert!(sup.line_disabled.is_empty());
1797    }
1798
1799    #[test]
1800    fn parse_inline_comment_in_quoted_string() {
1801        let text = "description: 'no # rsigma-disable here'\ntitle: Test\n";
1802        let sup = parse_inline_suppressions(text);
1803        assert!(!sup.disable_all);
1804        assert!(sup.file_disabled.is_empty());
1805    }
1806
1807    #[test]
1808    fn apply_suppressions_disables_rule() {
1809        let warnings = vec![
1810            info(LintRule::MissingDescription, "desc", "/description"),
1811            info(LintRule::MissingAuthor, "author", "/author"),
1812            warning(LintRule::TitleTooLong, "title", "/title"),
1813        ];
1814        let mut config = LintConfig::default();
1815        config
1816            .disabled_rules
1817            .insert("missing_description".to_string());
1818        let inline = InlineSuppressions::default();
1819
1820        let result = apply_suppressions(warnings, &config, &inline);
1821        assert_eq!(result.len(), 2);
1822        assert!(
1823            result
1824                .iter()
1825                .all(|w| w.rule != LintRule::MissingDescription)
1826        );
1827    }
1828
1829    #[test]
1830    fn apply_suppressions_severity_override() {
1831        let warnings = vec![warning(LintRule::TitleTooLong, "title too long", "/title")];
1832        let mut config = LintConfig::default();
1833        config
1834            .severity_overrides
1835            .insert("title_too_long".to_string(), Severity::Info);
1836        let inline = InlineSuppressions::default();
1837
1838        let result = apply_suppressions(warnings, &config, &inline);
1839        assert_eq!(result.len(), 1);
1840        assert_eq!(result[0].severity, Severity::Info);
1841    }
1842
1843    #[test]
1844    fn apply_suppressions_inline_file_disable() {
1845        let warnings = vec![
1846            info(LintRule::MissingDescription, "desc", "/description"),
1847            info(LintRule::MissingAuthor, "author", "/author"),
1848        ];
1849        let config = LintConfig::default();
1850        let mut inline = InlineSuppressions::default();
1851        inline.file_disabled.insert("missing_author".to_string());
1852
1853        let result = apply_suppressions(warnings, &config, &inline);
1854        assert_eq!(result.len(), 1);
1855        assert_eq!(result[0].rule, LintRule::MissingDescription);
1856    }
1857
1858    #[test]
1859    fn apply_suppressions_inline_disable_all() {
1860        let warnings = vec![
1861            err(LintRule::MissingTitle, "title", "/title"),
1862            warning(LintRule::TitleTooLong, "long", "/title"),
1863        ];
1864        let config = LintConfig::default();
1865        let inline = InlineSuppressions {
1866            disable_all: true,
1867            ..Default::default()
1868        };
1869
1870        let result = apply_suppressions(warnings, &config, &inline);
1871        assert!(result.is_empty());
1872    }
1873
1874    #[test]
1875    fn apply_suppressions_inline_next_line() {
1876        let mut w1 = warning(LintRule::TitleTooLong, "long", "/title");
1877        w1.span = Some(Span {
1878            start_line: 5,
1879            start_col: 0,
1880            end_line: 5,
1881            end_col: 10,
1882        });
1883        let mut w2 = err(LintRule::InvalidStatus, "bad", "/status");
1884        w2.span = Some(Span {
1885            start_line: 6,
1886            start_col: 0,
1887            end_line: 6,
1888            end_col: 10,
1889        });
1890
1891        let config = LintConfig::default();
1892        let mut inline = InlineSuppressions::default();
1893        inline.line_disabled.insert(5, None);
1894
1895        let result = apply_suppressions(vec![w1, w2], &config, &inline);
1896        assert_eq!(result.len(), 1);
1897        assert_eq!(result[0].rule, LintRule::InvalidStatus);
1898    }
1899
1900    #[test]
1901    fn lint_with_config_disables_rules() {
1902        let text = r#"title: Test
1903logsource:
1904    category: test
1905detection:
1906    selection:
1907        field: value
1908    condition: selection
1909level: medium
1910"#;
1911        let mut config = LintConfig::default();
1912        config
1913            .disabled_rules
1914            .insert("missing_description".to_string());
1915        config.disabled_rules.insert("missing_author".to_string());
1916
1917        let warnings = lint_yaml_str_with_config(text, &config);
1918        assert!(
1919            !warnings
1920                .iter()
1921                .any(|w| w.rule == LintRule::MissingDescription)
1922        );
1923        assert!(!warnings.iter().any(|w| w.rule == LintRule::MissingAuthor));
1924    }
1925
1926    #[test]
1927    fn lint_with_inline_disable_next_line() {
1928        let text = r#"title: Test
1929# rsigma-disable-next-line missing_level
1930logsource:
1931    category: test
1932detection:
1933    selection:
1934        field: value
1935    condition: selection
1936"#;
1937        let config = LintConfig::default();
1938        let warnings = lint_yaml_str_with_config(text, &config);
1939        assert!(warnings.iter().any(|w| w.rule == LintRule::MissingLevel));
1940    }
1941
1942    #[test]
1943    fn lint_with_inline_file_disable() {
1944        let text = r#"# rsigma-disable missing_description, missing_author
1945title: Test
1946logsource:
1947    category: test
1948detection:
1949    selection:
1950        field: value
1951    condition: selection
1952level: medium
1953"#;
1954        let config = LintConfig::default();
1955        let warnings = lint_yaml_str_with_config(text, &config);
1956        assert!(
1957            !warnings
1958                .iter()
1959                .any(|w| w.rule == LintRule::MissingDescription)
1960        );
1961        assert!(!warnings.iter().any(|w| w.rule == LintRule::MissingAuthor));
1962    }
1963
1964    #[test]
1965    fn lint_with_inline_disable_all() {
1966        let text = r#"# rsigma-disable
1967title: Test
1968status: invalid_status
1969logsource:
1970    category: test
1971detection:
1972    selection:
1973        field: value
1974    condition: selection
1975"#;
1976        let config = LintConfig::default();
1977        let warnings = lint_yaml_str_with_config(text, &config);
1978        assert!(warnings.is_empty());
1979    }
1980
1981    #[test]
1982    fn lint_config_merge() {
1983        let mut base = LintConfig::default();
1984        base.disabled_rules.insert("rule_a".to_string());
1985        base.severity_overrides
1986            .insert("rule_b".to_string(), Severity::Info);
1987
1988        let other = LintConfig {
1989            disabled_rules: ["rule_c".to_string()].into_iter().collect(),
1990            severity_overrides: [("rule_d".to_string(), Severity::Hint)]
1991                .into_iter()
1992                .collect(),
1993            exclude_patterns: vec!["test/**".to_string()],
1994            tag_namespaces: vec!["myns".to_string()],
1995            ads: None,
1996        };
1997
1998        base.merge(&other);
1999        assert!(base.disabled_rules.contains("rule_a"));
2000        assert!(base.disabled_rules.contains("rule_c"));
2001        assert_eq!(base.severity_overrides.get("rule_b"), Some(&Severity::Info));
2002        assert_eq!(base.severity_overrides.get("rule_d"), Some(&Severity::Hint));
2003        assert_eq!(base.exclude_patterns, vec!["test/**".to_string()]);
2004        assert!(base.tag_namespaces.contains(&"myns".to_string()));
2005    }
2006
2007    #[test]
2008    fn lint_config_merge_dedups_lists() {
2009        let mut base = LintConfig {
2010            exclude_patterns: vec!["config/**".to_string(), "shared/**".to_string()],
2011            tag_namespaces: vec!["myorg".to_string(), "shared".to_string()],
2012            ..Default::default()
2013        };
2014        let other = LintConfig {
2015            // "shared/**" and "shared" overlap with base on purpose.
2016            exclude_patterns: vec!["shared/**".to_string(), "extra/**".to_string()],
2017            tag_namespaces: vec!["shared".to_string(), "internal".to_string()],
2018            ..Default::default()
2019        };
2020
2021        base.merge(&other);
2022
2023        assert_eq!(
2024            base.exclude_patterns,
2025            vec![
2026                "config/**".to_string(),
2027                "shared/**".to_string(),
2028                "extra/**".to_string()
2029            ]
2030        );
2031        assert_eq!(
2032            base.tag_namespaces,
2033            vec![
2034                "myorg".to_string(),
2035                "shared".to_string(),
2036                "internal".to_string()
2037            ]
2038        );
2039    }
2040
2041    #[test]
2042    fn lint_config_load_dedups_and_normalises() {
2043        let yaml = r#"
2044exclude:
2045  - "config/**"
2046  - "config/**"
2047tag_namespaces:
2048  - MyOrg
2049  - myorg
2050  - internal
2051"#;
2052        let mut tmp = tempfile::NamedTempFile::with_suffix(".yml").unwrap();
2053        std::io::Write::write_all(&mut tmp, yaml.as_bytes()).unwrap();
2054        let config = LintConfig::load(tmp.path()).unwrap();
2055
2056        assert_eq!(config.exclude_patterns, vec!["config/**".to_string()]);
2057        // "MyOrg" lowercases to "myorg" and then collapses with the duplicate.
2058        assert_eq!(
2059            config.tag_namespaces,
2060            vec!["myorg".to_string(), "internal".to_string()]
2061        );
2062    }
2063
2064    #[test]
2065    fn lint_config_is_disabled() {
2066        let mut config = LintConfig::default();
2067        config.disabled_rules.insert("missing_title".to_string());
2068        assert!(config.is_disabled(&LintRule::MissingTitle));
2069        assert!(!config.is_disabled(&LintRule::EmptyTitle));
2070    }
2071
2072    #[test]
2073    fn find_yaml_comment_basic() {
2074        assert_eq!(find_yaml_comment("# comment"), Some(0));
2075        assert_eq!(find_yaml_comment("key: value # comment"), Some(11));
2076        assert_eq!(find_yaml_comment("key: 'value # not comment'"), None);
2077        assert_eq!(find_yaml_comment("key: \"value # not comment\""), None);
2078        assert_eq!(find_yaml_comment("key: value"), None);
2079    }
2080
2081    #[test]
2082    fn no_fix_for_unfixable_rule() {
2083        let w = lint(
2084            r#"
2085title: Test
2086logsource:
2087    category: test
2088"#,
2089        );
2090        assert!(has_rule(&w, LintRule::MissingDetection));
2091        let fix = w
2092            .iter()
2093            .find(|w| w.rule == LintRule::MissingDetection)
2094            .and_then(|w| w.fix.as_ref());
2095        assert!(fix.is_none());
2096    }
2097
2098    #[test]
2099    fn lint_config_exclude_from_yaml() {
2100        let yaml = r#"
2101disabled_rules:
2102  - missing_description
2103exclude:
2104  - "config/**"
2105  - "**/unsupported/**"
2106"#;
2107        let tmp = std::env::temp_dir().join("rsigma_test_exclude.yml");
2108        std::fs::write(&tmp, yaml).unwrap();
2109        let config = LintConfig::load(&tmp).unwrap();
2110        std::fs::remove_file(&tmp).ok();
2111
2112        assert!(config.disabled_rules.contains("missing_description"));
2113        assert_eq!(config.exclude_patterns.len(), 2);
2114        assert_eq!(config.exclude_patterns[0], "config/**");
2115        assert_eq!(config.exclude_patterns[1], "**/unsupported/**");
2116    }
2117
2118    #[test]
2119    fn lint_config_build_exclude_set_empty() {
2120        let config = LintConfig::default();
2121        assert!(config.build_exclude_set().is_none());
2122    }
2123
2124    #[test]
2125    fn lint_config_build_exclude_set_matches() {
2126        let config = LintConfig {
2127            exclude_patterns: vec!["config/**".to_string()],
2128            ..Default::default()
2129        };
2130        let gs = config.build_exclude_set().expect("should build");
2131        assert!(gs.is_match("config/data_mapping/foo.yaml"));
2132        assert!(gs.is_match("config/nested/deep/bar.yml"));
2133        assert!(!gs.is_match("rules/windows/test.yml"));
2134    }
2135
2136    #[test]
2137    fn cross_ref_version_mismatch_within_file() {
2138        // A correlation (major 3) referencing a base rule (major 2) by name, in
2139        // the same file, flags the mismatch. unknown_rule_reference does NOT
2140        // fire for a single file (the index is not complete).
2141        let yaml = r#"
2142title: Base Rule
2143name: base_rule
2144sigma-version: 2
2145logsource:
2146    category: test
2147detection:
2148    selection:
2149        EventID: 1
2150    condition: selection
2151---
2152title: Brute Force
2153sigma-version: 3
2154correlation:
2155    type: event_count
2156    rules:
2157        - base_rule
2158    group-by:
2159        - SourceIP
2160    timespan: 5m
2161    condition:
2162        gte: 10
2163"#;
2164        let w = lint_yaml_str(yaml);
2165        assert!(has_rule(&w, LintRule::SigmaVersionMismatch));
2166        assert!(has_no_rule(&w, LintRule::UnknownRuleReference));
2167    }
2168
2169    #[test]
2170    fn cross_ref_matching_version_no_mismatch() {
2171        let yaml = r#"
2172title: Base Rule
2173name: base_rule
2174sigma-version: 3
2175logsource:
2176    category: test
2177detection:
2178    selection:
2179        EventID: 1
2180    condition: selection
2181---
2182title: Brute Force
2183sigma-version: 3
2184correlation:
2185    type: event_count
2186    rules:
2187        - base_rule
2188    group-by:
2189        - SourceIP
2190    timespan: 5m
2191    condition:
2192        gte: 10
2193"#;
2194        assert!(has_no_rule(
2195            &lint_yaml_str(yaml),
2196            LintRule::SigmaVersionMismatch
2197        ));
2198    }
2199
2200    #[test]
2201    fn filter_reference_by_title_is_deprecated() {
2202        let yaml = r#"
2203title: Base Rule
2204id: 00000000-0000-4000-8000-000000000001
2205name: base_rule
2206logsource:
2207    category: test
2208detection:
2209    selection:
2210        EventID: 1
2211    condition: selection
2212---
2213title: Filter by Title
2214logsource:
2215    category: test
2216filter:
2217    rules: [Base Rule]
2218    selection:
2219        User: admin
2220    condition: not selection
2221"#;
2222        assert!(has_rule(
2223            &lint_yaml_str(yaml),
2224            LintRule::FilterReferenceByTitle
2225        ));
2226    }
2227
2228    #[test]
2229    fn filter_reference_by_name_is_not_deprecated() {
2230        let yaml = r#"
2231title: Base Rule
2232name: base_rule
2233logsource:
2234    category: test
2235detection:
2236    selection:
2237        EventID: 1
2238    condition: selection
2239---
2240title: Filter by Name
2241logsource:
2242    category: test
2243filter:
2244    rules: [base_rule]
2245    selection:
2246        User: admin
2247    condition: not selection
2248"#;
2249        assert!(has_no_rule(
2250            &lint_yaml_str(yaml),
2251            LintRule::FilterReferenceByTitle
2252        ));
2253    }
2254
2255    #[test]
2256    fn filter_reference_by_name_wins_over_another_rules_title() {
2257        let yaml = r#"
2258title: Stable Target
2259name: target
2260logsource:
2261    category: test
2262detection:
2263    selection:
2264        EventID: 1
2265    condition: selection
2266---
2267title: target
2268logsource:
2269    category: test
2270detection:
2271    selection:
2272        EventID: 2
2273    condition: selection
2274---
2275title: Filter by Name
2276logsource:
2277    category: test
2278filter:
2279    rules: [target]
2280    selection:
2281        User: admin
2282    condition: not selection
2283"#;
2284        assert!(has_no_rule(
2285            &lint_yaml_str(yaml),
2286            LintRule::FilterReferenceByTitle
2287        ));
2288    }
2289
2290    #[test]
2291    fn filter_reference_ignores_correlation_identities() {
2292        let yaml = r#"
2293title: brute_force
2294logsource:
2295    category: test
2296detection:
2297    selection:
2298        EventID: 4625
2299    condition: selection
2300---
2301title: Brute Force Correlation
2302name: brute_force
2303correlation:
2304    type: event_count
2305    rules: [brute_force]
2306    group-by: [User]
2307    timespan: 5m
2308    condition:
2309        gte: 10
2310---
2311title: Filter Brute Force
2312logsource:
2313    category: test
2314filter:
2315    rules: [brute_force]
2316    selection:
2317        User: admin
2318    condition: not selection
2319"#;
2320        let warnings = lint_yaml_str(yaml);
2321        let filter_title_refs = warnings
2322            .iter()
2323            .filter(|w| w.rule == LintRule::FilterReferenceByTitle)
2324            .count();
2325        assert_eq!(filter_title_refs, 1);
2326    }
2327
2328    #[test]
2329    fn cross_ref_unknown_only_with_complete_index() {
2330        let yaml = r#"
2331title: Brute Force
2332correlation:
2333    type: event_count
2334    rules:
2335        - nonexistent_rule
2336    group-by:
2337        - SourceIP
2338    timespan: 5m
2339    condition:
2340        gte: 10
2341"#;
2342        // Single file: the referenced rule may live elsewhere, so it is out of
2343        // scope and unknown_rule_reference must not fire.
2344        assert!(has_no_rule(
2345            &lint_yaml_str(yaml),
2346            LintRule::UnknownRuleReference
2347        ));
2348
2349        // Directory: the index is complete, so the missing reference is flagged.
2350        let tmp = tempfile::tempdir().unwrap();
2351        std::fs::write(tmp.path().join("corr.yml"), yaml).unwrap();
2352        let results = lint_yaml_directory(tmp.path()).unwrap();
2353        assert!(
2354            results
2355                .iter()
2356                .flat_map(|r| &r.warnings)
2357                .any(|w| w.rule == LintRule::UnknownRuleReference)
2358        );
2359    }
2360
2361    #[test]
2362    fn cross_ref_resolves_across_files() {
2363        // Base rule in one file, correlation in another: the directory index
2364        // resolves the reference and flags the major mismatch across files.
2365        let tmp = tempfile::tempdir().unwrap();
2366        std::fs::write(
2367            tmp.path().join("base.yml"),
2368            r#"
2369title: Base Rule
2370name: base_rule
2371sigma-version: 2
2372logsource:
2373    category: test
2374detection:
2375    selection:
2376        EventID: 1
2377    condition: selection
2378"#,
2379        )
2380        .unwrap();
2381        std::fs::write(
2382            tmp.path().join("corr.yml"),
2383            r#"
2384title: Brute Force
2385sigma-version: 3
2386correlation:
2387    type: event_count
2388    rules:
2389        - base_rule
2390    group-by:
2391        - SourceIP
2392    timespan: 5m
2393    condition:
2394        gte: 10
2395"#,
2396        )
2397        .unwrap();
2398        let results = lint_yaml_directory(tmp.path()).unwrap();
2399        let all: Vec<_> = results.iter().flat_map(|r| &r.warnings).collect();
2400        assert!(all.iter().any(|w| w.rule == LintRule::SigmaVersionMismatch));
2401        assert!(!all.iter().any(|w| w.rule == LintRule::UnknownRuleReference));
2402    }
2403
2404    #[test]
2405    fn lint_directory_with_excludes() {
2406        let tmp = tempfile::tempdir().unwrap();
2407        let rules_dir = tmp.path().join("rules");
2408        let config_dir = tmp.path().join("config");
2409        std::fs::create_dir_all(&rules_dir).unwrap();
2410        std::fs::create_dir_all(&config_dir).unwrap();
2411
2412        std::fs::write(
2413            rules_dir.join("good.yml"),
2414            r#"
2415title: Good Rule
2416logsource:
2417    category: test
2418detection:
2419    sel:
2420        field: value
2421    condition: sel
2422level: medium
2423"#,
2424        )
2425        .unwrap();
2426
2427        std::fs::write(
2428            config_dir.join("mapping.yaml"),
2429            r#"
2430Title: Logon
2431Channel: Security
2432EventID: 4624
2433"#,
2434        )
2435        .unwrap();
2436
2437        let no_exclude = LintConfig::default();
2438        let results = lint_yaml_directory_with_config(tmp.path(), &no_exclude).unwrap();
2439        let config_warnings: Vec<_> = results
2440            .iter()
2441            .filter(|r| r.path.to_string_lossy().contains("config"))
2442            .flat_map(|r| &r.warnings)
2443            .collect();
2444        assert!(
2445            !config_warnings.is_empty(),
2446            "config file should produce warnings without excludes"
2447        );
2448
2449        let with_exclude = LintConfig {
2450            exclude_patterns: vec!["config/**".to_string()],
2451            ..Default::default()
2452        };
2453        let results = lint_yaml_directory_with_config(tmp.path(), &with_exclude).unwrap();
2454        let config_results: Vec<_> = results
2455            .iter()
2456            .filter(|r| r.path.to_string_lossy().contains("config"))
2457            .collect();
2458        assert!(config_results.is_empty(), "config file should be excluded");
2459
2460        let rule_results: Vec<_> = results
2461            .iter()
2462            .filter(|r| r.path.to_string_lossy().contains("good.yml"))
2463            .collect();
2464        assert_eq!(rule_results.len(), 1);
2465    }
2466
2467    #[test]
2468    fn all_lint_keys_are_cached() {
2469        const ALL_LINT_KEYS: &[&str] = &[
2470            "action",
2471            "author",
2472            "condition",
2473            "correlation",
2474            "date",
2475            "description",
2476            "detection",
2477            "field",
2478            "filter",
2479            "generate",
2480            "group-by",
2481            "id",
2482            "level",
2483            "logsource",
2484            "modified",
2485            "name",
2486            "rules",
2487            "selection",
2488            "status",
2489            "tags",
2490            "taxonomy",
2491            "timeframe",
2492            "timespan",
2493            "title",
2494            "type",
2495        ];
2496        for key_str in ALL_LINT_KEYS {
2497            assert!(KEY_CACHE.contains_key(key_str), "key not cached: {key_str}");
2498        }
2499    }
2500
2501    #[test]
2502    fn extra_tag_namespace_suppresses_warning() {
2503        let text = r#"title: Test
2504logsource:
2505    category: test
2506detection:
2507    selection:
2508        field: value
2509    condition: selection
2510level: medium
2511tags:
2512    - myorg.custom_tag
2513"#;
2514        // Without extra namespaces, unknown_tag_namespace fires.
2515        let warnings = lint_yaml_str(text);
2516        assert!(has_rule(&warnings, LintRule::UnknownTagNamespace));
2517
2518        // With "myorg" added, the warning is gone.
2519        let config = LintConfig {
2520            tag_namespaces: vec!["myorg".to_string()],
2521            ..Default::default()
2522        };
2523        let warnings = lint_yaml_str_with_config(text, &config);
2524        assert!(has_no_rule(&warnings, LintRule::UnknownTagNamespace));
2525    }
2526
2527    #[test]
2528    fn extra_tag_namespace_from_config_file() {
2529        let yaml = r#"
2530tag_namespaces:
2531  - myorg
2532  - internal
2533"#;
2534        let mut tmp = tempfile::NamedTempFile::with_suffix(".yml").unwrap();
2535        std::io::Write::write_all(&mut tmp, yaml.as_bytes()).unwrap();
2536        let config = LintConfig::load(tmp.path()).unwrap();
2537
2538        assert!(config.tag_namespaces.contains(&"myorg".to_string()));
2539        assert!(config.tag_namespaces.contains(&"internal".to_string()));
2540    }
2541}