diff --git a/crates/bashkit/tests/security_audit_pocs.rs b/crates/bashkit/tests/security_audit_pocs.rs new file mode 100644 index 00000000..09090315 --- /dev/null +++ b/crates/bashkit/tests/security_audit_pocs.rs @@ -0,0 +1,530 @@ +//! Security Audit Regression Tests +//! +//! Tests for vulnerabilities discovered during the 2026-03 security audit. +//! Each test asserts the DESIRED secure behavior. Tests are #[ignore] until +//! the corresponding fix lands -- they flip from ignored to green on fix. +//! +//! Run all (including ignored): `cargo test security_audit_ -- --ignored` +//! Run only passing: `cargo test security_audit_` + +#![allow(unused_variables, unused_imports)] + +use bashkit::{Bash, ExecutionLimits}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +// ============================================================================= +// 1. INTERNAL VARIABLE PREFIX INJECTION (TM-INJ-012 to TM-INJ-016) +// +// Root cause: declare, readonly, local, export insert directly into the +// variables HashMap via ctx.variables.insert(), bypassing the +// is_internal_variable() guard in set_variable(). +// +// Files: +// - interpreter/mod.rs:5574 (declare bypass) +// - builtins/vars.rs:223 (local bypass), :265 (readonly bypass) +// - builtins/export.rs:41 (export bypass) +// - interpreter/mod.rs:7634-7641 (is_internal_variable) +// - interpreter/mod.rs:4042-4057 (_ARRAY_READ_ post-processing) +// ============================================================================= + +mod internal_variable_injection { + use super::*; + + /// TM-INJ-012: `declare` must not create namerefs via _NAMEREF_ prefix. + #[tokio::test] + #[ignore = "TM-INJ-012: declare bypasses is_internal_variable()"] + async fn security_audit_declare_blocks_nameref_prefix() { + let mut bash = Bash::builder().build(); + + let result = bash + .exec( + r#" + secret="sensitive_data" + declare _NAMEREF_alias=secret + echo "$alias" + "#, + ) + .await + .unwrap(); + + // $alias must NOT resolve to $secret — the _NAMEREF_ prefix should be blocked + assert_ne!( + result.stdout.trim(), + "sensitive_data", + "declare must block _NAMEREF_ prefix injection" + ); + } + + /// TM-INJ-013: `readonly` must not create namerefs via _NAMEREF_ prefix. + #[tokio::test] + #[ignore = "TM-INJ-013: readonly bypasses is_internal_variable()"] + async fn security_audit_readonly_blocks_nameref_prefix() { + let mut bash = Bash::builder().build(); + + let result = bash + .exec( + r#" + target="important_value" + readonly _NAMEREF_sneaky=target + echo "$sneaky" + "#, + ) + .await + .unwrap(); + + assert_ne!( + result.stdout.trim(), + "important_value", + "readonly must block _NAMEREF_ prefix injection" + ); + } + + /// TM-INJ-012: `declare` must not inject _UPPER_ case conversion marker. + #[tokio::test] + #[ignore = "TM-INJ-012: declare bypasses is_internal_variable()"] + async fn security_audit_declare_blocks_upper_prefix() { + let mut bash = Bash::builder().build(); + + let result = bash + .exec( + r#" + declare _UPPER_myvar=1 + myvar="should be lowercase" + echo "$myvar" + "#, + ) + .await + .unwrap(); + + // Assignment must NOT be forced to uppercase + assert_eq!( + result.stdout.trim(), + "should be lowercase", + "declare must block _UPPER_ prefix injection" + ); + } + + /// TM-INJ-012: `declare` must not inject _LOWER_ case conversion marker. + #[tokio::test] + #[ignore = "TM-INJ-012: declare bypasses is_internal_variable()"] + async fn security_audit_declare_blocks_lower_prefix() { + let mut bash = Bash::builder().build(); + + let result = bash + .exec( + r#" + declare _LOWER_myvar=1 + myvar="SHOULD BE UPPERCASE" + echo "$myvar" + "#, + ) + .await + .unwrap(); + + assert_eq!( + result.stdout.trim(), + "SHOULD BE UPPERCASE", + "declare must block _LOWER_ prefix injection" + ); + } + + /// TM-INJ-016: _ARRAY_READ_ prefix must be rejected by is_internal_variable(). + #[tokio::test] + #[ignore = "TM-INJ-016: _ARRAY_READ_ not in is_internal_variable()"] + async fn security_audit_array_read_prefix_blocked() { + let mut bash = Bash::builder().build(); + + let result = bash + .exec( + "export \"_ARRAY_READ_injected=val0\x1Fval1\x1Fval2\"\ntrue\necho \"${injected[0]} ${injected[1]} ${injected[2]}\"", + ) + .await + .unwrap(); + + // Array must NOT be created via _ARRAY_READ_ marker injection + assert!( + !result.stdout.trim().contains("val0"), + "_ARRAY_READ_ prefix must be blocked. Got: '{}'", + result.stdout.trim() + ); + } + + /// TM-INJ-015: `export` must not inject _READONLY_ marker prefix. + #[tokio::test] + #[ignore = "TM-INJ-015: export bypasses is_internal_variable()"] + async fn security_audit_export_blocks_readonly_prefix() { + let mut bash = Bash::builder().build(); + + let result = bash + .exec( + r#" + myvar="original" + export _READONLY_myvar=1 + myvar="changed" + echo "$myvar" + "#, + ) + .await + .unwrap(); + + // Marker injection must be blocked — the var assignment should succeed + // and _READONLY_ marker should not appear in `set` output + assert_eq!(result.stdout.trim(), "changed"); + let leak = bash.exec("set | grep _READONLY_myvar").await.unwrap(); + assert!( + leak.stdout.trim().is_empty(), + "_READONLY_ marker must not be injectable via export" + ); + } + + /// TM-INJ-014: `local` (interpreter-level) must not inject _NAMEREF_ prefix. + /// execute_local_builtin at interpreter/mod.rs:4572 inserts into + /// frame.locals without calling is_internal_variable(). + /// The marker ends up in the call frame, which set_variable traverses. + #[tokio::test] + #[ignore = "TM-INJ-014: execute_local_builtin bypasses is_internal_variable()"] + async fn security_audit_local_blocks_internal_prefixes() { + let mut bash = Bash::builder().build(); + + // Outside a function, execute_local_builtin inserts into self.variables + // at line 4599. This bypasses is_internal_variable(). + let result = bash + .exec( + r#" + secret="stolen" + local _NAMEREF_sneaky=secret + echo "$sneaky" + "#, + ) + .await + .unwrap(); + + // local must not create a nameref — $sneaky must NOT resolve to $secret + assert_ne!( + result.stdout.trim(), + "stolen", + "local must block _NAMEREF_ prefix injection" + ); + } +} + +// ============================================================================= +// 2. INTERNAL VARIABLE INFO LEAK (TM-INF-017) +// +// Root cause: `set` and `declare -p` iterate all variables without filtering +// internal prefixes. +// Files: builtins/vars.rs:114-119, interpreter/mod.rs:5367-5374 +// ============================================================================= + +mod internal_variable_leak { + use super::*; + + /// TM-INF-017: `set` must not expose internal _NAMEREF_/_READONLY_ markers. + #[tokio::test] + #[ignore = "TM-INF-017: set leaks internal marker variables"] + async fn security_audit_set_hides_internal_markers() { + let mut bash = Bash::builder().build(); + + let result = bash + .exec( + r#" + declare -n myref=target + readonly myval=123 + set | grep -E "^_(NAMEREF|READONLY)_" + "#, + ) + .await + .unwrap(); + + assert!( + result.stdout.trim().is_empty(), + "`set` must filter internal markers from output. Got:\n{}", + result.stdout.trim() + ); + } + + /// TM-INF-017: `declare -p` must not expose internal markers. + #[tokio::test] + #[ignore = "TM-INF-017: declare -p leaks internal marker variables"] + async fn security_audit_declare_p_hides_internal_markers() { + let mut bash = Bash::builder().build(); + + let result = bash + .exec( + r#" + declare -n myref=target + readonly locked=42 + declare -p | grep -E "_(NAMEREF|READONLY)_" + "#, + ) + .await + .unwrap(); + + assert!( + result.stdout.trim().is_empty(), + "`declare -p` must filter internal markers. Got:\n{}", + result.stdout.trim() + ); + } +} + +// ============================================================================= +// 3. ARITHMETIC COMPOUND ASSIGNMENT OVERFLOW (TM-DOS-043) +// +// Root cause: execute_arithmetic_with_side_effects() at interpreter/mod.rs:1563 +// uses native + instead of wrapping_add. Panics in debug mode. +// Files: interpreter/mod.rs:1563, :7022-7043 +// ============================================================================= + +mod arithmetic_overflow { + use super::*; + + /// TM-DOS-043: i64::MAX + 1 in ((x+=1)) must not panic. + /// Should use wrapping arithmetic like the non-compound path. + #[tokio::test] + #[ignore = "TM-DOS-043: compound += panics on i64 overflow in debug mode"] + async fn security_audit_compound_add_no_panic() { + let limits = ExecutionLimits::new().timeout(Duration::from_secs(5)); + let mut bash = Bash::builder().limits(limits).build(); + + // This must not panic — should wrap or return error + let result = bash.exec("x=9223372036854775807; ((x+=1)); echo $x").await; + + assert!( + result.is_ok(), + "Compound += with i64::MAX must not panic. Got: {:?}", + result.err() + ); + } + + /// TM-DOS-043: Compound <<= must clamp shift amount like non-compound path. + #[tokio::test] + #[ignore = "TM-DOS-043: compound <<= uses unclamped shift"] + async fn security_audit_compound_shift_clamped() { + let limits = ExecutionLimits::new().timeout(Duration::from_secs(5)); + let mut bash = Bash::builder().limits(limits).build(); + + // Shift by 64 must not panic — should clamp to 0..=63 + let result = bash.exec("x=1; let 'x<<=64'; echo $x").await; + + assert!( + result.is_ok(), + "Compound <<= with shift>=64 must not panic. Got: {:?}", + result.err() + ); + } +} + +// ============================================================================= +// 4. VFS LIMIT BYPASS (TM-DOS-047, TM-DOS-048) +// +// Root cause: InMemoryFs::copy() skips check_write_limits when dest exists. +// InMemoryFs::rename() silently overwrites directories. +// Files: fs/memory.rs:1155-1183, :1136-1153 +// ============================================================================= + +mod vfs_limit_bypass { + use super::*; + use bashkit::{FileSystem, FsLimits, InMemoryFs}; + use std::path::Path; + + /// TM-DOS-047: copy() must enforce limits even when destination exists. + #[tokio::test] + #[ignore = "TM-DOS-047: copy() skips check_write_limits on existing dest"] + async fn security_audit_copy_enforces_limit_on_overwrite() { + let limits = FsLimits::new() + .max_total_bytes(600) + .max_file_size(600) + .max_file_count(10); + let fs = InMemoryFs::with_limits(limits); + + // 10-byte target, 500-byte source → copy adds 490 bytes net + fs.write_file(Path::new("/target"), b"tiny_file!") + .await + .unwrap(); + fs.write_file(Path::new("/source"), &vec![b'A'; 500]) + .await + .unwrap(); + + // Copy would bring total to 1000 bytes (> 600 limit). + // Must fail because it exceeds max_total_bytes. + let result = fs.copy(Path::new("/source"), Path::new("/target")).await; + assert!( + result.is_err(), + "copy() must enforce size limits on overwrite" + ); + } + + /// TM-DOS-048: rename(file, dir) must fail per POSIX, not silently overwrite. + #[tokio::test] + #[ignore = "TM-DOS-048: rename(file, dir) silently overwrites, orphans children"] + async fn security_audit_rename_rejects_file_over_dir() { + let fs = InMemoryFs::new(); + + fs.mkdir(Path::new("/mydir"), false).await.unwrap(); + fs.write_file(Path::new("/mydir/child.txt"), b"child data") + .await + .unwrap(); + fs.write_file(Path::new("/myfile"), b"file data") + .await + .unwrap(); + + // rename(file, dir) must fail + let result = fs.rename(Path::new("/myfile"), Path::new("/mydir")).await; + assert!(result.is_err(), "rename(file, dir) must fail per POSIX"); + } +} + +// ============================================================================= +// 5. OVERLAY FS SYMLINK LIMIT BYPASS (TM-DOS-045) +// +// Root cause: OverlayFs::symlink() has no check_write_limits() call. +// Files: fs/overlay.rs:683-691 +// ============================================================================= + +mod overlay_symlink_bypass { + use super::*; + use bashkit::{FileSystem, FsLimits, InMemoryFs, OverlayFs}; + use std::path::Path; + + /// TM-DOS-045: OverlayFs::symlink() must enforce file count limits. + #[tokio::test] + #[ignore = "TM-DOS-045: OverlayFs::symlink() bypasses file count limit"] + async fn security_audit_overlay_symlink_enforces_limit() { + let lower: Arc = Arc::new(InMemoryFs::new()); + let limits = FsLimits::new().max_file_count(5); + let overlay = OverlayFs::with_limits(lower, limits); + + for i in 0..5 { + let link = format!("/link{}", i); + overlay + .symlink(Path::new("/target"), Path::new(&link)) + .await + .unwrap(); + } + + // 6th must fail + let result = overlay + .symlink(Path::new("/target"), Path::new("/link_overflow")) + .await; + assert!( + result.is_err(), + "symlink() must reject creation beyond max_file_count" + ); + } +} + +// ============================================================================= +// 6. INFORMATION DISCLOSURE: date leaks real host time (TM-INF-018) +// +// Root cause: date builtin uses chrono::Local/Utc (real system clock). +// Files: builtins/date.rs +// ============================================================================= + +mod information_disclosure { + use super::*; + + /// TM-INF-018: `date` should use a configurable/virtual time source. + #[tokio::test] + #[ignore = "TM-INF-018: date uses real host clock, not virtualized"] + async fn security_audit_date_uses_virtual_time() { + let mut bash = Bash::builder() + .username("sandboxuser") + .hostname("sandbox.local") + .build(); + + // hostname and whoami are virtualized + let host = bash.exec("hostname").await.unwrap(); + assert_eq!(host.stdout.trim(), "sandbox.local"); + + let result = bash.exec("date +%s").await.unwrap(); + let script_epoch: i64 = result.stdout.trim().parse().unwrap_or(0); + let real_epoch = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + // date must NOT return real host time + assert!( + (script_epoch - real_epoch).abs() >= 10, + "date must use virtual time, not real host epoch (script={} real={})", + script_epoch, + real_epoch + ); + } +} + +// ============================================================================= +// 7. BRACE EXPANSION UNBOUNDED RANGE (TM-DOS-041) +// +// Root cause: try_expand_range() has no cap on (M - N). +// Files: interpreter/mod.rs:8049-8060 +// ============================================================================= + +mod brace_expansion_dos { + use super::*; + + /// TM-DOS-041: Brace expansion {1..N} must cap range size. + #[tokio::test] + #[ignore = "TM-DOS-041: brace expansion {N..M} has no upper bound"] + async fn security_audit_brace_expansion_capped() { + let limits = ExecutionLimits::new() + .max_commands(100) + .timeout(Duration::from_secs(10)); + let mut bash = Bash::builder().limits(limits).build(); + + // {1..1000000} should be rejected or capped, not expand to 1M strings + let result = bash.exec("echo {1..1000000} > /dev/null").await; + assert!( + result.is_err(), + "Brace expansion with 1M elements must be rejected" + ); + } +} + +// ============================================================================= +// 8. LEXER STACK OVERFLOW (TM-DOS-044) +// +// Root cause: read_command_subst_into() recurses without depth tracking. +// Files: parser/lexer.rs:1109-1188 +// ============================================================================= + +mod lexer_stack_overflow { + use super::*; + + /// TM-DOS-044: Deeply nested $() must fail gracefully, not stack overflow. + /// Confirmed crash at depth ~50 in debug mode. Using depth=15 here to + /// test the graceful error path without crashing the runner. + #[tokio::test] + async fn security_audit_nested_subst_graceful_error() { + let limits = ExecutionLimits::new() + .max_ast_depth(10) + .timeout(Duration::from_secs(5)); + let mut bash = Bash::builder().limits(limits).build(); + + let mut script = String::new(); + let depth = 15; + for _ in 0..depth { + script.push_str("echo \"$("); + } + script.push_str("echo hi"); + for _ in 0..depth { + script.push_str(")\""); + } + + let result = bash.exec(&script).await; + match result { + Ok(_) => {} // Fine if it works at this depth + Err(e) => { + let msg = e.to_string(); + assert!( + !msg.contains("stack overflow"), + "Must fail with depth limit, not stack overflow: {}", + msg + ); + } + } + // NOTE: depth=50 causes SIGABRT (TM-DOS-044). Not tested here. + } +} diff --git a/specs/006-threat-model.md b/specs/006-threat-model.md index afd98c83..a8df9733 100644 --- a/specs/006-threat-model.md +++ b/specs/006-threat-model.md @@ -251,7 +251,7 @@ max_ast_depth: 100, // Parser recursion (TM-DOS-022) | TM-DOS-027 | Builtin parser recursion | Deeply nested awk/jq expressions | `MAX_AWK_PARSER_DEPTH` (100) + `MAX_JQ_JSON_DEPTH` (100) | **MITIGATED** | | TM-DOS-028 | Diff algorithm DoS | `diff` on two large unrelated files | LCS matrix capped at 10M cells; falls back to simple line-by-line output | **MITIGATED** | | TM-DOS-029 | Arithmetic overflow/panic | `$(( 2 ** -1 ))`, `$(( 1 << 64 ))`, `i64::MIN / -1` | — | **OPEN** | -| TM-DOS-030 | Parser limit bypass via eval/source/trap | `eval`, `source`, trap handlers, alias expansion create `Parser::new()` ignoring configured limits | — | **OPEN** | +| TM-DOS-030 | Parser limit bypass via eval/source/trap | `eval`, `source`, trap handlers now use `Parser::with_limits()` | — | **FIXED** (2026-03 audit verified) | | TM-DOS-031 | ExtGlob exponential blowup | `+(a\|aa)` against long string causes O(n!) recursion in `glob_match_impl` | — | **OPEN** | | TM-DOS-032 | Tokio runtime exhaustion (Python) | Rapid `execute_sync()` calls each create new tokio runtime, exhausting OS threads | — | **OPEN** | | TM-DOS-033 | AWK unbounded loops | `BEGIN { while(1){} }` has no iteration limit in AWK interpreter | Timeout (30s) backstop | **PARTIAL** | @@ -377,8 +377,8 @@ All execution stays within the virtual interpreter — no OS subprocess is spawn | TM-INF-003 | Proc secrets | `/proc/self/environ` | No /proc filesystem | **MITIGATED** | | TM-INF-004 | Memory dump | Core dumps | No crash dumps | **MITIGATED** | -| TM-INF-013 | Host env leak via jq | jq builtin calls `std::env::set_var()`, exposing host process env vars | — | **OPEN** | -| TM-INF-014 | Real PID leak via $$ | `$$` returns `std::process::id()` instead of virtual value | — | **OPEN** | +| TM-INF-013 | Host env leak via jq | jq now uses custom `$__bashkit_env__` variable, not `std::env` | — | **FIXED** (2026-03 audit verified) | +| TM-INF-014 | Real PID leak via $$ | `$$` now returns virtual PID (1) instead of real process ID | — | **FIXED** (2026-03 audit verified) | | TM-INF-015 | URL credentials in errors | Allowlist "blocked" error echoes full URL including credentials | — | **OPEN** | | TM-INF-016 | Internal state in error messages | `std::io::Error`, reqwest errors, Debug-formatted errors leak host paths/IPs/TLS info | — | **OPEN** | @@ -981,15 +981,17 @@ This section maps former vulnerability IDs to the new threat ID scheme and track |-----------|---------------|--------|----------------| | TM-DOS-029 | Arithmetic overflow/panic | Interpreter crash/hang | Use wrapping arithmetic, clamp shift/exponent | | TM-ESC-012 | VFS limit bypass via add_file()/restore() | Unlimited VFS writes | Add limit checks or restrict visibility | -| TM-INF-013 | Host env leak + thread-unsafe set_var in jq | Info leak + data race | Custom env impl for jaq | | TM-INJ-009 | Internal variable namespace injection | Bypass readonly, manipulate interpreter | Separate internal state HashMap | +| TM-INJ-012–015 | Builtin bypass of is_internal_variable() | Unauthorized nameref/case attr injection via declare/readonly/local/export | Add is_internal_variable() check to all builtin insert paths | +| TM-DOS-043 | Arithmetic panic in compound assignment | Process crash (DoS) in debug mode | wrapping_* ops in execute_arithmetic_with_side_effects | +| TM-DOS-044 | Lexer stack overflow on nested $() | Process crash (SIGABRT) | Depth tracking in read_command_subst_into | ### Open (High Priority) | Threat ID | Vulnerability | Impact | Recommendation | |-----------|---------------|--------|----------------| -| TM-DOS-030 | Parser limit bypass via eval/source/trap | Unlimited parser depth/operations | Use `Parser::with_limits()` everywhere | -| TM-DOS-031 | ExtGlob exponential blowup | CPU exhaustion / stack overflow | Add depth limit to glob_match_impl | +| TM-DOS-031 | ExtGlob exponential blowup | CPU exhaustion / stack overflow | Add fuel counter to glob_match_impl | +| TM-DOS-041 | Brace expansion unbounded range | OOM DoS | Cap range size in try_expand_range() | | TM-DOS-032 | Tokio runtime per sync call (Python) | OS thread/fd exhaustion | Shared runtime | | TM-PY-023 | Shell injection in deepagents.py | Command injection within VFS | Use shlex.quote() or direct API | | TM-PY-024 | Heredoc content injection in write() | Command injection within VFS | Random delimiter or direct API | @@ -1029,6 +1031,29 @@ This section maps former vulnerability IDs to the new threat ID scheme and track | TM-UNI-018 | Interpreter arithmetic byte/char confusion | Wrong operator detection on multi-byte expressions | Use `char_indices()` instead of `.find()` + `.chars().nth()` (issue #437) | | TM-UNI-019 | Network allowlist byte/char confusion | Wrong path boundary check on multi-byte URLs | Use byte offset consistently in URL matching (issue #438) | +### Open (From 2026-03 Deep Audit — New Findings) + +| Threat ID | Vulnerability | Impact | Recommendation | +|-----------|---------------|--------|----------------| +| TM-INJ-012 | `declare` bypasses `is_internal_variable()` | Unauthorized nameref creation, case conversion injection | Route declare assignments through `set_variable()` or add `is_internal_variable()` check at `interpreter/mod.rs:5574` | +| TM-INJ-013 | `readonly` bypasses `is_internal_variable()` | Unauthorized nameref creation via `readonly _NAMEREF_x=target` | Add `is_internal_variable()` check at `builtins/vars.rs:265` | +| TM-INJ-014 | `local` bypasses `is_internal_variable()` | Internal prefix injection in function scope | Add `is_internal_variable()` check at `builtins/vars.rs:223` | +| TM-INJ-015 | `export` bypasses `is_internal_variable()` | Internal prefix injection via export | Add `is_internal_variable()` check at `builtins/export.rs:41` | +| TM-INJ-016 | `_ARRAY_READ_` prefix not in `is_internal_variable()` | Arbitrary array creation/overwrite via marker injection | Add `_ARRAY_READ_` prefix to `is_internal_variable()` at `interpreter/mod.rs:7634` | +| TM-INF-017 | `set` and `declare -p` leak internal markers | Internal state disclosure (_NAMEREF_, _READONLY_, _UPPER_, _LOWER_) | Filter `is_internal_variable()` names from output | +| TM-INF-018 | `date` builtin returns real host time | Timezone fingerprinting, timing correlation | Configurable time source (fixed or offset) | +| TM-DOS-041 | Brace expansion `{N..M}` unbounded range | OOM via `{1..999999999}` allocating billions of strings | Cap range size (e.g., 10,000 elements) in `try_expand_range()` at `interpreter/mod.rs:8049` | +| TM-DOS-042 | Brace expansion combinatorial explosion | OOM via `{1..100}{1..100}{1..100}` = 1M strings | Cap total expansion count in `expand_braces()` at `interpreter/mod.rs:7967` | +| TM-DOS-043 | Arithmetic overflow in `execute_arithmetic_with_side_effects` | Panic (DoS) in debug mode via `((x+=1))` with x=i64::MAX | Use `wrapping_add/sub/mul` at `interpreter/mod.rs:1563-1565` | +| TM-DOS-044 | Lexer `read_command_subst_into` stack overflow | Process crash (SIGABRT) via ~50 nested `$()` in double-quotes | Add depth parameter to `read_command_subst_into()` at `parser/lexer.rs:1109` | +| TM-DOS-045 | OverlayFs `symlink()` bypasses all limits | Unlimited symlink creation despite `max_file_count` | Add `check_write_limits()` + `validate_path()` to `fs/overlay.rs:683` | +| TM-DOS-046 | MountableFs has zero `validate_path()` calls | Path validation completely bypassed for mounted filesystems | Add `validate_path()` to all FileSystem methods in `fs/mountable.rs:348-491` | +| TM-DOS-047 | InMemoryFs `copy()` skips limit check when dest exists | Total VFS bytes can exceed `max_total_bytes` | Always call `check_write_limits()` in `fs/memory.rs:1176`, accounting for size delta | +| TM-DOS-048 | InMemoryFs `rename()` overwrites dirs, orphans children | VFS corruption — orphaned entries consume memory but are unreachable | Check dest type in `rename()`, reject file-over-directory per POSIX | +| TM-DOS-049 | `collect_dirs_recursive` has no depth limit | Deep recursion on VFS trees (mitigated by `max_path_depth`) | Add explicit depth parameter at `interpreter/mod.rs:8352` | +| TM-DOS-050 | `parse_word_string` uses default parser limits | Caller-configured tighter limits ignored for parameter expansion | Propagate limits through `parse_word_string()` at `parser/mod.rs:109` | +| TM-PY-028 | BashTool.reset() in Python drops security config | Resource limits silently removed after reset | Preserve limits like `PyBash.reset()` does (see `bashkit-python/src/lib.rs:470`) | + ### Accepted (Low Priority) | Threat ID | Vulnerability | Impact | Rationale | @@ -1112,6 +1137,22 @@ This section maps former vulnerability IDs to the new threat ID scheme and track | Error message sanitization gaps | TM-INF-016 | Consistent Display format, wrap external errors | **NEEDED** | | 32-bit integer safety | TM-DOS-040 | `usize::try_from()` for `u64` casts | **NEEDED** | +### Open Controls (From 2026-03 Deep Audit) + +| Finding | Threat IDs | Required Control | Status | +|---------|------------|------------------|--------| +| Internal prefix injection via builtins | TM-INJ-012 to TM-INJ-015 | Add `is_internal_variable()` check to `declare`, `readonly`, `local`, `export` | **NEEDED** | +| Missing `_ARRAY_READ_` in prefix guard | TM-INJ-016 | Add prefix to `is_internal_variable()` | **NEEDED** | +| Internal marker info leak | TM-INF-017 | Filter internal vars from `set` and `declare -p` output | **NEEDED** | +| Brace expansion DoS | TM-DOS-041, TM-DOS-042 | Cap range size and total expansion count | **NEEDED** | +| Arithmetic overflow in compound assignment | TM-DOS-043 | Use `wrapping_*` ops in `execute_arithmetic_with_side_effects` | **NEEDED** | +| Lexer stack overflow | TM-DOS-044 | Depth tracking in `read_command_subst_into` | **NEEDED** | +| OverlayFs symlink limit bypass | TM-DOS-045 | `check_write_limits()` + `validate_path()` in `symlink()` | **NEEDED** | +| MountableFs path validation gap | TM-DOS-046 | `validate_path()` in all MountableFs methods | **NEEDED** | +| VFS copy/rename semantic bugs | TM-DOS-047, TM-DOS-048 | Fix limit check in copy(), type check in rename() | **NEEDED** | +| Date time info leak | TM-INF-018 | Configurable time source | **NEEDED** | +| Python BashTool.reset() drops limits | TM-PY-028 | Preserve config on reset (match PyBash.reset()) | **NEEDED** | + --- ## Recommended Limits for Production @@ -1699,3 +1740,4 @@ specs are rare in practice). - `tests/threat_model_tests.rs` - Threat model test suite - `tests/security_failpoint_tests.rs` - Fail-point security tests - `tests/unicode_security_tests.rs` - Unicode security tests (TM-UNI-*) +- `tests/security_audit_pocs.rs` - PoC tests for 2026-03 deep audit (TM-INJ-012–016, TM-INF-017–018, TM-DOS-041–050, TM-PY-028) diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 5d14a37d..9ff1b7e6 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -95,11 +95,11 @@ version = "1.5.0" criteria = "safe-to-deploy" [[exemptions.aws-lc-rs]] -version = "1.16.0" +version = "1.16.1" criteria = "safe-to-deploy" [[exemptions.aws-lc-sys]] -version = "0.37.1" +version = "0.38.0" criteria = "safe-to-deploy" [[exemptions.base64]]