killer 2.0.0

A Rust security platform: static analysis, the .klr test language, a parallel test framework, project intelligence, code review, and a CI gate.
Documentation
//! The analysis core: the [`Rule`] trait, the [`Finding`] type, and the
//! [`Analyzer`] that runs a set of rules over scanned files.

use std::fmt;

use crate::config::Config;
use crate::scanner::{FileData, ScanResult};

/// Severity of a finding, ordered from most to least serious.
///
/// Marked `#[non_exhaustive]` so a level can be added without a breaking
/// release. Downstream matches must carry a wildcard arm; fold it into the
/// least serious branch rather than assuming a new level blocks a build.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[non_exhaustive]
pub enum Severity {
    /// A serious issue that should block a release (e.g. an exposed secret).
    Critical,
    /// A likely-dangerous issue worth reviewing.
    High,
    /// A quality concern.
    Warning,
    /// Informational; low priority.
    Info,
}

impl Severity {
    pub fn label(&self) -> &'static str {
        match self {
            Severity::Critical => "CRITICAL",
            Severity::High => "HIGH",
            Severity::Warning => "WARNING",
            Severity::Info => "INFO",
        }
    }

    /// Parse a severity word as used in `.klr` files and config.
    ///
    /// Recognizes `critical`, `high`, `medium`/`warning`, and `low`/`info`
    /// (case-insensitive). Unknown words map to `None`.
    pub fn from_word(word: &str) -> Option<Severity> {
        match word.to_ascii_lowercase().as_str() {
            "critical" => Some(Severity::Critical),
            "high" => Some(Severity::High),
            "medium" | "warning" | "warn" => Some(Severity::Warning),
            "low" | "info" | "informational" => Some(Severity::Info),
            _ => None,
        }
    }

    /// Points deducted from the 100-point health score per occurrence.
    pub fn score_weight(&self) -> u32 {
        match self {
            Severity::Critical => 25,
            Severity::High => 10,
            Severity::Warning => 3,
            Severity::Info => 1,
        }
    }
}

impl fmt::Display for Severity {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.label())
    }
}

/// The broad category a rule belongs to, used to group report output.
///
/// Marked `#[non_exhaustive]` so a new rule family can bring its own category
/// without a breaking release. Downstream matches must carry a wildcard arm.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[non_exhaustive]
pub enum Category {
    Security,
    Quality,
    Dependencies,
}

impl Category {
    pub fn title(&self) -> &'static str {
        match self {
            Category::Security => "Security",
            Category::Quality => "Quality",
            Category::Dependencies => "Dependencies",
        }
    }
}

/// A single issue reported by a rule.
///
/// Marked `#[non_exhaustive]` because this is expected to gain fields, a stable
/// fingerprint for deduplicating findings across runs being the obvious one.
/// Build one with [`Finding::new`] rather than struct-literal syntax, which is
/// unavailable outside this crate.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Finding {
    /// The stable id of the rule that produced this finding.
    pub rule: String,
    /// A short human-readable title for the finding.
    pub title: String,
    pub category: Category,
    pub severity: Severity,
    /// File path relative to the scan root.
    pub file: String,
    /// 1-indexed line number (0 if not line-specific).
    pub line: usize,
    /// Detailed message explaining the finding.
    pub message: String,
    /// Optional remediation suggestion.
    pub suggestion: Option<String>,
}

impl Finding {
    /// Build a finding from the fields every rule must supply.
    ///
    /// The optional remediation hint is attached with
    /// [`Finding::with_suggestion`]. Fields added in future releases will get
    /// their own `with_*` method so this signature stays put.
    pub fn new(
        rule: impl Into<String>,
        title: impl Into<String>,
        category: Category,
        severity: Severity,
        file: impl Into<String>,
        line: usize,
        message: impl Into<String>,
    ) -> Finding {
        Finding {
            rule: rule.into(),
            title: title.into(),
            category,
            severity,
            file: file.into(),
            line,
            message: message.into(),
            suggestion: None,
        }
    }

    /// Attach a remediation suggestion.
    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Finding {
        self.suggestion = Some(suggestion.into());
        self
    }
}

/// A lint/security rule. Implementations inspect a single file and return any
/// findings. Rules must be stateless and cheap to construct so the analyzer can
/// run them across every file.
///
/// # Implementing this trait out of crate
///
/// Third-party rules are supported: the trait is deliberately *not* sealed, and
/// the roadmap's plugin SDK builds on it. The price of that promise is that
/// every method except [`Rule::id`] and [`Rule::check`] carries a default body,
/// so a release that adds a method does not break existing implementors. New
/// methods will keep that guarantee.
pub trait Rule: Send + Sync {
    /// Stable identifier (kebab-case), used in config toggles and output.
    fn id(&self) -> &str;

    /// Inspect a file and return any findings.
    fn check(&self, file: &FileData) -> Vec<Finding>;

    /// Human-readable name. Defaults to the rule's id.
    fn name(&self) -> &str {
        self.id()
    }

    /// One-line description of what the rule detects. Defaults to empty, which
    /// callers render as "no description given" rather than inventing one.
    fn description(&self) -> &str {
        ""
    }

    /// The category this rule reports under. Defaults to [`Category::Quality`]:
    /// a rule that has not claimed to be a security rule should not be counted
    /// as one. Note that each [`Finding`] carries its own category, which is
    /// what the report groups by.
    fn category(&self) -> Category {
        Category::Quality
    }
}

/// Runs a collection of rules over a set of scanned files.
pub struct Analyzer {
    rules: Vec<Box<dyn Rule>>,
}

impl Analyzer {
    /// Build an analyzer with the given rules.
    pub fn new(rules: Vec<Box<dyn Rule>>) -> Self {
        Analyzer { rules }
    }

    /// Build the default analyzer, honoring which rules are enabled in `config`.
    pub fn with_default_rules(config: &Config) -> Self {
        let rules = crate::rules::default_rules(config);
        Analyzer::new(rules)
    }

    /// Number of active rules.
    pub fn rule_count(&self) -> usize {
        self.rules.len()
    }

    /// Run every rule over every file and collect all findings, sorted by
    /// severity (most serious first), then file, then line.
    pub fn analyze(&self, scan: &ScanResult) -> Vec<Finding> {
        self.analyze_files(&scan.files)
    }

    /// Run every rule over the given files and collect all findings, sorted by
    /// severity (most serious first), then file, then line.
    pub fn analyze_files(&self, files: &[FileData]) -> Vec<Finding> {
        let mut findings = Vec::new();
        for file in files {
            for rule in &self.rules {
                findings.extend(rule.check(file));
            }
        }
        findings.sort_by(|a, b| {
            a.severity
                .cmp(&b.severity)
                .then_with(|| a.file.cmp(&b.file))
                .then_with(|| a.line.cmp(&b.line))
        });
        findings
    }
}

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

    #[test]
    fn severity_ordering() {
        assert!(Severity::Critical < Severity::High);
        assert!(Severity::High < Severity::Warning);
        assert!(Severity::Warning < Severity::Info);
    }

    #[test]
    fn score_weights_decrease_with_severity() {
        assert!(Severity::Critical.score_weight() > Severity::High.score_weight());
        assert!(Severity::High.score_weight() > Severity::Warning.score_weight());
        assert!(Severity::Warning.score_weight() > Severity::Info.score_weight());
    }

    #[test]
    fn finding_constructor_matches_struct_literal() {
        let built = Finding::new(
            "my-rule",
            "Something happened",
            Category::Quality,
            Severity::Info,
            "src/lib.rs",
            7,
            "details",
        )
        .with_suggestion("fix it");

        assert_eq!(
            built,
            Finding {
                rule: "my-rule".to_string(),
                title: "Something happened".to_string(),
                category: Category::Quality,
                severity: Severity::Info,
                file: "src/lib.rs".to_string(),
                line: 7,
                message: "details".to_string(),
                suggestion: Some("fix it".to_string()),
            }
        );
    }

    #[test]
    fn finding_constructor_leaves_suggestion_unset() {
        let built = Finding::new(
            "my-rule",
            "t",
            Category::Security,
            Severity::Critical,
            "f",
            1,
            "m",
        );
        assert_eq!(built.suggestion, None);
    }

    /// A rule that implements only the two required methods, proving the other
    /// three have usable defaults.
    struct MinimalRule;

    impl Rule for MinimalRule {
        fn id(&self) -> &str {
            "minimal"
        }

        fn check(&self, _file: &FileData) -> Vec<Finding> {
            Vec::new()
        }
    }

    #[test]
    fn rule_defaults_are_conservative() {
        let rule = MinimalRule;
        assert_eq!(rule.name(), "minimal", "name falls back to the id");
        assert_eq!(rule.description(), "");
        assert_eq!(
            rule.category(),
            Category::Quality,
            "an undeclared rule must not count as a security rule"
        );
    }
}