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
78 changes: 78 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,13 @@ jobs:
- name: Audit dependencies
run: node scripts/audit-deps.js --check

# Build-pipeline network egress monitor: every outbound connection made
# while dependencies are fetched and while `npm run build` runs is captured
# (tcpdump) and classified against the allowlist in
# scripts/monitor-build-egress.sh. Unauthorized destinations fail the job
# and the audit logs are uploaded for security review.
build-egress-monitor:
name: Build Egress Monitor & Data Exfiltration Gate
# #541 / #543 — Soroban contract unit tests (aegis_vault treasury, DAO oracle).
contracts:
name: Soroban Contract Tests
Expand All @@ -168,6 +175,77 @@ jobs:
- name: Checkout Codebase
uses: actions/checkout@v4

- name: Setup Node.js Environment
uses: actions/setup-node@v4
with:
node-version: '22.x'
cache: 'npm'

- name: Install packet capture tooling
run: sudo apt-get update -qq && sudo apt-get install -y -qq tcpdump

- name: Install dependencies inside the egress monitor (strict gate)
run: >
bash scripts/monitor-build-egress.sh --strict
--log-dir artifacts/build-egress/install
-- npm ci --ignore-scripts

- name: Run production build inside the egress monitor (egress gate)
# This job owns the network verdict only: the app build's own exit
# code is recorded in the summary artifact but does not fail the job
# (compilation errors belong to the build, see docs/security-runbook.md).
run: >
bash scripts/monitor-build-egress.sh --strict --ignore-command-exit
--log-dir artifacts/build-egress/build
-- npm run build

- name: Upload build egress audit logs
if: always()
uses: actions/upload-artifact@v4
with:
name: build-egress-audit
path: artifacts/build-egress/
if-no-files-found: warn

# Automated dependency version drift & breaking API change analyzer: the
# exported TypeScript surface of every protected package (functions, class
# members, call signatures) is extracted from its .d.ts entry point with the
# TypeScript Compiler API and compared against the reviewed baseline
# committed in package.json -> apiDrift.baseline.
#
# Version pinning guard: a semver-compatible (minor/patch) upgrade that
# drops or re-types a public signature fails this job; major upgrades are
# reported as warnings and need a re-baseline. The Rust half checks that
# Cargo.toml carries [workspace.metadata.api-drift].
api-drift-guard:
name: Dependency API Drift & Version Pinning Guard
runs-on: ubuntu-latest
steps:
- name: Checkout Codebase
uses: actions/checkout@v4

- name: Setup Node.js Environment
uses: actions/setup-node@v4
with:
node-version: '22.x'
cache: 'npm'

- name: Install Dependencies (no lifecycle scripts)
run: npm ci --ignore-scripts

- name: Check app dependencies against the committed API baseline
run: npm run security:api-drift | tee api-drift-report.txt

- name: Check server dependencies against the committed API baseline
run: npm run security:api-drift:server | tee -a api-drift-report.txt

- name: Publish drift report artifact
if: failure()
uses: actions/upload-artifact@v4
with:
name: api-drift-report
path: api-drift-report.txt
if-no-files-found: ignore
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable

Expand Down
41 changes: 41 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,44 @@
# Root Cargo workspace for the Soroban contracts under contracts/.
# (This manifest previously contained a `// Implementation added` placeholder,
# which is not valid TOML: every `cargo` invocation under the repository failed
# with a parse error before reaching a crate.)

[workspace]
resolver = "2"
members = ["contracts/*"]
exclude = [
# Own workspace root.
"contract",
# Standalone spike crates, not part of the contract workspace.
"scripts/spikes/webtransport_server",
"src/wasm/telemetry_reader",
]

# Member crates declare this same release profile; it is mirrored here because
# Cargo only honours `[profile]` tables at the workspace root, so building from
# the repo root must produce the same wasm artifact as building in a crate dir.
[workspace]
resolver = "2"
members = ["contracts/aegis_vault", "contracts/helphone_dao"]
# Standalone crates that keep their own lockfiles / workspaces.
exclude = ["contract", "contracts/emergency_vault", "contracts/maintainer_vault"]

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true
overflow-checks = true

# ---------------------------------------------------------------------------
# Version pinning guard (scripts/detect-api-drift.js) — Rust half.
# A semver-compatible `cargo update` may silently pull a release whose public
# API differs from the version the contracts were reviewed against, so shared
# requirements are pinned exactly and the guard fails CI when they are not.
# ---------------------------------------------------------------------------
[workspace.metadata.api-drift]
require-exact-pin = true
# Crate names whose version requirement must be exact regardless of the
# default above (checked wherever they are declared in this manifest).
protected-crates = []
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,23 @@ HelPhone is a React + Vite community emergency response application built on Ste
- **Runbook**: [docs/security-runbook.md](docs/security-runbook.md).
- **Tests**: `test/dep-audit.test.js`.

### 7. Build Pipeline Egress Monitoring & Data Exfiltration Prevention
- **Monitor**: `scripts/monitor-build-egress.sh` wraps a build command (`npm run build` by default) with a packet capture (`tcpdump`, or `iptables` LOG/REJECT when running as root) and classifies every observed destination — tcpdump `src > dst` lines, iptables `DST=` log lines, and DNS query names.
- **Unauthorized Connection Gate**: loopback/RFC1918/CGNAT plus a curated registry allowlist (npm, GitHub, PyPI, crates.io, Node.js) are permitted; anything else — including cloud metadata endpoints (`169.254.169.254`) — fails the build with exit code 1. `--enforce` additionally REJECTs the connection through an `iptables` `OUTPUT` chain while the build runs.
- **Egress Audit Logs**: `egress-capture.log` (raw packets), `egress-audit.log` (per-destination verdicts) and `egress-summary.log` are written to `artifacts/build-egress/` and uploaded as CI artifacts for security review.
- **CI Gate**: the `build-egress-monitor` job in `.github/workflows/ci.yml` runs installation and the production build inside the monitor in `--strict` mode (fails when capture is unavailable or unauthorized egress is seen).
- **Runbook**: [docs/security-runbook.md](docs/security-runbook.md).
- **Tests**: `test/egress-detector.test.js`.

### 8. Automated Dependency Version Drift & Breaking API Change Analyzer
- **Analyzer**: `scripts/detect-api-drift.js` extracts the exported type surface of every protected package — functions, interfaces, class members, call signatures and `export =` modules — from its `.d.ts` entry point with the TypeScript Compiler API, then diffs the installed surface against the reviewed baseline committed in `package.json` → `apiDrift.baseline`. Signatures are normalized (whitespace, `import("…")` specifiers rewritten to their `node_modules/` form) so the same package produces byte-identical baselines on CI runners and developer machines.
- **Version Pinning Guard**: dropped or re-typed signatures are *breaking*, new exports are *additive*. A breaking diff inside a semver-compatible (same/minor/patch) upgrade fails with exit 1 and names the version to pin; a breaking diff in a major upgrade is reported as a warning and needs a re-baseline. When a package does not bundle its own declarations the drift is classified with the `@types/<pkg>` version, so an `@types` minor bump that breaks call sites is caught too.
- **Exact Pin Opt-In**: `npm run security:api-drift:pin` (`--require-exact-pin`) additionally fails protected dependencies declared as `^` / `~` / `>=` instead of an exact `1.2.3`.
- **Rust half**: `Cargo.toml` → `[workspace.metadata.api-drift]` (`require-exact-pin`, `protected-crates`) is validated against `[workspace.dependencies]`, `[dependencies]` and `[dev-dependencies]`, where only `=1.2.3` counts as pinned (a bare `1.2.3` means `^1.2.3`).
- **Baselines**: `npm run security:api-drift:update` re-extracts and merges into `package.json` (root: cors, express, express-rate-limit, fuse.js, graphql, pg, react-dom; `server/package.json`: @stellar/stellar-sdk, @aztec/bb.js, @noir-lang/noir_js). Extraction options live in `tsconfig.json` → `apiDrift.compilerOptions`, deliberately outside `compilerOptions` so `tsc --noEmit` ignores them.
- **CI Gate**: the `api-drift-guard` job in `.github/workflows/ci.yml` installs with `npm ci --ignore-scripts`, runs `npm run security:api-drift` and `security:api-drift:server`, and uploads the drift report when it fails.
- **Tests**: `test/api-drift.test.js`.

---

## Quick Start
Expand Down
72 changes: 72 additions & 0 deletions docs/security-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,75 @@ The header and the markup use the same value because both come from `res.locals.
**The service worker precaches `index.html`.** A precached shell would serve a stale nonce. Keep the navigation route network-first for HTML when this ships; until then a service-worker-served page will be blocked by the policy rather than run unnonced scripts.

**Unchanged for API-only deploys.** The HTML route falls through when `dist/index.html` does not exist.

# Browser Sandbox Isolation for Untrusted Web Workers

Worker code is not fully trusted: the ZK prover pulls in third-party WASM and JS runtimes, and the canvas/cluster workers run vendored algorithm modules. A worker normally shares the page's origin, so a bug or compromise there could read `localStorage` / `sessionStorage`, reach the app origin's IndexedDB and Cache storage, use DOM globals a library happened to leak, or smuggle prototype-polluting payloads across `postMessage`.

Three independent layers close that off (`src/lib/workerSandbox.ts`, wired in by `plugins/vite-plugin-worker-sandbox.js`, covered by `test/worker-sandbox.test.js`):

1. **Origin isolation.** The worker is launched from a `blob:` URL created *inside* an iframe marked `sandbox="allow-scripts"` (no `allow-same-origin`), so it inherits an opaque ("null") origin: no cookies, no app storage, no same-origin privileges. The iframe owns the `Worker` and relays both directions, re-transferring transferables (`ArrayBuffer`s, `OffscreenCanvas`, …) so nothing is copied.
2. **Lockdown.** A bootstrap blob runs `installWorkerLockdown()` *before* any application module is imported, deleting (or, where a property cannot be deleted, making access throw on) `localStorage`, `sessionStorage`, `indexedDB`, `caches`, `BroadcastChannel`, `SharedWorker`, `importScripts`, `window`, `document`, `parent`, `top`, `frames` and `opener`. `self` is never touched, the function is idempotent, and it refuses to run against a window-like realm (anything with a `document`), so the same call is safe as a second line of defence inside the worker module.
3. **Message sanitization.** Every payload is parsed with a strict zod schema chosen for that worker's protocol, in *both* directions. Rejected payloads never cross the boundary; they are recorded as violations on the handle.

## Launch flow

`vite build`/`vite dev` transform (`enforce: 'post'`, i.e. after `vite:worker-import-meta-url` has turned `new URL('../workers/x.js', import.meta.url)` into a worker chunk URL):

```js
new Worker(new URL("../workers/zk-worker.js", import.meta.url), { type: "module" })
// becomes
import { createSandboxedWorker } from "/src/lib/workerSandbox.ts";
createSandboxedWorker(new URL("../workers/zk-worker.js", import.meta.url), { type: "module" })
```

The argument list is untouched, so Vite's worker chunking and `worker: { format: "es" }` keep working. `src/workers/**`, `test/**` and `node_modules/**` are excluded, and the transform is skipped entirely while `process.env.VITEST` is set (tests supply their own Worker doubles).

At runtime the handle resolves in this order:

1. Build the bootstrap (lockdown source + `import(workerUrl)`), create the sandboxed iframe, pass it the embedding document's CSP nonce (`srcdoc` documents inherit the parent's policy, so its inline script has to carry the nonce).
2. The frame mints the blob URL, creates the worker, and relays `hello`/`create`/`message`/`terminate` envelopes back and forth; both ends authenticate with a per-launch token and check `event.source`/`event.origin`.
3. Messages posted before the frame connects are queued (cap 64) and flushed once it is ready.
4. The worker posts `__helphone: 'boot'` after the lockdown runs; the handle marks the worker booted and clears the boot timer. Control messages are consumed by the handle and never reach `onmessage`.

## Violation codes

| Code | Meaning |
| --- | --- |
| `inbound-schema` | A message *to* the worker failed its protocol schema; dropped |
| `outbound-schema` | A message *from* the worker failed its protocol schema; dropped |
| `boot-failure` | Frame error, missing boot handshake, or the bootstrap could not import the worker module |
| `frame-timeout` | The opaque transport did not become ready in time |
| `queue-overflow` | More than 64 messages were posted while connecting (oldest dropped) |
| `transport-error` | The relay threw while posting |

Violations are exposed as `handle.violations` (and through `onViolation`); without a handler they are logged with a `[worker-sandbox]` prefix.

## Degradation

Opaque isolation costs something in restricted environments, so it fails *open to a weaker but still protected* configuration rather than breaking the worker:

- frame unavailable, frame never connects, boot handshake missing, or the module graph is not CORS-readable → **same-origin blob worker** (lockdown + sanitization still apply), with a `frame-timeout` / `boot-failure` violation and a console warning;
- `origin: "same-origin"` skips the iframe by design;
- `origin: "opaque"` (or `fallbackToSameOrigin: false`) never degrades — it surfaces the failure through `onerror` instead.

## Production deployment notes

The sandbox is fully functional under `npm run dev` and `npm run preview`, where the plugin also answers `Origin: null` fetches with `Access-Control-Allow-Origin: null` for non-`/api` GETs. Two deployment knobs are required for the opaque path to work behind the production CSP/server:

- **Static assets need `Access-Control-Allow-Origin: null`** (or `*`). An opaque-origin worker fetches its module graph as a cross-origin CORS request; without the header the import fails, the bootstrap reports `boot-error`, and the handle degrades to same-origin.
- **`script-src` is evaluated against the worker's origin.** For a `blob:null/...` worker the inherited `'self'` is the null origin, so the inherited production policy can reject the module import for the same reason. Serving the app without that inheritance (or with an explicit source for the worker entry) keeps opaque mode enabled; otherwise the automatic same-origin fallback keeps the app working.

Known trade-offs: an opaque worker reports `crossOriginIsolated === false`, so `zk-worker.getThreadCount()` falls back to one thread (slower proving — pass `origin: "same-origin"` to that launch site if threaded proving matters more than isolation); and `frame-src` is not needed for the frame, because the frame document is created from `srcdoc` rather than navigated to a URL.

## Design decisions

**The parent never talks to the worker directly in opaque mode.** A `blob:null/...` URL can only be resolved inside the frame that minted it, so the iframe owns the `Worker` and the parent only ever sees relayed messages. The relay is what keeps `transfer` semantics intact on both hops.

**Schemas live with the protocol, not the call site.** `selectWorkerSchemas()` picks the boundary from the emitted worker file name (`zk-worker*` → zk, `clusterWorker*` → cluster, everything else → generic), so a renamed chunk still gets a sanitizer and a test can assert exactly which protocol enforced a rejection. The two in-scope workers validate on their side too — defence in depth, and it keeps a future same-origin launch just as strict.

**The lockdown is stringified, not imported.** It has to run before any application code, from a blob that cannot resolve imports, so it may not reference module scope. That constraint is enforced by construction (and by `buildWorkerBootstrap`'s source assertions).

**Schema failures drop the payload rather than the worker.** A malformed message is a bug or an attack on one boundary, not a reason to tear down the prover; every drop is counted on the handle so callers can see them.

**The rewrite is masked text substitution, not a regex over raw source.** Comments and string literals are blanked (same length, same offsets) before searching, so documentation mentioning `new Worker(` cannot be rewritten, and the injected import is only added when the module does not already import the launcher.
Loading