Skip to content

Latest commit

 

History

History
200 lines (159 loc) · 11.5 KB

File metadata and controls

200 lines (159 loc) · 11.5 KB

Security in Bashkit

Bashkit is a virtual Bash interpreter designed for safe, sandboxed script execution. Security is a first-class concern, every design decision considers what an untrusted script could do and how to prevent it.

This article gives a high-level overview. For the full threat model with individual threat IDs and mitigation status, see the rustdoc threat model guide.

Core security boundaries

Boundary What it does
Virtual filesystem Scripts run against an in-memory VFS. No real filesystem access by default. Path traversal (../../../etc/passwd) is normalised away. Symlinks are followed inside the VFS only: a target is always a VFS path, so a link can never reach the host.
No process execution exec is excluded entirely. bash -c re-invokes the virtual interpreter instead of spawning a real process. Background jobs (&) parse but run synchronously.
Network allowlist HTTP/HTTPS only, pre-validated against an explicit host allowlist. No DNS resolution, no auto-redirect, no auto-decompression.
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).

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 for every input, including empty files. Input, row and output storage also share max_live_intermediate_bytes; work, aggregate input, cancellation and deadline checks apply during pagination. Exceeding a limit returns a resource-limit error. A subsequent host execution can reuse the session with a fresh execution budget.

od -w / od --width accepts at most 65,536 bytes per row. Larger widths fail before rendering, even with empty input. Numeric fields use at most eight bytes of stack padding; output capacity is admitted against ExecutionLimits::max_live_intermediate_bytes before fallible allocation. This memory budget applies to pipelines and redirected output too.

Shell diagnostic names ($0 or the current source file) show at most 1,024 UTF-8 bytes; the actual $0 value is unchanged. Repeating a prefix across builtin errors uses the shared live-memory and work budget before allocation, including the cached text representation. A refused reservation aborts the request before capture, streaming, or redirection; the next host execution can reuse the shell with a fresh budget. Capture limits remain downstream so command-local redirected diagnostics keep their full content when they fit the memory budget. An outer redirect on a child-shell invocation receives that child's already capped capture.

fmt reserves output capacity against max_live_intermediate_bytes before allocation, including repeated indentation and prefixes. Generated bytes and optimal-fit searches consume the shared work budget and check cancellation and deadlines. Exceeding a budget returns a resource-limit error. Output within the budget preserves its formatting through pipelines and file redirects, even when it exceeds the stdout capture limit.

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 cancellation and deadlines, including when every candidate is missing.

Indexed compound-array assignments expand values before evaluating subscripts. Pending keys, values, fields and container capacity count against max_live_intermediate_bytes, including repeated writes to the same index. Exceeding that budget returns a resource-limit error even when the final array would fit the retained-variable budget.

Command hash entries (hash -p FILE NAME... and automatic PATH lookups) charge each retained name and pathname against the same live-byte budget before copying. Entries that survive between executions count toward the next execution's budget. Deleting, replacing, evicting or clearing entries releases their storage; subshells share pathname storage while maintaining independent hash state. The table also retains its 512-entry ceiling.

MemoryLimits::max_function_body_bytes also includes retained function-definition filenames and metadata keys. Filename storage is shared; each function is charged conservatively before insertion. An oversized source operand therefore fails with a resource-limit error even when redundant slashes normalize to a short VFS path. Redefinition replaces the charge, unset releases it, and subshell rollback restores filenames together with functions. Executable child scripts discard their own metadata on return. Functions and their admitted metadata persist across executions; shell-state restore discards previous filenames because the serialized state carries function source without definition filenames.

Recursive grep -R skips directory symlinks that resolve to an ancestor and reports warning: recursive directory loop (suppressed by -s). Separate aliases of a non-ancestor directory remain searchable. Traversal uses the shared execution work and deadline budget; file contents are admitted against aggregate input and live intermediate limits and scanned one file at a time. -q stops at the first match, before further traversal.

Tilde expansion (~, ~+, ~-, including repeated assignment prefixes after :) checks ExecutionLimits::max_live_intermediate_bytes before growing its output and charges copied bytes to max_work_units. Large directory values cannot bypass these limits by expanding before variable insertion. A refused expansion returns a resource-limit error; the next host execution receives a fresh execution budget. Syntax-error reports are limited to 1 KiB during construction, including an explicit [diagnostic truncated] marker when text is omitted. This prevents multiline unexpected tokens and long bash -c shell names from multiplying host-memory usage before stderr capture. Ordinary short reports retain Bash's text, line numbers, and exit status.

Threat model

Prompt expansion (${x@P} and interactive prompts) shares the execution work, aggregate-input, cancellation and deadline budgets. Recursive prompt reparses stop at min(max_ast_depth, 8) with a resource-limit error, including cycles through command substitutions. Raising the AST limit cannot remove this host stack protection. Finite prompt expansion and shopt -u promptvars retain their normal behavior within these limits.

Bashkit maintains a living threat model in knowledge/security/threat-model.md with stable threat IDs across these categories:

Category ID prefix Examples
Denial of Service TM-DOS Resource exhaustion, infinite loops, parser bombs
Sandbox Escape TM-ESC Path traversal, real FS access, privilege escalation
Information Disclosure TM-INF Secret leakage, host info exposure, data exfiltration
Injection TM-INJ Command injection, variable namespace pollution
Network TM-NET DNS rebinding, allowlist bypass, response flooding
Multi-Tenant Isolation TM-ISO Cross-tenant data leaks
Internal Errors TM-INT Panics, error message information leaks
Git TM-GIT Repo access control, remote URL injection
Logging TM-LOG Sensitive data in logs, log injection
Python Sandbox TM-PY Monty resource limits, VFS bridge escapes
Unicode TM-UNI Byte-boundary panics, homoglyph attacks

The full threat model, including mitigation status for each threat, is published in the rustdoc: bashkit::threat_model.

POSIX deviations for security

Bashkit intentionally deviates from POSIX where compliance would compromise the sandbox. Key exclusions:

  • exec: would break sandbox containment (TM-ESC-005)
  • trap: handles shell events and self-sent virtual signals. Nested handlers share the function/source/child-shell depth budget (hard ceiling 16) plus a dedicated trap-nesting cap of 3 that trips first on small stacks; recursive signal delivery past either limit is skipped with a diagnostic and the first trip fails the exec with a resource-limit error
  • Real process spawning: all subprocess commands stay within the virtual interpreter (TM-ESC-015)

These decisions are documented in knowledge/operations/limitations.md.

Security testing

Bashkit uses multiple layers of security testing:

Threat model tests: 232 tests in threat_model_tests.rs that directly validate mitigations against documented threat IDs. Each test maps to a specific TM-* threat.

Fail-point injection: A framework defined in knowledge/security/security-testing.md that injects failures at specific points to verify the interpreter handles them safely. 14+ tests in security_failpoint_tests.rs.

Network security tests: 68 tests covering allowlist enforcement, URL validation, timeout behaviour, and response limits.

Error handling tests: 39 tests verifying that builtins wrapped with catch_unwind never leak panic messages, stack traces, or memory addresses.

Logging security tests: 26 tests confirming that sensitive data (passwords, tokens, API keys, JWTs) is redacted in logs and that log injection is prevented.

Fuzz testing: Parser and lexer fuzzing to catch panics and unexpected behaviour on malformed input.

Differential tests: Compare Bashkit output against real Bash to ensure behaviour parity where expected, and confirm intentional divergences where security requires it.

Arithmetic limits cover textual dollar expansion inside expressions. Nested array subscripts share the enclosing expression's recursion and work limits, including quoted arguments to let. Exceeding either limit returns an arithmetic error; subsequent executions can reuse the session. Expanded arithmetic expressions and associative-array keys are capped at 64 KiB. Their buffers reserve capacity against max_live_intermediate_bytes before allocation, including temporary copies of variable values. Each append charges max_work_units and checks cancellation and the execution deadline. Shared-budget exhaustion returns a resource-limit error rather than continuing the script; subsequent executions start with a fresh request budget.

Panic safety

All builtin commands are wrapped with catch_unwind. If a builtin panics, the error is caught and converted to a sanitised error message, no stack traces, no memory addresses, no real filesystem paths leak to the caller (TM-INT-001, TM-INT-002).

Reporting security issues

Do not open a public GitHub issue for security vulnerabilities.

Email: security@everruns.com

Please include a description of the vulnerability, steps to reproduce, and potential impact. We acknowledge reports within 48 hours, provide an initial assessment within 7 days, and target 30-day resolution for critical issues.

See SECURITY.md for the full policy.

We appreciate responsible disclosure and acknowledge researchers who report valid vulnerabilities (with permission).