Skip to content

Commit b394ee3

Browse files
committed
test(security): add adversarial tests for sparse arrays, extreme indices, expansion bombs
Add 5 targeted security tests inspired by zapcode's adversarial test suite: - Sparse array huge index (TM-DOS-060): verify no mass allocation for arr[999999999] - Extreme negative array index (TM-DOS-060): verify no panic/crash - Array entry exhaustion under load (TM-DOS-060): verify max_array_entries enforcement - Brace expansion bomb via printf (TM-DOS-041): verify {1..999999999} is capped - Parameter expansion replacement bomb (TM-DOS-059): verify multiplicative amplification is bounded Updates threat model (specs/006, docs/threat-model.md) with TM-DOS-059 and TM-DOS-060. Closes #934
1 parent 0fc22e7 commit b394ee3

File tree

3 files changed

+181
-0
lines changed

3 files changed

+181
-0
lines changed

crates/bashkit/docs/threat-model.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ through configurable limits.
107107
| source self-recursion (TM-DOS-056) | Script that sources itself | Track source depth | **OPEN** |
108108
| sleep bypasses timeout (TM-DOS-057) | `sleep N` ignores `ExecutionLimits::timeout` | Implement tokio timeout wrapper | **OPEN** |
109109
| Unbounded builtin output (TM-DOS-058) | `seq 1 1000000` produces 1M lines | Add `max_stdout_bytes` limit | **OPEN** |
110+
| Param expansion bomb (TM-DOS-059) | `${x//a/bigstring}` multiplicative amplification | `max_total_variable_bytes` + `max_stdout_bytes` | MITIGATED |
111+
| Sparse array huge-index (TM-DOS-060) | `arr[999999999]=x` | HashMap storage; `max_array_entries` | MITIGATED |
110112

111113
**Configuration:**
112114
```rust

crates/bashkit/tests/threat_model_tests.rs

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3841,6 +3841,7 @@ mod trace_events {
38413841
}
38423842
}
38433843

3844+
// =============================================================================
38443845
// =============================================================================
38453846
// TYPESCRIPT / ZAPCODE SECURITY (TM-TS)
38463847
//
@@ -3967,3 +3968,179 @@ mod typescript_security {
39673968
);
39683969
}
39693970
}
3971+
3972+
// =============================================================================
3973+
// ADVERSARIAL TESTS — SPARSE ARRAYS, EXTREME INDICES, EXPANSION BOMBS
3974+
// Inspired by zapcode's adversarial test suite (issue #934)
3975+
// =============================================================================
3976+
3977+
mod zapcode_inspired_adversarial {
3978+
use super::*;
3979+
3980+
/// TM-DOS-060: Huge sparse index allocation.
3981+
/// Assigning to index 999999999 must not allocate ~1B empty slots.
3982+
/// bashkit uses HashMap internally, so the storage is O(1) per entry,
3983+
/// but the max_array_entries limit should still cap total entries.
3984+
#[tokio::test]
3985+
async fn sparse_array_huge_index() {
3986+
let mem = MemoryLimits::new().max_array_entries(100);
3987+
let limits = ExecutionLimits::new().max_commands(1_000);
3988+
let mut bash = Bash::builder()
3989+
.limits(limits)
3990+
.memory_limits(mem)
3991+
.session_limits(SessionLimits::unlimited())
3992+
.build();
3993+
3994+
let result = bash
3995+
.exec("declare -a arr; arr[999999999]=x; echo ${#arr[@]}")
3996+
.await
3997+
.unwrap();
3998+
// Should succeed (HashMap-based, only 1 entry) or be capped — no OOM
3999+
assert_eq!(result.exit_code, 0);
4000+
// The array has at most 1 entry — certainly not ~1B
4001+
let count: usize = result.stdout.trim().parse().unwrap_or(0);
4002+
assert!(
4003+
count <= 100,
4004+
"Sparse index must not cause mass allocation, got count={}",
4005+
count
4006+
);
4007+
}
4008+
4009+
/// TM-DOS-060: Extreme negative array index.
4010+
/// Must not panic, no OOB memory access, no memory corruption.
4011+
/// The key security property is: no panic, no crash, no unbounded allocation.
4012+
/// Bash wraps negative indices modulo array length, so some element may be returned.
4013+
#[tokio::test]
4014+
async fn sparse_array_extreme_negative_index() {
4015+
let limits = ExecutionLimits::new().max_commands(1_000);
4016+
let mut bash = Bash::builder().limits(limits).build();
4017+
4018+
let result = bash
4019+
.exec("declare -a arr=(a b c); echo \"${arr[-999999999]}\"")
4020+
.await
4021+
.unwrap();
4022+
// Security property: no panic, no crash, graceful completion
4023+
assert_eq!(result.exit_code, 0);
4024+
// Output should be either empty or one of the valid elements (wrapping is acceptable)
4025+
let out = result.stdout.trim();
4026+
assert!(
4027+
out.is_empty() || ["a", "b", "c"].contains(&out),
4028+
"Extreme negative index should return empty or valid element, got: {:?}",
4029+
out
4030+
);
4031+
}
4032+
4033+
/// TM-DOS-060: Array entry exhaustion under load.
4034+
/// Populating 200K entries via loop must be stopped by max_array_entries (100K default)
4035+
/// or by the loop iteration limit — whichever fires first.
4036+
#[tokio::test]
4037+
async fn array_entry_exhaustion_under_load() {
4038+
let mem = MemoryLimits::new().max_array_entries(100);
4039+
let limits = ExecutionLimits::new()
4040+
.max_commands(500_000)
4041+
.max_loop_iterations(500_000)
4042+
.max_total_loop_iterations(500_000);
4043+
let mut bash = Bash::builder()
4044+
.limits(limits)
4045+
.memory_limits(mem)
4046+
.session_limits(SessionLimits::unlimited())
4047+
.build();
4048+
4049+
let script = r#"
4050+
declare -a arr
4051+
i=0
4052+
while [ $i -lt 200 ]; do
4053+
arr[$i]=x
4054+
i=$((i+1))
4055+
done
4056+
echo ${#arr[@]}
4057+
"#;
4058+
let result = bash.exec(script).await.unwrap();
4059+
assert_eq!(result.exit_code, 0);
4060+
let count: usize = result.stdout.trim().parse().unwrap_or(0);
4061+
// max_array_entries=100 means at most 100 entries created
4062+
assert!(
4063+
count <= 100,
4064+
"Array entries should be capped at max_array_entries, got {}",
4065+
count
4066+
);
4067+
}
4068+
4069+
/// TM-DOS-041: Printf format repeat via brace expansion.
4070+
/// `{1..999999999}` must be rejected by the brace expansion cap before
4071+
/// printf ever runs. Without the cap, this would generate ~1B arguments.
4072+
#[tokio::test]
4073+
async fn brace_expansion_bomb_printf() {
4074+
let limits = ExecutionLimits::new()
4075+
.max_commands(1_000)
4076+
.max_stdout_bytes(1_000_000);
4077+
let mut bash = Bash::builder().limits(limits).build();
4078+
4079+
let result = bash.exec("printf '%0.s-' {1..999999999}").await;
4080+
// Either the brace expansion is capped (producing truncated output)
4081+
// or the parser rejects it statically. Either way: no OOM, no hang.
4082+
match result {
4083+
Ok(r) => {
4084+
// If it succeeded, the output must be bounded (cap at 100K expansions or stdout limit)
4085+
assert!(
4086+
r.stdout.len() <= 1_000_000,
4087+
"Brace expansion bomb produced {} bytes — should be capped",
4088+
r.stdout.len()
4089+
);
4090+
}
4091+
Err(e) => {
4092+
// Static rejection by parser budget is also acceptable
4093+
let msg = e.to_string();
4094+
assert!(
4095+
msg.contains("brace")
4096+
|| msg.contains("range")
4097+
|| msg.contains("too large")
4098+
|| msg.contains("exceeded")
4099+
|| msg.contains("budget"),
4100+
"Expected brace/range limit error, got: {}",
4101+
msg
4102+
);
4103+
}
4104+
}
4105+
}
4106+
4107+
/// TM-DOS-059: Parameter expansion replacement bomb.
4108+
/// `${x//a/$(echo bbbbbbbb)}` replaces each 'a' with 'bbbbbbbb'.
4109+
/// At scale (10K 'a's × 1K 'b's = 10MB), this must be caught by
4110+
/// max_total_variable_bytes or max_stdout_bytes.
4111+
#[tokio::test]
4112+
async fn parameter_expansion_replacement_bomb() {
4113+
let mem = MemoryLimits::new().max_total_variable_bytes(100_000);
4114+
let limits = ExecutionLimits::new()
4115+
.max_commands(50_000)
4116+
.max_loop_iterations(50_000)
4117+
.max_total_loop_iterations(50_000)
4118+
.max_stdout_bytes(1_000_000);
4119+
let mut bash = Bash::builder()
4120+
.limits(limits)
4121+
.memory_limits(mem)
4122+
.session_limits(SessionLimits::unlimited())
4123+
.build();
4124+
4125+
// Create a string of 10K 'a' chars, then replace each with 1K 'b' chars
4126+
// This attempts 10K × 1K = 10MB output
4127+
let script = r#"
4128+
x=$(printf 'a%.0s' {1..10000})
4129+
echo "${x//a/$(printf 'b%.0s' {1..1000})}"
4130+
"#;
4131+
let result = bash.exec(script).await;
4132+
match result {
4133+
Ok(r) => {
4134+
// If it completes, output must be bounded by limits
4135+
assert!(
4136+
r.stdout.len() <= 1_000_000,
4137+
"Expansion bomb produced {} bytes of stdout — should be capped",
4138+
r.stdout.len()
4139+
);
4140+
}
4141+
Err(_) => {
4142+
// Limit enforcement error is also acceptable
4143+
}
4144+
}
4145+
}
4146+
}

specs/006-threat-model.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,8 @@ max_ast_depth: 100, // Parser recursion (TM-DOS-022)
268268
| TM-DOS-056 | `source` self-recursion stack overflow | Script that sources itself causes unbounded recursion; function depth limit doesn't apply to `source` || **OPEN** |
269269
| TM-DOS-057 | `sleep` bypasses execution timeout | `sleep`, `(sleep N)`, `echo x \| sleep N`, `sleep N & wait`, `timeout N sleep N` all ignore `ExecutionLimits::timeout` || **OPEN** |
270270
| TM-DOS-058 | Single-builtin unbounded output | `seq 1 1000000` produces 1M lines despite command limit; single builtin call generates unbounded output (see also #648) || **OPEN** |
271+
| TM-DOS-059 | Parameter expansion replacement bomb | `${x//a/$(printf 'b%.0s' {1..1000})}` on large `x` amplifies output multiplicatively (10K × 1K = 10MB) | `max_total_variable_bytes` + `max_stdout_bytes` | **MITIGATED** |
272+
| TM-DOS-060 | Sparse array huge-index allocation | `arr[999999999]=x` could allocate ~1B empty slots if arrays are Vec-backed; negative indices could cause OOB | HashMap-based arrays; `max_array_entries` caps total entries | **MITIGATED** |
271273

272274
**TM-DOS-051**: `builtins/yaml.rs``parse_yaml_block`, `parse_yaml_map`, `parse_yaml_list` recurse
273275
on nested YAML structures with no depth counter. Crafted YAML with 1000+ nesting levels causes stack

0 commit comments

Comments
 (0)