From 55d813df77c4951b0fa55ecc513439ea13cd55f8 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 6 Apr 2026 10:35:07 +0000 Subject: [PATCH] feat(fuzz): add jq_fuzz target for jq builtin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add fuzz target for the jq builtin which has a complex custom filter expression parser (jaq) processing arbitrary JSON data — high crash and ReDoS risk. The target splits fuzz input into a filter expression (first line) and JSON data (remainder), then exercises the jq builtin through the interpreter with tight resource limits. Includes scaffold tests validating the jq builtin handles valid filters, malformed JSON, invalid filters, deeply nested expressions, and null bytes without panicking. Closes #1094 --- crates/bashkit/fuzz/Cargo.toml | 7 ++ crates/bashkit/fuzz/fuzz_targets/jq_fuzz.rs | 86 +++++++++++++++++++ .../bashkit/tests/jq_fuzz_scaffold_tests.rs | 57 ++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 crates/bashkit/fuzz/fuzz_targets/jq_fuzz.rs create mode 100644 crates/bashkit/tests/jq_fuzz_scaffold_tests.rs 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 +}