Skip to content

v2.0.0 — security hardening + language completeness - #75

Merged
escapeboy merged 22 commits into
masterfrom
sprint/2.0-remediation
Jul 17, 2026
Merged

v2.0.0 — security hardening + language completeness#75
escapeboy merged 22 commits into
masterfrom
sprint/2.0-remediation

Conversation

@escapeboy

Copy link
Copy Markdown
Owner

First major release. Remediates every finding from a whole-codebase research audit (3 High, several Medium, plus the "statically typed but unchecked" language gaps).

Security

  • SSRF: DNS-resolve + IPv6 bracket-strip + per-hop redirect re-validation
  • Coordinator cross-worker claim hijack closed (S6, 403 coord.claim_not_owned)
  • Coordinator approval-gate token required (S9, 403 coord.approval_token_invalid)
  • Evidence tamper-evidence: external anchor + ed25519 manifest signing + require-encryption
  • Content-addressing verified at coordinator; XSS escaped in evidence serve
  • Key material zeroized; path-traversal guards; crafted-SpawnActor DoS fixed

Language

  • User enum construction Enum::Variant(payload) + real per-variant match tags
  • Higher-order / indirect calls (Op::CallIndirect)
  • for loops, Map<K,V>/Fn types, ensures postconditions
  • Static arity checking (compile error)
  • Warn-only type-consistency diagnostics (E009 warnings) — 0 false positives on 64 corpus files

Breaking changes (hence 2.0.0)

  • Integer overflow is now a runtime error (VmError::ArithmeticOverflow)
  • Coordinator refuses non-loopback bind without auth (override BORUNA_COORD_ALLOW_INSECURE=1)
  • Coordinator rejects output_hash != output_json
  • Framework empty/malformed policies() now denies (was allow-all)
  • Codegen rejects >255 element/field/arg counts

All crates gated green individually (compiler/vm/tooling/framework/orchestrator/cli), fmt + clippy clean. Full-workspace validation runs in CI here.

See the [2.0.0] entry in CHANGELOG.md for detail + migrations.

escapeboy added 21 commits July 16, 2026 14:37
…harden VM (2.0 tranche)

Remediation of confirmed findings from the codebase security research
(claudedocs/research/boruna/). This tranche ships the gated security +
arithmetic-correctness fixes; evidence signing, coordinator ownership model,
and the language type-system buildout are staged (see REMEDIATION-PLAN.md).

Security:
- SSRF (crates/llmvm/src/http_handler.rs): resolve every DNS address and reject
  private/reserved targets, strip IPv6 brackets so literal checks fire, and
  follow redirects manually with per-hop re-validation (was: literal-IP-only +
  unvalidated auto-redirects).
- Content-addressing (coordinator handle_complete): reject a worker output_hash
  that doesn't match SHA-256(output_json) before it reaches the store + audit
  chain (+ regression test).
- Coordinator fail-closed: refuse to start on a non-loopback bind without auth
  unless BORUNA_COORD_ALLOW_INSECURE=1 (was: warn + serve).
- Stored XSS (evidence serve): HTML-escape all bundle-derived output (+ tests).
- Path traversal: reject '..'/'.' in storage ref_to_run_id (s3/gcs/azure) and
  in the MCP template-apply name.

Correctness (2.0 breaking):
- Checked i64 Add/Sub/Mul/Div/Mod/Neg -> VmError::ArithmeticOverflow (was:
  debug-panic / release silent-wrap, which broke determinism; also fixes the
  i64::MIN/-1 panic past the div-zero guard).
- Actor: a crafted SpawnActor with an out-of-range function index now fails the
  actor instead of panicking the scheduler process (was: .expect() -> DoS).

Docs: correct version/count drift (v1.9.0, 33 builtins, 12 MCP tools, 11 crates,
all 13 stdlib packages 1.0-stable, stability.md heading contradiction).

Includes the full research knowledge base under claudedocs/research/boruna/.

Gates: cargo test --workspace --features boruna-cli/serve, cargo fmt --check,
cargo clippy --workspace --all-targets --features boruna-cli/serve,boruna-vm/http
-- -D warnings — all green.
Closes the detection half of the #1 High finding (plaintext evidence bundles
were forgeable because verify was purely self-referential over a rewritable
manifest — F1/F2, verify-A CONFIRMED).

- verify_bundle_with_opts(): recompute + check manifest.bundle_hash (internal
  consistency), plus an operator-supplied out-of-band expected_bundle_hash ANCHOR
  and a require_encryption flag (blocks the downgrade-to-plaintext strip).
- CLI: `evidence verify --expected-bundle-hash <hex>` and `--require-encryption`.
- The anchor is the real fix: recomputing the hash alone proves nothing (an
  attacker recomputes it too) — test_verify_anchor_detects_forged_manifest forges
  a fully self-consistent manifest that plain verify accepts but the anchor rejects.
- verify_bundle / verify_bundle_with_kek retained as thin wrappers.

Full ed25519 manifest signing (removes the out-of-band-anchor requirement) remains
staged — see claudedocs/research/boruna/REMEDIATION-PLAN.md.

Gates: cargo test --workspace --features boruna-cli/serve (48 suites, 0 failed),
cargo fmt --check, cargo clippy ... --features boruna-cli/serve,boruna-vm/http
-- -D warnings — all green.
Any authenticated worker could complete/fail/extend ANOTHER worker's in-flight
step: the terminal CAS keyed only on (run_id, step_id, claim_id), and claim_id is
a predictable per-step counter from 1 (verify-B CONFIRMED, highest-value
distributed finding).

- persistence: add RunCheckpointStore::step_claimed_by(run_id, step_id) -> the
  worker_id holding the row's lease.
- coordinator: reject_if_not_claim_owner() gates handle_complete/fail/extend_lease
  at the trust boundary (under the store lock, so read+CAS are atomic in-process),
  returning 403 coord.claim_not_owned when the caller is not the claim holder.
- tests: complete_by_non_owner_returns_403 (new); complete_with_stale_claim_returns_409
  rewritten so the SAME worker reclaims (still exercises the lease-expiry CAS).

Boundary-check approach (matching the S12 content-addressing fix) avoids threading
worker_id through the shared persistence CAS + ~22 test call sites. S9 (approval-gate
seizure) remains to do.

Gates: cargo test -p boruna-cli --features serve (green; one pre-existing dashboard
HTTP-timing flake confirmed passing in isolation), plus workspace gate to follow.
Wipe DEK and KEK bytes from memory so freed heap/stack pages don't
retain key material:
- Envelope now impls Drop, zeroizing its DEK.
- unwrap() zeroizes the decrypted DEK Vec the AEAD returns.
- resolve_kek() zeroizes the BORUNA_BUNDLE_KEK env hex String.
rotate.rs clones the (non-secret) info since Envelope is no longer
movable-out. Adds zeroize dep to orchestrator.
[S1] Return a compile error instead of silently truncating list/record
sizes, call arity, and function arity when they exceed u8::MAX (255).
Wrapping the count corrupted the operand stack at runtime.

[G11] Pop the trailing bare expression of a while body so the operand
stack stays balanced each iteration; previously one value leaked per
iteration and grew unbounded until stack overflow.
/api/runs/{id}/approve took no token and was first-writer-wins, so any bearer or
worker-cert holder could seize AND pre-empt a human-in-the-loop approval gate
(verify-B S9 + N1). Root cause: the ApprovalGate arm minted no token at all
(unlike ExternalTrigger).

- runner: the ApprovalGate open path now mints a per-gate token via
  acquire_trigger_token (stashed in metadata.triggers, payload empty), mirroring
  ExternalTrigger. New `approval_gate_token(store, run_id, step_id)` read helper.
- coordinator: handle_approve_run requires the token, constant-time compared to
  the stashed one; 403 coord.approval_token_invalid on missing/mismatch. Unknown
  run / not-a-gate falls through to the precise 404/state error (no masking).
- CLI: `workflow approve|reject --token <hex>`; send_approve_remote forwards it.
- tests: approve_run_rejects_wrong_token (new, +token-less), and the existing
  advances/double-decision/integration tests fetch the stashed token.

This completes finding F (S6 landed earlier). Boundary approach (like S6/S12) —
record_approval_decision_in_store and its ~10 tests are untouched.

Gates: orchestrator --lib 454/0, boruna-cli --features serve unit 152/0 +
approve integration test green, fmt clean.
The `!self.capabilities.is_empty() &&` guard made an EMPTY capability set
allow EVERY effect (fail-open). Because a malformed/empty explicit
policies() lands on PolicySet::default() (empty capabilities), an app
could ship an empty or malformed policies() and silently get allow-all.

Remove the short-circuit so an effect whose capability is not in the
declared set is DENIED even when the set is empty. Explicit empty/malformed
policies() now fails closed.

Scope: does NOT touch the runtime.rs "no policies() function at all ->
allow_all()" convenience (lines 81-86); that documented default for apps
that omit policies() entirely is unchanged. Only EXPLICIT empty/malformed
policies() now denies.

Tests: renamed test_policy_empty_capabilities_allows_all ->
test_policy_empty_capabilities_denies_all and flipped its assertion to
is_err(); test_policy_default_is_restrictive now actually asserts the
default policy DENIES an effect (previously only checked is_empty(), which
under the old fail-open code was a no-op guarantee — the name lied);
test_policy_batch_limit_exact_boundary now declares net.fetch so it still
exercises the effect-count boundary rather than tripping the new capability
deny.

Self-review (policy-trust change — account-takeover / privilege-escalation pass):
- Every EffectKind maps to a capability_name() (net.fetch, db.query,
  fs.read, fs.write, time.now, random, actor.spawn, ui.render, llm.call,
  actor.send). No effect kind bypasses the check; the deny now covers all
  of them when the cap is absent, including empty sets.
- check_batch: limit check first, then check_effect per effect. Empty caps
  now deny every effect; empty effects list still passes (no capability
  used). No bypass in the compose.
- send() and send_with_executor both route through send()->check_batch, so
  the executor path is gated too. HostEffectExecutor's own allow_all gateway
  is downstream of this authoritative gate; unchanged.
- from_value: non-Record -> default() (empty caps -> deny); Record with
  missing/wrong capabilities field -> extract_string_list -> empty caps ->
  deny. Malformed policies() reaches the deny path. Fail-closed holds.
- Change is strictly a privilege REDUCTION (fail-open -> fail-closed); the
  no-policies() allow_all escalation path is untouched. No new vector.
…tions

G7: parse and codegen `for x in list { ... }` — desugars to an index
loop over a List reusing ListLen/ListGet opcodes.
G8: parse `Map<K, V>` and `Fn(A, B) -> R` type annotations into the
existing TypeExpr::Map/Fn variants.
G1: emit parsed-but-dead `ensures` clauses — bind the return value to a
local `result`, assert each postcondition (Op::Assert), then return it.

Propagates the new Stmt::For variant through typeck and the exhaustive
Stmt matches in boruna-tooling and boruna-cli.
Indirect call targets (a function passed as a value, or any computed callee)
were silently dispatched to function #0 — codegen hardcoded Op::Call(0, argc)
in the fallback path, so every higher-order call ran the wrong function
(frontend finding G2).

- bytecode: new Op::CallIndirect(u8) (byte tag 0x14) — callee FnRef on top of
  the stack, N args below.
- vm: CallIndirect pops the FnRef, dispatches to its func_idx (TypeError if the
  callee is not a function reference).
- codegen: the indirect-call path now pushes the callee expr and emits
  CallIndirect instead of the hardcoded Call(0, ..).
- test: apply(double, 21) == 42 through a Fn(Int)->Int parameter.

Gates: bytecode/vm/compiler green (+ new test), workspace clippy -D warnings = 0.
Closes the "sign-side" of verify-A finding #1: a signed evidence bundle
proves integrity via the operator's own key, removing the need to carry
an out-of-band bundle_hash anchor.

Additive and forward-compatible — existing unsigned 1.0 bundles verify
unchanged (format_version stays "1.0"):

- BundleManifest gains an OPTIONAL `signature: Option<ManifestSignature>`
  ({ algorithm, public_key hex, signature hex }) with
  skip_serializing_if = "Option::is_none". The unsigned manifest bytes
  (and thus bundle_hash) are byte-for-byte unchanged.
- EvidenceBundleBuilder::with_signing_key(&[u8;32]) signs the finalized
  bundle_hash bytes (added AFTER hash computation, never feeds the hash).
- verify_bundle_with_opts(dir, VerifyOptions{kek, trusted_pubkey,
  require_signature}). When a signature is present it is verified over
  bundle_hash, bundle_hash is recomputed to bind actual manifest content,
  and (when pinned) the signer key must equal trusted_pubkey — the actual
  trust root. Errors: evidence.signature_invalid / _untrusted_key /
  _required. verify_bundle / verify_bundle_with_kek preserved as wrappers.
- CLI `evidence verify` gains --verify-key <hex> and --require-signature,
  mirroring the existing --bundle-encryption-key wiring.
- rotate::compute_bundle_hash clears signature to match finalize.

ed25519-dalek 2 added default-features=false (std, zeroize) per ADR 001.
6 new verify tests: signed passes, file tamper, bundle_hash tamper,
wrong pinned key, require-signature on unsigned, unsigned still passes.
Enums could be declared and pattern-matched but never CONSTRUCTED — there
was no expression form for a user enum value, so `pattern_to_tag` returning
-1 for every EnumVariant arm was dead code (every enum match collapsed to
the first arm). This completes the feature end to end:

- lexer: new `::` token
- ast/parser: `Enum::Variant` / `Enum::Variant(payload)` construction expr
- codegen: emit MakeEnum(type_id, variant_idx); resolve_enum_variant() maps
  the qualified name to (type_id, index); pattern_to_tag is now a method that
  resolves a variant name to its declaration index (VM Op::Match already
  dispatches on Value::Enum.variant, so no VM change)
- typeck + tooling (format printer, diagnostics walkers, serve tag collector):
  handle the new Expr variant

Additive, non-breaking (`::` was never a valid token before). +2 e2e tests.

Known limitation: duplicate variant names across enums resolve to the first
match in patterns (VM matches on variant index, ignoring type_id).
The type checker did name resolution only. It now rejects a direct call to a
named function with the wrong argument count (CompileError::Type). Calls whose
callee is a local binding (first-class function value / higher-order param) are
skipped — their arity isn't known at this layer, and Op::CallIndirect handles
them at runtime.

Non-breaking: the existing .ax corpus (stdlib libs, example workflows,
framework apps) all type-check and run correctly, so none carry arity
mismatches. Verified green across compiler/vm/tooling/framework/orchestrator.

Deeper strict checking (type inference, match exhaustiveness, record-field
typing) remains a deferred, intentionally LTS-breaking follow-up: it rejects
existing loose programs and needs a corpus-migration + strictness decision.
…arnings)

First step toward strict typing, per the chosen warn-only rollout: a new
analyzer pass surfaces type mismatches as Severity::Warning (E009) via
`lang check` / `boruna_check` without blocking compilation or execution.

Two checks, both inference-free and conservative:
- let bindings: `let x: T = <expr>` where the initializer's concrete type != T
- call arguments: a direct call to a user function whose argument's concrete
  type != the declared parameter type (skipped when the callee is a local/param
  first-class function value)

The local type environment only reasons about types it can name with
confidence — literals, annotated bindings, record/enum constructors, and user
function return types. Generics (Option/Result/List/Map/Fn), builtins and
binary ops stay untyped, so a mismatch is reported only when BOTH sides resolve
to differing concrete names. Verified zero false positives across all 64 corpus
.ax files (examples + libs). +3 tests.

Deferred to a later strict pass: type inference through binary ops, match
exhaustiveness as types (already covered separately as E005), and promoting
these warnings to hard errors at 2.0.
Security-hardening + language-completeness major. Remediates the whole-codebase
research audit (3 High, several Medium, language gaps). Breaking: integer
overflow is now a runtime error; coordinator and framework defaults fail closed
(each with a documented override). See CHANGELOG 2.0.0.
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

Bench compare

Threshold for regression: ≥ 10% slower mean.

Benchmark Mean change 99% CI
compile_crud_admin_template -3.85% [-6.87%, -0.92%]
compile_medium_program +3.85% [+1.34%, +6.95%]
compile_small_program +14.45% [+8.79%, +20.60%]
evidence_build_5_steps -2.70% [-32.81%, +32.53%]
evidence_build_empty -14.10% [-17.39%, -10.62%]
evidence_verify_10_steps -22.02% [-31.07%, -15.08%]
evidence_verify_5_steps +37.89% [+32.53%, +43.31%]
vm_call_dispatch_loop/iters=1000 -12.91% [-14.56%, -11.29%]
vm_call_dispatch_loop/iters=10000 -8.98% [-12.69%, -4.86%]
vm_pure_loop/iters=1000 -43.56% [-44.56%, -42.47%]
vm_pure_loop/iters=10000 -17.09% [-20.39%, -13.97%]
vm_pure_loop/iters=100000 -32.46% [-35.87%, -29.22%]
vm_record_loop/iters=1000 -44.37% [-45.97%, -42.60%]
vm_record_loop/iters=10000 -14.49% [-18.39%, -10.65%]

⚠️ 2 benchmark(s) regressed past threshold:

  • compile_small_program
  • evidence_verify_5_steps

…message

The SSRF hardening renamed the literal-IP block message to 'blocked request to
private/reserved IP', but two http-feature tests still asserted the old
'private IP' substring. These tests only run under `--features http` (a
separate CI step), so per-crate gating didn't exercise them. Behavior is
unchanged — the private IP is still blocked; only the assertion text was stale.
@escapeboy
escapeboy merged commit 49b5c5e into master Jul 17, 2026
4 of 5 checks passed
@escapeboy
escapeboy deleted the sprint/2.0-remediation branch July 17, 2026 11:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant