use crate::ai::{
consensus_to_core_finding, create_provider, AuditContext, ConsensusConfig, ConsensusEngine,
GasAuditor, LlmProvider, LogicAuditor, SecurityAuditor,
};
use crate::chains::ChainRegistry;
use crate::core::{
AuditResult, AuditSummary, OutputFormat, ProjectConfig, RiskLevel, SecurityScores,
};
use crate::exploit;
use crate::gas;
use crate::plugins::PluginRegistry;
use crate::reports;
use crate::security::SecurityEngine;
use crate::utils::Cache;
use super::AuditArgs;
use anyhow::Result;
use std::time::Instant;
pub fn run(args: &AuditArgs) -> Result<()> {
let start = Instant::now();
let mut config = ProjectConfig::from_default_location();
apply_args(&mut config, args);
let cache = Cache::new(&config)?;
let chain_registry = ChainRegistry::default();
let plugin_registry = PluginRegistry::new(&config)?;
let security_engine = SecurityEngine::new(&config, &plugin_registry)?;
let source_files = discover_sources(&config)?;
if source_files.is_empty() {
anyhow::bail!("No Solidity source files found in {:?}", config.src_dirs);
}
let changed_files = cache.filter_changed_files(&source_files);
let skipped_count = source_files.len() - changed_files.len();
let files_to_analyze = if changed_files.is_empty() && !source_files.is_empty() {
source_files.clone()
} else {
changed_files
};
if skipped_count > 0 && !files_to_analyze.is_empty() {
eprintln!(
"⏭️ Skipping {} unchanged files (content hash matched)",
skipped_count
);
}
if args.quick {
eprintln!(
"⚡ Running quick audit on {} files (chain: {})...",
files_to_analyze.len(),
args.shared.chain
);
} else {
eprintln!(
"🔍 Running security audit on {} files (chain: {})...",
files_to_analyze.len(),
args.shared.chain
);
}
let findings = if args.quick {
security_engine.analyze_files_quick(&files_to_analyze, &chain_registry)?
} else {
security_engine.analyze_files(&files_to_analyze, &chain_registry)?
};
if !args.quick {
for file in &files_to_analyze {
let _ = cache.record_file_hash(file);
}
}
let exploit_findings = if !args.quick && (args.exploit || args.full) {
eprintln!("💥 Running exploit path analysis...");
exploit::analyze_exploit_paths(&findings, &files_to_analyze)?
} else {
Vec::new()
};
let gas_findings = if !args.quick && (args.gas || args.full) {
eprintln!("⛽ Running gas analysis...");
gas::analyze_gas(&files_to_analyze, &config)?
} else {
Vec::new()
};
let ai_findings = if !args.quick && args.ai {
run_ai_analysis(&files_to_analyze, args, &config)?
} else {
Vec::new()
};
let mut all_findings = findings;
all_findings.extend(exploit_findings);
all_findings.extend(gas_findings);
all_findings.extend(ai_findings);
let scores = security_engine.calculate_scores(&all_findings);
let overall_score = calculate_overall(&scores);
let risk_level = determine_risk_level(overall_score, &all_findings);
let production_ready = overall_score >= config.min_deployment_score;
let deployment_approved = !has_blocking_findings(&all_findings) && production_ready;
let duration = start.elapsed().as_secs_f64();
let result = AuditResult {
project_name: config.project_root.to_string_lossy().to_string(),
chain: args.shared.chain.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
duration_seconds: duration,
findings: all_findings.clone(),
scores,
overall_score,
risk_level,
production_ready,
deployment_approved,
summary: build_summary(&all_findings, &source_files),
};
match config.output {
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&result)?);
}
OutputFormat::Markdown => {
let md = reports::markdown::generate_report(&result)?;
println!("{}", md);
}
OutputFormat::Terminal => {
if args.quick || args.summary {
let summary = reports::generate_executive_summary(&result);
println!("{}", summary);
} else {
print_terminal_report(&result, args);
}
}
}
if args.shared.report {
let report_dir = std::path::PathBuf::from("reports");
std::fs::create_dir_all(&report_dir)?;
reports::json::write_report(&result, &report_dir.join("audit.json"))?;
let md = reports::markdown::generate_report(&result)?;
std::fs::write(report_dir.join("audit.md"), md)?;
eprintln!("📄 Reports saved to reports/");
}
cache.store("last_audit", &result)?;
if args.shared.strict && all_findings.iter().any(|f| f.severity.score() >= 3) {
anyhow::bail!("Strict mode: findings detected with severity MEDIUM or higher");
}
Ok(())
}
fn apply_args(config: &mut ProjectConfig, args: &AuditArgs) {
config.chain = args.shared.chain.clone();
config.project_root = args.shared.project.clone();
config.strict = args.shared.strict;
config.offline = args.shared.offline;
config.production = args.shared.production;
config.parallelism = args.shared.parallelism;
config.output = if args.shared.json {
OutputFormat::Json
} else if args.shared.markdown {
OutputFormat::Markdown
} else {
OutputFormat::Terminal
};
if let Some(exclude) = &args.exclude {
config.exclude = exclude.split(',').map(String::from).collect();
}
}
fn discover_sources(config: &ProjectConfig) -> Result<Vec<std::path::PathBuf>> {
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)
}
fn calculate_overall(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
}
fn determine_risk_level(score: u8, findings: &[crate::core::Finding]) -> RiskLevel {
let has_critical = findings
.iter()
.any(|f| f.severity == crate::core::Severity::Critical);
let has_high = findings
.iter()
.any(|f| f.severity == crate::core::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
}
}
fn has_blocking_findings(findings: &[crate::core::Finding]) -> bool {
findings.iter().any(|f| f.blocks_deployment)
}
fn build_summary(
findings: &[crate::core::Finding],
sources: &[std::path::PathBuf],
) -> AuditSummary {
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: sources.len(),
lines_analyzed: 0,
contracts_analyzed: sources.len(),
};
for f in findings {
match f.severity {
crate::core::Severity::Critical => summary.critical_count += 1,
crate::core::Severity::High => summary.high_count += 1,
crate::core::Severity::Medium => summary.medium_count += 1,
crate::core::Severity::Low => summary.low_count += 1,
crate::core::Severity::Informational => summary.info_count += 1,
}
}
for src in sources {
if let Ok(content) = std::fs::read_to_string(src) {
summary.lines_analyzed += content.lines().count();
}
}
summary
}
fn print_terminal_report(result: &AuditResult, _args: &AuditArgs) {
use colored::*;
println!(
"\n{}",
"═══════════════════════════════════════════════".bright_blue()
);
println!(
"{}",
" FORGE AUDIT — SECURITY REPORT "
.bright_blue()
.bold()
);
println!(
"{}",
"═══════════════════════════════════════════════".bright_blue()
);
println!("\n📋 Project: {}", result.project_name);
println!("⛓️ Chain: {}", result.chain);
println!("🕐 Duration: {:.2}s", result.duration_seconds);
println!("📁 Files: {}", result.summary.files_analyzed);
println!("\n{}", "── Findings ──".bold());
println!(
" 🛑 Critical: {}",
result.summary.critical_count.to_string().red().bold()
);
println!(
" 🔴 High: {}",
result.summary.high_count.to_string().red()
);
println!(
" 🟡 Medium: {}",
result.summary.medium_count.to_string().yellow()
);
println!(
" 🔵 Low: {}",
result.summary.low_count.to_string().blue()
);
println!(
" ⚪ Info: {}",
result.summary.info_count.to_string().dimmed()
);
println!("\n{}", "── Scores ──".bold());
println!(
" 🔐 Access Control: {:>3}/100",
result.scores.access_control
);
println!(
" 🛡️ Security: {:>3}/100",
result.scores.security
);
println!(" 🎯 Fuzzing: {:>3}/100", result.scores.fuzzing);
println!(" ⛽ Gas: {:>3}/100", result.scores.gas);
println!(
" 🏗️ Architecture: {:>3}/100",
result.scores.architecture
);
println!(
" ⬆️ Upgradeability: {:>3}/100",
result.scores.upgradeability
);
println!(
" 📦 Dependencies: {:>3}/100",
result.scores.dependencies
);
println!(
" 🚀 Deployment: {:>3}/100",
result.scores.deployment
);
println!(
" 🔗 Proxy Safety: {:>3}/100",
result.scores.proxy_safety
);
println!(
" ⛓️ Chain Compat: {:>3}/100",
result.scores.chain_compatibility
);
println!(
" ✅ Production Ready: {:>3}/100",
result.scores.production_readiness
);
println!(
" 💥 Exploit Resistance: {:>3}/100",
result.scores.exploit_resistance
);
let overall_color = if result.overall_score >= 85 {
"green"
} else if result.overall_score >= 70 {
"yellow"
} else {
"red"
};
println!(
"\n{} {}",
"Overall Score:".bold(),
result.overall_score.to_string().color(overall_color).bold()
);
println!(
"{} {}",
"Risk Level:".bold(),
format!("{}", result.risk_level)
.color(match result.risk_level {
RiskLevel::Critical => "red",
RiskLevel::High => "red",
RiskLevel::Medium => "yellow",
RiskLevel::Low => "green",
RiskLevel::Minimal => "green",
})
.bold()
);
println!(
"{} {}",
"Production Ready:".bold(),
if result.production_ready {
"✅ YES".green().bold()
} else {
"❌ NO".red().bold()
}
);
println!(
"{} {}",
"Deployment:".bold(),
if result.deployment_approved {
"✅ APPROVED".green().bold()
} else {
"❌ BLOCKED".red().bold()
}
);
let critical_high: Vec<_> = result
.findings
.iter()
.filter(|f| {
f.severity == crate::core::Severity::Critical
|| f.severity == crate::core::Severity::High
})
.collect();
if !critical_high.is_empty() {
println!("\n{}", "── Top Findings ──".bold().red());
for f in critical_high.iter().take(5) {
println!(
"\n [{}] {}",
f.severity.to_string().red().bold(),
f.title.bold()
);
println!(
" 📄 {}:{}",
f.file.as_deref().unwrap_or("?"),
f.line.map_or("?".into(), |l| l.to_string())
);
println!(" 💡 {}", f.recommendation);
}
if critical_high.len() > 5 {
println!(
"\n ... and {} more critical/high findings",
critical_high.len() - 5
);
}
}
println!(
"\n{}",
"═══════════════════════════════════════════════".bright_blue()
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::*;
#[test]
fn test_calculate_overall_perfect() {
let scores = SecurityScores::perfect();
assert_eq!(calculate_overall(&scores), 100);
}
#[test]
fn test_calculate_overall_mixed() {
let scores = SecurityScores {
access_control: 85,
security: 55,
fuzzing: 100,
gas: 92,
architecture: 75,
upgradeability: 100,
dependencies: 100,
deployment: 80,
proxy_safety: 100,
chain_compatibility: 100,
production_readiness: 65,
exploit_resistance: 70,
};
let overall = calculate_overall(&scores);
assert!(overall > 0 && overall <= 100);
assert_eq!(overall, 85);
}
#[test]
fn test_calculate_overall_low_scores() {
let scores = SecurityScores {
access_control: 30,
security: 20,
fuzzing: 50,
gas: 40,
architecture: 10,
upgradeability: 0,
dependencies: 60,
deployment: 25,
proxy_safety: 0,
chain_compatibility: 100,
production_readiness: 15,
exploit_resistance: 35,
};
let overall = calculate_overall(&scores);
assert!(overall < 50);
}
#[test]
fn test_determine_risk_level_critical_from_findings() {
let findings = vec![
Finding::builder()
.id("T1").title("Critical").description("")
.severity(Severity::Critical).file("x.sol").code("x")
.recommendation("Fix").category("Security").build(),
];
let level = determine_risk_level(100, &findings);
assert_eq!(level, RiskLevel::Critical);
}
#[test]
fn test_determine_risk_level_high_from_findings() {
let findings = vec![
Finding::builder()
.id("T2").title("High").description("")
.severity(Severity::High).file("x.sol").code("x")
.recommendation("Fix").category("Security").build(),
];
let level = determine_risk_level(100, &findings);
assert_eq!(level, RiskLevel::High);
}
#[test]
fn test_determine_risk_level_by_score() {
assert_eq!(determine_risk_level(20, &[]), RiskLevel::Critical);
assert_eq!(determine_risk_level(29, &[]), RiskLevel::Critical);
assert_eq!(determine_risk_level(30, &[]), RiskLevel::High);
assert_eq!(determine_risk_level(49, &[]), RiskLevel::High);
assert_eq!(determine_risk_level(50, &[]), RiskLevel::Medium);
assert_eq!(determine_risk_level(69, &[]), RiskLevel::Medium);
assert_eq!(determine_risk_level(70, &[]), RiskLevel::Low);
assert_eq!(determine_risk_level(84, &[]), RiskLevel::Low);
assert_eq!(determine_risk_level(85, &[]), RiskLevel::Minimal);
assert_eq!(determine_risk_level(100, &[]), RiskLevel::Minimal);
}
#[test]
fn test_has_blocking_findings_true() {
let f = Finding::builder()
.id("B").title("Blocks").description("")
.severity(Severity::High).file("x.sol").code("x")
.recommendation("Fix").category("Security")
.blocks_deployment(true).build();
assert!(has_blocking_findings(&[f]));
}
#[test]
fn test_has_blocking_findings_false() {
let f = Finding::builder()
.id("NB").title("No Block").description("")
.severity(Severity::Low).file("x.sol").code("x")
.recommendation("Fix").category("Best Practices")
.blocks_deployment(false).build();
assert!(!has_blocking_findings(&[f]));
}
#[test]
fn test_has_blocking_findings_empty() {
assert!(!has_blocking_findings(&[]));
}
#[test]
fn test_build_summary_empty() {
let summary = build_summary(&[], &[]);
assert_eq!(summary.total_findings, 0);
assert_eq!(summary.files_analyzed, 0);
assert_eq!(summary.lines_analyzed, 0);
}
#[test]
fn test_build_summary_with_findings() {
let findings = vec![
Finding::builder()
.id("F1").title("Critical").description("")
.severity(Severity::Critical).file("x.sol").code("x")
.recommendation("Fix").category("Security").build(),
Finding::builder()
.id("F2").title("High").description("")
.severity(Severity::High).file("x.sol").code("x")
.recommendation("Fix").category("Security").build(),
Finding::builder()
.id("F3").title("Medium").description("")
.severity(Severity::Medium).file("x.sol").code("x")
.recommendation("Fix").category("Gas").build(),
Finding::builder()
.id("F4").title("Low").description("")
.severity(Severity::Low).file("x.sol").code("x")
.recommendation("Fix").category("Best Practices").build(),
Finding::builder()
.id("F5").title("Info").description("")
.severity(Severity::Informational).file("x.sol").code("x")
.recommendation("Fix").category("Style").build(),
];
let sources = vec![std::path::PathBuf::from("a.sol"), std::path::PathBuf::from("b.sol")];
let summary = build_summary(&findings, &sources);
assert_eq!(summary.total_findings, 5);
assert_eq!(summary.critical_count, 1);
assert_eq!(summary.high_count, 1);
assert_eq!(summary.medium_count, 1);
assert_eq!(summary.low_count, 1);
assert_eq!(summary.info_count, 1);
assert_eq!(summary.files_analyzed, 2);
assert_eq!(summary.contracts_analyzed, 2);
}
#[test]
fn test_determine_risk_level_critical_override_is_stronger() {
let f = Finding::builder()
.id("C").title("Critical").description("")
.severity(Severity::Critical).file("x.sol").code("x")
.recommendation("Fix").category("Security").build();
assert_eq!(determine_risk_level(95, &[f]), RiskLevel::Critical);
}
}
fn run_ai_analysis(
files: &[std::path::PathBuf],
args: &AuditArgs,
config: &ProjectConfig,
) -> Result<Vec<crate::core::Finding>> {
use std::sync::Arc;
eprintln!(
"🤖 Running AI analysis (provider: {}, model: {})...",
args.ai_provider, args.ai_model
);
let boxed: Box<dyn LlmProvider> = create_provider(
&args.ai_provider,
&args.ai_model,
config.ai.temperature,
config.ai.max_tokens,
args.ai_api_key.clone(),
args.ollama_endpoint.clone(),
)?;
let provider: Arc<dyn LlmProvider> = Arc::from(boxed);
let mut engine = ConsensusEngine::new(ConsensusConfig {
min_confidence: config.ai.min_confidence,
..Default::default()
});
engine.register(Box::new(SecurityAuditor::new(Arc::clone(&provider))));
if args.ai_full || args.full {
engine.register(Box::new(GasAuditor::new(Arc::clone(&provider))));
engine.register(Box::new(LogicAuditor::new(Arc::clone(&provider))));
}
let mut all_ai_findings = Vec::new();
for file_path in files {
let source_code = match std::fs::read_to_string(file_path) {
Ok(c) => c,
Err(e) => {
eprintln!(" ⚠️ Could not read {}: {e}", file_path.display());
continue;
}
};
let file_name = file_path.to_string_lossy();
let ctx = AuditContext::new(&source_code, &file_name, &config.chain);
let report = engine.analyze(&ctx);
if report.auditor_count > 0 {
eprintln!(
" 🤖 {}: {} findings ({} consensus, {} filtered)",
file_name,
report.findings.len(),
report.deduplicated_count,
report.filtered_count
);
}
for cf in &report.findings {
all_ai_findings.push(consensus_to_core_finding(cf, &file_name));
}
}
eprintln!(
"🤖 AI analysis complete — {} findings reported",
all_ai_findings.len()
);
Ok(all_ai_findings)
}