sentio-core 0.1.5

AST-based security scanner for Solana/Anchor programs
Documentation
use crate::finding::Finding;
use crate::rules::{convert_severity, RuleContext, RuleRegistry, SuppressionSet};
use crate::syntax::{parse_rust_files, ParseFailure, ParsedFile, SyntaxReport};
use serde::Serialize;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

#[derive(Debug, Clone, Default)]
pub struct ScanOptions {
    pub include_tests: bool,
    pub rule_filter: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize)]
pub struct ScanResult {
    pub findings: Vec<Finding>,
    pub files_scanned: usize,
    pub files_parsed: usize,
    pub parse_failures: Vec<ParseFailure>,
}

#[derive(Default)]
pub struct Scanner {
    rules: RuleRegistry,
}

impl Scanner {
    pub fn new() -> Self {
        Self {
            rules: RuleRegistry::baseline(),
        }
    }

    pub fn scan_path(&self, path: &str, options: &ScanOptions) -> ScanResult {
        let file_paths: Vec<PathBuf> = discover_rust_files(path, options).collect();
        let files_scanned = file_paths.len();
        let syntax_report = parse_rust_files(file_paths);
        self.scan_report(files_scanned, syntax_report, options)
    }

    pub fn scan_report(
        &self,
        files_scanned: usize,
        report: SyntaxReport,
        options: &ScanOptions,
    ) -> ScanResult {
        let files_parsed = report.files.len();
        let findings = self.run_rules(&report.files, options);
        let parse_failures = report.parse_failures;

        ScanResult {
            findings,
            files_scanned,
            files_parsed,
            parse_failures,
        }
    }

    fn run_rules(&self, files: &[ParsedFile], options: &ScanOptions) -> Vec<Finding> {
        let ctx = RuleContext { files };
        let suppressions: Vec<(String, SuppressionSet)> = files
            .iter()
            .map(|file| {
                (
                    file.path.display().to_string(),
                    SuppressionSet::from_source(&file.source),
                )
            })
            .collect();

        let mut findings = Vec::new();
        for file in files {
            for rule in self.rules.matching_rules(options.rule_filter.as_deref()) {
                for matched in rule.match_file(file, &ctx) {
                    let finding = Finding {
                        rule_id: matched.rule_id.to_string(),
                        severity: convert_severity(matched.severity),
                        message: matched.message,
                        location: matched.location,
                        help: matched.help,
                        suppressed: false,
                    };

                    if is_suppressed(&finding, &suppressions) {
                        continue;
                    }

                    findings.push(finding);
                }
            }
        }

        findings
    }
}

fn is_suppressed(finding: &Finding, suppressions: &[(String, SuppressionSet)]) -> bool {
    suppressions
        .iter()
        .find(|(path, _)| path == &finding.location.path)
        .is_some_and(|(_, set)| set.is_suppressed(finding))
}

fn discover_rust_files<'a>(
    path: &'a str,
    options: &'a ScanOptions,
) -> impl Iterator<Item = PathBuf> + 'a {
    WalkDir::new(path)
        .into_iter()
        .filter_map(Result::ok)
        .filter(|entry| entry.file_type().is_file())
        .filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("rs"))
        .filter(|entry| !is_excluded_path(entry.path()))
        .filter(move |entry| options.include_tests || !is_test_path(entry.path()))
        .map(|entry| entry.into_path())
}

fn is_excluded_path(path: &Path) -> bool {
    path.components().any(|component| {
        let part = component.as_os_str().to_string_lossy();
        matches!(part.as_ref(), "target" | ".git")
    })
}

fn is_test_path(path: &Path) -> bool {
    path.components().any(|component| {
        let part = component.as_os_str().to_string_lossy();
        matches!(part.as_ref(), "tests" | "test" | "fixtures")
    })
}