diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index c8068e32..8f6d3b20 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -31,7 +31,7 @@ through configurable limits. |--------|---------------|------------|--------| | Large input (TM-DOS-001) | 1GB script | `max_input_bytes` limit (10MB) | MITIGATED | | Output flooding (TM-DOS-002) | `yes \| head -n 1000000000` | Command limit stops loop | MITIGATED | -| Variable explosion (TM-DOS-003) | `x=$(cat /dev/urandom)` | No /dev/urandom in VFS | MITIGATED | +| Variable explosion (TM-DOS-003) | `x=$(cat /dev/urandom)` | /dev/urandom returns bounded 8KB | MITIGATED | | Array growth (TM-DOS-004) | `arr+=(element)` in loop | Command limit | MITIGATED | **Filesystem Exhaustion:** diff --git a/crates/bashkit/src/fs/memory.rs b/crates/bashkit/src/fs/memory.rs index e5620c21..80285d55 100644 --- a/crates/bashkit/src/fs/memory.rs +++ b/crates/bashkit/src/fs/memory.rs @@ -353,6 +353,23 @@ impl InMemoryFs { }, ); + // /dev/urandom and /dev/random - random byte sources (bounded reads) + for dev in &["/dev/urandom", "/dev/random"] { + entries.insert( + PathBuf::from(dev), + FsEntry::File { + content: Vec::new(), + metadata: Metadata { + file_type: FileType::File, + size: 0, + mode: 0o666, + modified: SystemTime::now(), + created: SystemTime::now(), + }, + }, + ); + } + // /dev/fd - directory for process substitution file descriptors entries.insert( PathBuf::from("/dev/fd"), @@ -373,6 +390,23 @@ impl InMemoryFs { } } + /// THREAT[TM-DOS-003]: Generate bounded random bytes for /dev/urandom. + /// Returns exactly 8192 bytes to prevent unbounded reads while + /// supporting common patterns like `od -N8 -tx1 /dev/urandom`. + fn generate_random_bytes() -> Vec { + use std::collections::hash_map::RandomState; + use std::hash::{BuildHasher, Hasher}; + + const SIZE: usize = 8192; + let mut buf = Vec::with_capacity(SIZE); + while buf.len() < SIZE { + let h = RandomState::new().build_hasher().finish(); + buf.extend_from_slice(&h.to_ne_bytes()); + } + buf.truncate(SIZE); + buf + } + /// Compute current usage statistics. fn compute_usage(&self) -> FsUsage { let entries = self.entries.read().unwrap(); @@ -778,6 +812,12 @@ impl FileSystem for InMemoryFs { }); let path = Self::normalize_path(path); + + // /dev/urandom and /dev/random: return bounded random bytes + if path == Path::new("/dev/urandom") || path == Path::new("/dev/random") { + return Ok(Self::generate_random_bytes()); + } + let entries = self.entries.read().unwrap(); match entries.get(&path) { @@ -1477,8 +1517,8 @@ mod tests { #[tokio::test] async fn test_file_count_limit() { - // Note: InMemoryFs starts with /dev/null as 1 file - let limits = FsLimits::new().max_file_count(4); // 1 existing + 3 new + // Note: InMemoryFs starts with 3 files: /dev/null, /dev/urandom, /dev/random + let limits = FsLimits::new().max_file_count(6); // 3 existing + 3 new let fs = InMemoryFs::with_limits(limits); // Should succeed - under limit @@ -1492,7 +1532,7 @@ mod tests { .await .unwrap(); - // Should fail - at limit (4 files: /dev/null + 3 new) + // Should fail - at limit (6 files: 3 dev + 3 new) let result = fs.write_file(Path::new("/tmp/file4.txt"), b"4").await; assert!(result.is_err()); let err = result.unwrap_err().to_string(); @@ -1501,8 +1541,8 @@ mod tests { #[tokio::test] async fn test_overwrite_does_not_increase_count() { - // Note: InMemoryFs starts with /dev/null as 1 file - let limits = FsLimits::new().max_file_count(3); // 1 existing + 2 new + // Note: InMemoryFs starts with 3 files: /dev/null, /dev/urandom, /dev/random + let limits = FsLimits::new().max_file_count(5); // 3 existing + 2 new let fs = InMemoryFs::with_limits(limits); // Create two files @@ -1518,7 +1558,7 @@ mod tests { .await .unwrap(); - // New file should fail (we're at 3: /dev/null + 2 files) + // New file should fail (we're at 5: 3 dev + 2 files) let result = fs.write_file(Path::new("/tmp/file3.txt"), b"new").await; assert!(result.is_err()); } @@ -1552,7 +1592,7 @@ mod tests { // Initial usage (only default directories) let usage = fs.usage(); assert_eq!(usage.total_bytes, 0); // No file content yet - assert_eq!(usage.file_count, 1); // /dev/null + assert_eq!(usage.file_count, 3); // /dev/null + /dev/urandom + /dev/random // Add a file fs.write_file(Path::new("/tmp/test.txt"), b"hello") @@ -1561,7 +1601,7 @@ mod tests { let usage = fs.usage(); assert_eq!(usage.total_bytes, 5); - assert_eq!(usage.file_count, 2); // /dev/null + test.txt + assert_eq!(usage.file_count, 4); // 3 dev files + test.txt } #[tokio::test] @@ -1878,4 +1918,58 @@ mod tests { let result = fs.chmod(deep, 0o755).await; assert!(result.is_err(), "chmod on deep path should be rejected"); } + + // ==================== /dev/urandom tests ==================== + + #[tokio::test] + async fn test_dev_urandom_returns_bytes() { + let fs = InMemoryFs::new(); + let content = fs.read_file(Path::new("/dev/urandom")).await.unwrap(); + assert_eq!(content.len(), 8192); + } + + #[tokio::test] + async fn test_dev_random_returns_bytes() { + let fs = InMemoryFs::new(); + let content = fs.read_file(Path::new("/dev/random")).await.unwrap(); + assert_eq!(content.len(), 8192); + } + + #[tokio::test] + async fn test_dev_urandom_returns_different_data() { + let fs = InMemoryFs::new(); + let a = fs.read_file(Path::new("/dev/urandom")).await.unwrap(); + let b = fs.read_file(Path::new("/dev/urandom")).await.unwrap(); + // Extremely unlikely to be equal + assert_ne!(a, b); + } + + #[tokio::test] + async fn test_dev_urandom_exists_in_fs() { + let fs = InMemoryFs::new(); + let exists = fs.exists(Path::new("/dev/urandom")).await.unwrap(); + assert!(exists, "/dev/urandom should exist in VFS"); + } + + #[tokio::test] + async fn test_dev_urandom_write_succeeds() { + let fs = InMemoryFs::new(); + // Writing to /dev/urandom should succeed (like real device) + let result = fs.write_file(Path::new("/dev/urandom"), b"ignored").await; + assert!(result.is_ok()); + // But reads still return random data, not what was written + let content = fs.read_file(Path::new("/dev/urandom")).await.unwrap(); + assert_eq!(content.len(), 8192); + } + + #[tokio::test] + async fn test_dev_urandom_path_normalization() { + let fs = InMemoryFs::new(); + // Path traversal attempt should still resolve to /dev/urandom + let content = fs + .read_file(Path::new("/dev/../dev/urandom")) + .await + .unwrap(); + assert_eq!(content.len(), 8192); + } } diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 399e32af..f5312343 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -10577,4 +10577,41 @@ echo "count=$COUNT" let result = run_script("mkfifo /tmp/mypipe && test -p /tmp/mypipe && echo yes").await; assert_eq!(result.stdout.trim(), "yes"); } + + // /dev/urandom integration tests + + #[tokio::test] + async fn test_od_dev_urandom() { + let result = run_script("od -An -N8 -tx1 /dev/urandom").await; + assert_eq!(result.exit_code, 0); + // Should produce hex output - 8 bytes = 8 hex pairs + let trimmed = result.stdout.trim(); + assert!(!trimmed.is_empty(), "od /dev/urandom should produce output"); + } + + #[tokio::test] + async fn test_dev_urandom_read_succeeds() { + // Reading /dev/urandom should succeed (not error with "file not found") + let result = run_script("cat /dev/urandom > /dev/null && echo ok").await; + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout.trim(), "ok"); + } + + #[tokio::test] + async fn test_dev_urandom_input_redirect() { + // Input redirect from /dev/urandom should provide data to stdin + let result = run_script("od -An -N4 -tx1 < /dev/urandom").await; + assert_eq!(result.exit_code, 0); + assert!( + !result.stdout.trim().is_empty(), + "should produce hex output" + ); + } + + #[tokio::test] + async fn test_dev_random_also_works() { + let result = run_script("od -An -N4 -tx1 /dev/random").await; + assert_eq!(result.exit_code, 0); + assert!(!result.stdout.trim().is_empty()); + } } diff --git a/crates/bashkit/tests/security_audit_pocs.rs b/crates/bashkit/tests/security_audit_pocs.rs index fde702ea..660f937f 100644 --- a/crates/bashkit/tests/security_audit_pocs.rs +++ b/crates/bashkit/tests/security_audit_pocs.rs @@ -379,9 +379,9 @@ mod overlay_symlink_bypass { #[tokio::test] async fn security_audit_overlay_symlink_enforces_limit() { let lower: Arc = Arc::new(InMemoryFs::new()); - // Both lower and upper InMemoryFs have /dev/null (1 file each = 2 base). - // limit=7 allows 5 new symlinks (2 + 5 = 7). - let limits = FsLimits::new().max_file_count(7); + // Both lower and upper InMemoryFs have 3 files each + // (/dev/null, /dev/urandom, /dev/random). limit=11 allows 5 new symlinks (6 + 5 = 11). + let limits = FsLimits::new().max_file_count(11); let overlay = OverlayFs::with_limits(lower, limits); for i in 0..5 { @@ -392,7 +392,7 @@ mod overlay_symlink_bypass { .unwrap(); } - // 6th must fail (7 total = 2 existing + 5 symlinks = at limit) + // 6th must fail (11 total = 6 existing + 5 symlinks = at limit) let result = overlay .symlink(Path::new("/target"), Path::new("/link_overflow")) .await; diff --git a/specs/003-vfs.md b/specs/003-vfs.md index 3ced4fe3..3731bf2a 100644 --- a/specs/003-vfs.md +++ b/specs/003-vfs.md @@ -130,7 +130,7 @@ The `size` field in `Metadata` must be set correctly for builtins like `ls -l`, - All files stored in `HashMap` - Thread-safe via `RwLock` - Initial directories: `/`, `/tmp`, `/home`, `/home/user`, `/dev` -- Special handling for `/dev/null` +- Special handling for `/dev/null`, `/dev/urandom`, `/dev/random` - No persistence - state lost on drop - Synchronous `add_file()` method for pre-population during construction @@ -401,6 +401,20 @@ echo "secret" > /dev/../dev/null // Discarded (normalized) echo "secret" > /dev/./null // Discarded (normalized) ``` +#### /dev/urandom and /dev/random + +Both `/dev/urandom` and `/dev/random` are handled at the **filesystem level** in `InMemoryFs::read_file`: + +- **Reads**: Return 8192 bytes of random data per read (THREAT[TM-DOS-003]) +- **Writes**: Accepted and discarded (like writing to the real device) +- **Bounded size**: Prevents `x=$(cat /dev/urandom)` from causing unbounded memory growth + +```bash +# These work as expected +od -An -N8 -tx1 /dev/urandom # 8 random hex bytes +head -c 16 /dev/urandom | xxd # 16 random bytes in hex +``` + ### POSIX Semantics Contract All `FileSystem` implementations MUST enforce these POSIX-like semantics: diff --git a/specs/006-threat-model.md b/specs/006-threat-model.md index b2980fec..7fc225b6 100644 --- a/specs/006-threat-model.md +++ b/specs/006-threat-model.md @@ -99,7 +99,7 @@ embedded in rustdoc. It contains: |----|--------|--------------|------------|--------| | TM-DOS-001 | Large script input | `Bash::exec(huge_string)` | `max_input_bytes` limit (10MB) | **MITIGATED** | | TM-DOS-002 | Output flooding | `yes \| head -n 1000000000` | Command limit stops loop | Mitigated | -| TM-DOS-003 | Variable explosion | `x=$(cat /dev/urandom)` | No /dev/urandom in VFS | Mitigated | +| TM-DOS-003 | Variable explosion | `x=$(cat /dev/urandom)` | /dev/urandom returns bounded 8KB | Mitigated | | TM-DOS-004 | Array growth | `arr+=(element)` in loop | Command limit | Mitigated | **Current Risk**: LOW - Input size and command limits prevent unbounded memory consumption