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
58 changes: 42 additions & 16 deletions crates/bashkit/src/builtins/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@ fn parse_base_date(s: &str, now: DateTime<Utc>) -> std::result::Result<DateTime<

// Relative: "N unit(s) ago" or "+N unit(s)" or "-N unit(s)"
if let Some(duration) = parse_relative_date(&lower) {
return Ok(now + duration);
return now
.checked_add_signed(duration)
.ok_or_else(|| format!("date out of range: '{}'", s));
}

// Try ISO-like formats: YYYY-MM-DD HH:MM:SS, YYYY-MM-DD
Expand Down Expand Up @@ -230,8 +232,15 @@ fn parse_date_string(s: &str, now: DateTime<Utc>) -> std::result::Result<DateTim
// and ISO dates correctly.
let orig_base = s[..base_match.end()].trim();
if let Ok(base_dt) = parse_base_date(orig_base, now) {
let offset = unit_duration(unit, sign * n);
return Ok(base_dt + offset);
let offset = unit_duration(
unit,
sign.checked_mul(n)
.ok_or_else(|| format!("date out of range: '{}'", s))?,
)
.ok_or_else(|| format!("date out of range: '{}'", s))?;
return base_dt
.checked_add_signed(offset)
.ok_or_else(|| format!("date out of range: '{}'", s));
}
}
}
Expand All @@ -258,7 +267,7 @@ fn parse_relative_date(s: &str) -> Option<Duration> {
regex::Regex::new(r"^(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago$").ok()?;
if let Some(caps) = re_ago.captures(s) {
let n: i64 = caps[1].parse().ok()?;
return Some(unit_duration(&caps[2], -n));
return unit_duration(&caps[2], n.checked_neg()?);
}

// "+N unit(s)" or "-N unit(s)" or "N unit(s)"
Expand All @@ -267,32 +276,35 @@ fn parse_relative_date(s: &str) -> Option<Duration> {
if let Some(caps) = re_rel.captures(s) {
let sign = if &caps[1] == "-" { -1i64 } else { 1i64 };
let n: i64 = caps[2].parse().ok()?;
return Some(unit_duration(&caps[3], sign * n));
return unit_duration(&caps[3], sign.checked_mul(n)?);
}

// "next unit" / "last unit"
if let Some(unit) = s.strip_prefix("next ") {
let unit = unit.trim().trim_end_matches('s');
return Some(unit_duration(unit, 1));
return unit_duration(unit, 1);
}
if let Some(unit) = s.strip_prefix("last ") {
let unit = unit.trim().trim_end_matches('s');
return Some(unit_duration(unit, -1));
return unit_duration(unit, -1);
}

None
}

fn unit_duration(unit: &str, n: i64) -> Duration {
// Returns None when the requested offset overflows i64 (the multiply) or the
// chrono Duration range, so callers can report "date out of range" instead of
// panicking on inputs like `date -d "30000000000000000 years"`.
fn unit_duration(unit: &str, n: i64) -> Option<Duration> {
match unit {
"second" => Duration::seconds(n),
"minute" => Duration::minutes(n),
"hour" => Duration::hours(n),
"day" => Duration::days(n),
"week" => Duration::weeks(n),
"month" => Duration::days(n * 30), // Approximate
"year" => Duration::days(n * 365), // Approximate
_ => Duration::zero(),
"second" => Duration::try_seconds(n),
"minute" => Duration::try_minutes(n),
"hour" => Duration::try_hours(n),
"day" => Duration::try_days(n),
"week" => Duration::try_weeks(n),
"month" => n.checked_mul(30).and_then(Duration::try_days), // Approximate
"year" => n.checked_mul(365).and_then(Duration::try_days), // Approximate
_ => Some(Duration::zero()),
}
}

Expand Down Expand Up @@ -777,6 +789,20 @@ mod tests {
assert_eq!(date.len(), 10);
}

/// Relative offsets that overflow i64 or the chrono range must report an
/// error, not panic (e.g. `n * 365` in unit_duration, then base + offset).
#[tokio::test]
async fn test_date_d_relative_overflow_no_panic() {
for spec in [
"30000000000000000 years",
"+9000000000000000000 days",
"300000000000000000 months ago",
] {
let result = run_date(&["-d", spec, "+%Y"]).await;
assert_ne!(result.exit_code, 0, "spec '{spec}' should fail cleanly");
}
}

#[tokio::test]
async fn test_date_d_tomorrow() {
let result = run_date(&["-d", "tomorrow", "+%Y-%m-%d"]).await;
Expand Down
30 changes: 28 additions & 2 deletions crates/bashkit/src/builtins/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,14 @@ fn evaluate(args: &[&str]) -> std::result::Result<String, String> {
if b == 0 {
return Err("division by zero".to_string());
}
a / b
// checked_div guards i64::MIN / -1 (overflow panic).
a.checked_div(b).ok_or("integer overflow")?
}
"%" => {
if b == 0 {
return Err("division by zero".to_string());
}
a % b
a.checked_rem(b).ok_or("integer overflow")?
}
_ => unreachable!(),
};
Expand Down Expand Up @@ -545,6 +546,31 @@ mod tests {
assert_eq!(result.stdout.trim(), "0");
}

// ==================== arithmetic overflow ====================

/// i64::MIN / -1 (and % -1) overflow; must return a clean error, not panic.
#[tokio::test]
async fn expr_div_overflow_no_panic() {
let (fs, mut cwd, mut variables) = setup().await;
let env = HashMap::new();
for op in ["/", "%"] {
let args = vec![
"-9223372036854775808".to_string(),
op.to_string(),
"-1".to_string(),
];
let ctx =
Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
let result = Expr.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 2, "op {op} should error");
assert!(
result.stderr.contains("integer overflow"),
"op {op}: expected overflow error, got {:?}", // debug-ok: test assertion
result.stderr
);
}
}

// ==================== pattern matching ====================

#[tokio::test]
Expand Down
29 changes: 28 additions & 1 deletion crates/bashkit/src/builtins/jq/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ use jaq_core::{Compiler, Ctx, Vars, data};
use jaq_json::Val;
use jaq_std::input::{HasInputs, Inputs, RcIter};

use super::{Builtin, Context, read_text_file, resolve_path};
use super::{Builtin, Context, ExecutionDeadline, read_text_file, resolve_path};
use crate::error::Result;
use crate::interpreter::ExecResult;
use crate::limits::ExecutionLimits;

mod args;
mod compat;
Expand Down Expand Up @@ -338,6 +339,19 @@ async fn run_jq(ctx: Context<'_>, parsed: JqArgs<'_>) -> Result<ExecResult> {
let mut has_output = false;
let mut all_null_or_false = true;

// THREAT[TM-DOS-093]: jaq evaluation is a synchronous iterator that the
// async execution timeout cannot preempt. Unbounded generators
// (`jq -n 'repeat(1)'`, `range(0;1e18)`) would grow `output` without limit
// (OOM) or spin forever producing no output (hang). Cap accumulated output
// bytes against the caller's stdout limit and check the wall-clock deadline
// periodically so a runaway filter aborts instead of wedging the host.
let max_output_bytes = ctx
.execution_extension::<ExecutionLimits>()
.map(|l| l.max_stdout_bytes)
.unwrap_or_else(|| ExecutionLimits::default().max_stdout_bytes);
let deadline = ctx.execution_extension::<ExecutionDeadline>();
let mut values_emitted: usize = 0;

// Drive the outer loop from the shared input iterator so `input`/`inputs`
// inside the filter consume from the same source (matching real jq:
// `[inputs]` on a 3-value stream returns the *remaining* values, not all).
Expand Down Expand Up @@ -405,6 +419,19 @@ async fn run_jq(ctx: Context<'_>, parsed: JqArgs<'_>) -> Result<ExecResult> {
if !parsed.join_output {
output.push('\n');
}

if output.len() > max_output_bytes {
return Ok(ExecResult::err(
format!("jq: output limit exceeded ({max_output_bytes} bytes)\n"),
5,
));
}
values_emitted += 1;
if values_emitted.is_multiple_of(4096)
&& deadline.is_some_and(ExecutionDeadline::is_expired)
{
return Ok(ExecResult::err("jq: execution timed out\n".to_string(), 5));
}
}
Err(e) => {
return Ok(ExecResult::err(format_runtime_error(&e), 5));
Expand Down
23 changes: 23 additions & 0 deletions crates/bashkit/src/builtins/jq/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,29 @@ async fn keys_pretty_prints_array() {
assert_eq!(result.trim(), "[\n \"a\",\n \"b\"\n]");
}

/// TM-DOS-093: an unbounded generator must not grow output without limit or
/// hang the host. jaq's iterator is synchronous so the execution timeout
/// cannot preempt it; the output-byte cap (here the 1 MB default, since the
/// test harness supplies no shell/limits) must abort with a clean error.
#[tokio::test]
async fn unbounded_generator_is_capped() {
let result = run_jq_result_with_args(&["-n", "repeat(1)"], "")
.await
.unwrap();
assert_ne!(result.exit_code, 0, "runaway generator should fail");
assert!(
result.stderr.contains("output limit exceeded"),
"expected output-limit error, got stderr={:?} stdout_len={}", // debug-ok: test assertion
result.stderr,
result.stdout.len()
);
assert!(
result.stdout.len() <= 1_048_576 + 64,
"output must be bounded by the cap, got {} bytes",
result.stdout.len()
);
}

#[tokio::test]
async fn length_returns_number() {
let result = run_jq("length", r#"[1,2,3,4,5]"#).await.unwrap();
Expand Down
7 changes: 7 additions & 0 deletions crates/bashkit/src/builtins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,13 @@ impl ExecutionDeadline {
remaining
}
}

/// Whether the wall-clock budget is exhausted. Builtins with synchronous
/// loops the async timeout cannot preempt poll this to abort.
#[allow(dead_code)]
pub(crate) fn is_expired(&self) -> bool {
self.started_at.elapsed() >= self.timeout
}
}

impl ExecutionExtensions {
Expand Down
16 changes: 14 additions & 2 deletions crates/bashkit/src/builtins/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;

use super::{Builtin, Context, resolve_path};
use super::{Builtin, Context, ExecutionDeadline, resolve_path};
use crate::error::Result;
use crate::fs::{FileSystem, FileType};
use crate::interpreter::ExecResult;
Expand Down Expand Up @@ -445,13 +445,25 @@ impl Builtin for Python {
.map(|(k, v)| (k.clone(), v.clone())),
);

// Clamp Monty's wall-clock budget to the caller's remaining execution
// deadline (like the TypeScript builtin) so a CPU-bound script cannot
// overrun a tighter bash timeout — the async timeout cannot preempt
// Monty's synchronous start/resume sections.
let mut limits = self.limits.clone();
if let Some(remaining) = ctx
.execution_extension::<ExecutionDeadline>()
.map(ExecutionDeadline::remaining)
{
limits.max_duration = limits.max_duration.min(remaining);
}

run_python(
&code,
&filename,
ctx.fs.clone(),
ctx.cwd,
&merged_env,
&self.limits,
&limits,
self.external_fns.as_ref(),
)
.await
Expand Down
10 changes: 6 additions & 4 deletions crates/bashkit/src/builtins/sqlite/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ impl Builtin for Sqlite {
let engine = match SqliteEngine::open_pure_memory() {
Ok(e) => e,
Err(msg) => {
return Ok(ExecResult::err(format!("sqlite: {msg}\n"), 1));
return Ok(ExecResult::err(format!("sqlite: {}\n", sanitize(&msg)), 1));
}
};
let outcome = run_statements(
Expand All @@ -443,7 +443,7 @@ impl Builtin for Sqlite {
)
.await;
if let Err(e) = outcome {
stderr.push_str(&format!("sqlite: {e}\n"));
stderr.push_str(&format!("sqlite: {}\n", sanitize(&e)));
exit_code = 1;
}
}
Expand Down Expand Up @@ -473,7 +473,9 @@ impl Builtin for Sqlite {
if reopen {
match open_file_engine(backend, path, &ctx.fs, &self.limits).await {
Ok(e) => *guard = Some(e),
Err(msg) => return Ok(ExecResult::err(format!("sqlite: {msg}\n"), 1)),
Err(msg) => {
return Ok(ExecResult::err(format!("sqlite: {}\n", sanitize(&msg)), 1));
}
}
}
let engine = guard.as_ref().expect("engine populated above");
Expand All @@ -491,7 +493,7 @@ impl Builtin for Sqlite {
)
.await;
if let Err(e) = outcome {
stderr.push_str(&format!("sqlite: {e}\n"));
stderr.push_str(&format!("sqlite: {}\n", sanitize(&e)));
exit_code = 1;
}

Expand Down
47 changes: 46 additions & 1 deletion crates/bashkit/src/credential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,21 @@ impl CredentialPolicy {
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case(header_name))
{
*header_value = header_value.replace(placeholder_str, secret_value);
// Substitute only when the placeholder appears
// exactly once. A legitimate value carries one
// placeholder; multiple occurrences are a
// segmentation/fingerprinting attempt (a script
// splitting the value to read the secret back in
// chunks from a header-reflecting endpoint), so
// we refuse and fail closed — the request goes
// out with placeholders, never the real secret.
// (The single-echo risk where an approved host
// reflects the whole header remains a documented
// v1 limitation; see credential-injection.md.)
if header_value.matches(placeholder_str).count() == 1 {
*header_value =
header_value.replacen(placeholder_str, secret_value, 1);
}
}
}
}
Expand Down Expand Up @@ -458,6 +472,37 @@ mod tests {
}
}

#[test]
fn test_placeholder_multiple_occurrences_fail_closed() {
// A script repeating the placeholder is attempting to segment the
// secret across a header-reflecting endpoint. We must not substitute
// anything: the request goes out with placeholders, never the secret.
let mut policy = CredentialPolicy::new();
let placeholder =
policy.add_placeholder("https://api.openai.com", Credential::bearer("sk-real-key"));

let hook = policy.into_hook();
let event = HttpRequestEvent {
method: "POST".into(),
url: "https://api.openai.com/v1/chat/completions".into(),
headers: vec![(
"Authorization".into(),
format!("Bearer {placeholder}.{placeholder}"),
)],
};

match hook(event) {
HookAction::Continue(e) => {
assert!(
!e.headers[0].1.contains("sk-real-key"),
"secret must not be injected when placeholder is repeated"
);
assert!(e.headers[0].1.contains("bk_placeholder_"));
}
HookAction::Cancel(_) => panic!("should not cancel"),
}
}

#[test]
fn test_placeholder_not_replaced_for_wrong_host() {
let mut policy = CredentialPolicy::new();
Expand Down
Loading
Loading