diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index 27ea37ddf..7cbc4c977 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -131,7 +131,7 @@ stdout capture limit remains downstream of pipelines and redirects. | Interactive terminal flooding (TM-DOS-119) | Unbounded type-ahead, undrained output, or a `vi` buffer grown by pastes | Caps on unread input (1 MiB), retained output (4 MiB), scrollback (1000 rows), transcript (1 MiB), line length, `vi` buffer (8 MiB) and undo history; `:w` goes through VFS limits | MITIGATED | | Input wait vs. timeout (TM-DOS-120) | Abusing the keyboard-wait exclusion to run work past the deadline | Only time blocked reading the terminal is excluded; CPU work and `sleep` still count | MITIGATED | | Concurrent pipeline amplification (TM-DOS-124) | An endless loop piped into `head`, a producer whose reader quit, or recursive pipelines `f() { f \| f; }` | 4 KiB pipes with backpressure; a writer whose reader is gone ends with 141 (SIGPIPE); streaming nesting counts toward `max_subshell_depth` and falls back to sequential stages past 4 levels; stages share the session budget, timeout and cancellation | MITIGATED | -| make amplification (TM-DOS-126) | Exponential recursive variables, self-including makefiles, deep `$(MAKE)` recursion, huge graphs | Expansion depth 200 and 4 MiB per expansion; include depth 16; target, query and glob caps per run; `$(MAKE)` stops at level 4; recipes run under the session budget and timeout | MITIGATED | +| make amplification (TM-DOS-126) | Exponential recursive variables, self-including makefiles, deep `$(MAKE)` recursion, huge graphs | Expansion depth 200 and 4 MiB per expansion; substitution checks its exact size before construction, with shared capacity leases retained by expanded values and work/deadline checkpoints while matching and copying; include depth 16; target, query and glob caps per run; `$(MAKE)` stops at level 4; recipes run under the session budget and timeout | MITIGATED | | Random generator amplification (TM-DOS-123) | `openssl rand` asked for gigabytes | Requests capped at 1 MiB; fixed-size `uuidgen`/`$SRANDOM` | MITIGATED | | Child-shell recursion (TM-DOS-125) | Script that re-runs itself via `sh` | Child shells count against function depth, max 8 nested | MITIGATED | | Pattern substitution (TM-DOS-127) | `${x//*a*/b}` over a long value | Globs run as a size-capped linear-time regex; extglob search capped at 10,000 attempts | MITIGATED | diff --git a/crates/bashkit/src/builtins/make/expand.rs b/crates/bashkit/src/builtins/make/expand.rs index 19a2b5705..bccd22e86 100644 --- a/crates/bashkit/src/builtins/make/expand.rs +++ b/crates/bashkit/src/builtins/make/expand.rs @@ -5,8 +5,76 @@ //! cache; a miss returns [`Stop::Need`], the driver answers it (running a //! command or touching the VFS) and expansion starts over. Results are //! cached, so each query runs once and a restart is deterministic. +//! +//! Substitution admits its checked result size before construction. Generated +//! text owns its capacity lease through parent copies and retained variables; +//! immutable clones share the same storage and lease (TM-DOS-126). use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use crate::limits::{BudgetedString, ExecutionBudget, LimitExceeded}; + +/// Immutable expansion storage; sharing a value must also share its lease. +#[derive(Clone)] +pub(super) struct Text(Arc); + +impl Text { + pub(super) fn from_buffer(buffer: BudgetedString) -> Self { + Self(Arc::new(buffer)) + } + + pub(super) fn copy(s: &str, budget: Option<&ExecutionBudget>) -> Result { + let mut out = BudgetedString::new(budget)?; + out.try_push_str(s)?; + Ok(Self(Arc::new(out))) + } +} + +impl From for Text { + fn from(s: String) -> Self { + Self(Arc::new( + BudgetedString::try_from_string(s, None).expect("unbudgeted text"), + )) + } +} + +impl Default for Text { + fn default() -> Self { + String::new().into() + } +} + +impl std::ops::Deref for Text { + type Target = str; + fn deref(&self) -> &str { + &self.0 + } +} + +impl std::borrow::Borrow for Text { + fn borrow(&self) -> &str { + self + } +} + +impl std::fmt::Display for Text { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self) + } +} + +impl std::fmt::Debug for Text { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Debug::fmt(&**self, f) + } +} + +impl PartialEq for Text { + fn eq(&self, other: &Self) -> bool { + **self == **other + } +} /// Expansion nesting cap (recursive variables, nested calls). const MAX_DEPTH: usize = 200; @@ -64,7 +132,7 @@ pub(super) enum Origin { #[derive(Debug, Clone)] pub(super) struct Var { - pub value: String, + pub value: Text, pub recursive: bool, pub origin: Origin, } @@ -83,7 +151,8 @@ pub(super) struct Expander<'a> { target_vars: Option<&'a Vars>, oracle: &'a Oracle, /// Automatic variables, `$(call)` arguments, `$(foreach)` variables. - locals: Vec>, + locals: Vec>, + budget: Option<&'a ExecutionBudget>, active: HashSet, depth: usize, produced: usize, @@ -140,6 +209,7 @@ impl<'a> Expander<'a> { ) -> Self { Self { vars, + budget: None, target_vars: None, oracle, locals: Vec::new(), @@ -152,13 +222,51 @@ impl<'a> Expander<'a> { } } + pub(super) fn with_budget(mut self, budget: Option<&'a ExecutionBudget>) -> Self { + self.budget = budget; + self + } + + fn limit(&self, error: LimitExceeded) -> Stop { + Stop::Fatal { + loc: self.loc.clone(), + msg: error.to_string(), + } + } + + fn work(&self, bytes: usize) -> R<()> { + if let Some(budget) = self.budget { + budget + .consume_work(u64::try_from(bytes).unwrap_or(u64::MAX)) + .map_err(|error| self.limit(error))?; + } + Ok(()) + } + + fn owned(&self, text: String) -> R { + BudgetedString::try_from_string(text, self.budget) + .map(|text| Text(Arc::new(text))) + .map_err(|error| self.limit(error)) + } + + fn copy(&self, text: &str) -> R { + self.work(text.len())?; + Text::copy(text, self.budget).map_err(|error| self.limit(error)) + } + + fn append(&self, out: &mut BudgetedString, text: &str) -> R<()> { + self.work(text.len())?; + out.try_push_str(text).map_err(|error| self.limit(error)) + } + pub(super) fn with_target_vars(mut self, tv: Option<&'a Vars>) -> Self { self.target_vars = tv; self } pub(super) fn with_locals(mut self, locals: HashMap) -> Self { - self.locals.push(locals); + self.locals + .push(locals.into_iter().map(|(k, v)| (k, v.into())).collect()); self } @@ -175,29 +283,29 @@ impl<'a> Expander<'a> { } /// Expand `$` references in `s`. - pub(super) fn expand(&mut self, s: &str) -> R { + pub(super) fn expand(&mut self, s: &str) -> R { if !s.contains('$') { - return Ok(s.to_string()); + return self.copy(s); } self.depth += 1; if self.depth > MAX_DEPTH { return self.fatal("variable expansion nested too deeply"); } - let mut out = String::new(); + let mut out = BudgetedString::new(self.budget).map_err(|error| self.limit(error))?; let mut i = 0; while i < s.len() { let Some(off) = s[i..].find('$') else { - out.push_str(&s[i..]); + self.append(&mut out, &s[i..])?; break; }; - out.push_str(&s[i..i + off]); + self.append(&mut out, &s[i..i + off])?; i += off + 1; let Some(c) = s[i..].chars().next() else { break; }; match c { '$' => { - out.push('$'); + self.append(&mut out, "$")?; i += 1; } '(' | '{' => { @@ -208,22 +316,22 @@ impl<'a> Expander<'a> { let inner = &s[i + 1..end]; let v = self.reference(inner)?; self.charge(v.len())?; - out.push_str(&v); + self.append(&mut out, &v)?; i = end + 1; } _ => { let v = self.lookup(&c.to_string())?; self.charge(v.len())?; - out.push_str(&v); + self.append(&mut out, &v)?; i += c.len_utf8(); } } } self.depth -= 1; - Ok(out) + Ok(Text(Arc::new(out))) } - fn reference(&mut self, inner: &str) -> R { + fn reference(&mut self, inner: &str) -> R { // Function call: `$(name args)`. if let Some(ws) = inner.find([' ', '\t']) { let name = &inner[..ws]; @@ -241,21 +349,23 @@ impl<'a> Expander<'a> { let to = self.expand(&inner[colon + 2 + eq..])?; let value = self.lookup(name.trim())?; let (from, to) = if from.contains('%') { - (from, to) + (from.to_string(), to.to_string()) } else { (format!("%{from}"), format!("%{to}")) }; - return Ok(words(&value) - .map(|w| patsubst_word(&from, &to, w)) - .collect::>() - .join(" ")); + return self.owned( + words(&value) + .map(|w| patsubst_word(&from, &to, w)) + .collect::>() + .join(" "), + ); } let name = self.expand(inner)?; self.lookup(&name) } /// Value of variable `name`, expanded if recursive. - pub(super) fn lookup(&mut self, name: &str) -> R { + pub(super) fn lookup(&mut self, name: &str) -> R { for scope in self.locals.iter().rev() { if let Some(v) = scope.get(name) { return Ok(v.clone()); @@ -268,17 +378,19 @@ impl<'a> Expander<'a> { && (b == 'D' || b == 'F') { let base = self.lookup(&a.to_string())?; - return Ok(words(&base) - .map(|w| if b == 'D' { dir_of(w) } else { notdir(w) }) - .collect::>() - .join(" ")); + return self.owned( + words(&base) + .map(|w| if b == 'D' { dir_of(w) } else { notdir(w) }) + .collect::>() + .join(" "), + ); } let var = self .target_vars .and_then(|tv| tv.get(name)) .or_else(|| self.vars.get(name)); let Some(var) = var else { - return Ok(String::new()); + return Ok(Text::default()); }; if !var.recursive { return Ok(var.value.clone()); @@ -294,7 +406,7 @@ impl<'a> Expander<'a> { r } - fn function(&mut self, name: &str, rest: &str) -> R { + fn function(&mut self, name: &str, rest: &str) -> R { match name { "subst" => { let a = self.args(rest, 3)?; @@ -302,20 +414,25 @@ impl<'a> Expander<'a> { if from.is_empty() { return Ok(text); } - Ok(text.replace(&from, &to)) + self.subst(&from, &to, &text) } "patsubst" => { let [pat, rep, text] = three(self.args(rest, 3)?); - Ok(words(&text) - .map(|w| patsubst_word(&pat, &rep, w)) - .collect::>() - .join(" ")) + self.owned( + words(&text) + .map(|w| patsubst_word(&pat, &rep, w)) + .collect::>() + .join(" "), + ) + } + "strip" => { + let text = self.expand(rest)?; + self.owned(words(&text).collect::>().join(" ")) } - "strip" => Ok(words(&self.expand(rest)?).collect::>().join(" ")), "findstring" => { let [find, text, _] = three(self.args(rest, 2)?); - Ok(if text.contains(&find) { - find + self.owned(if text.contains(&*find) { + find.to_string() } else { String::new() }) @@ -324,17 +441,19 @@ impl<'a> Expander<'a> { let [pats, text, _] = three(self.args(rest, 2)?); let pats: Vec<&str> = words(&pats).collect(); let keep = name == "filter"; - Ok(words(&text) - .filter(|w| pats.iter().any(|p| pattern_match(p, w).is_some()) == keep) - .collect::>() - .join(" ")) + self.owned( + words(&text) + .filter(|w| pats.iter().any(|p| pattern_match(p, w).is_some()) == keep) + .collect::>() + .join(" "), + ) } "sort" => { let text = self.expand(rest)?; let mut v: Vec<&str> = words(&text).collect(); v.sort_unstable(); v.dedup(); - Ok(v.join(" ")) + self.owned(v.join(" ")) } "word" => { let [n, text, _] = three(self.args(rest, 2)?); @@ -342,7 +461,7 @@ impl<'a> Expander<'a> { if n == 0 { return self.fatal("first argument to 'word' function must be greater than 0"); } - Ok(words(&text).nth(n - 1).unwrap_or("").to_string()) + self.owned(words(&text).nth(n - 1).unwrap_or("").to_string()) } "wordlist" => { let [s, e, text] = three(self.args(rest, 3)?); @@ -351,49 +470,66 @@ impl<'a> Expander<'a> { if s == 0 { return self.fatal("invalid first argument to 'wordlist' function: '0'"); } - Ok(words(&text) - .skip(s - 1) - .take((e + 1).saturating_sub(s)) - .collect::>() - .join(" ")) + self.owned( + words(&text) + .skip(s - 1) + .take((e + 1).saturating_sub(s)) + .collect::>() + .join(" "), + ) + } + "words" => { + let text = self.expand(rest)?; + self.owned(words(&text).count().to_string()) + } + "firstword" => { + let text = self.expand(rest)?; + self.owned(words(&text).next().unwrap_or("").to_string()) + } + "lastword" => { + let text = self.expand(rest)?; + self.owned(words(&text).last().unwrap_or("").to_string()) } - "words" => Ok(words(&self.expand(rest)?).count().to_string()), - "firstword" => Ok(words(&self.expand(rest)?).next().unwrap_or("").to_string()), - "lastword" => Ok(words(&self.expand(rest)?).last().unwrap_or("").to_string()), "dir" | "notdir" | "suffix" | "basename" | "abspath" => { let text = self.expand(rest)?; - Ok(words(&text) - .filter_map(|w| match name { - "dir" => Some(dir_with_slash(w)), - "notdir" => Some(notdir(w)), - "suffix" => suffix(w).map(str::to_string), - "basename" => Some(basename(w).to_string()), - _ => Some(abspath(self.curdir, w)), - }) - .collect::>() - .join(" ")) + self.owned( + words(&text) + .filter_map(|w| match name { + "dir" => Some(dir_with_slash(w)), + "notdir" => Some(notdir(w)), + "suffix" => suffix(w).map(str::to_string), + "basename" => Some(basename(w).to_string()), + _ => Some(abspath(self.curdir, w)), + }) + .collect::>() + .join(" "), + ) } "addsuffix" | "addprefix" => { let [fix, text, _] = three(self.args(rest, 2)?); - Ok(words(&text) - .map(|w| { - if name == "addsuffix" { - format!("{w}{fix}") - } else { - format!("{fix}{w}") - } - }) - .collect::>() - .join(" ")) + self.owned( + words(&text) + .map(|w| { + if name == "addsuffix" { + format!("{w}{fix}") + } else { + format!("{fix}{w}") + } + }) + .collect::>() + .join(" "), + ) } "join" => { let [a, b, _] = three(self.args(rest, 2)?); let a: Vec<&str> = words(&a).collect(); let b: Vec<&str> = words(&b).collect(); - Ok((0..a.len().max(b.len())) - .map(|i| format!("{}{}", a.get(i).unwrap_or(&""), b.get(i).unwrap_or(&""))) - .collect::>() - .join(" ")) + self.owned( + (0..a.len().max(b.len())) + .map(|i| format!("{}{}", a.get(i).unwrap_or(&""), b.get(i).unwrap_or(&""))) + .collect::>() + .join(" "), + ) } "wildcard" | "realpath" => { let text = self.expand(rest)?; @@ -409,7 +545,7 @@ impl<'a> Expander<'a> { None => return Err(Stop::Need(Need::Glob(q))), } } - Ok(out.join(" ")) + self.owned(out.join(" ")) } "if" => { let parts = split_args(rest, 3); @@ -424,20 +560,20 @@ impl<'a> Expander<'a> { for p in split_args(rest, usize::MAX) { let v = self.expand(p)?; if !v.trim().is_empty() { - return Ok(v.trim().to_string()); + return self.copy(v.trim()); } } - Ok(String::new()) + Ok(Text::default()) } "and" => { - let mut last = String::new(); + let mut last = Text::default(); for p in split_args(rest, usize::MAX) { last = self.expand(p)?; if last.trim().is_empty() { - return Ok(String::new()); + return Ok(Text::default()); } } - Ok(last.trim().to_string()) + self.copy(last.trim()) } "foreach" => { let parts = split_args(rest, 3); @@ -450,24 +586,24 @@ impl<'a> Expander<'a> { let mut out = Vec::new(); for w in words(&list) { self.locals - .push(HashMap::from([(var.clone(), w.to_string())])); + .push(HashMap::from([(var.clone(), w.to_string().into())])); let r = self.expand(parts[2]); self.locals.pop(); out.push(r?); } - Ok(out.join(" ")) + self.owned(out.join(" ")) } "call" => { let parts = split_args(rest, usize::MAX); let fname = self.expand(parts.first().copied().unwrap_or(""))?; let fname = fname.trim().to_string(); - let mut scope = HashMap::from([("0".to_string(), fname.clone())]); + let mut scope = HashMap::from([("0".to_string(), fname.clone().into())]); for (i, p) in parts.iter().enumerate().skip(1) { scope.insert(i.to_string(), self.expand(p)?); } // Unset higher-numbered arguments of an enclosing call. for i in parts.len().max(1)..10 { - scope.insert(i.to_string(), String::new()); + scope.insert(i.to_string(), Text::default()); } let body = match self .target_vars @@ -475,7 +611,7 @@ impl<'a> Expander<'a> { .or_else(|| self.vars.get(&fname)) { Some(v) => v.value.clone(), - None => return Ok(String::new()), + None => return Ok(Text::default()), }; self.depth += 1; if self.depth > MAX_DEPTH { @@ -503,25 +639,27 @@ impl<'a> Expander<'a> { .target_vars .and_then(|tv| tv.get(n)) .or_else(|| self.vars.get(n)); - Ok(match (name, v) { - ("origin", _) if local => "automatic", - ("origin", None) | ("flavor", None) if !local => "undefined", - ("flavor", None) => "simple", - ("origin", Some(v)) => match v.origin { - Origin::Default => "default", - Origin::Environment => "environment", - Origin::File => "file", - Origin::CommandLine => "command line", - Origin::Override => "override", - }, - (_, Some(v)) if v.recursive => "recursive", - _ => "simple", - } - .to_string()) + self.owned( + match (name, v) { + ("origin", _) if local => "automatic", + ("origin", None) | ("flavor", None) if !local => "undefined", + ("flavor", None) => "simple", + ("origin", Some(v)) => match v.origin { + Origin::Default => "default", + Origin::Environment => "environment", + Origin::File => "file", + Origin::CommandLine => "command line", + Origin::Override => "override", + }, + (_, Some(v)) if v.recursive => "recursive", + _ => "simple", + } + .to_string(), + ) } "error" => { let msg = self.expand(rest)?; - self.fatal(msg) + self.fatal(msg.to_string()) } "warning" => { let msg = self.expand(rest)?; @@ -530,18 +668,18 @@ impl<'a> Expander<'a> { None => String::new(), }; self.messages.push(Message::Err(format!("{prefix}{msg}\n"))); - Ok(String::new()) + Ok(Text::default()) } "info" => { let msg = self.expand(rest)?; self.messages.push(Message::Out(format!("{msg}\n"))); - Ok(String::new()) + Ok(Text::default()) } "shell" => { let cmd = self.expand(rest)?; - match self.oracle.shell.get(&cmd) { - Some(out) => Ok(out.clone()), - None => Err(Stop::Need(Need::Shell(cmd))), + match self.oracle.shell.get(&*cmd) { + Some(out) => self.copy(out), + None => Err(Stop::Need(Need::Shell(cmd.to_string()))), } } // WTF: `$(eval)` would let expansion define rules mid-parse; @@ -550,7 +688,45 @@ impl<'a> Expander<'a> { } } - fn args(&mut self, rest: &str, n: usize) -> R> { + // THREAT[TM-DOS-126]: Count and admit before allocating any replacement + // result. Both passes meter scanning/copying and poll the shared deadline. + fn subst(&self, from: &str, to: &str, text: &str) -> R { + self.work(text.len().saturating_add(from.len()))?; + let mut matches = 0usize; + for _ in text.match_indices(from) { + self.work(1)?; + matches = matches.checked_add(1).ok_or_else(|| Stop::Fatal { + loc: self.loc.clone(), + msg: "expansion exceeds 4 MiB".into(), + })?; + } + let remaining = MAX_EXPANSION_BYTES.saturating_sub(self.produced); + let size = + subst_size(text.len(), from.len(), to.len(), matches, remaining).ok_or_else(|| { + Stop::Fatal { + loc: self.loc.clone(), + msg: "expansion exceeds 4 MiB".into(), + } + })?; + let mut out = BudgetedString::new(self.budget).map_err(|error| self.limit(error))?; + self.work(text.len())?; + let mut end = 0; + for (start, matched) in text.match_indices(from) { + self.work(1)?; + for piece in [&text[end..start], to] { + self.work(piece.len())?; + out.try_push_str_with_limit(piece, size) + .map_err(|error| self.limit(error))?; + } + end = start + matched.len(); + } + self.work(text.len() - end)?; + out.try_push_str_with_limit(&text[end..], size) + .map_err(|error| self.limit(error))?; + Ok(Text(Arc::new(out))) + } + + fn args(&mut self, rest: &str, n: usize) -> R> { split_args(rest, n) .into_iter() .map(|a| self.expand(a)) @@ -567,8 +743,21 @@ impl<'a> Expander<'a> { } } -fn three(mut v: Vec) -> [String; 3] { - v.resize(3, String::new()); +/// Numeric-only admission; rejected sizes never become allocations. +fn subst_size( + text: usize, + from: usize, + to: usize, + matches: usize, + remaining: usize, +) -> Option { + let unchanged = text.checked_sub(matches.checked_mul(from)?)?; + let size = unchanged.checked_add(matches.checked_mul(to)?)?; + (size <= remaining).then_some(size) +} + +fn three(mut v: Vec) -> [Text; 3] { + v.resize(3, Text::default()); let c = v.pop().unwrap_or_default(); let b = v.pop().unwrap_or_default(); let a = v.pop().unwrap_or_default(); @@ -711,7 +900,7 @@ mod tests { fn var(v: &str, recursive: bool) -> Var { Var { - value: v.to_string(), + value: v.to_string().into(), recursive, origin: Origin::File, } @@ -721,7 +910,7 @@ mod tests { let oracle = Oracle::default(); let mut e = Expander::new(vars, &oracle, "/w", None); match e.expand(s) { - Ok(v) => v, + Ok(v) => v.to_string(), Err(Stop::Fatal { msg, .. }) => format!("FATAL {msg}"), Err(Stop::Need(n)) => format!("NEED {n:?}"), // debug-ok: test-only } @@ -797,6 +986,99 @@ mod tests { assert_eq!(e.expand("[$(shell echo hi)]").ok().as_deref(), Some("[hi]")); } + #[test] + fn subst_size_checked_without_allocating() { + assert_eq!(subst_size(6, 2, 3, 2, 8), Some(8)); + assert_eq!(subst_size(6, 2, 3, 2, 7), None); + assert_eq!(subst_size(6, 2, 0, 2, 2), Some(2)); + assert_eq!(subst_size(6, 2, 3, 0, 6), Some(6)); + assert_eq!(subst_size(usize::MAX, 1, usize::MAX, 2, usize::MAX), None); + assert_eq!( + subst_size(usize::MAX, 1, 1, usize::MAX, usize::MAX), + Some(usize::MAX) + ); + assert_eq!(subst_size(3, usize::MAX, 1, 2, usize::MAX), None); + assert_eq!(subst_size(3, 2, 1, 2, 3), None); + } + + #[test] + fn subst_small_text_semantics() { + let vars = Vars::new(); + for (expression, expected) in [ + ("$(subst aa,z,aaa)", "za"), + ("$(subst é,🌱,café é)", "caf🌱 🌱"), + ("$(subst ,z,café)", "café"), + ("$(subst é,,café)", "caf"), + ("$(subst x,z,café)", "café"), + ("$(subst x,z,x,x)", "z,z"), + ] { + assert_eq!(exp(&vars, expression), expected); + } + } + + fn budget(live: u64, work: u64) -> ExecutionBudget { + ExecutionBudget::new( + &crate::ExecutionLimits::new() + .max_live_intermediate_bytes(live) + .max_work_units(work), + Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + } + + #[test] + fn subst_result_lease_survives_expander_and_shared_clones() { + let vars = Vars::new(); + let oracle = Oracle::default(); + let budget = budget(5, 100); + let value = { + let e = Expander::new(&vars, &oracle, "/w", None).with_budget(Some(&budget)); + e.subst("x", "ab", "xx").unwrap() + }; + let shared = value.clone(); + drop(value); + let spare = budget.lease_bytes(1).unwrap(); + drop(shared); + let restored = budget.lease_bytes(4).unwrap(); + drop((spare, restored)); + assert!(budget.lease_bytes(5).is_ok()); + + let budget = self::budget(4, 100); + let e = Expander::new(&vars, &oracle, "/w", None).with_budget(Some(&budget)); + let value = e.subst("x", "ab", "xx").unwrap(); + assert_eq!(&*value, "abab"); + assert!(budget.lease_bytes(1).is_err()); + } + + #[test] + fn subst_checks_tiny_live_work_and_cancel_budgets() { + let vars = Vars::new(); + let oracle = Oracle::default(); + for budget in [budget(3, 100), budget(100, 4)] { + let e = Expander::new(&vars, &oracle, "/w", None).with_budget(Some(&budget)); + assert!(matches!(e.subst("x", "ab", "xx"), Err(Stop::Fatal { .. }))); + assert!(budget.check().is_err()); + } + let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let budget = ExecutionBudget::new(&crate::ExecutionLimits::new(), cancelled); + let e = Expander::new(&vars, &oracle, "/w", None).with_budget(Some(&budget)); + assert!(matches!(e.subst("x", "ab", "xx"), Err(Stop::Fatal { .. }))); + } + + #[test] + fn subst_admits_remaining_bytes_before_construction() { + let vars = Vars::new(); + let oracle = Oracle::default(); + let mut e = Expander::new(&vars, &oracle, "/w", None); + // Only the counter approaches the cap; the actual input/output is tiny. + e.produced = MAX_EXPANSION_BYTES - 3; + assert!(matches!( + e.function("subst", "x,ab,xx"), + Err(Stop::Fatal { msg, .. }) if msg == "expansion exceeds 4 MiB" + )); + e.produced = MAX_EXPANSION_BYTES - 4; + assert_eq!(e.function("subst", "x,ab,xx").ok().as_deref(), Some("abab")); + } + #[test] fn expansion_bomb_is_capped() { let mut v = Vars::new(); diff --git a/crates/bashkit/src/builtins/make/mod.rs b/crates/bashkit/src/builtins/make/mod.rs index cbae34ed4..e330d1298 100644 --- a/crates/bashkit/src/builtins/make/mod.rs +++ b/crates/bashkit/src/builtins/make/mod.rs @@ -26,7 +26,8 @@ use crate::StreamData; use crate::error::Result; use crate::fs::FileSystem; use crate::interpreter::ExecResult; -use expand::{Expander, Message, Need, Oracle, Origin, Stop, Var, Vars}; +use crate::limits::{BudgetedString, ExecutionBudget}; +use expand::{Expander, Message, Need, Oracle, Origin, Stop, Text, Var, Vars}; use parse::{Makefile, ParseInput, RecipeLine}; /// `$(MAKE)` recursion cap. Each level nests make, `sh -c` and the @@ -297,6 +298,14 @@ impl Builtin for Make { .map(|(k, v)| (k.clone(), v.clone())) .collect(); env_vars.sort(); + let budget = ctx + .execution_budget() + .map(|budget| { + budget + .try_with(Clone::clone) + .map_err(|_| crate::Error::Cancelled) + }) + .transpose()?; let stdin = ctx.stdin.map(|s| s.text_lossy().into_owned()); Ok(Some(ExecutionPlan::Driver(Box::new(MakeRun::new( ctx.fs.clone(), @@ -305,6 +314,7 @@ impl Builtin for Make { level, env_vars, stdin, + budget, ))))) } @@ -357,7 +367,7 @@ struct Frame { } struct Prepared { - cmd: String, + cmd: Text, echo: bool, ignore: bool, run_in_dry: bool, @@ -384,6 +394,7 @@ enum Awaiting { struct MakeRun { fs: Arc, + budget: Option, dir: PathBuf, curdir: String, opts: Opts, @@ -413,7 +424,7 @@ struct MakeRun { exit: Option, entered: bool, /// Variables exported to recipes. - exports: Vec<(String, String)>, + exports: Vec<(String, Text)>, /// Bytes of `stdout`/`stderr` already streamed (by an `Emit` or by the /// sub-command that produced them). emitted: (usize, usize), @@ -429,6 +440,7 @@ impl MakeRun { level: u32, env_vars: Vec<(String, String)>, stdin: Option, + budget: Option, ) -> Self { let prog = if level == 0 { "make".to_string() @@ -438,6 +450,7 @@ impl MakeRun { let curdir = dir.to_string_lossy().into_owned(); Self { fs, + budget, dir, curdir, opts, @@ -639,7 +652,7 @@ impl MakeRun { vars.insert( k.to_string(), Var { - value: v.to_string(), + value: v.to_string().into(), recursive: false, origin, }, @@ -670,7 +683,7 @@ impl MakeRun { vars.insert( k.clone(), Var { - value: v.clone(), + value: v.clone().into(), recursive: true, origin: Origin::CommandLine, }, @@ -737,6 +750,7 @@ impl MakeRun { vars, curdir: &self.curdir, env_override: self.opts.env_override, + budget: self.budget.as_ref(), }, &self.oracle, partial, @@ -776,7 +790,7 @@ impl MakeRun { }; self.goals = goals.into(); // Variables exported to recipes. - let mut exports: Vec<(String, String)> = Vec::new(); + let mut exports: Vec<(String, Text)> = Vec::new(); let mut flags = String::new(); for (on, c) in [ (self.opts.always, 'B'), @@ -798,8 +812,8 @@ impl MakeRun { makeflags.push_str(&format!(" {k}={v}")); } } - exports.push(("MAKEFLAGS".to_string(), makeflags)); - exports.push(("MAKELEVEL".to_string(), (self.level + 1).to_string())); + exports.push(("MAKEFLAGS".to_string(), makeflags.into())); + exports.push(("MAKELEVEL".to_string(), (self.level + 1).to_string().into())); let names: Vec = { let mut n: Vec = self .mk @@ -822,7 +836,8 @@ impl MakeRun { }; for n in names { let oracle = Oracle::default(); - let mut e = Expander::new(&self.mk.vars, &oracle, &self.curdir, None); + let mut e = Expander::new(&self.mk.vars, &oracle, &self.curdir, None) + .with_budget(self.budget.as_ref()); if let Ok(v) = e.lookup(&n) { exports.push((n, v)); } @@ -1017,6 +1032,7 @@ impl MakeRun { &self.curdir, Some((l.file.to_string(), l.line)), ) + .with_budget(self.budget.as_ref()) .with_target_vars(tv) .with_locals(autos.clone()); let text = e.expand(&l.text)?; @@ -1028,14 +1044,24 @@ impl MakeRun { let (first, l) = &expanded[0]; let (mut echo, mut ignore, mut force) = (true, false, false); let body = strip_prefixes(first, &mut echo, &mut ignore, &mut force); - let mut cmd = body.to_string(); - for (t, _) in &expanded[1..] { - cmd.push('\n'); - cmd.push_str(t.trim_start_matches(['@', '-', '+'])); + let mut cmd = + BudgetedString::new(self.budget.as_ref()).map_err(|error| Stop::Fatal { + loc: Some((l.file.to_string(), l.line)), + msg: error.to_string(), + })?; + for part in std::iter::once(body).chain( + expanded[1..] + .iter() + .flat_map(|(t, _)| ["\n", t.trim_start_matches(['@', '-', '+'])]), + ) { + cmd.try_push_str(part).map_err(|error| Stop::Fatal { + loc: Some((l.file.to_string(), l.line)), + msg: error.to_string(), + })?; } out.push_back(Prepared { run_in_dry: force || mentions_make(&cmd), - cmd, + cmd: Text::from_buffer(cmd), echo: echo && !silent_target, ignore: ignore || self.opts.ignore || self.mk.ignore_all, file: l.file.clone(), @@ -1047,13 +1073,16 @@ impl MakeRun { // One recipe line may expand to several (`define` bodies). for piece in text.split('\n') { let (mut echo, mut ignore, mut force) = (true, false, false); - let cmd = strip_prefixes(piece, &mut echo, &mut ignore, &mut force).to_string(); + let cmd = strip_prefixes(piece, &mut echo, &mut ignore, &mut force); if cmd.trim().is_empty() { continue; } out.push_back(Prepared { run_in_dry: force || mentions_make(&l.text), - cmd, + cmd: Text::copy(cmd, self.budget.as_ref()).map_err(|error| Stop::Fatal { + loc: Some((l.file.to_string(), l.line)), + msg: error.to_string(), + })?, echo: echo && !silent_target, ignore: ignore || self.opts.ignore || self.mk.ignore_all, file: l.file.clone(), @@ -1127,9 +1156,13 @@ impl MakeRun { return Ok(Some(PlanStep::Run { command: SubCommand { name: "sh".to_string(), - args: vec!["-c".to_string(), cmd], + args: vec!["-c".to_string(), cmd.to_string()], stdin: None, - assignments: self.exports.clone(), + assignments: self + .exports + .iter() + .map(|(name, value)| (name.clone(), value.to_string())) + .collect(), }, cwd: Some(self.dir.clone()), })); @@ -1259,7 +1292,7 @@ impl MakeRun { PlanStep::Capture { command: SubCommand { name: "sh".to_string(), - args: vec!["-c".to_string(), cmd], + args: vec!["-c".to_string(), cmd.to_string()], stdin: None, assignments: Vec::new(), }, diff --git a/crates/bashkit/src/builtins/make/parse.rs b/crates/bashkit/src/builtins/make/parse.rs index 5a25a038e..80abebdfe 100644 --- a/crates/bashkit/src/builtins/make/parse.rs +++ b/crates/bashkit/src/builtins/make/parse.rs @@ -2,11 +2,12 @@ //! assignments and rules. Targets and prerequisites are expanded while //! parsing (like GNU make); recipes are kept raw and expanded when run. +use crate::limits::{BudgetedString, ExecutionBudget}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use super::expand::{ - Expander, Message, Need, Oracle, Origin, R, Stop, Var, Vars, fatal, top_level_find, words, + Expander, Message, Need, Oracle, Origin, R, Stop, Text, Var, Vars, fatal, top_level_find, words, }; /// `include` nesting cap (an include cycle would otherwise never end). @@ -81,6 +82,7 @@ struct Cond { struct Parser<'a> { mk: Makefile, oracle: &'a Oracle, + budget: Option<&'a ExecutionBudget>, curdir: &'a str, env_override: bool, current: Current, @@ -96,6 +98,7 @@ pub(super) struct ParseInput<'a> { pub vars: Vars, pub curdir: &'a str, pub env_override: bool, + pub budget: Option<&'a ExecutionBudget>, } /// `partial` receives `$(info)`/`$(warning)` output printed before a fatal @@ -111,6 +114,7 @@ pub(super) fn parse( ..Default::default() }, oracle, + budget: input.budget, curdir: input.curdir, env_override: input.env_override, current: Current::None, @@ -171,9 +175,10 @@ impl Parser<'_> { self.curdir, Some((file.to_string(), line)), ) + .with_budget(self.budget) } - fn expand(&mut self, s: &str, file: &str, line: usize) -> R { + fn expand(&mut self, s: &str, file: &str, line: usize) -> R { let mut e = self.expander(file, line); let r = e.expand(s); let msgs = std::mem::take(&mut e.messages); @@ -505,7 +510,7 @@ impl Parser<'_> { }; let var = match op { "=" => Var { - value: value.to_string(), + value: value.to_string().into(), recursive: true, origin, }, @@ -519,7 +524,7 @@ impl Parser<'_> { return Ok(()); } Var { - value: value.to_string(), + value: value.to_string().into(), recursive: true, origin, } @@ -527,14 +532,25 @@ impl Parser<'_> { "+=" => match existing { Some(e) => { let add = if e.recursive { - value.to_string() + value.to_string().into() } else { self.expand(value, file, lineno)? }; let joined = if e.value.is_empty() { add } else { - format!("{} {add}", e.value) + let mut joined = + BudgetedString::new(self.budget).map_err(|error| Stop::Fatal { + loc: Some((file.to_string(), lineno)), + msg: error.to_string(), + })?; + for part in [&*e.value, " ", &*add] { + joined.try_push_str(part).map_err(|error| Stop::Fatal { + loc: Some((file.to_string(), lineno)), + msg: error.to_string(), + })?; + } + Text::from_buffer(joined) }; Var { value: joined, @@ -547,7 +563,7 @@ impl Parser<'_> { } } None => Var { - value: value.to_string(), + value: value.to_string().into(), recursive: true, origin, }, @@ -555,12 +571,12 @@ impl Parser<'_> { // `!=`: run the value as a shell command now. _ => { let cmd = self.expand(value, file, lineno)?; - let out = match self.oracle.shell.get(&cmd) { + let out = match self.oracle.shell.get(&*cmd) { Some(o) => o.clone(), - None => return Err(Stop::Need(Need::Shell(cmd))), + None => return Err(Stop::Need(Need::Shell(cmd.to_string()))), }; Var { - value: out, + value: out.into(), recursive: false, origin, } @@ -574,7 +590,7 @@ impl Parser<'_> { self.mk.vars.insert( name.to_string(), Var { - value, + value: value.into(), recursive: false, origin, }, @@ -640,7 +656,7 @@ impl Parser<'_> { // Static pattern rule: `targets: target-pattern: prereq-patterns`. let (static_pat, prereq_text) = match expanded.split_once(':') { Some((tp, pp)) => (Some(tp.trim().to_string()), pp.to_string()), - None => (None, expanded), + None => (None, expanded.to_string()), }; let (normal, order): (Vec, Vec) = { let mut normal = Vec::new(); @@ -887,6 +903,7 @@ mod tests { vars: Vars::new(), curdir: "/w", env_override: false, + budget: None, }, &oracle, &mut Vec::new(), @@ -905,8 +922,8 @@ mod tests { %.o: %.c\n\t$(CC) -c $<\nclean: ; rm -f prog\n", ); assert_eq!(mk.default_goal.as_deref(), Some("all")); - assert_eq!(mk.vars["OBJ"].value, "a.o b.o"); - assert_eq!(mk.vars["MODE"].value, "yes"); + assert_eq!(&*mk.vars["OBJ"].value, "a.o b.o"); + assert_eq!(&*mk.vars["MODE"].value, "yes"); let prog = &mk.rules["prog"]; assert_eq!(prog.prereqs, vec!["a.o", "b.o"]); assert_eq!(prog.order_only, vec!["outdir"]); @@ -930,7 +947,7 @@ mod tests { let mk = parse_str( "define GREET\necho hi\necho there\nendef\nobjs = x.o y.o\n$(objs): %.o: %.c\n\tcc $<\n.c.o:\n\tcc -c $<\n", ); - assert_eq!(mk.vars["GREET"].value, "echo hi\necho there"); + assert_eq!(&*mk.vars["GREET"].value, "echo hi\necho there"); assert_eq!(mk.rules["x.o"].prereqs, vec!["x.c"]); assert_eq!(mk.rules["y.o"].stem.as_deref(), Some("y")); assert_eq!(mk.patterns[0].targets, vec!["%.o"]); @@ -947,6 +964,7 @@ mod tests { vars: Vars::new(), curdir: "/w", env_override: false, + budget: None, }, &oracle, &mut Vec::new(), @@ -956,6 +974,39 @@ mod tests { ); } + #[test] + fn substitution_assignment_values_keep_and_release_leases() { + use std::sync::atomic::AtomicBool; + let budget = ExecutionBudget::new( + &crate::ExecutionLimits::new().max_live_intermediate_bytes(16), + Arc::new(AtomicBool::new(false)), + ); + let oracle = Oracle::default(); + let mut mk = parse( + ParseInput { + name: "Makefile", + text: "A := $(subst x,ab,xx)\nB := $(subst y,cd,yy)\n", + vars: Vars::new(), + curdir: "/w", + env_override: false, + budget: Some(&budget), + }, + &oracle, + &mut Vec::new(), + ) + .unwrap(); + assert_eq!(&*mk.vars["A"].value, "abab"); + assert_eq!(&*mk.vars["B"].value, "cdcd"); + let shared = mk.vars.clone(); + let spare = budget.lease_bytes(8).unwrap(); + mk.vars.remove("A"); + drop(mk); + drop(shared); + let released = budget.lease_bytes(8).unwrap(); + drop((spare, released)); + assert!(budget.lease_bytes(16).is_ok()); + } + #[test] fn assignment_operators() { assert_eq!(assignment_op("A = b"), Some((2, "="))); diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index 820a046ea..19cb7ed61 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -54,6 +54,7 @@ pub mod custom_builtins_tests; pub mod custom_fs_tests; pub mod function_metadata_security_tests; pub mod interactive_builtins_tests; +pub mod make_substitution_tests; pub mod memory_growth_security_tests; pub mod tar_files_from_budget_tests; // Both assert named-IANA-zone behavior, which only exists with `tzdata`. diff --git a/crates/bashkit/tests/integration/make_substitution_tests.rs b/crates/bashkit/tests/integration/make_substitution_tests.rs new file mode 100644 index 000000000..626c70329 --- /dev/null +++ b/crates/bashkit/tests/integration/make_substitution_tests.rs @@ -0,0 +1,26 @@ +//! Tiny ordinary Make substitution fixtures; no expansion chains or large inputs. +use bashkit::{Bash, ExecutionLimits}; +use std::path::Path; + +const MAKEFILE: &str = "A := $(subst é,🌱,café é)\nB = $(subst aa,z,aaa)\nA += tail\nEMPTY := $(subst ,z,keep)\nall:\n\t@printf '%s|%s|%s\\n' '$(A)' '$(B)' '$(EMPTY)'\n"; + +#[tokio::test] +async fn tiny_make_substitution_assignments_and_recipes() { + let mut bash = Bash::builder() + .cwd("/") + .limits(ExecutionLimits::new().max_live_intermediate_bytes(32_768)) + .build(); + bash.fs() + .write_file(Path::new("/Makefile"), MAKEFILE.as_bytes()) + .await + .unwrap(); + let result = bash.exec("make").await.unwrap(); + assert_eq!( + result.exit_code, 0, + "stdout={} stderr={}", + result.stdout, result.stderr + ); + assert_eq!(result.stdout, "caf🌱 🌱 tail|za|keep\n"); + assert_eq!(result.stderr, ""); + assert_eq!(bash.exec("echo ready").await.unwrap().stdout, "ready\n"); +} diff --git a/docs/security.md b/docs/security.md index 08f1894ca..df14808df 100644 --- a/docs/security.md +++ b/docs/security.md @@ -64,6 +64,15 @@ resource-limit error; a later host execution starts with a fresh budget. Stdout capture limits remain downstream, so pipelines and redirects receive complete join output when it fits the execution budget. +Make's `$(subst)` checks its exact replacement length against the remaining +4 MiB expansion allowance before constructing the result. Its generated +arguments, output and parent expansion buffers reserve capacity against +`max_live_intermediate_bytes` before growth. Expanded variable values retain +their storage leases until replacement or the end of the Make run; shared +parser views share the same storage. Matching and copying consume the request's +work budget and check cancellation and deadlines. Empty patterns and ordinary +Unicode substitution keep their existing behavior. + `cd` searches `CDPATH` one candidate at a time. Temporary path and normalization storage is reserved against `max_live_intermediate_bytes` before allocation. Searches also consume the shared work budget and check diff --git a/knowledge/foundations/builtins.md b/knowledge/foundations/builtins.md index 2fe97a2e9..6622ad6c6 100644 --- a/knowledge/foundations/builtins.md +++ b/knowledge/foundations/builtins.md @@ -530,6 +530,19 @@ interleave with recipe output in order. Expansion is synchronous: a `$(shell)`, and parsing restarts with the answer cached. Messages and exit codes follow GNU (`*** No rule to make target`, `Error N`, `-k`, `-q` exit 1, `-n`). +`$(subst)` counts non-overlapping UTF-8 matches and computes the exact result +length with checked arithmetic before construction, admitting it against the +remaining 4 MiB expansion allowance. `BudgetedString` reserves each capacity +growth before allocation; arguments, result and parent expansion copies share +the request's live-memory budget. Expanded variable values are immutable, +reference-counted owners of that storage and its lease, so parser scratch views +share them and replacement releases the old value after its last owner drops. +Prepared recipes and expanded exports retain their generated storage too. +Both substitution passes charge work and check cancellation/deadlines. Empty +patterns still return the input unchanged. Other Make function algorithms and +raw makefile storage retain their existing behavior; this contract concerns +substitution and the generated expansion buffers that carry its results. + Deliberate gaps are L-MAKE-001..003 in [Known Limitations](../operations/limitations.md); amplification caps are TM-DOS-126 in the [Threat Model](../security/threat-model.md). diff --git a/knowledge/security/security-testing.md b/knowledge/security/security-testing.md index 9fa527358..5ffcf5bb3 100644 --- a/knowledge/security/security-testing.md +++ b/knowledge/security/security-testing.md @@ -121,6 +121,20 @@ cancellation checkpoint without growing a buffer after rejection. Resource probes run in externally supervised subprocesses; full memory-exhaustion payloads are unnecessary. +## Make Substitution Admission + +`builtins::make::expand::tests::subst_*` covers checked result-size arithmetic, +remaining allowance, non-overlapping Unicode/empty-pattern semantics, and tiny +shared live-memory/work/cancellation budgets. Near-cap tests modify only a +numeric counter; the rejected result would contain four bytes. Overflow tests +operate on lengths without allocating them. Parser ownership tests retain two +four-byte results and verify shared clones and final lease release. +`make_substitution_tests` exercises ordinary immediate/recursive/append +assignments and recipes through `Bash::exec`. No allocation or recursive +amplification reproducer is needed for these admission contracts. +See [Builtins](../foundations/builtins.md) and TM-DOS-126 in the +[Threat Model](threat-model.md). + ## Adding New Fail Points Add `fail_point!("module::function", |action| ...)` under diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index f9d4ec3d0..514626b77 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -297,7 +297,7 @@ on default 2 MiB test threads (TM-DOS-044). | TM-DOS-123 | Random generator amplification | `openssl rand 99999999999` asks one builtin call for gigabytes of CSPRNG output | `openssl rand` caps a request at `OPENSSL_RAND_MAX_BYTES` (1 MiB) and fails above it; `uuidgen` and `$SRANDOM` emit fixed sizes. Bytes come from `getrandom` (OS CSPRNG), never the `$RANDOM` LCG. Regression: `builtins::random::tests::openssl_rand_limits` | **MITIGATED** | | TM-DOS-124 | Concurrent pipeline amplification | An endless producer (`while :; do echo x; done \| head`) buffering without bound, a producer whose reader quit running forever, or recursive pipelines (`f() { f \| f; }`) nesting forked stages and native stack | Pipes hold 4 KiB and writers wait at command boundaries; a writer whose reader is gone ends with 141 at its next command; a reader that never reads stdin ends the producer when it finishes. A streaming group counts as one subshell level (`max_subshell_depth`) and past 4 levels stages run in sequence, keeping stack cost near plain function recursion. Stages are forked like jobs: they share the execution budget, timeout and cancel token and never outlive the pipeline. Regressions: `pipeline_tests::threat_endless_producer_into_non_reader_ends`, `threat_recursive_pipeline_is_bounded`, `pipes-redirects.test.sh` SIGPIPE cases | **MITIGATED** | | TM-DOS-125 | Child-shell recursion stack overflow | A script that runs itself via `sh`/`bash` (`echo 'sh s.sh' > s.sh; sh s.sh`) nested interpreters until the process stack overflowed, aborting every tenant in the process | Each child shell counts against `max_function_depth` and a separate cap of `MAX_CHILD_SHELL_DEPTH` (8) nested shells, sized so the deepest mix fits a 2 MiB debug stack; over the cap the shell fails `maximum nesting depth exceeded` (exit 2). Forked jobs inherit the depth because they are polled on the caller's stack. Regression: `recursive_child_shell_is_bounded` | **MITIGATED** | -| TM-DOS-126 | make amplification | Recursive variables (`X = $(X)$(X)`), self-including makefiles, `$(MAKE)` recursion, huge dependency graphs or `$(wildcard **)` fan-out turning one `make` call into unbounded CPU, memory or stack | Expansion depth 200 and 4 MiB per expansion, recursive self-reference is fatal as in GNU; include depth 16; at most 100,000 targets, 10,000 `$(shell)`/`$(wildcard)`/`include` answers and 100,000 paths per `$(wildcard)` per run; `$(MAKE)` stops at MAKELEVEL 4, sized so the deepest recursion fits a 2 MiB debug thread stack (each level is roughly 200 KiB of make, `sh -c` and interpreter frames). Recipes run as ordinary `sh -c` commands under the session's budget, timeout and cancel token. Regressions: `builtins::make::expand::tests`, `l_make_003_sequential_sandbox_shell` | **MITIGATED** | +| TM-DOS-126 | make amplification | Recursive variables (`X = $(X)$(X)`), self-including makefiles, `$(MAKE)` recursion, huge dependency graphs or `$(wildcard **)` fan-out turning one `make` call into unbounded CPU, memory or stack | Expansion depth 200 and 4 MiB per expansion; `$(subst)` computes its exact non-overlapping replacement size with checked arithmetic and admits it against the remaining allowance before construction. Arguments, replacement output and parent expansion buffers reserve capacity through the shared execution budget; immutable expanded variable values share storage and leases through scratch views and release them on replacement/drop. Matching/copying polls work, cancellation and deadlines. Tiny numeric admission and ordinary Unicode/assignment fixtures cover this ordering without oversized allocations (`subst_*`, `substitution_assignment_values_keep_and_release_leases`, `make_substitution_tests`). Recursive self-reference is fatal as in GNU; include depth 16; at most 100,000 targets, 10,000 `$(shell)`/`$(wildcard)`/`include` answers and 100,000 paths per `$(wildcard)` per run; `$(MAKE)` stops at MAKELEVEL 4, sized so the deepest recursion fits a 2 MiB debug thread stack (each level is roughly 200 KiB of make, `sh -c` and interpreter frames). Recipes run as ordinary `sh -c` commands under the session's budget, timeout and cancel token. Regressions: `builtins::make::expand::tests`, `l_make_003_sequential_sandbox_shell` | **MITIGATED** | | TM-DOS-127 | Pattern substitution amplification | `${x//pattern/rep}` with a glob pattern over a long value, or a huge pattern, turning one expansion into quadratic matching | Glob patterns compile to a size-capped (1 MiB) linear-time regex; extglob patterns fall back to at most 10,000 `glob_match` attempts and leave the value unchanged past the budget; results stay under `MAX_EXPANSION_RESULT_BYTES`. Regressions: `param-operand-quotes.test.sh`, `replace_pattern_extglob_budget` | **MITIGATED** | | TM-DOS-128 | awk command amplification | `system()`, `print \| cmd` and `cmd \| getline` in a loop, or a command whose output floods awk | Commands run as ordinary `sh -c` commands through the awk execution-plan driver, under the session's command budget, timeout and cancel token; loops around them hit the awk loop caps (TM-DOS-033); `print \| cmd` input and `cmd \| getline` output count toward the awk output cap (TM-DOS-028 caps) and the getline open-file and retained-byte caps (TM-DOS-116), charged to the request budget. Per awk call level the evaluator keeps one small boxed future per expression or statement, so the 64-level call cap fits in about 512 KiB of debug-build stack. Regressions: `test_awk_print_redirect_pipe_needs_plan_driver`, `test_awk_recursive_function_depth_limit` | **MITIGATED** | | TM-DOS-129 | Command hash table growth | `hash -p FILE name...` amplifies arbitrary path strings into retained copies; many commands or host executions can accumulate names and paths | `CommandHash` admits each name/path and entry metadata against the shared live-byte budget before allocation, with ownership leases released on deletion, replacement, eviction or clearing. Persistent entries are re-admitted to each host execution budget; copy-on-write forks share strings and leases. The 512-entry cap and PATH clearing remain. Regressions: `command_hash_path_clones_hit_live_byte_budget`, `command_hash_retention_is_charged_across_execs`, `command_hash_table_is_bounded` | **MITIGATED** |