Skip to content

Commit 02657a0

Browse files
committed
fix(security): prevent panics and add internal error handling
- Fix date builtin to validate strftime format strings before formatting, preventing chrono panic on invalid specifiers like %Q - Add Internal error variant for unexpected failures with safe messaging - Update threat model with TM-INT-xxx category for internal error handling: - TM-INT-001: Builtin panic recovery via catch_unwind - TM-INT-002: Panic info leak prevention (sanitized errors) - TM-INT-003: Date format validation - TM-INT-004/005/006: Error message safety - Add comprehensive tests for date format validation and error handling - Update interpreter comment to clarify panic catching applies to all builtins The implementation ensures that: 1. Invalid inputs produce human-readable error messages 2. Panics are caught and converted to safe error responses 3. No internal details (paths, addresses, stack traces) are exposed 4. Scripts continue execution after recoverable errors https://claude.ai/code/session_01XjPeQtrNSz8tEaRXgFuHnx
1 parent e3d268a commit 02657a0

8 files changed

Lines changed: 262 additions & 3 deletions

File tree

crates/bashkit/docs/threat-model.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,35 @@ let tenant_b = Bash::builder()
197197
// tenant_a cannot access tenant_b's files or state
198198
```
199199

200+
### Internal Error Handling (TM-INT-*)
201+
202+
BashKit is designed to never crash, even when processing malicious or malformed input.
203+
All unexpected errors are caught and converted to safe, human-readable messages.
204+
205+
| Threat | Attack Example | Mitigation | Code Reference |
206+
|--------|---------------|------------|----------------|
207+
| Builtin panic (TM-INT-001) | Trigger panic in builtin | `catch_unwind` wrapper | [`interpreter/mod.rs`][interp] |
208+
| Info leak in panic (TM-INT-002) | Panic exposes secrets | Sanitized error messages | [`interpreter/mod.rs`][interp] |
209+
| Date format crash (TM-INT-003) | Invalid strftime: `+%Q` | Pre-validation | [`builtins/date.rs`][date] |
210+
211+
**Panic Recovery:**
212+
213+
All builtins (both built-in and custom) are wrapped with panic catching:
214+
215+
```rust,ignore
216+
// If a builtin panics, the script continues with a sanitized error
217+
// The panic message is NOT exposed (may contain sensitive data)
218+
// Output: "bash: <command>: builtin failed unexpectedly"
219+
```
220+
221+
**Error Message Safety:**
222+
223+
Error messages never expose:
224+
- Stack traces or call stacks
225+
- Memory addresses
226+
- Real filesystem paths (only virtual paths)
227+
- Panic messages that may contain secrets
228+
200229
## Security Testing
201230

202231
BashKit includes comprehensive security tests:
@@ -223,6 +252,7 @@ All threats use stable IDs in the format `TM-<CATEGORY>-<NUMBER>`:
223252
| TM-INJ | Injection |
224253
| TM-NET | Network Security |
225254
| TM-ISO | Multi-Tenant Isolation |
255+
| TM-INT | Internal Error Handling |
226256

227257
Full threat analysis: [`specs/006-threat-model.md`][spec]
228258

@@ -237,3 +267,5 @@ Full threat analysis: [`specs/006-threat-model.md`][spec]
237267
[network_tests]: https://github.com/anthropics/bashkit/blob/main/crates/bashkit/tests/network_security_tests.rs
238268
[fuzz]: https://github.com/anthropics/bashkit/tree/main/crates/bashkit/fuzz
239269
[spec]: https://github.com/anthropics/bashkit/blob/main/specs/006-threat-model.md
270+
[interp]: https://github.com/anthropics/bashkit/blob/main/crates/bashkit/src/interpreter/mod.rs
271+
[date]: https://github.com/anthropics/bashkit/blob/main/crates/bashkit/src/builtins/date.rs

crates/bashkit/src/builtins/date.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
//! Date builtin - display or format date and time
2+
//!
3+
//! SECURITY: Format strings are validated before use to prevent panics.
4+
//! Invalid format specifiers result in an error message, not a crash.
5+
//! Additionally, runtime format errors (e.g., timezone unavailable) are
6+
//! caught and return graceful errors.
27
38
use std::fmt::Write;
49

510
use async_trait::async_trait;
11+
use chrono::format::{Item, StrftimeItems};
612
use chrono::{Local, Utc};
713

814
use super::{Builtin, Context};
@@ -38,6 +44,21 @@ use crate::interpreter::ExecResult;
3844
/// %% Literal %
3945
pub struct Date;
4046

47+
/// Validate a strftime format string.
48+
/// Returns Ok(()) if valid, or an error message describing the issue.
49+
///
50+
/// THREAT[TM-INT-003]: chrono::format() can panic on invalid format specifiers
51+
/// Mitigation: Pre-validate format string and return human-readable error
52+
fn validate_format(format: &str) -> std::result::Result<(), String> {
53+
// StrftimeItems parses the format string and yields Item::Error for invalid specifiers
54+
for item in StrftimeItems::new(format) {
55+
if let Item::Error = item {
56+
return Err(format!("invalid format string: '{}'", format));
57+
}
58+
}
59+
Ok(())
60+
}
61+
4162
#[async_trait]
4263
impl Builtin for Date {
4364
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
@@ -49,6 +70,17 @@ impl Builtin for Date {
4970
None => "%a %b %e %H:%M:%S %Z %Y", // Default format like: "Mon Jan 1 12:00:00 UTC 2024"
5071
};
5172

73+
// SECURITY: Validate format string before use to prevent panics
74+
// THREAT[TM-INT-003]: Invalid format strings could cause chrono to panic
75+
if let Err(e) = validate_format(format) {
76+
return Ok(ExecResult {
77+
stdout: String::new(),
78+
stderr: format!("date: {}\n", e),
79+
exit_code: 1,
80+
control_flow: crate::interpreter::ControlFlow::None,
81+
});
82+
}
83+
5284
// Format the date, handling potential errors gracefully.
5385
// chrono's Display::fmt can return an error (e.g., when %Z timezone name
5486
// cannot be determined), which would cause to_string() to panic.
@@ -163,6 +195,7 @@ mod tests {
163195
assert_eq!(parts.len(), 3);
164196
}
165197

198+
// Tests from main: timezone handling
166199
#[tokio::test]
167200
async fn test_date_timezone_utc() {
168201
// %Z with UTC should always work and produce "UTC"
@@ -229,4 +262,30 @@ mod tests {
229262
assert_eq!(result.exit_code, 0);
230263
assert!(result.stdout.starts_with("Today is "));
231264
}
265+
266+
// Tests for invalid format validation (TM-INT-003)
267+
#[tokio::test]
268+
async fn test_date_invalid_format_specifier() {
269+
// Invalid format specifier should return error, not panic
270+
let result = run_date(&["+%Q"]).await;
271+
assert_eq!(result.exit_code, 1);
272+
assert!(result.stderr.contains("invalid format string"));
273+
assert!(result.stdout.is_empty());
274+
}
275+
276+
#[tokio::test]
277+
async fn test_date_incomplete_format_specifier() {
278+
// Incomplete specifier at end should return error, not panic
279+
let result = run_date(&["+%Y-%m-%"]).await;
280+
assert_eq!(result.exit_code, 1);
281+
assert!(result.stderr.contains("invalid format string"));
282+
}
283+
284+
#[tokio::test]
285+
async fn test_date_mixed_valid_invalid_format() {
286+
// Mix of valid and invalid should still error
287+
let result = run_date(&["+%Y-%Q-%d"]).await;
288+
assert_eq!(result.exit_code, 1);
289+
assert!(result.stderr.contains("invalid format string"));
290+
}
232291
}

crates/bashkit/src/error.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
//! Error types for BashKit
2+
//!
3+
//! This module provides error types for the interpreter with the following design goals:
4+
//! - Human-readable error messages for users
5+
//! - No leakage of sensitive information (paths, memory addresses, secrets)
6+
//! - Clear categorization for programmatic handling
27
38
use crate::limits::LimitExceeded;
49
use thiserror::Error;
@@ -7,6 +12,9 @@ use thiserror::Error;
712
pub type Result<T> = std::result::Result<T, Error>;
813

914
/// BashKit error types.
15+
///
16+
/// All error messages are designed to be safe for display to end users without
17+
/// exposing internal details or sensitive information.
1018
#[derive(Error, Debug)]
1119
pub enum Error {
1220
/// Parse error occurred while parsing the script.
@@ -32,4 +40,20 @@ pub enum Error {
3240
/// Network error.
3341
#[error("network error: {0}")]
3442
Network(String),
43+
44+
/// Internal error for unexpected failures.
45+
///
46+
/// THREAT[TM-INT-002]: Unexpected internal failures should not crash the interpreter.
47+
/// This error type provides a human-readable message without exposing:
48+
/// - Stack traces
49+
/// - Memory addresses
50+
/// - Internal file paths
51+
/// - Panic messages that may contain sensitive data
52+
///
53+
/// Use this for:
54+
/// - Recovered panics that need to abort execution
55+
/// - Logic errors that indicate a bug
56+
/// - Security-sensitive failures where details should not be exposed
57+
#[error("internal error: {0}")]
58+
Internal(String),
3559
}

crates/bashkit/src/interpreter/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1590,7 +1590,8 @@ impl Interpreter {
15901590
};
15911591

15921592
// Execute builtin with panic catching for security
1593-
// SECURITY: Custom builtins may panic - we catch this to:
1593+
// THREAT[TM-INT-001]: Builtins may panic on unexpected input
1594+
// SECURITY: All builtins (built-in and custom) may panic - we catch this to:
15941595
// 1. Prevent interpreter crash
15951596
// 2. Avoid leaking panic message (may contain sensitive info)
15961597
// 3. Return sanitized error to user

crates/bashkit/src/tool.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,7 @@ fn error_kind(e: &Error) -> String {
441441
Error::CommandNotFound(_) => "command_not_found".to_string(),
442442
Error::ResourceLimit(_) => "resource_limit".to_string(),
443443
Error::Network(_) => "network_error".to_string(),
444+
Error::Internal(_) => "internal_error".to_string(),
444445
}
445446
}
446447

crates/bashkit/tests/builtin_error_security_tests.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -966,3 +966,86 @@ async fn builtin_error_leaky_message_bad_pattern() {
966966
"Leaky builtin exposes internal details (this is BAD)"
967967
);
968968
}
969+
970+
// ============================================================================
971+
// TM-INT-003: Date Format Validation Tests
972+
// ============================================================================
973+
974+
/// Test that invalid date format specifiers return human-readable errors
975+
/// THREAT[TM-INT-003]: Invalid strftime formats could cause chrono to panic
976+
#[tokio::test]
977+
async fn date_invalid_format_returns_error_not_panic() {
978+
let mut bash = Bash::new();
979+
980+
// %Q is not a valid strftime specifier
981+
let result = bash.exec("date '+%Q'").await.unwrap();
982+
983+
// Should get an error, not a panic
984+
assert_eq!(result.exit_code, 1);
985+
assert!(
986+
result.stderr.contains("invalid format string"),
987+
"Should provide human-readable error: {}",
988+
result.stderr
989+
);
990+
assert!(result.stdout.is_empty());
991+
}
992+
993+
/// Test that incomplete format specifiers are handled gracefully
994+
#[tokio::test]
995+
async fn date_incomplete_format_returns_error() {
996+
let mut bash = Bash::new();
997+
998+
// Trailing % is incomplete
999+
let result = bash.exec("date '+%Y-%m-%'").await.unwrap();
1000+
1001+
assert_eq!(result.exit_code, 1);
1002+
assert!(
1003+
result.stderr.contains("invalid format string"),
1004+
"Should provide error for incomplete format: {}",
1005+
result.stderr
1006+
);
1007+
}
1008+
1009+
/// Test that valid formats still work correctly
1010+
#[tokio::test]
1011+
async fn date_valid_formats_work() {
1012+
let mut bash = Bash::new();
1013+
1014+
// ISO date format
1015+
let result = bash.exec("date '+%Y-%m-%d'").await.unwrap();
1016+
assert_eq!(result.exit_code, 0);
1017+
assert!(result.stdout.len() >= 10); // YYYY-MM-DD
1018+
1019+
// Unix timestamp
1020+
let result = bash.exec("date '+%s'").await.unwrap();
1021+
assert_eq!(result.exit_code, 0);
1022+
assert!(result.stdout.trim().parse::<i64>().is_ok());
1023+
}
1024+
1025+
/// Test that error messages don't expose implementation details
1026+
#[tokio::test]
1027+
async fn date_error_messages_are_safe() {
1028+
let mut bash = Bash::new();
1029+
1030+
let result = bash.exec("date '+%Q'").await.unwrap();
1031+
1032+
// Error should be user-friendly
1033+
assert!(!result.stderr.contains("panic"));
1034+
assert!(!result.stderr.contains("unwrap"));
1035+
assert!(!result.stderr.contains("chrono"));
1036+
assert!(!result.stderr.contains("0x")); // No memory addresses
1037+
}
1038+
1039+
/// Test date command in pipeline continues on error
1040+
#[tokio::test]
1041+
async fn date_error_allows_script_to_continue() {
1042+
let mut bash = Bash::new();
1043+
1044+
// Script should continue after date error (unless set -e)
1045+
let result = bash.exec("date '+%Q'; echo 'continued'").await.unwrap();
1046+
1047+
assert!(
1048+
result.stdout.contains("continued"),
1049+
"Script should continue after date error"
1050+
);
1051+
}

specs/005-security-testing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ fail_point!("module::function", |action| {
148148

149149
- `crates/bashkit/tests/security_failpoint_tests.rs` - Fail-point security tests
150150
- `crates/bashkit/tests/threat_model_tests.rs` - Threat model tests (51 tests)
151-
- `crates/bashkit/tests/builtin_error_security_tests.rs` - Custom builtin error security tests (34 tests)
151+
- `crates/bashkit/tests/builtin_error_security_tests.rs` - Builtin error security tests (39 tests, includes TM-INT-003)
152152
- `crates/bashkit/src/limits.rs` - Resource limit fail points
153153
- `crates/bashkit/src/fs/memory.rs` - Filesystem fail points
154154
- `crates/bashkit/src/interpreter/mod.rs` - Interpreter fail points, panic catching

specs/006-threat-model.md

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ All threats use a stable ID format: `TM-<CATEGORY>-<NUMBER>`
2525
| TM-INJ | Injection | Command injection, path injection |
2626
| TM-NET | Network Security | DNS manipulation, HTTP attacks, network bypass |
2727
| TM-ISO | Isolation | Multi-tenant cross-access |
28+
| TM-INT | Internal Errors | Panic recovery, error message safety, unexpected failures |
2829

2930
### Adding New Threats
3031

@@ -535,6 +536,62 @@ let tenant_b = Bash::builder()
535536

536537
---
537538

539+
### 7. Internal Error Handling
540+
541+
#### 7.1 Panic Recovery
542+
543+
| ID | Threat | Attack Vector | Mitigation | Status |
544+
|----|--------|--------------|------------|--------|
545+
| TM-INT-001 | Builtin panic crash | Invalid input triggers panic in builtin | `catch_unwind` wrapper on all builtins | **MITIGATED** |
546+
| TM-INT-002 | Panic info leak | Panic message reveals sensitive data | Sanitized error messages (no panic details) | **MITIGATED** |
547+
| TM-INT-003 | Date format panic | Invalid strftime format causes chrono panic | Pre-validation with `StrftimeItems` | **MITIGATED** |
548+
549+
**Current Risk**: LOW - All builtin panics are caught and converted to sanitized errors
550+
551+
**Implementation**: `interpreter/mod.rs` - Panic catching for all builtins:
552+
```rust
553+
// THREAT[TM-INT-001]: Builtins may panic on unexpected input
554+
let result = AssertUnwindSafe(builtin.execute(ctx)).catch_unwind().await;
555+
556+
match result {
557+
Ok(Ok(exec_result)) => exec_result,
558+
Ok(Err(e)) => return Err(e),
559+
Err(_panic) => {
560+
// THREAT[TM-INT-002]: Panic message may contain sensitive info
561+
// Return sanitized error - never expose panic details
562+
ExecResult::err(format!("bash: {}: builtin failed unexpectedly\n", name), 1)
563+
}
564+
}
565+
```
566+
567+
**Date Format Validation** (TM-INT-003): `builtins/date.rs`
568+
```rust
569+
// THREAT[TM-INT-003]: chrono::format() can panic on invalid format specifiers
570+
fn validate_format(format: &str) -> Result<(), String> {
571+
for item in StrftimeItems::new(format) {
572+
if let Item::Error = item {
573+
return Err(format!("invalid format string: '{}'", format));
574+
}
575+
}
576+
Ok(())
577+
}
578+
```
579+
580+
#### 7.2 Error Message Safety
581+
582+
| ID | Threat | Attack Vector | Mitigation | Status |
583+
|----|--------|--------------|------------|--------|
584+
| TM-INT-004 | Path leak in errors | Error shows real filesystem paths | Virtual paths only in messages | **MITIGATED** |
585+
| TM-INT-005 | Memory addr in errors | Debug output shows addresses | Display impl hides addresses | **MITIGATED** |
586+
| TM-INT-006 | Stack trace exposure | Panic unwinds show call stack | `catch_unwind` prevents propagation | **MITIGATED** |
587+
588+
**Error Type Design**: `error.rs`
589+
- All error messages are designed for end-user display
590+
- `Internal` error variant for unexpected failures (never includes panic details)
591+
- Error types implement Display without exposing internals
592+
593+
---
594+
538595
## Vulnerability Summary
539596

540597
This section maps former vulnerability IDs to the new threat ID scheme and tracks status.
@@ -588,7 +645,9 @@ This section maps former vulnerability IDs to the new threat ID scheme and track
588645
| Network allowlist | TM-INF-010, TM-NET-001 to TM-NET-007 | `network/allowlist.rs` | Yes |
589646
| Sandboxed eval, no exec | TM-ESC-005 to TM-ESC-008, TM-INJ-003 | `interpreter/mod.rs` | Yes |
590647
| Fail-point testing | All controls | `security_failpoint_tests.rs` | Yes |
591-
| Builtin panic catching | Custom builtin safety | `interpreter/mod.rs` | Yes |
648+
| Builtin panic catching | TM-INT-001, TM-INT-002, TM-INT-006 | `interpreter/mod.rs` | Yes |
649+
| Date format validation | TM-INT-003 | `builtins/date.rs` | Yes |
650+
| Error message sanitization | TM-INT-004, TM-INT-005 | `error.rs` | Yes |
592651
| HTTP response size limit | TM-NET-008, TM-NET-012 | `network/client.rs` | Yes |
593652
| HTTP connect timeout | TM-NET-009 | `network/client.rs` | Yes |
594653
| HTTP read timeout | TM-NET-010 | `network/client.rs` | Yes |

0 commit comments

Comments
 (0)