rsigma-parser 0.16.0

Parser for Sigma detection rules, correlations, and filters
Documentation
//! Programmatic metadata for every [`LintRule`] variant.
//!
//! [`catalogue`] returns one [`LintRuleInfo`] per lint rule (its stable
//! snake_case id, default severity, fix disposition, and a one-line
//! description). It lets tools — the MCP server's `rsigma://lint/catalogue`
//! resource, docs generators, editor integrations — ground themselves on the
//! exact lint vocabulary without scraping the rule modules.
//!
//! The list is generated by one macro so the same source drives both the
//! catalogue and an *exhaustive* `match`: adding a [`LintRule`] variant without
//! a catalogue entry is a compile error.
//!
//! # Example
//!
//! ```rust
//! use rsigma_parser::lint::catalogue::catalogue;
//!
//! let entries = catalogue();
//! assert_eq!(entries.len(), 75);
//! let invalid_status = entries.iter().find(|e| e.name == "invalid_status").unwrap();
//! assert!(invalid_status.fix.is_some()); // has a safe auto-fix
//! ```

use serde::Serialize;

use super::{FixDisposition, LintRule, Severity};

/// Metadata describing one lint rule.
#[derive(Debug, Clone, Copy, Serialize)]
pub struct LintRuleInfo {
    /// Stable snake_case identifier (matches `LintRule`'s `Display`).
    pub name: &'static str,
    /// The severity the rule fires at by default (before config overrides).
    pub default_severity: Severity,
    /// The disposition of the auto-fix the rule can attach, if any. `None`
    /// means the rule never carries a fix; `Some(Safe)` means it can attach a
    /// safe, auto-appliable fix.
    pub fix: Option<FixDisposition>,
    /// One-line, human-readable description of what the rule checks.
    pub description: &'static str,
}

/// Build the catalogue plus the exhaustive metadata lookup from one list.
///
/// Every `LintRule` variant must appear exactly once. The generated `describe`
/// match has no wildcard arm, so a new variant fails to compile until it is
/// added here.
macro_rules! lint_catalogue {
    ($($variant:ident => ($sev:expr, $fix:expr, $desc:expr)),+ $(,)?) => {
        /// All lint rules, in declaration order.
        const ALL_LINT_RULES: &[LintRule] = &[$(LintRule::$variant),+];

        fn describe(rule: LintRule) -> (Severity, Option<FixDisposition>, &'static str) {
            match rule {
                $(LintRule::$variant => ($sev, $fix, $desc)),+
            }
        }
    };
}

const SAFE: Option<FixDisposition> = Some(FixDisposition::Safe);
const NONE: Option<FixDisposition> = None;

lint_catalogue! {
    // ── Infrastructure / parse errors ────────────────────────────────────
    YamlParseError => (Severity::Error, NONE, "The document is not valid YAML."),
    NotAMapping => (Severity::Error, NONE, "The document root is not a YAML mapping."),
    FileReadError => (Severity::Error, NONE, "The file could not be read from disk."),
    SchemaViolation => (Severity::Error, NONE, "Reserved for JSON-schema validation failures."),

    // ── Shared (all document types) ──────────────────────────────────────
    MissingTitle => (Severity::Error, NONE, "Required field 'title' is missing."),
    EmptyTitle => (Severity::Error, NONE, "'title' is present but empty."),
    TitleTooLong => (Severity::Warning, NONE, "'title' exceeds the 256-character maximum."),
    MissingDescription => (Severity::Info, NONE, "Recommended field 'description' is missing."),
    MissingAuthor => (Severity::Info, NONE, "Recommended field 'author' is missing."),
    InvalidId => (Severity::Warning, NONE, "'id' is not a valid UUID."),
    InvalidStatus => (Severity::Error, SAFE, "'status' is not one of the allowed values."),
    MissingLevel => (Severity::Warning, NONE, "Recommended field 'level' is missing."),
    InvalidLevel => (Severity::Error, SAFE, "'level' is not one of the allowed values."),
    InvalidDate => (Severity::Error, NONE, "'date' is not a valid YYYY-MM-DD date."),
    InvalidModified => (Severity::Error, NONE, "'modified' is not a valid YYYY-MM-DD date."),
    ModifiedBeforeDate => (Severity::Warning, NONE, "'modified' is earlier than 'date'."),
    DescriptionTooLong => (Severity::Warning, NONE, "'description' exceeds the 65535-character maximum."),
    NameTooLong => (Severity::Warning, NONE, "'name' exceeds the 256-character maximum."),
    TaxonomyTooLong => (Severity::Warning, NONE, "'taxonomy' exceeds the 256-character maximum."),
    NonLowercaseKey => (Severity::Warning, SAFE, "A top-level key is not lowercase."),

    // ── Detection rules ──────────────────────────────────────────────────
    MissingLogsource => (Severity::Error, NONE, "Detection rule is missing 'logsource'."),
    MissingDetection => (Severity::Error, NONE, "Detection rule is missing 'detection'."),
    MissingCondition => (Severity::Error, NONE, "'detection' is missing 'condition'."),
    EmptyDetection => (Severity::Warning, NONE, "'detection' has no named search identifiers."),
    InvalidRelatedType => (Severity::Error, NONE, "A 'related' entry uses an invalid relation type."),
    InvalidRelatedId => (Severity::Warning, NONE, "A 'related' entry id is not a valid UUID."),
    RelatedMissingRequired => (Severity::Error, NONE, "A 'related' entry is missing 'id' or 'type'."),
    DeprecatedWithoutRelated => (Severity::Warning, NONE, "A deprecated rule has no 'related' replacement link."),
    InvalidTag => (Severity::Warning, NONE, "A tag does not match the expected pattern."),
    UnknownTagNamespace => (Severity::Warning, NONE, "A tag uses an unrecognised namespace."),
    DuplicateTags => (Severity::Warning, SAFE, "The 'tags' list has duplicate entries."),
    DuplicateReferences => (Severity::Warning, SAFE, "The 'references' list has duplicate entries."),
    DuplicateFields => (Severity::Warning, SAFE, "The 'fields' list has duplicate entries."),
    FalsepositiveTooShort => (Severity::Warning, NONE, "A 'falsepositives' entry is too short."),
    ScopeTooShort => (Severity::Warning, NONE, "A 'scope' entry is too short."),
    LogsourceValueNotLowercase => (Severity::Warning, SAFE, "A logsource value is not lowercase."),
    ConditionReferencesUnknown => (Severity::Error, NONE, "The condition references an undefined selection."),
    DeprecatedAggregationSyntax => (Severity::Warning, NONE, "The condition uses deprecated v1.x aggregation syntax."),

    // ── Correlation rules ────────────────────────────────────────────────
    MissingCorrelation => (Severity::Error, NONE, "Correlation rule is missing 'correlation'."),
    MissingCorrelationType => (Severity::Error, NONE, "'correlation' is missing 'type'."),
    InvalidCorrelationType => (Severity::Error, NONE, "'correlation.type' is not a known correlation type."),
    MissingCorrelationRules => (Severity::Error, NONE, "'correlation' is missing 'rules'."),
    EmptyCorrelationRules => (Severity::Warning, NONE, "'correlation.rules' is present but empty."),
    MissingCorrelationTimespan => (Severity::Error, NONE, "'correlation' is missing 'timespan'."),
    InvalidTimespanFormat => (Severity::Error, NONE, "'correlation.timespan' is not a valid duration."),
    InvalidWindowMode => (Severity::Error, NONE, "'correlation.window' is not sliding, tumbling, or session."),
    MissingSessionGap => (Severity::Error, NONE, "window: session requires a 'gap'."),
    GapWithoutSession => (Severity::Error, NONE, "'gap' is only valid with window: session."),
    InvalidGapFormat => (Severity::Error, NONE, "'gap' is not a valid duration."),
    MissingGroupBy => (Severity::Error, NONE, "This correlation type requires 'group-by'."),
    MissingCorrelationCondition => (Severity::Error, NONE, "This correlation type requires a 'condition'."),
    MissingConditionField => (Severity::Error, NONE, "The correlation condition requires 'field'."),
    InvalidConditionOperator => (Severity::Error, NONE, "The correlation condition uses an invalid operator."),
    ConditionValueNotNumeric => (Severity::Error, NONE, "The correlation condition value must be numeric."),
    GenerateNotBoolean => (Severity::Error, NONE, "'generate' must be a boolean."),

    // ── Filter rules ─────────────────────────────────────────────────────
    MissingFilter => (Severity::Error, NONE, "Filter rule is missing 'filter'."),
    MissingFilterRules => (Severity::Error, NONE, "'filter.rules' is malformed."),
    EmptyFilterRules => (Severity::Warning, NONE, "'filter.rules' is present but empty."),
    MissingFilterSelection => (Severity::Error, NONE, "'filter' is missing 'selection'."),
    MissingFilterCondition => (Severity::Error, NONE, "'filter' is missing 'condition'."),
    FilterHasLevel => (Severity::Warning, SAFE, "Filter rules should not have a 'level' field."),
    FilterHasStatus => (Severity::Warning, SAFE, "Filter rules should not have a 'status' field."),
    MissingFilterLogsource => (Severity::Error, NONE, "Filter rule is missing 'logsource'."),

    // ── Detection logic (cross-cutting) ──────────────────────────────────
    NullInValueList => (Severity::Warning, NONE, "A value list contains a null entry."),
    SingleValueAllModifier => (Severity::Warning, SAFE, "|all is used with a single value."),
    AllWithRe => (Severity::Warning, SAFE, "|all combined with |re is redundant."),
    IncompatibleModifiers => (Severity::Warning, NONE, "A field uses an incompatible combination of modifiers."),
    EmptyValueList => (Severity::Warning, NONE, "A field has an empty value list."),
    WildcardOnlyValue => (Severity::Warning, SAFE, "A value is a lone wildcard; consider |exists instead."),
    FlattenedArrayCorrelation => (Severity::Warning, NONE, "A correlation references a flattened array field."),
    UnsupportedSigmaVersion => (Severity::Error, NONE, "The declared sigma-version is unsupported."),
    ArrayMatchingWithoutVersion => (Severity::Warning, NONE, "Array matching is used without declaring sigma-version 3."),
    SigmaVersionMismatch => (Severity::Warning, NONE, "Cross-referenced rules declare different spec majors."),
    UnknownRuleReference => (Severity::Warning, NONE, "A reference resolves to no known rule."),
    UnknownKey => (Severity::Info, SAFE, "An unknown top-level key (likely a typo)."),
}

/// Return metadata for every [`LintRule`] variant, in declaration order.
pub fn catalogue() -> Vec<LintRuleInfo> {
    ALL_LINT_RULES
        .iter()
        .map(|&rule| {
            let (default_severity, fix, description) = describe(rule);
            LintRuleInfo {
                name: rule_name(rule),
                default_severity,
                fix,
                description,
            }
        })
        .collect()
}

/// The stable snake_case id for a rule (its `Display` form).
fn rule_name(rule: LintRule) -> &'static str {
    // `LintRule: Copy` and its `Display` returns a fixed `&'static str` per
    // variant; resolve it through a small lookup so the catalogue can hold a
    // `&'static str` rather than an owned `String`.
    LINT_RULE_NAMES
        .iter()
        .find(|(r, _)| *r == rule)
        .map(|(_, name)| *name)
        .expect("every rule has a name")
}

/// Static (rule, name) pairs. Built once from `ALL_LINT_RULES` Display strings
/// is not possible at const time, so the names are listed here and verified
/// against `Display` by the test below.
const LINT_RULE_NAMES: &[(LintRule, &str)] = &[
    (LintRule::YamlParseError, "yaml_parse_error"),
    (LintRule::NotAMapping, "not_a_mapping"),
    (LintRule::FileReadError, "file_read_error"),
    (LintRule::SchemaViolation, "schema_violation"),
    (LintRule::MissingTitle, "missing_title"),
    (LintRule::EmptyTitle, "empty_title"),
    (LintRule::TitleTooLong, "title_too_long"),
    (LintRule::MissingDescription, "missing_description"),
    (LintRule::MissingAuthor, "missing_author"),
    (LintRule::InvalidId, "invalid_id"),
    (LintRule::InvalidStatus, "invalid_status"),
    (LintRule::MissingLevel, "missing_level"),
    (LintRule::InvalidLevel, "invalid_level"),
    (LintRule::InvalidDate, "invalid_date"),
    (LintRule::InvalidModified, "invalid_modified"),
    (LintRule::ModifiedBeforeDate, "modified_before_date"),
    (LintRule::DescriptionTooLong, "description_too_long"),
    (LintRule::NameTooLong, "name_too_long"),
    (LintRule::TaxonomyTooLong, "taxonomy_too_long"),
    (LintRule::NonLowercaseKey, "non_lowercase_key"),
    (LintRule::MissingLogsource, "missing_logsource"),
    (LintRule::MissingDetection, "missing_detection"),
    (LintRule::MissingCondition, "missing_condition"),
    (LintRule::EmptyDetection, "empty_detection"),
    (LintRule::InvalidRelatedType, "invalid_related_type"),
    (LintRule::InvalidRelatedId, "invalid_related_id"),
    (LintRule::RelatedMissingRequired, "related_missing_required"),
    (
        LintRule::DeprecatedWithoutRelated,
        "deprecated_without_related",
    ),
    (LintRule::InvalidTag, "invalid_tag"),
    (LintRule::UnknownTagNamespace, "unknown_tag_namespace"),
    (LintRule::DuplicateTags, "duplicate_tags"),
    (LintRule::DuplicateReferences, "duplicate_references"),
    (LintRule::DuplicateFields, "duplicate_fields"),
    (LintRule::FalsepositiveTooShort, "falsepositive_too_short"),
    (LintRule::ScopeTooShort, "scope_too_short"),
    (
        LintRule::LogsourceValueNotLowercase,
        "logsource_value_not_lowercase",
    ),
    (
        LintRule::ConditionReferencesUnknown,
        "condition_references_unknown",
    ),
    (
        LintRule::DeprecatedAggregationSyntax,
        "deprecated_aggregation_syntax",
    ),
    (LintRule::MissingCorrelation, "missing_correlation"),
    (LintRule::MissingCorrelationType, "missing_correlation_type"),
    (LintRule::InvalidCorrelationType, "invalid_correlation_type"),
    (
        LintRule::MissingCorrelationRules,
        "missing_correlation_rules",
    ),
    (LintRule::EmptyCorrelationRules, "empty_correlation_rules"),
    (
        LintRule::MissingCorrelationTimespan,
        "missing_correlation_timespan",
    ),
    (LintRule::InvalidTimespanFormat, "invalid_timespan_format"),
    (LintRule::InvalidWindowMode, "invalid_window_mode"),
    (LintRule::MissingSessionGap, "missing_session_gap"),
    (LintRule::GapWithoutSession, "gap_without_session"),
    (LintRule::InvalidGapFormat, "invalid_gap_format"),
    (LintRule::MissingGroupBy, "missing_group_by"),
    (
        LintRule::MissingCorrelationCondition,
        "missing_correlation_condition",
    ),
    (LintRule::MissingConditionField, "missing_condition_field"),
    (
        LintRule::InvalidConditionOperator,
        "invalid_condition_operator",
    ),
    (
        LintRule::ConditionValueNotNumeric,
        "condition_value_not_numeric",
    ),
    (LintRule::GenerateNotBoolean, "generate_not_boolean"),
    (LintRule::MissingFilter, "missing_filter"),
    (LintRule::MissingFilterRules, "missing_filter_rules"),
    (LintRule::EmptyFilterRules, "empty_filter_rules"),
    (LintRule::MissingFilterSelection, "missing_filter_selection"),
    (LintRule::MissingFilterCondition, "missing_filter_condition"),
    (LintRule::FilterHasLevel, "filter_has_level"),
    (LintRule::FilterHasStatus, "filter_has_status"),
    (LintRule::MissingFilterLogsource, "missing_filter_logsource"),
    (LintRule::NullInValueList, "null_in_value_list"),
    (
        LintRule::SingleValueAllModifier,
        "single_value_all_modifier",
    ),
    (LintRule::AllWithRe, "all_with_re"),
    (LintRule::IncompatibleModifiers, "incompatible_modifiers"),
    (LintRule::EmptyValueList, "empty_value_list"),
    (LintRule::WildcardOnlyValue, "wildcard_only_value"),
    (
        LintRule::FlattenedArrayCorrelation,
        "flattened_array_correlation",
    ),
    (
        LintRule::UnsupportedSigmaVersion,
        "unsupported_sigma_version",
    ),
    (
        LintRule::ArrayMatchingWithoutVersion,
        "array_matching_without_version",
    ),
    (LintRule::SigmaVersionMismatch, "sigma_version_mismatch"),
    (LintRule::UnknownRuleReference, "unknown_rule_reference"),
    (LintRule::UnknownKey, "unknown_key"),
];

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashSet;

    #[test]
    fn catalogue_covers_every_rule() {
        // 75 LintRule variants. The exhaustive `describe` match guarantees a
        // metadata entry per variant at compile time; this asserts the count
        // and the `ALL_LINT_RULES`/`LINT_RULE_NAMES` lists stay in sync.
        let entries = catalogue();
        assert_eq!(entries.len(), 75, "expected 75 catalogue entries");
        assert_eq!(ALL_LINT_RULES.len(), 75);
        assert_eq!(LINT_RULE_NAMES.len(), 75);
    }

    #[test]
    fn names_match_display_and_are_unique() {
        let mut seen = HashSet::new();
        for &rule in ALL_LINT_RULES {
            let name = rule_name(rule);
            assert_eq!(
                name,
                rule.to_string(),
                "catalogue name must match LintRule Display"
            );
            assert!(seen.insert(name), "duplicate catalogue name: {name}");
        }
    }

    #[test]
    fn fixable_rules_are_safe() {
        for info in catalogue() {
            if let Some(disposition) = info.fix {
                assert_eq!(
                    disposition,
                    FixDisposition::Safe,
                    "{} has a non-safe fix in the catalogue",
                    info.name
                );
            }
        }
    }
}