code-buster 0.11.0

Repository-aware static analysis for AI-assisted development
Documentation
use std::collections::BTreeSet;
use std::fs;
use std::path::Path;

use crate::rule_catalog;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use time::format_description::well_known::Rfc3339;
use time::{Date, OffsetDateTime, PrimitiveDateTime, Time};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Severity {
    Info,
    Warn,
    Error,
}

impl Severity {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Info => "info",
            Self::Warn => "warn",
            Self::Error => "error",
        }
    }

    pub fn parse(value: &str) -> Option<Self> {
        match value.to_ascii_lowercase().as_str() {
            "info" => Some(Self::Info),
            "warn" | "warning" => Some(Self::Warn),
            "error" => Some(Self::Error),
            _ => None,
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CodeFlowStep {
    pub path: String,
    pub line: usize,
    pub message: String,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Finding {
    pub code: String,
    pub severity: Severity,
    pub path: String,
    pub line: usize,
    pub end_line: usize,
    pub message: String,
    pub confidence: String,
    pub why: String,
    pub suggestion: String,
    pub related_files: Vec<String>,
    pub snippet: String,
    pub code_flow: Vec<CodeFlowStep>,
}

impl Finding {
    pub fn structure(
        code: &str,
        path: String,
        message: String,
        suggestion: &str,
        related_files: Vec<String>,
    ) -> Self {
        Self {
            code: code.to_owned(),
            severity: Severity::Warn,
            path,
            line: 1,
            end_line: 0,
            message,
            confidence: String::new(),
            why: String::new(),
            suggestion: suggestion.to_owned(),
            related_files,
            snippet: String::new(),
            code_flow: Vec::new(),
        }
    }

    pub fn enrich(mut self) -> Self {
        if self.confidence.is_empty() {
            self.confidence = if self.severity == Severity::Error {
                "high".to_owned()
            } else {
                "medium".to_owned()
            };
        }
        if self.why.is_empty() {
            self.why = rule_why(&self.code).to_owned();
        }
        if self.suggestion.is_empty() {
            self.suggestion = rule_suggestion(&self.code).to_owned();
        }
        self
    }

    pub fn key(&self) -> String {
        format!("{}|{}|{}", self.code, self.path, self.message)
    }

    pub fn fingerprint(&self) -> String {
        let digest = Sha256::digest(self.key().as_bytes());
        format!("{digest:X}")[..12].to_owned()
    }

    pub fn to_json(&self) -> Value {
        let mut finding = json!({
            "code": self.code,
            "severity": self.severity.as_str(),
            "path": self.path,
            "line": self.line,
            "end_line": self.end_line,
            "message": self.message,
            "confidence": self.confidence,
            "why": self.why,
            "suggestion": self.suggestion,
            "related_files": self.related_files,
            "snippet": self.snippet,
        });
        let Some(object) = finding.as_object_mut() else {
            return finding;
        };
        if let Some(metadata) = rule_catalog::metadata(&self.code)
            && metadata.security_kind != "none"
        {
            object.insert(
                "security_kind".to_owned(),
                Value::String(metadata.security_kind.to_owned()),
            );
        }
        if !self.code_flow.is_empty() {
            object.insert(
                "code_flow".to_owned(),
                Value::Array(
                    self.code_flow
                        .iter()
                        .map(|step| {
                            json!({
                                "path": step.path,
                                "line": step.line,
                                "message": step.message,
                            })
                        })
                        .collect(),
                ),
            );
        }
        finding
    }
}

pub fn rule_why(code: &str) -> &'static str {
    match code {
        "duplicate-block" => "The same normalized code block appears in more than one location.",
        "complex-function" => "The function exceeds configured complexity thresholds.",
        "feature-flag" => "A feature/config flag reference was found.",
        "dead-file" | "dead-export" => {
            "The symbol or file was not reached by the heuristic dependency graph."
        }
        _ => "This finding was produced by a Code Buster heuristic analyzer.",
    }
}

pub fn rule_suggestion(code: &str) -> &'static str {
    match code {
        "duplicate-block" => "Extract shared logic or raise min_duplication_lines if intentional.",
        "complex-function" => "Split branches into smaller helpers or simplify control flow.",
        "feature-flag" => "Review ownership and lifecycle for this flag.",
        "dead-file" | "dead-export" => {
            "Remove it or add an entry point/reference if it is loaded dynamically."
        }
        _ => "Review the finding and update code or configuration as appropriate.",
    }
}

pub fn read_baseline(path: Option<&Path>) -> Result<BTreeSet<String>, String> {
    let Some(path) = path else {
        return Ok(BTreeSet::new());
    };
    if !path.is_file() {
        return Ok(BTreeSet::new());
    }
    let content = fs::read_to_string(path)
        .map_err(|error| format!("failed to read baseline {}: {error}", path.display()))?;
    Ok(normalize_baseline(parse_baseline_document(&content)))
}

enum BaselineDocument<'a> {
    Ledger(Value),
    Legacy(&'a str),
}

fn parse_baseline_document(content: &str) -> BaselineDocument<'_> {
    match serde_json::from_str(content) {
        Ok(document) => BaselineDocument::Ledger(document),
        Err(_) => BaselineDocument::Legacy(content),
    }
}

fn normalize_baseline(document: BaselineDocument<'_>) -> BTreeSet<String> {
    match document {
        BaselineDocument::Ledger(document) => normalize_ledger(&document),
        BaselineDocument::Legacy(content) => content
            .lines()
            .map(str::trim)
            .filter(|line| !line.is_empty() && !line.starts_with('#'))
            .map(str::to_owned)
            .collect(),
    }
}

fn normalize_ledger(document: &Value) -> BTreeSet<String> {
    let mut result = BTreeSet::new();
    let Some(entries) = select_baseline_entries(document) else {
        return result;
    };
    for entry in entries.iter().filter_map(convert_baseline_entry) {
        if let Some((code, path, message)) = entry.identity {
            result.insert(format!("{code}|{path}|{message}"));
        }
        if let Some(fingerprint) = entry.fingerprint {
            result.insert(fingerprint.to_owned());
        }
    }
    result
}

fn select_baseline_entries(document: &Value) -> Option<&[Value]> {
    document
        .as_object()
        .and_then(|object| object.get("entries"))
        .unwrap_or(document)
        .as_array()
        .map(Vec::as_slice)
}

struct BaselineEntry<'a> {
    identity: Option<(&'a str, &'a str, &'a str)>,
    fingerprint: Option<&'a str>,
}

fn convert_baseline_entry(value: &Value) -> Option<BaselineEntry<'_>> {
    let entry = value.as_object()?;
    if entry
        .get("status")
        .and_then(Value::as_str)
        .is_some_and(|status| !["accepted", "false-positive", "wont-fix"].contains(&status))
        || entry
            .get("expiry")
            .and_then(Value::as_str)
            .is_some_and(expiry_is_past)
    {
        return None;
    }
    let identity = match (
        entry.get("code").and_then(Value::as_str),
        entry.get("path").and_then(Value::as_str),
        entry.get("message").and_then(Value::as_str),
    ) {
        (Some(code), Some(path), Some(message)) => Some((code, path, message)),
        _ => None,
    };
    let fingerprint = entry
        .get("fingerprint")
        .and_then(Value::as_str)
        .filter(|fingerprint| !fingerprint.is_empty());
    Some(BaselineEntry {
        identity,
        fingerprint,
    })
}

fn expiry_is_past(value: &str) -> bool {
    if value.is_empty() {
        return false;
    }
    let expiry = OffsetDateTime::parse(value, &Rfc3339).or_else(|_| {
        Date::parse(
            value,
            time::macros::format_description!("[year]-[month]-[day]"),
        )
        .map(|date| PrimitiveDateTime::new(date, Time::MIDNIGHT).assume_utc())
    });
    expiry.is_ok_and(|expiry| expiry < OffsetDateTime::now_utc())
}