Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/bashkit/docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
110 changes: 102 additions & 8 deletions crates/bashkit/src/fs/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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<u8> {
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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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();
Expand All @@ -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
Expand All @@ -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());
}
Expand Down Expand Up @@ -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")
Expand All @@ -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]
Expand Down Expand Up @@ -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);
}
}
37 changes: 37 additions & 0 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
8 changes: 4 additions & 4 deletions crates/bashkit/tests/security_audit_pocs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,9 +379,9 @@ mod overlay_symlink_bypass {
#[tokio::test]
async fn security_audit_overlay_symlink_enforces_limit() {
let lower: Arc<dyn FileSystem> = 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 {
Expand All @@ -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;
Expand Down
16 changes: 15 additions & 1 deletion specs/003-vfs.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ The `size` field in `Metadata` must be set correctly for builtins like `ls -l`,
- All files stored in `HashMap<PathBuf, FsEntry>`
- 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

Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion specs/006-threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading