forge-guard 0.3.6

Pre-deployment smart contract auditing framework for Foundry
Documentation
//! Deployment guard — blocks unsafe deployments by default.

pub mod verifier;

use crate::chains::ChainRegistry;
use crate::core::{
    AuditResult, AuditSummary, Finding, ForgeGuardError, ProjectConfig, RiskLevel, SecurityScores,
    Severity,
};
use crate::security::SecurityEngine;
use verifier::{ContractVerifier, VerificationMethod, VerificationResult};

#[cfg(test)]
use crate::plugins::PluginRegistry;

/// The deployment guard enforces pre-deployment security checks.
pub struct DeploymentGuard {
    config: ProjectConfig,
    security_engine: SecurityEngine,
    verifier: Option<ContractVerifier>,
}

impl DeploymentGuard {
    /// Create a new deployment guard.
    pub fn new(
        config: &ProjectConfig,
        security_engine: &SecurityEngine,
    ) -> Result<Self, ForgeGuardError> {
        // Create verifier if auto-verify is enabled
        let verifier = if config.deployment.auto_verify || config.deployment.require_verification {
            Some(ContractVerifier::new(
                &config.chain,
                config.deployment.explorer_api_key.clone(),
            ))
        } else {
            None
        };

        Ok(Self {
            config: config.clone(),
            security_engine: security_engine.clone(),
            verifier,
        })
    }

    /// Run all pre-deployment checks and return findings.
    pub fn run_pre_deployment_checks(
        &self,
        config: &ProjectConfig,
        chain_registry: &ChainRegistry,
    ) -> Result<Vec<Finding>, ForgeGuardError> {
        // Discover source files
        let source_files = self.discover_sources(config)?;

        // Run security analysis
        let findings = self
            .security_engine
            .analyze_files(&source_files, chain_registry)?;

        Ok(findings)
    }

    /// Determine if deployment should be allowed.
    pub fn can_deploy(
        &self,
        findings: &[Finding],
        overall_score: u8,
        risk_level: RiskLevel,
    ) -> bool {
        let deploy_cfg = &self.config.deployment;

        // Check critical findings
        if deploy_cfg.block_on_critical && findings.iter().any(|f| f.severity == Severity::Critical)
        {
            return false;
        }

        // Check high findings
        if deploy_cfg.block_on_high && findings.iter().any(|f| f.severity == Severity::High) {
            return false;
        }

        // Check medium findings
        if deploy_cfg.block_on_medium && findings.iter().any(|f| f.severity == Severity::Medium) {
            return false;
        }

        // Check minimum score
        if overall_score < deploy_cfg.min_score {
            return false;
        }

        // Check risk level
        if matches!(risk_level, RiskLevel::Critical | RiskLevel::High) {
            return false;
        }

        // Check for explicitly blocking findings
        if findings.iter().any(|f| f.blocks_deployment) {
            return false;
        }

        true
    }

    /// Calculate overall security score.
    pub fn calculate_overall_score(&self, scores: &SecurityScores) -> u8 {
        let vals = [
            scores.access_control,
            scores.security,
            scores.fuzzing,
            scores.gas,
            scores.architecture,
            scores.upgradeability,
            scores.dependencies,
            scores.deployment,
            scores.proxy_safety,
            scores.chain_compatibility,
            scores.production_readiness,
            scores.exploit_resistance,
        ];
        (vals.iter().copied().map(u16::from).sum::<u16>() / vals.len() as u16) as u8
    }

    /// Determine risk level from score and findings.
    pub fn determine_risk_level(&self, score: u8, findings: &[Finding]) -> RiskLevel {
        let has_critical = findings.iter().any(|f| f.severity == Severity::Critical);
        let has_high = findings.iter().any(|f| f.severity == Severity::High);

        if has_critical || score < 30 {
            RiskLevel::Critical
        } else if has_high || score < 50 {
            RiskLevel::High
        } else if score < 70 {
            RiskLevel::Medium
        } else if score < 85 {
            RiskLevel::Low
        } else {
            RiskLevel::Minimal
        }
    }

    /// Build a deployment decision result.
    pub fn build_result(
        &self,
        findings: Vec<Finding>,
        scores: SecurityScores,
        overall_score: u8,
        risk_level: RiskLevel,
        source_count: usize,
    ) -> AuditResult {
        let production_ready = overall_score >= self.config.min_deployment_score;
        let deployment_approved = self.can_deploy(&findings, overall_score, risk_level);

        let mut summary = AuditSummary {
            total_findings: findings.len(),
            critical_count: 0,
            high_count: 0,
            medium_count: 0,
            low_count: 0,
            info_count: 0,
            files_analyzed: source_count,
            lines_analyzed: 0,
            contracts_analyzed: source_count,
        };

        for f in &findings {
            match f.severity {
                Severity::Critical => summary.critical_count += 1,
                Severity::High => summary.high_count += 1,
                Severity::Medium => summary.medium_count += 1,
                Severity::Low => summary.low_count += 1,
                Severity::Informational => summary.info_count += 1,
            }
        }

        AuditResult {
            project_name: self.config.project_root.to_string_lossy().to_string(),
            chain: self.config.chain.clone(),
            chains: vec![self.config.chain.clone()],
            timestamp: chrono::Utc::now().to_rfc3339(),
            duration_seconds: 0.0,
            findings,
            scores,
            overall_score,
            risk_level,
            production_ready,
            deployment_approved,
            summary,
        }
    }

    /// Verify a contract after deployment.
    ///
    /// Returns `Ok(true)` if verification passed, `Ok(false)` if not required,
    /// or an error if verification failed.
    pub fn verify_contract(
        &self,
        address: &str,
        contract_name: &str,
        constructor_args: Option<&str>,
    ) -> Result<VerificationResult, ForgeGuardError> {
        let verifier = self.verifier.as_ref().ok_or_else(|| {
            ForgeGuardError::Config(
                "Verification not configured. Set auto_verify = true in [deployment] config."
                    .into(),
            )
        })?;

        Ok(verifier.forge_verify(address, contract_name, &self.config.chain, constructor_args))
    }

    /// Verify bytecode match via RPC.
    pub fn verify_bytecode(
        &self,
        address: &str,
        contract_name: &str,
        rpc_url: &str,
    ) -> VerificationResult {
        match &self.verifier {
            Some(v) => v.verify_bytecode_match(address, contract_name, rpc_url),
            None => VerificationResult {
                verified: false,
                method: VerificationMethod::BytecodeMatch,
                details: "Verifier not configured.".into(),
                duration: std::time::Duration::from_secs(0),
            },
        }
    }

    fn discover_sources(
        &self,
        config: &ProjectConfig,
    ) -> Result<Vec<std::path::PathBuf>, ForgeGuardError> {
        let mut files = Vec::new();
        for dir in &config.src_dirs {
            let dir_path = if dir.is_absolute() {
                dir.clone()
            } else {
                config.project_root.join(dir)
            };
            if !dir_path.exists() {
                continue;
            }
            for entry in walkdir::WalkDir::new(&dir_path)
                .into_iter()
                .filter_entry(|e| {
                    !config
                        .exclude
                        .iter()
                        .any(|p| e.file_name().to_string_lossy().contains(p))
                })
                .filter_map(|e| e.ok())
            {
                let path = entry.path();
                if path.extension().is_some_and(|ext| ext == "sol") {
                    files.push(path.to_path_buf());
                }
            }
        }
        Ok(files)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::{Finding, SecurityScores, Severity};

    #[test]
    fn test_can_deploy_clean() {
        let config = ProjectConfig::default();
        let engine = SecurityEngine::new(&config, &PluginRegistry::new(&config).unwrap()).unwrap();
        let guard = DeploymentGuard::new(&config, &engine).unwrap();

        let result = guard.can_deploy(&[], 100, RiskLevel::Minimal);
        assert!(result);
    }

    #[test]
    fn test_can_deploy_blocked_by_critical() {
        let mut config = ProjectConfig::default();
        config.deployment.block_on_critical = true;
        let engine = SecurityEngine::new(&config, &PluginRegistry::new(&config).unwrap()).unwrap();
        let guard = DeploymentGuard::new(&config, &engine).unwrap();

        let findings = vec![Finding::builder()
            .title("Critical Bug")
            .description("A critical vulnerability")
            .severity(Severity::Critical)
            .build()];

        assert!(!guard.can_deploy(&findings, 100, RiskLevel::Minimal));
    }

    #[test]
    fn test_can_deploy_blocked_by_high() {
        let config = ProjectConfig::default();
        let engine = SecurityEngine::new(&config, &PluginRegistry::new(&config).unwrap()).unwrap();
        let guard = DeploymentGuard::new(&config, &engine).unwrap();

        let findings = vec![Finding::builder()
            .title("High Bug")
            .description("A high vulnerability")
            .severity(Severity::High)
            .build()];

        assert!(!guard.can_deploy(&findings, 100, RiskLevel::Minimal));
    }

    #[test]
    fn test_can_deploy_medium_not_blocked_by_default() {
        let config = ProjectConfig::default();
        let engine = SecurityEngine::new(&config, &PluginRegistry::new(&config).unwrap()).unwrap();
        let guard = DeploymentGuard::new(&config, &engine).unwrap();

        let findings = vec![Finding::builder()
            .title("Medium Issue")
            .description("A medium issue")
            .severity(Severity::Medium)
            .build()];

        assert!(guard.can_deploy(&findings, 100, RiskLevel::Minimal));
    }

    #[test]
    fn test_can_deploy_blocked_by_high_risk() {
        let config = ProjectConfig::default();
        let engine = SecurityEngine::new(&config, &PluginRegistry::new(&config).unwrap()).unwrap();
        let guard = DeploymentGuard::new(&config, &engine).unwrap();

        assert!(!guard.can_deploy(&[], 100, RiskLevel::Critical));
    }

    #[test]
    fn test_calculate_overall_score() {
        let config = ProjectConfig::default();
        let engine = SecurityEngine::new(&config, &PluginRegistry::new(&config).unwrap()).unwrap();
        let guard = DeploymentGuard::new(&config, &engine).unwrap();

        let scores = SecurityScores::perfect();
        assert_eq!(guard.calculate_overall_score(&scores), 100);
    }

    #[test]
    fn test_determine_risk_level() {
        let config = ProjectConfig::default();
        let engine = SecurityEngine::new(&config, &PluginRegistry::new(&config).unwrap()).unwrap();
        let guard = DeploymentGuard::new(&config, &engine).unwrap();

        assert_eq!(guard.determine_risk_level(100, &[]), RiskLevel::Minimal);
        assert_eq!(guard.determine_risk_level(50, &[]), RiskLevel::Medium);
        assert_eq!(guard.determine_risk_level(25, &[]), RiskLevel::Critical);
    }

    #[test]
    fn test_determine_risk_level_with_critical_finding() {
        let config = ProjectConfig::default();
        let engine = SecurityEngine::new(&config, &PluginRegistry::new(&config).unwrap()).unwrap();
        let guard = DeploymentGuard::new(&config, &engine).unwrap();

        let findings = vec![Finding::builder()
            .title("Critical")
            .description("desc")
            .severity(Severity::Critical)
            .build()];
        assert_eq!(
            guard.determine_risk_level(100, &findings),
            RiskLevel::Critical
        );
    }
}