diff --git a/tooling/sanctifier-cli/src/commands/fuzz.rs b/tooling/sanctifier-cli/src/commands/fuzz.rs new file mode 100644 index 0000000..c3c5e63 --- /dev/null +++ b/tooling/sanctifier-cli/src/commands/fuzz.rs @@ -0,0 +1,123 @@ +use anyhow::Context; +use clap::{Args, ValueEnum}; +use sanctifier_core::fuzz::{FuzzHarnessGenerator, FuzzHarnessTarget}; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +pub enum FuzzEngine { + Afl, + Honggfuzz, + All, +} + +#[derive(Args, Debug)] +pub struct FuzzArgs { + /// Path to a contract directory, Cargo.toml, or a single Rust source file + #[arg(default_value = ".")] + pub path: PathBuf, + + /// Which harness backend to generate + #[arg(long, value_enum, default_value_t = FuzzEngine::All)] + pub engine: FuzzEngine, + + /// Directory where harness files will be written + #[arg(short, long, default_value = "fuzz")] + pub output_dir: PathBuf, + + /// Overwrite existing generated harness files + #[arg(short, long)] + pub force: bool, +} + +pub fn exec(args: FuzzArgs) -> anyhow::Result<()> { + let source_path = resolve_contract_source(&args.path)?; + let source = fs::read_to_string(&source_path) + .with_context(|| format!("failed to read source file: {}", source_path.display()))?; + let spec = FuzzHarnessGenerator::inspect_source(&source, &source_path.display().to_string()); + + fs::create_dir_all(&args.output_dir).with_context(|| { + format!( + "failed to create output directory: {}", + args.output_dir.display() + ) + })?; + + let targets = selected_targets(args.engine); + for target in targets { + let output_path = args.output_dir.join(target.file_name()); + if output_path.exists() && !args.force { + anyhow::bail!( + "output file already exists: {} (use --force to overwrite)", + output_path.display() + ); + } + let rendered = FuzzHarnessGenerator::render(&spec, target); + fs::write(&output_path, rendered) + .with_context(|| format!("failed to write harness: {}", output_path.display()))?; + println!( + "Generated {} harness at {}", + target.file_name(), + output_path.display() + ); + } + + Ok(()) +} + +fn selected_targets(engine: FuzzEngine) -> Vec { + match engine { + FuzzEngine::Afl => vec![FuzzHarnessTarget::Afl], + FuzzEngine::Honggfuzz => vec![FuzzHarnessTarget::Honggfuzz], + FuzzEngine::All => vec![FuzzHarnessTarget::Afl, FuzzHarnessTarget::Honggfuzz], + } +} + +fn resolve_contract_source(path: &Path) -> anyhow::Result { + if path.is_file() { + if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { + return Ok(path.to_path_buf()); + } + if path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml") { + let source = path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("src") + .join("lib.rs"); + if source.exists() { + return Ok(source); + } + } + } + + let source = path.join("src").join("lib.rs"); + if source.exists() { + Ok(source) + } else { + anyhow::bail!("could not resolve contract source from {}", path.display()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn resolves_directory_source() { + let temp_dir = tempdir().unwrap(); + let src_dir = temp_dir.path().join("src"); + fs::create_dir_all(&src_dir).unwrap(); + fs::write(src_dir.join("lib.rs"), "pub fn demo() {}").unwrap(); + + let resolved = resolve_contract_source(temp_dir.path()).unwrap(); + assert_eq!(resolved, src_dir.join("lib.rs")); + } + + #[test] + fn selects_all_targets() { + let targets = selected_targets(FuzzEngine::All); + assert_eq!(targets.len(), 2); + } +} diff --git a/tooling/sanctifier-cli/src/commands/mod.rs b/tooling/sanctifier-cli/src/commands/mod.rs index fc348ab..cd6f526 100644 --- a/tooling/sanctifier-cli/src/commands/mod.rs +++ b/tooling/sanctifier-cli/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod analyze; pub mod badge; pub mod complexity; pub mod diff; +pub mod fuzz; pub mod init; pub mod reentrancy; pub mod report; diff --git a/tooling/sanctifier-cli/src/main.rs b/tooling/sanctifier-cli/src/main.rs index db309e2..dbc97c3 100644 --- a/tooling/sanctifier-cli/src/main.rs +++ b/tooling/sanctifier-cli/src/main.rs @@ -25,6 +25,8 @@ pub enum Commands { Analyze(commands::analyze::AnalyzeArgs), /// Compare current scan results against a baseline to find only NEW vulnerabilities Diff(commands::diff::DiffArgs), + /// Export fuzzing harness scaffolds for afl.rs and honggfuzz + Fuzz(commands::fuzz::FuzzArgs), /// Generate a dynamic Sanctifier status badge Badge(commands::badge::BadgeArgs), /// Generate a Markdown or HTML security report @@ -73,6 +75,7 @@ fn run() -> anyhow::Result<()> { match cli.command { Commands::Analyze(args) => commands::analyze::exec(args)?, Commands::Diff(args) => commands::diff::exec(args)?, + Commands::Fuzz(args) => commands::fuzz::exec(args)?, Commands::Badge(args) => { commands::badge::exec(args)?; } diff --git a/tooling/sanctifier-cli/tests/cli_tests.rs b/tooling/sanctifier-cli/tests/cli_tests.rs index 9680d99..110f187 100644 --- a/tooling/sanctifier-cli/tests/cli_tests.rs +++ b/tooling/sanctifier-cli/tests/cli_tests.rs @@ -296,6 +296,107 @@ fn test_update_help() { .stdout(predicates::str::contains("latest Sanctifier binary")); } +#[test] +fn test_fuzz_help() { + let mut cmd = Command::cargo_bin("sanctifier").unwrap(); + cmd.arg("fuzz") + .arg("--help") + .assert() + .success() + .stdout(predicates::str::contains("harness scaffolds")) + .stdout(predicates::str::contains("--engine")); +} + +#[test] +fn test_fuzz_generates_both_harnesses() { + let temp_dir = tempdir().unwrap(); + let contract_dir = temp_dir.path().join("contract"); + let src_dir = contract_dir.join("src"); + let output_dir = temp_dir.path().join("fuzz"); + fs::create_dir_all(&src_dir).unwrap(); + fs::write( + contract_dir.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + fs::write( + src_dir.join("lib.rs"), + r#" + use soroban_sdk::{contract, contractimpl, Address, Env}; + + #[contract] + pub struct Vault; + + #[contractimpl] + impl Vault { + pub fn deposit(env: Env, user: Address, amount: i128) -> Result<(), u32> { + Ok(()) + } + } + "#, + ) + .unwrap(); + + Command::cargo_bin("sanctifier") + .unwrap() + .arg("fuzz") + .arg(&contract_dir) + .arg("--output-dir") + .arg(&output_dir) + .assert() + .success() + .stdout(predicates::str::contains("Generated afl.rs harness")) + .stdout(predicates::str::contains("Generated honggfuzz.rs harness")); + + let afl = fs::read_to_string(output_dir.join("afl.rs")).unwrap(); + let honggfuzz = fs::read_to_string(output_dir.join("honggfuzz.rs")).unwrap(); + assert!(afl.contains("deposit(env: Env, user: Address, amount: i128)")); + assert!(honggfuzz.contains("use honggfuzz::fuzz;")); +} + +#[test] +fn test_fuzz_engine_afl_only() { + let temp_dir = tempdir().unwrap(); + let contract_dir = temp_dir.path().join("contract"); + let src_dir = contract_dir.join("src"); + let output_dir = temp_dir.path().join("fuzz"); + fs::create_dir_all(&src_dir).unwrap(); + fs::write( + contract_dir.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + fs::write( + src_dir.join("lib.rs"), + r#" + use soroban_sdk::{contract, contractimpl, Env}; + + #[contract] + pub struct Vault; + + #[contractimpl] + impl Vault { + pub fn ping(env: Env) {} + } + "#, + ) + .unwrap(); + + Command::cargo_bin("sanctifier") + .unwrap() + .arg("fuzz") + .arg(&contract_dir) + .arg("--engine") + .arg("afl") + .arg("--output-dir") + .arg(&output_dir) + .assert() + .success(); + + assert!(output_dir.join("afl.rs").exists()); + assert!(!output_dir.join("honggfuzz.rs").exists()); +} + #[test] fn test_init_creates_sanctify_toml_in_current_directory() { let temp_dir = tempdir().unwrap(); diff --git a/tooling/sanctifier-core/src/fuzz.rs b/tooling/sanctifier-core/src/fuzz.rs new file mode 100644 index 0000000..49d3ad4 --- /dev/null +++ b/tooling/sanctifier-core/src/fuzz.rs @@ -0,0 +1,277 @@ +//! Fuzz-harness scaffold generation for external engines such as afl.rs and honggfuzz. + +use syn::{FnArg, ImplItem, Item, ItemImpl, Pat, ReturnType, Type, Visibility}; + +/// Supported fuzzing backends that Sanctifier can scaffold. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FuzzHarnessTarget { + /// Generate an `afl.rs` harness. + Afl, + /// Generate a `honggfuzz` harness. + Honggfuzz, +} + +impl FuzzHarnessTarget { + /// Returns the canonical file name for this scaffold. + pub fn file_name(self) -> &'static str { + match self { + FuzzHarnessTarget::Afl => "afl.rs", + FuzzHarnessTarget::Honggfuzz => "honggfuzz.rs", + } + } + + fn crate_name(self) -> &'static str { + match self { + FuzzHarnessTarget::Afl => "afl", + FuzzHarnessTarget::Honggfuzz => "honggfuzz", + } + } +} + +/// A discovered public entrypoint that can guide harness authors. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FuzzEntrypoint { + /// Function name. + pub name: String, + /// Human-readable argument list excluding any receiver. + pub args: Vec, + /// Human-readable return type. + pub return_type: String, +} + +/// Parsed contract metadata used to render fuzzing scaffolds. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FuzzHarnessSpec { + /// Path to the source file used as the generator input. + pub source_path: String, + /// Contract type name when one can be inferred. + pub contract_name: Option, + /// Public entrypoints discovered in impl blocks. + pub entrypoints: Vec, +} + +/// Generates Soroban-oriented fuzzing harness scaffolds. +pub struct FuzzHarnessGenerator; + +impl FuzzHarnessGenerator { + /// Parse contract source and collect entrypoint metadata for scaffold rendering. + pub fn inspect_source(source: &str, source_path: &str) -> FuzzHarnessSpec { + let parsed = syn::parse_file(source); + let mut contract_name = None; + let mut entrypoints = Vec::new(); + + if let Ok(file) = parsed { + contract_name = file + .items + .iter() + .find_map(Self::extract_contract_struct_name); + + for item in &file.items { + if let Item::Impl(item_impl) = item { + entrypoints.extend(Self::extract_entrypoints(item_impl)); + } + } + } + + FuzzHarnessSpec { + source_path: source_path.to_string(), + contract_name, + entrypoints, + } + } + + /// Render a complete fuzz-harness source file for the chosen backend. + pub fn render(spec: &FuzzHarnessSpec, target: FuzzHarnessTarget) -> String { + let backend_comment = match target { + FuzzHarnessTarget::Afl => "afl.rs", + FuzzHarnessTarget::Honggfuzz => "honggfuzz", + }; + let contract_label = spec.contract_name.as_deref().unwrap_or("UnknownContract"); + let entrypoint_lines = if spec.entrypoints.is_empty() { + vec!["// - No public contract entrypoints were discovered.".to_string()] + } else { + spec.entrypoints + .iter() + .map(|entrypoint| { + format!( + "// - {}({}) -> {}", + entrypoint.name, + entrypoint.args.join(", "), + entrypoint.return_type + ) + }) + .collect::>() + }; + let invocation_stubs = if spec.entrypoints.is_empty() { + " // TODO: map the fuzzer input into one or more contract entrypoints.\n".to_string() + } else { + spec.entrypoints + .iter() + .map(|entrypoint| { + format!( + " // TODO: decode `data` into arguments for `{}` and invoke it here.\n", + entrypoint.name + ) + }) + .collect::() + }; + + format!( + r#"#![allow(dead_code)] +// Generated by Sanctifier to bridge static analysis into dynamic fuzzing. +// Backend: {backend_comment} +// Source: {source_path} +// Contract: {contract_label} +// +// Discovered public entrypoints: +{entrypoint_lines} +// +// Before running this harness: +// 1. Add `{crate_name}` to the fuzz crate's dependencies. +// 2. Replace the placeholder module path below with your contract crate. +// 3. Decode `data` into Soroban values and drive realistic contract state. + +use {crate_name}::fuzz; + +#[allow(unused_imports)] +use soroban_sdk::Env; + +// TODO: point this import at the contract crate or include the target source directly. +// use your_contract_crate::{contract_label}; + +fn exercise_target(data: &[u8]) {{ + let _ = data; + +{invocation_stubs} // Example: + // let _env: Env = todo!("construct a Soroban environment for this harness"); + // let contract = {contract_label}; + // let result = contract.some_entrypoint(_env, /* decoded args */); + // let _ = result; +}} + +fn main() {{ + fuzz!(|data: &[u8]| {{ + exercise_target(data); + }}); +}} +"#, + backend_comment = backend_comment, + source_path = spec.source_path, + contract_label = contract_label, + entrypoint_lines = entrypoint_lines.join("\n"), + crate_name = target.crate_name(), + invocation_stubs = invocation_stubs, + ) + } + + fn extract_contract_struct_name(item: &Item) -> Option { + let item_struct = match item { + Item::Struct(item_struct) => item_struct, + _ => return None, + }; + + if Self::has_attribute(&item_struct.attrs, "contract") { + Some(item_struct.ident.to_string()) + } else { + None + } + } + + fn extract_entrypoints(item_impl: &ItemImpl) -> Vec { + item_impl + .items + .iter() + .filter_map(|item| match item { + ImplItem::Fn(function) if matches!(function.vis, Visibility::Public(_)) => { + let args = function + .sig + .inputs + .iter() + .filter_map(|arg| match arg { + FnArg::Receiver(_) => None, + FnArg::Typed(typed) => { + let name = match typed.pat.as_ref() { + Pat::Ident(ident) => ident.ident.to_string(), + _ => "_".to_string(), + }; + let ty = Self::type_to_string(typed.ty.as_ref()); + Some(format!("{}: {}", name, ty)) + } + }) + .collect::>(); + let return_type = match &function.sig.output { + ReturnType::Default => "()".to_string(), + ReturnType::Type(_, ty) => Self::type_to_string(ty.as_ref()), + }; + Some(FuzzEntrypoint { + name: function.sig.ident.to_string(), + args, + return_type, + }) + } + _ => None, + }) + .collect() + } + + fn type_to_string(ty: &Type) -> String { + let tokens = quote::quote!(#ty).to_string(); + tokens + .replace(" < ", "<") + .replace(" >", ">") + .replace(" ,", ",") + } + + fn has_attribute(attrs: &[syn::Attribute], name: &str) -> bool { + attrs.iter().any(|attr| attr.path().is_ident(name)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inspect_source_collects_public_entrypoints() { + let source = r#" + use soroban_sdk::{contract, contractimpl, Address, Env}; + + #[contract] + pub struct Vault; + + #[contractimpl] + impl Vault { + pub fn deposit(env: Env, user: Address, amount: i128) -> Result<(), u32> { + Ok(()) + } + + fn helper(env: Env) {} + } + "#; + + let spec = FuzzHarnessGenerator::inspect_source(source, "src/lib.rs"); + assert_eq!(spec.contract_name.as_deref(), Some("Vault")); + assert_eq!(spec.entrypoints.len(), 1); + assert_eq!(spec.entrypoints[0].name, "deposit"); + assert_eq!(spec.entrypoints[0].args[0], "env: Env"); + assert_eq!(spec.entrypoints[0].return_type, "Result<(), u32>"); + } + + #[test] + fn render_mentions_backend_and_entrypoints() { + let spec = FuzzHarnessSpec { + source_path: "src/lib.rs".to_string(), + contract_name: Some("Vault".to_string()), + entrypoints: vec![FuzzEntrypoint { + name: "deposit".to_string(), + args: vec!["env: Env".to_string(), "amount: i128".to_string()], + return_type: "()".to_string(), + }], + }; + + let rendered = FuzzHarnessGenerator::render(&spec, FuzzHarnessTarget::Honggfuzz); + assert!(rendered.contains("Backend: honggfuzz")); + assert!(rendered.contains("deposit(env: Env, amount: i128) -> ()")); + assert!(rendered.contains("use honggfuzz::fuzz;")); + } +} diff --git a/tooling/sanctifier-core/src/lib.rs b/tooling/sanctifier-core/src/lib.rs index d3b2bc7..5a45bba 100644 --- a/tooling/sanctifier-core/src/lib.rs +++ b/tooling/sanctifier-core/src/lib.rs @@ -30,6 +30,8 @@ use std::panic::catch_unwind; pub mod complexity; /// Canonical finding codes (`S000` – `S012`) emitted by every analysis pass. pub mod finding_codes; +/// Fuzz harness scaffold generation for external dynamic analysis tools. +pub mod fuzz; /// Gas / instruction-cost estimation heuristics. pub mod gas_estimator; /// (Reserved) Gas report rendering. @@ -60,10 +62,10 @@ pub mod smt { pub location: String, } } -/// Storage-key collision detection (internal). -mod storage_collision; /// Soroban v21 (Protocol 21) host functions and storage types. pub mod soroban_v21; +/// Storage-key collision detection (internal). +mod storage_collision; use std::collections::HashSet; use syn::spanned::Spanned; use syn::visit::{self, Visit}; @@ -2351,7 +2353,10 @@ mod tests { "#; let matches = analyzer.analyze_custom_rules(source, &analyzer.config.custom_rules); assert_eq!(matches.len(), 2); - let todo_match = matches.iter().find(|m| m.rule_name == "todo_comment").unwrap(); + let todo_match = matches + .iter() + .find(|m| m.rule_name == "todo_comment") + .unwrap(); assert_eq!(todo_match.severity, RuleSeverity::Info); let unsafe_match = matches.iter().find(|m| m.rule_name == "no_unsafe").unwrap(); assert_eq!(unsafe_match.severity, RuleSeverity::Critical);