gruff-rs 0.1.0

Rust static analyzer and quality linter for CI: dead-code, complexity, security, secrets, and architecture rules with deterministic SARIF/JSON output and baseline support.
use super::*;

pub(crate) fn analyse_text_rules(
    unit: &SourceUnit<'_>,
    config: &Config,
    findings: &mut Vec<Finding>,
) {
    analyse_file_length(unit.file, unit.source, config, findings);
    analyse_config_security_blind_ignores(unit, findings);
    analyse_ci_github_event_shell_interpolation(unit, findings);
    analyse_sensitive_data(unit, config, findings);
}

fn analyse_file_length(
    file: &SourceFile,
    source: &str,
    config: &Config,
    findings: &mut Vec<Finding>,
) {
    if file_length_is_exempt(&file.display_path) {
        return;
    }
    let line_count = source.lines().count();
    let rule_id = "size.file-length";
    let threshold = config.threshold(rule_id, 600.0) as usize;
    if line_count > threshold {
        findings.push(finding(SimpleFindingDescriptor {
            rule_id,
            message: format!("File has {line_count} lines, above the threshold of {threshold}."),
            file,
            line: Some(1),
            severity: config.severity(rule_id, Severity::Warning),
            pillar: Pillar::Size,
        }));
    }
}

fn file_length_is_exempt(display_path: &str) -> bool {
    let normalized = display_path.replace('\\', "/");
    let file_name = normalized
        .rsplit('/')
        .next()
        .unwrap_or(&normalized)
        .to_ascii_lowercase();
    let lockfile = matches!(
        file_name.as_str(),
        "cargo.lock" | "package-lock.json" | "yarn.lock" | "pnpm-lock.yaml"
    ) || file_name.ends_with(".lock");
    let markdown = file_name.ends_with(".md") || file_name.ends_with(".markdown");
    let agent_hook = normalized.contains("/.codex/hooks/")
        || normalized.contains("/.claude/hooks/")
        || normalized.starts_with(".codex/hooks/")
        || normalized.starts_with(".claude/hooks/");
    lockfile || markdown || agent_hook
}

fn analyse_config_security_blind_ignores(unit: &SourceUnit<'_>, findings: &mut Vec<Finding>) {
    if unit.file.display_path != ".gruff-rs.yaml" {
        return;
    }

    let mut state = IgnoreListState::default();
    for (line_index, line) in unit.source.lines().enumerate() {
        if let Some(pattern) = state.next_pattern(line) {
            push_config_security_blind_ignore(unit, findings, line_index + 1, pattern);
        }
    }
}

fn analyse_ci_github_event_shell_interpolation(unit: &SourceUnit<'_>, findings: &mut Vec<Finding>) {
    if !is_github_workflow(&unit.file.display_path) {
        return;
    }

    let mut state = RunBlockState::default();
    for (line_index, line) in unit.source.lines().enumerate() {
        if state.line_has_event_shell_interpolation(line) {
            push_github_event_shell_finding(unit, findings, line_index + 1);
        }
    }
}

#[derive(Default)]
struct IgnoreListState {
    in_paths: bool,
    in_ignore: bool,
    ignore_indent: usize,
}

impl IgnoreListState {
    fn next_pattern(&mut self, line: &str) -> Option<String> {
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') {
            return None;
        }

        let indent = line_indent(line);
        if self.consume_top_level_header_if_matches(indent, trimmed) {
            return None;
        }
        if self.consume_ignore_header_if_matches(indent, trimmed) {
            return None;
        }
        self.ignore_item_pattern(indent, trimmed)
    }

    fn consume_top_level_header_if_matches(&mut self, indent: usize, trimmed: &str) -> bool {
        if indent != 0 || !trimmed.ends_with(':') {
            return false;
        }

        self.in_paths = trimmed == "paths:";
        self.in_ignore = false;
        true
    }

    fn consume_ignore_header_if_matches(&mut self, indent: usize, trimmed: &str) -> bool {
        if !self.in_paths || trimmed != "ignore:" {
            return false;
        }

        self.in_ignore = true;
        self.ignore_indent = indent;
        true
    }

    fn ignore_item_pattern(&mut self, indent: usize, trimmed: &str) -> Option<String> {
        if !self.in_ignore {
            return None;
        }
        if indent <= self.ignore_indent && !trimmed.starts_with("- ") {
            self.in_ignore = false;
            return None;
        }

        trimmed.strip_prefix("- ").map(trim_config_string)
    }
}

#[derive(Default)]
struct RunBlockState {
    in_run_block: bool,
    run_indent: usize,
}

impl RunBlockState {
    fn line_has_event_shell_interpolation(&mut self, line: &str) -> bool {
        let trimmed = line.trim();
        self.close_completed_block(line_indent(line), trimmed);
        if let Some(after_run) = workflow_run_value(trimmed) {
            return self.run_value_contains_event_shell_interpolation(line_indent(line), after_run);
        }

        self.in_run_block && trimmed.contains("github.event.")
    }

    fn close_completed_block(&mut self, indent: usize, trimmed: &str) {
        if self.in_run_block && indent <= self.run_indent && !trimmed.is_empty() {
            self.in_run_block = false;
        }
    }

    fn run_value_contains_event_shell_interpolation(
        &mut self,
        indent: usize,
        after_run: &str,
    ) -> bool {
        self.run_indent = indent;
        let value = after_run.trim();
        self.in_run_block = value.is_empty() || is_yaml_block_scalar(value);
        after_run.contains("github.event.")
    }
}

fn push_config_security_blind_ignore(
    unit: &SourceUnit<'_>,
    findings: &mut Vec<Finding>,
    line: usize,
    pattern: String,
) {
    let Some(surface) = security_relevant_ignore_surface(&pattern) else {
        return;
    };

    findings.push(Finding::new(FindingDescriptor {
        rule_id: "config.security-blind-ignore".to_string(),
        message: format!(
            "paths.ignore entry `{pattern}` can hide {surface} from security scanning."
        ),
        file_path: unit.file.display_path.clone(),
        line: Some(line),
        severity: Severity::Advisory,
        pillar: Pillar::Security,
        confidence: Confidence::High,
        symbol: Some(pattern.clone()),
        remediation: Some(
            "Use report-level `exclude` entries with reasons for reviewed findings instead of discovery-time ignores."
                .to_string(),
        ),
        metadata: json!({ "ignoredPath": pattern, "surface": surface }),
    }));
}

fn push_github_event_shell_finding(
    unit: &SourceUnit<'_>,
    findings: &mut Vec<Finding>,
    line: usize,
) {
    findings.push(Finding::new(FindingDescriptor {
        rule_id: "ci.github-event-shell-interpolation".to_string(),
        message:
            "GitHub event data is interpolated directly into a workflow shell step.".to_string(),
        file_path: unit.file.display_path.clone(),
        line: Some(line),
        severity: Severity::Warning,
        pillar: Pillar::Security,
        confidence: Confidence::High,
        symbol: None,
        remediation: Some(
            "Pass event data through environment variables or validated script inputs before shell use."
                .to_string(),
        ),
        metadata: json!({}),
    }));
}

fn is_github_workflow(path: &str) -> bool {
    let normalized = path.replace('\\', "/");
    normalized.starts_with(".github/workflows/")
        && (normalized.ends_with(".yml") || normalized.ends_with(".yaml"))
}

fn trim_config_string(value: &str) -> String {
    value
        .trim()
        .trim_matches('"')
        .trim_matches('\'')
        .to_string()
}

fn line_indent(line: &str) -> usize {
    line.len().saturating_sub(line.trim_start().len())
}

fn workflow_run_value(trimmed: &str) -> Option<&str> {
    trimmed
        .strip_prefix("- ")
        .unwrap_or(trimmed)
        .strip_prefix("run:")
}

fn is_yaml_block_scalar(value: &str) -> bool {
    matches!(value, "|" | "|-" | "|+" | ">" | ">-" | ">+")
}

fn security_relevant_ignore_surface(pattern: &str) -> Option<&'static str> {
    let lower = normalize_report_path(pattern).to_ascii_lowercase();
    SECURITY_IGNORE_SURFACES
        .iter()
        .find_map(|surface| surface.matches(&lower).then_some(surface.label))
}

struct SecurityIgnoreSurface {
    matcher: IgnoreSurfaceMatcher,
    label: &'static str,
}

impl SecurityIgnoreSurface {
    fn matches(&self, pattern: &str) -> bool {
        match self.matcher {
            IgnoreSurfaceMatcher::Prefix(prefix) => pattern.starts_with(prefix),
            IgnoreSurfaceMatcher::Exact(exact) => pattern == exact,
            IgnoreSurfaceMatcher::Contains(needle) => pattern.contains(needle),
            IgnoreSurfaceMatcher::Suffix(suffix) => pattern.ends_with(suffix),
        }
    }
}

enum IgnoreSurfaceMatcher {
    Prefix(&'static str),
    Exact(&'static str),
    Contains(&'static str),
    Suffix(&'static str),
}

const SECURITY_IGNORE_SURFACES: &[SecurityIgnoreSurface] = &[
    SecurityIgnoreSurface {
        matcher: IgnoreSurfaceMatcher::Prefix(".github"),
        label: "GitHub workflow and instruction files",
    },
    SecurityIgnoreSurface {
        matcher: IgnoreSurfaceMatcher::Prefix(".agents"),
        label: "agent, hook, or project-memory files",
    },
    SecurityIgnoreSurface {
        matcher: IgnoreSurfaceMatcher::Prefix(".codex"),
        label: "agent, hook, or project-memory files",
    },
    SecurityIgnoreSurface {
        matcher: IgnoreSurfaceMatcher::Prefix(".goat-flow"),
        label: "agent, hook, or project-memory files",
    },
    SecurityIgnoreSurface {
        matcher: IgnoreSurfaceMatcher::Prefix("scripts"),
        label: "shell entrypoints",
    },
    SecurityIgnoreSurface {
        matcher: IgnoreSurfaceMatcher::Exact("cargo.toml"),
        label: "Cargo dependency metadata",
    },
    SecurityIgnoreSurface {
        matcher: IgnoreSurfaceMatcher::Exact("cargo.lock"),
        label: "Cargo dependency metadata",
    },
    SecurityIgnoreSurface {
        matcher: IgnoreSurfaceMatcher::Prefix(".env"),
        label: "environment secret files",
    },
    SecurityIgnoreSurface {
        matcher: IgnoreSurfaceMatcher::Contains("/.env"),
        label: "environment secret files",
    },
    SecurityIgnoreSurface {
        matcher: IgnoreSurfaceMatcher::Suffix(".pem"),
        label: "PEM key or certificate files",
    },
    SecurityIgnoreSurface {
        matcher: IgnoreSurfaceMatcher::Contains("*.pem"),
        label: "PEM key or certificate files",
    },
    SecurityIgnoreSurface {
        matcher: IgnoreSurfaceMatcher::Suffix(".key"),
        label: "key files",
    },
    SecurityIgnoreSurface {
        matcher: IgnoreSurfaceMatcher::Contains("*.key"),
        label: "key files",
    },
];