Skip to main content

forge_guard/deployment/
mod.rs

1//! Deployment guard — blocks unsafe deployments by default.
2
3pub mod verifier;
4
5use crate::chains::ChainRegistry;
6use crate::core::{
7    AuditResult, AuditSummary, Finding, ForgeGuardError, ProjectConfig, RiskLevel, SecurityScores,
8    Severity,
9};
10use crate::security::SecurityEngine;
11use verifier::{ContractVerifier, VerificationMethod, VerificationResult};
12
13#[cfg(test)]
14use crate::plugins::PluginRegistry;
15
16/// The deployment guard enforces pre-deployment security checks.
17pub struct DeploymentGuard {
18    config: ProjectConfig,
19    security_engine: SecurityEngine,
20    verifier: Option<ContractVerifier>,
21}
22
23impl DeploymentGuard {
24    /// Create a new deployment guard.
25    pub fn new(
26        config: &ProjectConfig,
27        security_engine: &SecurityEngine,
28    ) -> Result<Self, ForgeGuardError> {
29        // Create verifier if auto-verify is enabled
30        let verifier = if config.deployment.auto_verify || config.deployment.require_verification {
31            Some(ContractVerifier::new(
32                &config.chain,
33                config.deployment.explorer_api_key.clone(),
34            ))
35        } else {
36            None
37        };
38
39        Ok(Self {
40            config: config.clone(),
41            security_engine: security_engine.clone(),
42            verifier,
43        })
44    }
45
46    /// Run all pre-deployment checks and return findings.
47    pub fn run_pre_deployment_checks(
48        &self,
49        config: &ProjectConfig,
50        chain_registry: &ChainRegistry,
51    ) -> Result<Vec<Finding>, ForgeGuardError> {
52        // Discover source files
53        let source_files = self.discover_sources(config)?;
54
55        // Run security analysis
56        let findings = self
57            .security_engine
58            .analyze_files(&source_files, chain_registry)?;
59
60        Ok(findings)
61    }
62
63    /// Determine if deployment should be allowed.
64    pub fn can_deploy(
65        &self,
66        findings: &[Finding],
67        overall_score: u8,
68        risk_level: RiskLevel,
69    ) -> bool {
70        let deploy_cfg = &self.config.deployment;
71
72        // Check critical findings
73        if deploy_cfg.block_on_critical && findings.iter().any(|f| f.severity == Severity::Critical)
74        {
75            return false;
76        }
77
78        // Check high findings
79        if deploy_cfg.block_on_high && findings.iter().any(|f| f.severity == Severity::High) {
80            return false;
81        }
82
83        // Check medium findings
84        if deploy_cfg.block_on_medium && findings.iter().any(|f| f.severity == Severity::Medium) {
85            return false;
86        }
87
88        // Check minimum score
89        if overall_score < deploy_cfg.min_score {
90            return false;
91        }
92
93        // Check risk level
94        if matches!(risk_level, RiskLevel::Critical | RiskLevel::High) {
95            return false;
96        }
97
98        // Check for explicitly blocking findings
99        if findings.iter().any(|f| f.blocks_deployment) {
100            return false;
101        }
102
103        true
104    }
105
106    /// Calculate overall security score.
107    pub fn calculate_overall_score(&self, scores: &SecurityScores) -> u8 {
108        let vals = [
109            scores.access_control,
110            scores.security,
111            scores.fuzzing,
112            scores.gas,
113            scores.architecture,
114            scores.upgradeability,
115            scores.dependencies,
116            scores.deployment,
117            scores.proxy_safety,
118            scores.chain_compatibility,
119            scores.production_readiness,
120            scores.exploit_resistance,
121        ];
122        (vals.iter().copied().map(u16::from).sum::<u16>() / vals.len() as u16) as u8
123    }
124
125    /// Determine risk level from score and findings.
126    pub fn determine_risk_level(&self, score: u8, findings: &[Finding]) -> RiskLevel {
127        let has_critical = findings.iter().any(|f| f.severity == Severity::Critical);
128        let has_high = findings.iter().any(|f| f.severity == Severity::High);
129
130        if has_critical || score < 30 {
131            RiskLevel::Critical
132        } else if has_high || score < 50 {
133            RiskLevel::High
134        } else if score < 70 {
135            RiskLevel::Medium
136        } else if score < 85 {
137            RiskLevel::Low
138        } else {
139            RiskLevel::Minimal
140        }
141    }
142
143    /// Build a deployment decision result.
144    pub fn build_result(
145        &self,
146        findings: Vec<Finding>,
147        scores: SecurityScores,
148        overall_score: u8,
149        risk_level: RiskLevel,
150        source_count: usize,
151    ) -> AuditResult {
152        let production_ready = overall_score >= self.config.min_deployment_score;
153        let deployment_approved = self.can_deploy(&findings, overall_score, risk_level);
154
155        let mut summary = AuditSummary {
156            total_findings: findings.len(),
157            critical_count: 0,
158            high_count: 0,
159            medium_count: 0,
160            low_count: 0,
161            info_count: 0,
162            files_analyzed: source_count,
163            lines_analyzed: 0,
164            contracts_analyzed: source_count,
165        };
166
167        for f in &findings {
168            match f.severity {
169                Severity::Critical => summary.critical_count += 1,
170                Severity::High => summary.high_count += 1,
171                Severity::Medium => summary.medium_count += 1,
172                Severity::Low => summary.low_count += 1,
173                Severity::Informational => summary.info_count += 1,
174            }
175        }
176
177        AuditResult {
178            project_name: self.config.project_root.to_string_lossy().to_string(),
179            chain: self.config.chain.clone(),
180            chains: vec![self.config.chain.clone()],
181            timestamp: chrono::Utc::now().to_rfc3339(),
182            duration_seconds: 0.0,
183            findings,
184            scores,
185            overall_score,
186            risk_level,
187            production_ready,
188            deployment_approved,
189            summary,
190        }
191    }
192
193    /// Verify a contract after deployment.
194    ///
195    /// Returns `Ok(true)` if verification passed, `Ok(false)` if not required,
196    /// or an error if verification failed.
197    pub fn verify_contract(
198        &self,
199        address: &str,
200        contract_name: &str,
201        constructor_args: Option<&str>,
202    ) -> Result<VerificationResult, ForgeGuardError> {
203        let verifier = self.verifier.as_ref().ok_or_else(|| {
204            ForgeGuardError::Config(
205                "Verification not configured. Set auto_verify = true in [deployment] config."
206                    .into(),
207            )
208        })?;
209
210        Ok(verifier.forge_verify(address, contract_name, &self.config.chain, constructor_args))
211    }
212
213    /// Verify bytecode match via RPC.
214    pub fn verify_bytecode(
215        &self,
216        address: &str,
217        contract_name: &str,
218        rpc_url: &str,
219    ) -> VerificationResult {
220        match &self.verifier {
221            Some(v) => v.verify_bytecode_match(address, contract_name, rpc_url),
222            None => VerificationResult {
223                verified: false,
224                method: VerificationMethod::BytecodeMatch,
225                details: "Verifier not configured.".into(),
226                duration: std::time::Duration::from_secs(0),
227            },
228        }
229    }
230
231    fn discover_sources(
232        &self,
233        config: &ProjectConfig,
234    ) -> Result<Vec<std::path::PathBuf>, ForgeGuardError> {
235        let mut files = Vec::new();
236        for dir in &config.src_dirs {
237            let dir_path = if dir.is_absolute() {
238                dir.clone()
239            } else {
240                config.project_root.join(dir)
241            };
242            if !dir_path.exists() {
243                continue;
244            }
245            for entry in walkdir::WalkDir::new(&dir_path)
246                .into_iter()
247                .filter_entry(|e| {
248                    !config
249                        .exclude
250                        .iter()
251                        .any(|p| e.file_name().to_string_lossy().contains(p))
252                })
253                .filter_map(|e| e.ok())
254            {
255                let path = entry.path();
256                if path.extension().is_some_and(|ext| ext == "sol") {
257                    files.push(path.to_path_buf());
258                }
259            }
260        }
261        Ok(files)
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::core::{Finding, SecurityScores, Severity};
269
270    #[test]
271    fn test_can_deploy_clean() {
272        let config = ProjectConfig::default();
273        let engine = SecurityEngine::new(&config, &PluginRegistry::new(&config).unwrap()).unwrap();
274        let guard = DeploymentGuard::new(&config, &engine).unwrap();
275
276        let result = guard.can_deploy(&[], 100, RiskLevel::Minimal);
277        assert!(result);
278    }
279
280    #[test]
281    fn test_can_deploy_blocked_by_critical() {
282        let mut config = ProjectConfig::default();
283        config.deployment.block_on_critical = true;
284        let engine = SecurityEngine::new(&config, &PluginRegistry::new(&config).unwrap()).unwrap();
285        let guard = DeploymentGuard::new(&config, &engine).unwrap();
286
287        let findings = vec![Finding::builder()
288            .title("Critical Bug")
289            .description("A critical vulnerability")
290            .severity(Severity::Critical)
291            .build()];
292
293        assert!(!guard.can_deploy(&findings, 100, RiskLevel::Minimal));
294    }
295
296    #[test]
297    fn test_can_deploy_blocked_by_high() {
298        let config = ProjectConfig::default();
299        let engine = SecurityEngine::new(&config, &PluginRegistry::new(&config).unwrap()).unwrap();
300        let guard = DeploymentGuard::new(&config, &engine).unwrap();
301
302        let findings = vec![Finding::builder()
303            .title("High Bug")
304            .description("A high vulnerability")
305            .severity(Severity::High)
306            .build()];
307
308        assert!(!guard.can_deploy(&findings, 100, RiskLevel::Minimal));
309    }
310
311    #[test]
312    fn test_can_deploy_medium_not_blocked_by_default() {
313        let config = ProjectConfig::default();
314        let engine = SecurityEngine::new(&config, &PluginRegistry::new(&config).unwrap()).unwrap();
315        let guard = DeploymentGuard::new(&config, &engine).unwrap();
316
317        let findings = vec![Finding::builder()
318            .title("Medium Issue")
319            .description("A medium issue")
320            .severity(Severity::Medium)
321            .build()];
322
323        assert!(guard.can_deploy(&findings, 100, RiskLevel::Minimal));
324    }
325
326    #[test]
327    fn test_can_deploy_blocked_by_high_risk() {
328        let config = ProjectConfig::default();
329        let engine = SecurityEngine::new(&config, &PluginRegistry::new(&config).unwrap()).unwrap();
330        let guard = DeploymentGuard::new(&config, &engine).unwrap();
331
332        assert!(!guard.can_deploy(&[], 100, RiskLevel::Critical));
333    }
334
335    #[test]
336    fn test_calculate_overall_score() {
337        let config = ProjectConfig::default();
338        let engine = SecurityEngine::new(&config, &PluginRegistry::new(&config).unwrap()).unwrap();
339        let guard = DeploymentGuard::new(&config, &engine).unwrap();
340
341        let scores = SecurityScores::perfect();
342        assert_eq!(guard.calculate_overall_score(&scores), 100);
343    }
344
345    #[test]
346    fn test_determine_risk_level() {
347        let config = ProjectConfig::default();
348        let engine = SecurityEngine::new(&config, &PluginRegistry::new(&config).unwrap()).unwrap();
349        let guard = DeploymentGuard::new(&config, &engine).unwrap();
350
351        assert_eq!(guard.determine_risk_level(100, &[]), RiskLevel::Minimal);
352        assert_eq!(guard.determine_risk_level(50, &[]), RiskLevel::Medium);
353        assert_eq!(guard.determine_risk_level(25, &[]), RiskLevel::Critical);
354    }
355
356    #[test]
357    fn test_determine_risk_level_with_critical_finding() {
358        let config = ProjectConfig::default();
359        let engine = SecurityEngine::new(&config, &PluginRegistry::new(&config).unwrap()).unwrap();
360        let guard = DeploymentGuard::new(&config, &engine).unwrap();
361
362        let findings = vec![Finding::builder()
363            .title("Critical")
364            .description("desc")
365            .severity(Severity::Critical)
366            .build()];
367        assert_eq!(
368            guard.determine_risk_level(100, &findings),
369            RiskLevel::Critical
370        );
371    }
372}