diff --git a/crates/bashkit/fuzz/Cargo.toml b/crates/bashkit/fuzz/Cargo.toml index afde0954..02d82374 100644 --- a/crates/bashkit/fuzz/Cargo.toml +++ b/crates/bashkit/fuzz/Cargo.toml @@ -52,3 +52,10 @@ path = "fuzz_targets/glob_fuzz.rs" test = false doc = false bench = false + +[[bin]] +name = "jq_fuzz" +path = "fuzz_targets/jq_fuzz.rs" +test = false +doc = false +bench = false diff --git a/crates/bashkit/fuzz/fuzz_targets/jq_fuzz.rs b/crates/bashkit/fuzz/fuzz_targets/jq_fuzz.rs new file mode 100644 index 00000000..184e9031 --- /dev/null +++ b/crates/bashkit/fuzz/fuzz_targets/jq_fuzz.rs @@ -0,0 +1,86 @@ +//! Fuzz target for the jq builtin +//! +//! Tests jq filter expression parsing and JSON processing to find: +//! - Panics in the jaq filter compiler +//! - Stack overflow from deeply nested filters +//! - ReDoS or CPU exhaustion from pathological patterns +//! - Memory exhaustion from recursive JSON generation +//! +//! Run with: cargo +nightly fuzz run jq_fuzz -- -max_total_time=300 + +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + // Only process valid UTF-8 + if let Ok(input) = std::str::from_utf8(data) { + // Limit input size to prevent OOM + if input.len() > 1024 { + return; + } + + // Split input into filter (first line) and JSON data (rest) + let (filter, json_data) = match input.find('\n') { + Some(pos) => (&input[..pos], &input[pos + 1..]), + None => (input, "{}" as &str), + }; + + // Skip empty filters + if filter.trim().is_empty() { + return; + } + + // Reject deeply nested expressions + let depth: i32 = filter + .bytes() + .map(|b| match b { + b'(' | b'[' | b'{' => 1, + b')' | b']' | b'}' => -1, + _ => 0, + }) + .scan(0i32, |acc, d| { + *acc += d; + Some(*acc) + }) + .max() + .unwrap_or(0); + if depth > 20 { + return; + } + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async { + let mut bash = bashkit::Bash::builder() + .limits( + bashkit::ExecutionLimits::new() + .max_commands(50) + .max_subst_depth(3) + .max_stdout_bytes(4096) + .max_stderr_bytes(4096) + .timeout(std::time::Duration::from_millis(200)), + ) + .build(); + + // Test 1: pipe JSON through jq filter + let script = format!( + "echo '{}' | jq '{}' 2>/dev/null; true", + json_data.replace('\'', "'\\''"), + filter.replace('\'', "'\\''"), + ); + let _ = bash.exec(&script).await; + + // Test 2: jq with -r (raw output) flag + let script2 = format!( + "echo '{}' | jq -r '{}' 2>/dev/null; true", + json_data.replace('\'', "'\\''"), + filter.replace('\'', "'\\''"), + ); + let _ = bash.exec(&script2).await; + }); + } +}); diff --git a/crates/bashkit/tests/jq_fuzz_scaffold_tests.rs b/crates/bashkit/tests/jq_fuzz_scaffold_tests.rs new file mode 100644 index 00000000..6f98b89d --- /dev/null +++ b/crates/bashkit/tests/jq_fuzz_scaffold_tests.rs @@ -0,0 +1,57 @@ +// Scaffold tests for the jq_fuzz target. +// Validates that the jq builtin handles arbitrary filter expressions and +// malformed JSON without panicking. + +use bashkit::{Bash, ExecutionLimits}; + +fn fuzz_bash() -> Bash { + Bash::builder() + .limits( + ExecutionLimits::new() + .max_commands(50) + .max_subst_depth(3) + .max_stdout_bytes(4096) + .max_stderr_bytes(4096) + .timeout(std::time::Duration::from_secs(2)), + ) + .build() +} + +#[tokio::test] +async fn jq_valid_filter() { + let mut bash = fuzz_bash(); + let result = bash.exec("echo '{\"a\":1}' | jq '.a'").await.unwrap(); + assert_eq!(result.stdout.trim(), "1"); +} + +#[tokio::test] +async fn jq_malformed_json() { + let mut bash = fuzz_bash(); + let _ = bash.exec("echo 'not json' | jq '.' 2>/dev/null").await; + // Must not panic +} + +#[tokio::test] +async fn jq_invalid_filter() { + let mut bash = fuzz_bash(); + let _ = bash.exec("echo '{}' | jq '.[[[[[' 2>/dev/null; true").await; + // Must not panic +} + +#[tokio::test] +async fn jq_deeply_nested_filter() { + let mut bash = fuzz_bash(); + let filter = ".a".repeat(50); + let script = format!("echo '{{}}' | jq '{}' 2>/dev/null; true", filter); + let _ = bash.exec(&script).await; + // Must not panic or hang +} + +#[tokio::test] +async fn jq_null_bytes_in_input() { + let mut bash = fuzz_bash(); + let _ = bash + .exec("printf '{\"a\":\\x00}' | jq '.' 2>/dev/null; true") + .await; + // Must not panic +}