vsec 0.0.1

Detect secrets and in Rust codebases
Documentation
// src/filters/pipeline.rs

use crate::filters::{
    BenignNameConfig, BenignNameFilter, ConsequenceAnalyzer, ConsequenceConfig, RhsAnalyzer,
    RhsConfig, ScopeFilter, ScopeFilterConfig,
};
use crate::models::{AnalysisContext, Comparison, ScoreFactor, SuspectValue};

/// Configuration for the entire filter pipeline
#[derive(Debug, Clone, Default)]
pub struct FilterPipelineConfig {
    /// Layer 1: Name-based filtering
    pub name_config: BenignNameConfig,

    /// Layer 2: Scope-based filtering
    pub scope_config: ScopeFilterConfig,

    /// Layer 3: Consequence analysis
    pub consequence_config: ConsequenceConfig,

    /// Layer 4: RHS analysis
    pub rhs_config: RhsConfig,

    /// Whether to enable Layer 1
    pub enable_name_filter: bool,

    /// Whether to enable Layer 2
    pub enable_scope_filter: bool,

    /// Whether to enable Layer 3
    pub enable_consequence_filter: bool,

    /// Whether to enable Layer 4
    pub enable_rhs_filter: bool,
}

impl FilterPipelineConfig {
    pub fn new() -> Self {
        Self {
            name_config: BenignNameConfig::default(),
            scope_config: ScopeFilterConfig::default(),
            consequence_config: ConsequenceConfig::default(),
            rhs_config: RhsConfig::default(),
            enable_name_filter: true,
            enable_scope_filter: true,
            enable_consequence_filter: true,
            enable_rhs_filter: true,
        }
    }
}

/// Result of running through the filter pipeline
#[derive(Debug)]
pub struct FilterResult {
    /// Whether the finding should be killed (not reported)
    pub killed: bool,

    /// Reason for killing (if killed)
    pub kill_reason: Option<String>,

    /// All scoring factors from the pipeline
    pub factors: Vec<ScoreFactor>,
}

impl FilterResult {
    pub fn killed(reason: impl Into<String>) -> Self {
        Self {
            killed: true,
            kill_reason: Some(reason.into()),
            factors: Vec::new(),
        }
    }

    pub fn passed(factors: Vec<ScoreFactor>) -> Self {
        Self {
            killed: false,
            kill_reason: None,
            factors,
        }
    }

    /// Get the total score contribution from all factors
    pub fn total_score(&self) -> i32 {
        self.factors.iter().map(|f| f.contribution).sum()
    }
}

/// The filter pipeline orchestrates all four filtering layers
pub struct FilterPipeline {
    name_filter: Option<BenignNameFilter>,
    scope_filter: Option<ScopeFilter>,
    consequence_analyzer: Option<ConsequenceAnalyzer>,
    rhs_analyzer: Option<RhsAnalyzer>,
}

impl FilterPipeline {
    /// Create a new filter pipeline with default configuration
    pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
        Self::with_config(FilterPipelineConfig::new())
    }

    /// Create a new filter pipeline with custom configuration
    pub fn with_config(config: FilterPipelineConfig) -> Result<Self, Box<dyn std::error::Error>> {
        let name_filter = if config.enable_name_filter {
            Some(BenignNameFilter::new(config.name_config)?)
        } else {
            None
        };

        let scope_filter = if config.enable_scope_filter {
            Some(ScopeFilter::new(config.scope_config)?)
        } else {
            None
        };

        let consequence_analyzer = if config.enable_consequence_filter {
            Some(ConsequenceAnalyzer::new(config.consequence_config))
        } else {
            None
        };

        let rhs_analyzer = if config.enable_rhs_filter {
            Some(RhsAnalyzer::new(config.rhs_config))
        } else {
            None
        };

        Ok(Self {
            name_filter,
            scope_filter,
            consequence_analyzer,
            rhs_analyzer,
        })
    }

    /// Run a suspect value through the entire pipeline
    pub fn analyze(
        &self,
        suspect: &SuspectValue,
        comparison: Option<&Comparison>,
        context: &AnalysisContext,
    ) -> FilterResult {
        let mut all_factors = Vec::new();

        // Layer 2: Scope filtering (check first for early exit)
        if let Some(scope_filter) = &self.scope_filter {
            if scope_filter.should_skip(context) {
                return FilterResult::killed("Finding is in ignored scope (test/example/bench)");
            }
            all_factors.extend(scope_filter.analyze(context));
        }

        // Layer 1: Name-based filtering
        if let Some(name_filter) = &self.name_filter {
            if let Some(name) = suspect.name() {
                let name_factors = name_filter.analyze(name);

                // Check for kill factors
                for factor in &name_factors {
                    if factor.contribution <= -100 {
                        return FilterResult::killed(format!(
                            "Name '{}' is benign: {}",
                            name, factor.reason
                        ));
                    }
                }

                all_factors.extend(name_factors);
            }
        }

        // Layer 3: Consequence analysis (if we have a comparison with consequence)
        if let Some(consequence_analyzer) = &self.consequence_analyzer {
            if let Some(comp) = comparison {
                if let Some(consequence) = &comp.consequence {
                    all_factors.extend(consequence_analyzer.score(consequence));
                }
            }
        }

        // Layer 4: RHS analysis (if we have a comparison)
        if let Some(rhs_analyzer) = &self.rhs_analyzer {
            if let Some(comp) = comparison {
                if let Some(variable) = comp.variable_side() {
                    all_factors.extend(rhs_analyzer.analyze(variable));
                }
            }
        }

        FilterResult::passed(all_factors)
    }

    /// Quick check if a name is definitely benign
    pub fn is_definitely_benign(&self, name: &str) -> bool {
        if let Some(name_filter) = &self.name_filter {
            name_filter.is_definitely_benign(name)
        } else {
            false
        }
    }

    /// Quick check if context should be skipped
    pub fn should_skip_context(&self, context: &AnalysisContext) -> bool {
        if let Some(scope_filter) = &self.scope_filter {
            scope_filter.should_skip(context)
        } else {
            false
        }
    }

    /// Check if a comparison looks like routing/command dispatch
    pub fn looks_like_routing(&self, comparison: &Comparison) -> bool {
        if let Some(rhs_analyzer) = &self.rhs_analyzer {
            if let Some(variable) = comparison.variable_side() {
                return rhs_analyzer.looks_like_routing(variable);
            }
        }
        false
    }

    /// Check if a comparison looks like authentication
    pub fn looks_like_auth(&self, comparison: &Comparison) -> bool {
        if let Some(rhs_analyzer) = &self.rhs_analyzer {
            if let Some(variable) = comparison.variable_side() {
                return rhs_analyzer.looks_like_auth(variable);
            }
        }
        false
    }
}

impl Default for FilterPipeline {
    fn default() -> Self {
        Self::new().expect("Failed to create default FilterPipeline")
    }
}

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

    fn test_context() -> AnalysisContext {
        AnalysisContext::new(PathBuf::from("src/lib.rs"))
    }

    fn test_context_in_tests() -> AnalysisContext {
        let mut ctx = AnalysisContext::new(PathBuf::from("tests/auth_test.rs"));
        ctx.in_test_context = true;
        ctx
    }

    #[test]
    fn test_version_constant_is_killed() {
        let pipeline = FilterPipeline::new().unwrap();
        let suspect = SuspectValue::Constant {
            name: "VERSION".into(),
            value: "1.0.0".into(),
            type_annotation: None,
        };

        let result = pipeline.analyze(&suspect, None, &test_context());
        assert!(result.killed, "VERSION should be killed");
    }

    #[test]
    fn test_auth_token_not_killed() {
        let pipeline = FilterPipeline::new().unwrap();
        let suspect = SuspectValue::Constant {
            name: "AUTH_TOKEN".into(),
            value: "secret123".into(),
            type_annotation: None,
        };

        let result = pipeline.analyze(&suspect, None, &test_context());
        assert!(!result.killed, "AUTH_TOKEN should not be killed");
        assert!(
            result.total_score() > 0,
            "AUTH_TOKEN should have positive score"
        );
    }

    #[test]
    fn test_test_context_kills() {
        let pipeline = FilterPipeline::new().unwrap();
        let suspect = SuspectValue::Constant {
            name: "SECRET_KEY".into(),
            value: "test_secret".into(),
            type_annotation: None,
        };

        let result = pipeline.analyze(&suspect, None, &test_context_in_tests());
        assert!(result.killed, "Test context should kill finding");
    }

    #[test]
    fn test_definitely_benign_check() {
        let pipeline = FilterPipeline::new().unwrap();
        assert!(pipeline.is_definitely_benign("VERSION"));
        assert!(pipeline.is_definitely_benign("MIME_TYPE"));
        assert!(!pipeline.is_definitely_benign("SECRET_KEY"));
    }
}