diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index f44c2a411..27ea37ddf 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -178,6 +178,12 @@ let bash = Bash::builder() # } ``` +`dd conv=sync` uses checked padding arithmetic and rejects padded lengths above +its 64 MiB copy cap before allocation. Copied payload capacity is reserved through +`BudgetedBytes` against the shared live-memory budget and retained through output +handling, including discarded output. Stdout cached text shares that budget +(TM-DOS-003, TM-DOS-103). + **Additional DoS hardening:** | Threat | Attack Example | Mitigation | Status | diff --git a/crates/bashkit/src/builtins/dd.rs b/crates/bashkit/src/builtins/dd.rs index 3b6af50b7..46862fe9f 100644 --- a/crates/bashkit/src/builtins/dd.rs +++ b/crates/bashkit/src/builtins/dd.rs @@ -6,6 +6,8 @@ //! files. Every invocation moves at most `DD_MAX_BYTES` //! (THREAT[TM-DOS-003]); an unbounded device read without `count=` stops at //! the cap and exits 1, like dd running out of space. +//! Sync padding is admitted by checked length and live-capacity budget before +//! allocation; the lease remains with the copy buffer through output handling. use async_trait::async_trait; @@ -13,6 +15,7 @@ use super::limits::DD_MAX_BYTES; use super::{Builtin, Context}; use crate::error::Result; use crate::interpreter::ExecResult; +use crate::limits::{BudgetedBytes, ExecutionBudget}; /// `dd` builtin. pub struct Dd; @@ -143,6 +146,37 @@ fn parse_opts(args: &[String]) -> std::result::Result { Ok(o) } +// THREAT[TM-DOS-003] THREAT[TM-DOS-103]: Padding must fit both limits before growth. +fn padded_len(len: usize, ibs: usize, limit: usize) -> Option { + let remainder = len % ibs; + let padding = if remainder == 0 { 0 } else { ibs - remainder }; + len.checked_add(padding).filter(|&size| size <= limit) +} + +fn copy_data( + input: &[u8], + ibs: usize, + sync: bool, + budget: Option<&ExecutionBudget>, +) -> Result { + let len = if sync { + padded_len(input.len(), ibs, DD_MAX_BYTES).ok_or_else(|| { + crate::LimitExceeded::Memory("dd padded data exceeds byte limit".into()) + })? + } else { + input.len() + }; + let mut buffer = BudgetedBytes::try_with_capacity(budget, len)?; + buffer.try_extend_from_slice(input)?; + // Every append stays within the already admitted full padded capacity. + const ZEROS: [u8; 1024] = [0; 1024]; + while buffer.len() < len { + let padding = (len - buffer.len()).min(ZEROS.len()); + buffer.try_extend_from_slice(&ZEROS[..padding])?; + } + Ok(buffer) +} + fn device_bytes(path: &str, len: usize) -> Option, String>> { match path { "/dev/zero" => Some(Ok(vec![0; len])), @@ -211,21 +245,20 @@ impl Builtin for Dd { } }; let start = skip_bytes.min(input.len()); - let mut data = input[start..].to_vec(); - if let Some(w) = want { - data.truncate(w); - } - if data.len() > DD_MAX_BYTES { - data.truncate(DD_MAX_BYTES); - capped = true; - } - let (full_in, partial_in) = ( - data.len() / o.ibs, - usize::from(!data.len().is_multiple_of(o.ibs)), - ); - if o.sync && partial_in == 1 { - data.resize((full_in + 1) * o.ibs, 0); - } + let available = input.len() - start; + let requested = want.unwrap_or(available).min(available); + capped |= requested > DD_MAX_BYTES; + let len = requested.min(DD_MAX_BYTES); + let budget = ctx + .execution_budget() + .map(|budget| { + budget + .try_with(Clone::clone) + .map_err(|_| crate::Error::Cancelled) + }) + .transpose()?; + let (full_in, partial_in) = (len / o.ibs, usize::from(!len.is_multiple_of(o.ibs))); + let mut data = copy_data(&input[start..start + len], o.ibs, o.sync, budget.as_ref())?; if o.ucase { data.make_ascii_uppercase(); } else if o.lcase { @@ -238,8 +271,18 @@ impl Builtin for Dd { let copied = data.len(); let mut result = ExecResult::ok(String::new()); + let mut _output_storage = [None, None]; match o.output.as_deref() { - None | Some("-") | Some("/dev/stdout") => result = ExecResult::ok_bytes(data), + None | Some("-") | Some("/dev/stdout") => { + let stdout = if let Some(budget) = &budget { + let (stdout, storage) = crate::StreamData::from_budgeted_bytes(data, budget)?; + _output_storage = storage; + stdout + } else { + crate::StreamData::from(data.into_parts().0) + }; + result = ExecResult::ok(stdout); + } Some("/dev/null") => {} Some(path) => { let p = super::resolve_path(ctx.cwd, path); @@ -325,6 +368,99 @@ impl Builtin for Dd { mod tests { use super::*; + fn tiny_budget(bytes: u64) -> ExecutionBudget { + ExecutionBudget::new( + &crate::ExecutionLimits::new().max_live_intermediate_bytes(bytes), + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + } + + #[test] + fn padding_size_admission_is_checked_without_allocation() { + assert_eq!(padded_len(0, 4, 8), Some(0)); + assert_eq!(padded_len(0, usize::MAX, 8), Some(0)); + assert_eq!(padded_len(1, usize::MAX, 8), None); + assert_eq!(padded_len(4, 4, 4), Some(4)); + assert_eq!(padded_len(5, 4, 8), Some(8)); + assert_eq!(padded_len(5, 4, 7), None); + assert_eq!(padded_len(usize::MAX - 1, 4, usize::MAX), None); + } + + #[test] + fn padded_copy_requires_live_capacity_admission() { + let budget = tiny_budget(4); + assert!(matches!( + copy_data(b"x", 8, true, Some(&budget)), + Err(crate::Error::ResourceLimit( + crate::LimitExceeded::ExecutionBudget( + crate::ExecutionBudgetExceeded::LiveBytes { .. } + ) + )) + )); + } + + #[test] + fn copy_capacity_lease_survives_until_owner_drops() { + let budget = tiny_budget(8); + let (data, lease) = copy_data(b"ab", 4, true, Some(&budget)) + .unwrap() + .into_parts(); + assert_eq!(data, b"ab\0\0"); + assert!(lease.is_some()); + let remaining = budget.lease_bytes(8 - data.capacity()).unwrap(); + drop(remaining); + drop(data); + drop(lease); + assert!(budget.lease_bytes(8).is_ok()); + + let budget = tiny_budget(4); + let (_data, _lease) = copy_data(b"ab", 4, true, Some(&budget)) + .unwrap() + .into_parts(); + assert!(matches!( + budget.lease_bytes(1), + Err(crate::LimitExceeded::ExecutionBudget( + crate::ExecutionBudgetExceeded::LiveBytes { .. } + )) + )); + } + + #[test] + fn ordinary_copy_and_complete_blocks_do_not_pad() { + for (input, ibs, sync, expected) in [ + (&b""[..], 8, true, &b""[..]), + (&b"abcd"[..], 4, true, &b"abcd"[..]), + (&b"abc"[..], 8, false, &b"abc"[..]), + (&b"abcde"[..], 4, true, &b"abcde\0\0\0"[..]), + ] { + let budget = tiny_budget(8); + let data = copy_data(input, ibs, sync, Some(&budget)).unwrap(); + assert_eq!(&*data, expected); + } + } + + #[test] + fn stdout_padding_admits_cached_text() { + for limit in [7, 8] { + let budget = tiny_budget(limit); + let data = copy_data(b"ab", 4, true, Some(&budget)).unwrap(); + let result = crate::StreamData::from_budgeted_bytes(data, &budget); + if limit == 7 { + assert!(matches!( + result, + Err(crate::LimitExceeded::ExecutionBudget( + crate::ExecutionBudgetExceeded::LiveBytes { .. } + )) + )); + } else { + let (stream, _storage) = result.unwrap(); + assert_eq!(stream.as_bytes(), b"ab\0\0"); + assert_eq!(stream.text_lossy(), "ab\0\0"); + assert!(budget.lease_bytes(1).is_err()); + } + } + } + #[test] fn parses_sizes_like_gnu() { assert_eq!(parse_size("512"), Some(512)); diff --git a/crates/bashkit/tests/integration/dd_tests.rs b/crates/bashkit/tests/integration/dd_tests.rs new file mode 100644 index 000000000..a45c63ee5 --- /dev/null +++ b/crates/bashkit/tests/integration/dd_tests.rs @@ -0,0 +1,121 @@ +//! Tiny ordinary dd copy contracts; admission arithmetic lives in dd unit tests. + +use bashkit::{Bash, ExecOptions}; + +fn is_gnu_dd_version(stdout: &[u8]) -> bool { + // GNU dd prints PACKAGE (`coreutils`), not PACKAGE_NAME (`GNU coreutils`). + stdout.starts_with(b"dd (coreutils) ") || stdout.starts_with(b"dd (GNU coreutils) ") +} + +#[test] +fn dd_oracle_recognizes_gnu_package_banner() { + assert!(is_gnu_dd_version( + b"dd (coreutils) 9.4\nCopyright (C) 2023 Free Software Foundation, Inc.\n" + )); + assert!(is_gnu_dd_version(b"dd (GNU coreutils) 9.12\n")); + assert!(!is_gnu_dd_version(b"dd (uutils coreutils) 0.12.0\n")); + assert!(!is_gnu_dd_version(b"dd (BSD)\n")); + assert!(!is_gnu_dd_version(b"other program GNU coreutils\n")); +} + +#[tokio::test] +async fn dd_normal_copies_match_gnu() { + use std::io::Write; + use std::process::{Command, Stdio}; + + let Some(program) = ["gdd", "dd"].into_iter().find(|program| { + Command::new(program) + .arg("--version") + .output() + .is_ok_and(|out| out.status.success() && is_gnu_dd_version(&out.stdout)) + }) else { + assert!( + !(cfg!(target_os = "linux") && std::env::var_os("CI").is_some()), + "GNU dd is required for Linux CI" + ); + eprintln!("skip: GNU dd is not installed"); + return; + }; + + for (input, args) in [ + ("abcde", "ibs=4 obs=2 conv=sync status=noxfer"), + ("abcd", "ibs=4 obs=2 conv=sync status=noxfer"), + ("abcde", "bs=2 count=2 skip=1 status=noxfer"), + ( + "abcde", + "ibs=4 count=3 iflag=count_bytes conv=sync status=noxfer", + ), + ("ab", "ibs=4 conv=sync,ucase status=none"), + ("abcde", "ibs=4 count=0 conv=sync status=none"), + ("", "ibs=4 conv=sync status=noxfer"), + ("ab", "ibs=4 conv=sync of=/dev/null status=noxfer"), + ] { + let mut host = Command::new(program) + .args(args.split_whitespace()) + .env("LC_ALL", "C") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + host.stdin + .take() + .unwrap() + .write_all(input.as_bytes()) + .unwrap(); + let expected = host.wait_with_output().unwrap(); + let actual = Bash::new() + .exec_with_options(&format!("dd {args}"), ExecOptions::new().stdin(input)) + .await + .unwrap(); + assert_eq!(actual.stdout.as_bytes(), expected.stdout, "{args}"); + assert_eq!(actual.stderr.as_bytes(), expected.stderr, "{args}"); + assert_eq!(actual.exit_code, expected.status.code().unwrap(), "{args}"); + } +} + +#[tokio::test] +async fn dd_padding_keeps_seek_and_file_errors() { + let mut bash = Bash::new(); + bash.fs() + .write_file(std::path::Path::new("/copy"), b"abcdef") + .await + .unwrap(); + let result = bash + .exec("printf XY | dd of=/copy ibs=4 obs=1 seek=1 conv=sync,notrunc status=none") + .await + .unwrap(); + assert_eq!(result.exit_code, 0); + assert!(result.stdout.is_empty()); + assert!(result.stderr.is_empty()); + assert_eq!( + bash.fs() + .read_file(std::path::Path::new("/copy")) + .await + .unwrap(), + b"aXY\0\0f" + ); + + for (script, stderr) in [ + ( + "printf XY | dd of=/copy ibs=4 conv=sync,excl status=none", + "dd: failed to open '/copy': File exists\n", + ), + ( + "printf XY | dd of=/missing ibs=4 conv=sync,nocreat status=none", + "dd: failed to open '/missing': No such file or directory\n", + ), + ] { + let result = bash.exec(script).await.unwrap(); + assert_eq!(result.exit_code, 1); + assert_eq!(result.stderr, stderr); + assert!(result.stdout.is_empty()); + } + assert_eq!( + bash.fs() + .read_file(std::path::Path::new("/copy")) + .await + .unwrap(), + b"aXY\0\0f" + ); +} diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index 69640799a..820a046ea 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -68,6 +68,7 @@ pub mod date_timezone_differential_tests; pub mod date_timezone_no_tzdata_tests; #[cfg(feature = "tzdata")] pub mod date_timezone_tests; +pub mod dd_tests; pub mod dev_fd_path_tests; pub mod dev_null_tests; pub mod diagnostic_prefix_tests; diff --git a/docs/security.md b/docs/security.md index 3b0f37f99..08f1894ca 100644 --- a/docs/security.md +++ b/docs/security.md @@ -18,6 +18,12 @@ individual threat IDs and mitigation status, see the | **Resource limits** | Configurable caps on commands, loop iterations, recursion depth, AST depth, timeouts, and parser operations prevent denial-of-service from malicious scripts. | | **Filesystem limits** | Total bytes, per-file size, file count, path depth, and filename length are all capped to prevent storage exhaustion (zip bombs, tar bombs, recursive copies). | +`dd conv=sync` checks its padded block length against the 64 MiB copy cap +before allocating or zero-filling. Copied payload capacity also counts against +`max_live_intermediate_bytes` until output handling completes, including when +output is discarded. Stdout cached text is admitted under the same budget. +Exceeding either limit returns a resource-limit error. + `pr` checks its 16 MiB output cap before appending headers, rows, margins, newlines or form feeds. Repeated long headers cannot grow the output buffer past that cap. Input dates are retained as timestamps, so long date formats are not copied diff --git a/knowledge/foundations/builtins.md b/knowledge/foundations/builtins.md index d984080f6..2fe97a2e9 100644 --- a/knowledge/foundations/builtins.md +++ b/knowledge/foundations/builtins.md @@ -613,6 +613,20 @@ probe of the 879 BRE/ERE regex cases except two whose pattern holds a raw Gaps are L-GREP-001..003 in [Known Limitations](../operations/limitations.md); regex caps are TM-DOS-023/025 in the [Threat Model](../security/threat-model.md). +### dd + +`dd` keeps its whole-buffer copy model. The selected input slice is capped before +copying. `conv=sync` computes the rounded block length with checked arithmetic +and rejects lengths above `DD_MAX_BYTES` before allocation. The entire copied +payload capacity is admitted through `BudgetedBytes` against the shared live +memory budget; its lease remains through conversion and output handling, +including discarded output. Stdout conversion also admits cached text while +retaining both storage guards. Budget failures propagate as resource-limit errors. +Ordinary count/skip, padding, conversion, record reports, seek, and file-error +behavior is preserved. Unit tests use tiny admission budgets and allocation-free +overflow arithmetic; `dd_tests` compares small copies with GNU dd and checks VFS +seek/error behavior (TM-DOS-003, TM-DOS-103). + ### fmt `fmt` vendors the uutils/coreutils `fmt` engine (MIT): `builtins/fmt/ diff --git a/knowledge/security/security-testing.md b/knowledge/security/security-testing.md index efa2e4547..9fa527358 100644 --- a/knowledge/security/security-testing.md +++ b/knowledge/security/security-testing.md @@ -73,6 +73,17 @@ probability `10%`, count `5*`): see [fail crate docs](https://docs.rs/fail). 2. **Filesystem failure**: I/O errors, permission denied, disk full, partial writes, data corruption 3. **Interpreter failure**: execution errors, panic recovery, unexpected exit codes +## dd GNU Regression Oracle + +`dd_tests` compares only tiny ordinary copies against a GNU dd binary. Discovery +requires a successful `--version` and recognizes the `dd (coreutils)` package +banner as well as `dd (GNU coreutils)`. GNU dd's +[version-option code](https://github.com/coreutils/coreutils/blob/v9.4/src/dd.c#L2288) +uses the package name; requiring the longer phrase anywhere in the output rejects +GNU versions without a matching footer. The inert banner fixture also rejects +uutils, BSD, and unrelated programs. Linux CI must find GNU dd; all byte, stderr, +and exit-status assertions remain mandatory. See [Builtins](../foundations/builtins.md). + ## od Width Regression Oracle `od_resource_tests` compares valid-width layouts with GNU `od` when available diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index c474a41f3..d4eaa5462 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -116,7 +116,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)`, `dd if=/dev/zero` | /dev/urandom returns bounded 8KB; `dd` generates `/dev/zero`/`/dev/urandom` only up to `DD_MAX_BYTES` (64 MiB) per invocation | Mitigated | +| TM-DOS-003 | Variable explosion | `x=$(cat /dev/urandom)`, `dd if=/dev/zero` | /dev/urandom returns bounded 8KB; `dd` selects at most `DD_MAX_BYTES` (64 MiB) per copy; checked `conv=sync` padding must also fit that cap before allocation, and copy capacity retains a shared live-memory lease through output handling (`dd` unit tests, `dd_tests`) | Mitigated | | TM-DOS-004 | Array growth | `arr+=(element)` in loop | Command limit | Mitigated | **Current Risk**: LOW. Implementation: `ExecutionLimits` in `limits.rs`, `max_input_bytes` 10MB (TM-DOS-001), `max_commands` 10K per `exec()` (TM-DOS-002, TM-DOS-004).