From c148dd2b5bd4ec7fb75a0e54d463fffd2bb9da60 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Tue, 10 Mar 2026 18:06:50 +0000 Subject: [PATCH] feat: add fuzzing targets for untrusted input parsers (#835) * feat: add fuzzing targets for untrusted input parsers Add cargo-fuzz infrastructure with 5 fuzz targets exercising security-critical code paths: - fuzz_safety_sanitizer: Aho-Corasick + regex injection detection - fuzz_safety_validator: Input validation (length, encoding, patterns) - fuzz_leak_detector: Secret leak scanning (API keys, tokens) - fuzz_tool_params: Tool parameter JSON validation - fuzz_config_env: TOML/JSON config parsing Each target exercises real IronClaw business logic with invariant assertions. Includes corpus directories and setup documentation. Co-Authored-By: Claude Opus 4.6 * fix: improve fuzz targets to exercise real IronClaw code paths - fuzz_config_env: exercise SafetyLayer end-to-end (sanitize, validate, policy check) instead of generic TOML/JSON parsing - fuzz_tool_params: add validate_tool_schema coverage alongside validate_tool_params - Add "fuzz" to workspace exclude in root Cargo.toml - Update README descriptions to match actual target behavior [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * fix: replace redundant detect() call with meaningful invariant assertion Replace the double sanitize()+detect() call with an assertion that critical severity warnings always trigger content modification. Co-Authored-By: Claude Opus 4.6 * fix: rewrite fuzz_config_env to exercise IronClaw safety code directly Replace SafetyLayer wrapper usage with direct Sanitizer, Validator, and LeakDetector instantiation and invocation. Adds meaningful consistency assertions (non-empty output, valid-means-no-errors, scan/clean agreement). Removes the config construction that was only exercising struct instantiation. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- Cargo.toml | 1 + fuzz/Cargo.toml | 40 ++++++++++++++++ fuzz/README.md | 43 +++++++++++++++++ fuzz/corpus/fuzz_config_env/.gitkeep | 0 fuzz/corpus/fuzz_leak_detector/.gitkeep | 0 fuzz/corpus/fuzz_safety_sanitizer/.gitkeep | 0 fuzz/corpus/fuzz_safety_validator/.gitkeep | 0 fuzz/corpus/fuzz_tool_params/.gitkeep | 0 fuzz/fuzz_targets/fuzz_config_env.rs | 55 ++++++++++++++++++++++ fuzz/fuzz_targets/fuzz_leak_detector.rs | 23 +++++++++ fuzz/fuzz_targets/fuzz_safety_sanitizer.rs | 23 +++++++++ fuzz/fuzz_targets/fuzz_safety_validator.rs | 21 +++++++++ fuzz/fuzz_targets/fuzz_tool_params.rs | 22 +++++++++ 13 files changed, 228 insertions(+) create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/README.md create mode 100644 fuzz/corpus/fuzz_config_env/.gitkeep create mode 100644 fuzz/corpus/fuzz_leak_detector/.gitkeep create mode 100644 fuzz/corpus/fuzz_safety_sanitizer/.gitkeep create mode 100644 fuzz/corpus/fuzz_safety_validator/.gitkeep create mode 100644 fuzz/corpus/fuzz_tool_params/.gitkeep create mode 100644 fuzz/fuzz_targets/fuzz_config_env.rs create mode 100644 fuzz/fuzz_targets/fuzz_leak_detector.rs create mode 100644 fuzz/fuzz_targets/fuzz_safety_sanitizer.rs create mode 100644 fuzz/fuzz_targets/fuzz_safety_validator.rs create mode 100644 fuzz/fuzz_targets/fuzz_tool_params.rs diff --git a/Cargo.toml b/Cargo.toml index 61293a20..b021f06f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ exclude = [ "tools-src/google-slides", "tools-src/slack", "tools-src/telegram", + "fuzz", ] [package] diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 00000000..d6865a24 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "ironclaw-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +serde_json = "1" + +[dependencies.ironclaw] +path = ".." + +[[bin]] +name = "fuzz_safety_sanitizer" +path = "fuzz_targets/fuzz_safety_sanitizer.rs" +doc = false + +[[bin]] +name = "fuzz_safety_validator" +path = "fuzz_targets/fuzz_safety_validator.rs" +doc = false + +[[bin]] +name = "fuzz_leak_detector" +path = "fuzz_targets/fuzz_leak_detector.rs" +doc = false + +[[bin]] +name = "fuzz_tool_params" +path = "fuzz_targets/fuzz_tool_params.rs" +doc = false + +[[bin]] +name = "fuzz_config_env" +path = "fuzz_targets/fuzz_config_env.rs" +doc = false diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 00000000..c4c27c69 --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,43 @@ +# IronClaw Fuzz Targets + +Fuzz testing for security-critical input parsing paths using [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) (libFuzzer). + +## Targets + +| Target | What it exercises | +|--------|-------------------| +| `fuzz_safety_sanitizer` | Prompt injection pattern detection (Aho-Corasick + regex) | +| `fuzz_safety_validator` | Input validation (length, encoding, forbidden patterns) | +| `fuzz_leak_detector` | Secret leak detection (API keys, tokens, credentials) | +| `fuzz_tool_params` | Tool parameter and schema JSON validation | +| `fuzz_config_env` | SafetyLayer end-to-end (sanitize, validate, policy check) | + +## Setup + +```bash +cargo install cargo-fuzz +rustup install nightly +``` + +## Running + +```bash +# Run a specific target (runs until stopped or crash found) +cargo +nightly fuzz run fuzz_safety_sanitizer + +# Run with a time limit (5 minutes) +cargo +nightly fuzz run fuzz_leak_detector -- -max_total_time=300 + +# Run all targets for 60 seconds each +for target in fuzz_safety_sanitizer fuzz_safety_validator fuzz_leak_detector fuzz_tool_params fuzz_config_env; do + echo "==> $target" + cargo +nightly fuzz run "$target" -- -max_total_time=60 +done +``` + +## Adding New Targets + +1. Create `fuzz/fuzz_targets/fuzz_.rs` following the existing pattern +2. Add a `[[bin]]` entry in `fuzz/Cargo.toml` +3. Create `fuzz/corpus/fuzz_/` for seed inputs +4. Exercise real IronClaw code paths, not just generic serde diff --git a/fuzz/corpus/fuzz_config_env/.gitkeep b/fuzz/corpus/fuzz_config_env/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/fuzz/corpus/fuzz_leak_detector/.gitkeep b/fuzz/corpus/fuzz_leak_detector/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/fuzz/corpus/fuzz_safety_sanitizer/.gitkeep b/fuzz/corpus/fuzz_safety_sanitizer/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/fuzz/corpus/fuzz_safety_validator/.gitkeep b/fuzz/corpus/fuzz_safety_validator/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/fuzz/corpus/fuzz_tool_params/.gitkeep b/fuzz/corpus/fuzz_tool_params/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/fuzz/fuzz_targets/fuzz_config_env.rs b/fuzz/fuzz_targets/fuzz_config_env.rs new file mode 100644 index 00000000..265a85e9 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_config_env.rs @@ -0,0 +1,55 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; + +use ironclaw::safety::{LeakDetector, Sanitizer, Validator}; + +fuzz_target!(|data: &[u8]| { + if let Ok(input) = std::str::from_utf8(data) { + // Exercise Sanitizer: detect and neutralize prompt injection attempts. + let sanitizer = Sanitizer::new(); + let sanitized = sanitizer.sanitize(input); + // The sanitized content must never be empty when input is non-empty, + // because sanitization wraps/escapes rather than deleting. + if !input.is_empty() { + assert!( + !sanitized.content.is_empty(), + "sanitize() produced empty content for non-empty input" + ); + } + // If no modification occurred, content must equal input. + if !sanitized.was_modified { + assert_eq!(sanitized.content, input); + } + + // Exercise Validator: input validation (length, encoding, patterns). + let validator = Validator::new(); + let result = validator.validate(input); + // ValidationResult must always be well-formed: if valid, no errors. + if result.is_valid { + assert!( + result.errors.is_empty(), + "valid result should have no errors" + ); + } + + // Exercise LeakDetector: secret detection (API keys, tokens, etc.). + let detector = LeakDetector::new(); + let scan = detector.scan(input); + // scan_and_clean must not panic and must return valid UTF-8. + let cleaned = detector.scan_and_clean(input); + if let Ok(ref clean_str) = cleaned { + // Cleaned output must never be longer than original + redaction markers. + // At minimum it should be valid UTF-8 (guaranteed by String type). + let _ = clean_str.len(); + } + // If scan found no matches, scan_and_clean should return the input unchanged. + if scan.matches.is_empty() { + if let Ok(ref clean_str) = cleaned { + assert_eq!( + clean_str, input, + "scan_and_clean changed content despite no matches" + ); + } + } + } +}); diff --git a/fuzz/fuzz_targets/fuzz_leak_detector.rs b/fuzz/fuzz_targets/fuzz_leak_detector.rs new file mode 100644 index 00000000..f1e6e09c --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_leak_detector.rs @@ -0,0 +1,23 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use ironclaw::safety::LeakDetector; + +fuzz_target!(|data: &[u8]| { + if let Ok(s) = std::str::from_utf8(data) { + let detector = LeakDetector::new(); + + // Exercise scan path + let result = detector.scan(s); + // Invariant: if should_block, there must be matches + if result.should_block { + assert!(!result.matches.is_empty()); + } + // Invariant: match locations must be valid + for m in &result.matches { + assert!(m.location.end <= s.len()); + } + + // Exercise scan_and_clean path + let _ = detector.scan_and_clean(s); + } +}); diff --git a/fuzz/fuzz_targets/fuzz_safety_sanitizer.rs b/fuzz/fuzz_targets/fuzz_safety_sanitizer.rs new file mode 100644 index 00000000..32db887d --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_safety_sanitizer.rs @@ -0,0 +1,23 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use ironclaw::safety::Sanitizer; + +fuzz_target!(|data: &[u8]| { + if let Ok(s) = std::str::from_utf8(data) { + let sanitizer = Sanitizer::new(); + + // Exercise the main sanitization path + let result = sanitizer.sanitize(s); + // Verify invariant: warnings should have valid ranges + for w in &result.warnings { + assert!(w.location.end <= s.len()); + } + // Verify invariant: critical severity triggers modification + let has_critical = result.warnings.iter().any(|w| { + w.severity == ironclaw::safety::Severity::Critical + }); + if has_critical { + assert!(result.was_modified); + } + } +}); diff --git a/fuzz/fuzz_targets/fuzz_safety_validator.rs b/fuzz/fuzz_targets/fuzz_safety_validator.rs new file mode 100644 index 00000000..065bc86d --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_safety_validator.rs @@ -0,0 +1,21 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use ironclaw::safety::Validator; + +fuzz_target!(|data: &[u8]| { + if let Ok(s) = std::str::from_utf8(data) { + let validator = Validator::new(); + + // Exercise input validation + let result = validator.validate(s); + // Invariant: empty input is always invalid + if s.is_empty() { + assert!(!result.is_valid); + } + + // Exercise tool parameter validation with arbitrary JSON + if let Ok(value) = serde_json::from_str::(s) { + let _ = validator.validate_tool_params(&value); + } + } +}); diff --git a/fuzz/fuzz_targets/fuzz_tool_params.rs b/fuzz/fuzz_targets/fuzz_tool_params.rs new file mode 100644 index 00000000..52e39867 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_tool_params.rs @@ -0,0 +1,22 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use ironclaw::safety::Validator; +use ironclaw::tools::validate_tool_schema; + +fuzz_target!(|data: &[u8]| { + if let Ok(s) = std::str::from_utf8(data) { + // Try parsing as JSON and validating as tool parameters + if let Ok(value) = serde_json::from_str::(s) { + // Exercise Validator::validate_tool_params with arbitrary JSON + let validator = Validator::new(); + let result = validator.validate_tool_params(&value); + // Invariant: result should always be well-formed + if !result.is_valid { + assert!(!result.errors.is_empty()); + } + + // Exercise validate_tool_schema with arbitrary JSON as a schema + let _ = validate_tool_schema(&value, "fuzz"); + } + } +});