Security hardening and release readiness
ober
43f0fef381fd099b3b19c4fe3b9b33c1ee7596f7
new file mode 100644 --- /dev/null +++ b/.github/workflows/security-baseline.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install build tools + run: sudo apt-get update && sudo apt-get install -y build-essential curl ca-certificates git ripgrep pkg-config + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-audit + run: cargo install cargo-audit --locked + + - name: Install jerbuild + run: | + set -eux + curl -fsSL "https://github.com/jerboa-lang/jerboa/releases/download/${JERBOA_VERSION}/jerbuild-linux-x86_64" -o /usr/local/bin/jerbuild + chmod +x /usr/local/bin/jerbuild + env: + JERBOA_VERSION: v0.2.3 + + - name: Verify + run: JERBUILD=/usr/local/bin/jerbuild make verify + + - name: Release evidence + run: JERBUILD=/usr/local/bin/jerbuild make release-evidence --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,15 @@ # Build artifacts (jerbuild build) /jerboa-edge +dist/ +.build/ vendor/ +/.jerboa/jerboa-native-rs/ /*-main.c support/ffi-symbols.gen *.so *.wpo +*.o +*.a *.boot petite_boot.h scheme_boot.h --- a/.jerboa/security.json +++ b/.jerboa/security.json @@ -3,20 +3,28 @@ "repo": "jerboa-edge", "extends": ["jerboa:daemon", "jerboa:ffi", "jerboa:network-service"], "paths": { - "production": ["*.ss", "*.sls", "lib/**/*.ss", "lib/**/*.sls", "src/**/*.{ss,sls,c,h,rs}", "native/**/*.{c,h,rs}", "Makefile"], + "production": ["*.ss", "*.sls", "lib/**/*.ss", "lib/**/*.sls", "src/**/*.{ss,sls,c,h,rs}", "native/**/*.{c,h,rs}", "support/**/*.{c,h,ss,sh}", "Makefile", ".jerbuild"], "tests": ["test/**", "tests/**", "**/*-test.ss"], "generated": ["build/**", "dist/**", "target/**", "*.so", "*.dylib", "*.wpo"], "vendor": ["vendor/**", "third_party/**"], "docs": ["README.md", "docs/**", "*.md"] }, "policy": { - "failOn": ["critical", "high"], - "imports": { "directChezscheme": "allow-in-ffi-boundaries" }, - "ffi": { "allowed": true, "requireDynamicWindCleanup": true }, + "failOn": ["critical", "high", "medium"], + "imports": { "directChezscheme": "deny-in-project-source" }, + "ffi": { "allowed": true, "nativeFeatures": ["crypto", "wasm"], "denyFeatures": ["tls"], "requireDynamicWindCleanup": true }, "process": { "shellInterpolation": "deny" }, - "network": { "requireTimeouts": true, "requireFailClosedSandbox": true, "requireMtlsForAdmin": true }, + "filesystem": { "allowedWritePaths": ["EDGE_DB_PATH", "temporary embedded-program file"], "requireDocumentedPaths": true }, + "network": { "ingress": "plain-http-behind-external-mtls", "requireTimeouts": true, "requireFailClosedSandbox": true, "requireMtlsForAdmin": true }, + "releaseEvidence": { + "linuxRuntime": "current-smoke-recorded-or-target-proof-required", + "sustainedLoad": "release-host-sustained-recorded-or-target-proof-required", + "proofFiles": ["JEDGE_TARGET_RUNTIME_PROOF_FILE", "JEDGE_TARGET_LOAD_PROOF_FILE"], + "failClosedProofFlags": ["JEDGE_REQUIRE_TARGET_RUNTIME_PROOF", "JEDGE_REQUIRE_TARGET_LOAD_PROOF"] + }, "eval": { "stringEval": "deny", "bareRead": "deny", "allowReadEval": false }, - "auth": { "rejectUnsignedWebhooks": true } + "auth": { "rejectUnsignedWebhooks": true, "allowUnsignedOnlyWithExplicitDevFlag": "EDGE_ALLOW_UNSIGNED" } }, + "runtimeTargets": ["linux"], "suppressions": [] } --- a/.jerbuild +++ b/.jerbuild @@ -1,8 +1,10 @@ ;; Build jerboa-edge with a standalone jerbuild binary: `jerbuild build`. ;; -;; jerbuild bundles Chez Scheme + the jerboa stdlib + the jerboa-native Rust -;; crate, so this needs only jerbuild + a C compiler + cargo — no jerboa -;; source checkout. jerboa-native provides rustls TLS and crypto. +;; The Makefile copies jerbuild's bundled jerboa-native-rs source into +;; .jerboa/jerboa-native-rs before invoking `jerbuild build`; the crate then +;; builds under this repo instead of the shared jerbuild cache target. +;; jerboa-native provides HMAC/constant-time crypto and WASM-backed restricted +;; execution. (entry "edge.ss") (output "jerboa-edge") @@ -14,6 +16,6 @@ (main-c "support/static-main.c") (ffi-symbols "support/ffi-symbols.gen") (rust-crates - ("@bundle/jerboa-native-rs/Cargo.toml" - features: "tls,crypto" + (".jerboa/jerboa-native-rs/Cargo.toml" + features: "crypto,wasm" no-default-features: #t)) new file mode 100644 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. --- a/Makefile +++ b/Makefile @@ -1,20 +1,31 @@ # jerbuild bundles Chez Scheme + the jerboa stdlib + the jerboa-native Rust -# crate, so building jerboa-edge needs only `jerbuild`, a C compiler, and -# cargo — no jerboa source checkout and no separately-built Chez/native lib. +# crate. The Makefile copies that native crate into .jerboa/jerboa-native-rs +# before building so the daemon's native archive is repo-local and feature +# isolated. JERBUILD ?= jerbuild JH := $(shell $(JERBUILD) --jerboa-home 2>/dev/null) ifeq ($(JH),) $(error jerbuild not found on PATH (or '$(JERBUILD) --jerboa-home' failed). Install jerbuild, or set JERBUILD=/path/to/jerbuild) endif -NATIVE_A := $(JH)/jerboa-native-rs/target/release/libjerboa_native.a +JERBOA_NATIVE_BUNDLE_DIR := $(JH)/jerboa-native-rs +NATIVE_DIR ?= $(CURDIR)/.jerboa/jerboa-native-rs +NATIVE_A ?= $(NATIVE_DIR)/target/release/libjerboa_native.a +NATIVE_FEATURES ?= crypto,wasm +NATIVE_NO_DEFAULT_FEATURES ?= 1 BIN := jerboa-edge BIN_DIR := $(HOME)/.local/bin VENDOR := $(CURDIR)/vendor JSQLITE_REPO ?= $(VENDOR)/jsqlite JSQLITE_URL ?= https://git.sr.ht/~lisp/jsqlite +LIBDIRS := .:$(JH)/lib:$(JSQLITE_REPO)/src +EVIDENCE_DIR ?= dist/release-evidence +SBOM_DIR ?= dist/sbom +REPRO_DIR ?= dist/reproducibility +SOAK_DIR ?= dist/soak +SMOKE_PORT ?= $(shell python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()' 2>/dev/null || echo 18080) -.PHONY: all build binary install clean ensure-jsqlite +.PHONY: all build binary run install clean clean-generated ensure-jsqlite ensure-native-src import-check security audit smoke sbom reproducibility-report soak-evidence verify release-evidence all: binary @@ -25,24 +36,135 @@ ensure-jsqlite: fi @test -f "$(JSQLITE_REPO)/src/jsqlite/api.ss" +ensure-native-src: + @test -f "$(JERBOA_NATIVE_BUNDLE_DIR)/Cargo.toml" || { \ + echo "ERROR: bundled jerboa-native-rs missing at $(JERBOA_NATIVE_BUNDLE_DIR)" >&2; \ + exit 1; \ + } + rm -rf "$(NATIVE_DIR)" + mkdir -p "$(dir $(NATIVE_DIR))" + (cd "$(JH)" && tar --exclude='jerboa-native-rs/target' --exclude='jerboa-native-rs/.git' -cf - jerboa-native-rs) | tar -xf - -C "$(dir $(NATIVE_DIR))" + # Standalone native binary via .jerbuild (entry edge.ss -> jerboa-edge). -# Two passes: the first cargo-builds jerboa-native into jerbuild's cache; we -# then regenerate the FFI symbol list from that archive (so it matches this +# Two passes: the first cargo-builds the repo-local jerboa-native copy; we then +# regenerate the FFI symbol list from that archive (so it matches this # platform — e.g. epoll on Linux) and relink. A static main.c sets # JERBOA_STATIC=1 so the std modules use the registered symbols, not dlopen. -binary: ensure-jsqlite +binary: ensure-jsqlite ensure-native-src @: > support/ffi-symbols.gen $(JERBUILD) build - sh support/gen-ffi-symbols.sh + JERBUILD="$(JERBUILD)" NATIVE_A="$(NATIVE_A)" sh support/gen-ffi-symbols.sh $(JERBUILD) build build: binary +run: binary + ./$(BIN) + install: binary mkdir -p $(BIN_DIR) install -m 0755 $(BIN) $(BIN_DIR)/$(BIN) @echo "Installed $(BIN) to $(BIN_DIR)/$(BIN)" +import-check: ensure-jsqlite + $(JERBUILD) exec --libdirs "$(LIBDIRS)" support/import-check.ss + +security: clean-generated + bash scripts/security-check.sh + +audit: security ensure-native-src + @echo "==> jerbuild" + @$(JERBUILD) --version + @echo "==> C compiler" + @cc --version 2>/dev/null | head -1 || cc -v 2>&1 | head -1 + @echo "==> Rust native bundle" + @if [ -f "$(NATIVE_DIR)/Cargo.toml" ]; then \ + cargo metadata --format-version 1 --manifest-path "$(NATIVE_DIR)/Cargo.toml" >/dev/null; \ + else \ + echo "ERROR: native bundle missing at $(NATIVE_DIR); run the binary target before audit." >&2; \ + exit 1; \ + fi + @if cargo audit --version >/dev/null 2>&1; then \ + cargo audit --file "$(NATIVE_DIR)/Cargo.lock" -D warnings; \ + else \ + echo "ERROR: cargo audit is not installed; install cargo-audit for the native dependency audit gate." >&2; \ + exit 1; \ + fi + +smoke: binary + @set -eu; \ + if [ "$$(uname -s)" != "Linux" ]; then \ + echo "smoke: skipped on $$(uname -s); static fiber HTTP runtime requires Linux epoll symbols and runs in CI/release Linux"; \ + exit 0; \ + fi; \ + log="dist/jerboa-edge-smoke.log"; \ + mkdir -p dist; \ + EDGE_PORT="$(SMOKE_PORT)" EDGE_ALLOW_UNSIGNED=1 ./$(BIN) > "$$log" 2>&1 & \ + pid=$$!; \ + trap 'kill $$pid >/dev/null 2>&1 || true; wait $$pid >/dev/null 2>&1 || true' EXIT INT TERM; \ + ok=0; \ + for i in $$(seq 1 50); do \ + if curl -fsS "http://127.0.0.1:$(SMOKE_PORT)/health" >/dev/null 2>&1; then ok=1; break; fi; \ + sleep 0.2; \ + done; \ + test "$$ok" = 1; \ + curl -fsS "http://127.0.0.1:$(SMOKE_PORT)/health" | grep -q '"status":"ok"'; \ + code=$$(curl -sS -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:$(SMOKE_PORT)/hooks/smoke" -H 'Content-Type: application/json' -d '{"id":"smoke_1","amount":1}'); \ + test "$$code" = 202; \ + echo "smoke: ok" + +sbom: binary + @REPO_ROOT="$(CURDIR)" \ + SBOM_DIR="$(SBOM_DIR)" \ + JERBUILD="$(JERBUILD)" \ + JERBOA_HOME="$(JH)" \ + NATIVE_DIR="$(NATIVE_DIR)" \ + NATIVE_A="$(NATIVE_A)" \ + NATIVE_FEATURES="$(NATIVE_FEATURES)" \ + NATIVE_NO_DEFAULT_FEATURES="$(NATIVE_NO_DEFAULT_FEATURES)" \ + BIN="$(BIN)" \ + sh scripts/sbom.sh + +reproducibility-report: ensure-jsqlite + @REPO_ROOT="$(CURDIR)" \ + REPRO_DIR="$(REPRO_DIR)" \ + JERBUILD="$(JERBUILD)" \ + MAKE="$(MAKE)" \ + BIN="$(BIN)" \ + NATIVE_A="$(NATIVE_A)" \ + sh scripts/reproducibility-report.sh + +soak-evidence: + @REPO_ROOT="$(CURDIR)" \ + SOAK_DIR="$(SOAK_DIR)" \ + JERBUILD="$(JERBUILD)" \ + MAKE="$(MAKE)" \ + SMOKE_PORT="$(SMOKE_PORT)" \ + sh scripts/soak-evidence.sh + +verify: security audit import-check smoke + +release-evidence: + @REPO_ROOT="$(CURDIR)" \ + EVIDENCE_DIR="$(EVIDENCE_DIR)" \ + SBOM_DIR="$(SBOM_DIR)" \ + REPRO_DIR="$(REPRO_DIR)" \ + SOAK_DIR="$(SOAK_DIR)" \ + JERBUILD="$(JERBUILD)" \ + JERBOA_HOME="$(JH)" \ + NATIVE_DIR="$(NATIVE_DIR)" \ + NATIVE_A="$(NATIVE_A)" \ + NATIVE_FEATURES="$(NATIVE_FEATURES)" \ + NATIVE_NO_DEFAULT_FEATURES="$(NATIVE_NO_DEFAULT_FEATURES)" \ + sh scripts/release-evidence.sh + +clean-generated: + rm -f $(BIN) $(BIN)-main.c support/ffi-symbols.gen + rm -f dist/jerboa-edge-smoke.log + find . \( -name '*.so' -o -name '*.wpo' -o -name '*.o' -o -name '*.a' \) -delete 2>/dev/null || true + clean: - rm -f $(BIN) $(BIN)-main.c - find . \( -name '*.so' -o -name '*.wpo' \) -delete 2>/dev/null || true + rm -rf dist + rm -rf .jerboa/jerboa-native-rs + rm -f $(BIN) $(BIN)-main.c support/ffi-symbols.gen + find . \( -name '*.so' -o -name '*.wpo' -o -name '*.o' -o -name '*.a' \) -delete 2>/dev/null || true --- a/README.md +++ b/README.md @@ -1 +1,77 @@ # jerboa-edge + +`jerboa-edge` is a Jerboa webhook processing daemon. It accepts plain HTTP +from a trusted local or private-network ingress, verifies webhook HMACs, runs +event filters and hot handlers in Jerboa restricted execution, persists optional +state through jsqlite, and exposes health, metrics, event query, dead-letter, +and dashboard endpoints. + +This daemon is not its own internet TLS endpoint. Production deployments must +put it behind an external mTLS terminator or service mesh and expose the daemon +only on a private listener. + +## Build + +```sh +JERBUILD=/path/to/jerbuild make build +``` + +The static binary is written to `./jerboa-edge`. The build uses Jerboa native +features `crypto,wasm`; TLS native code is intentionally not linked. + +Production runtime target is Linux. The static fiber HTTP server uses Jerboa's +Linux epoll-native backend; macOS can build the binary but does not provide the +runtime epoll symbols needed to execute it. + +## Run + +```sh +EDGE_SECRET='replace-with-webhook-secret' \ +EDGE_PORT=8080 \ +EDGE_DB_PATH=/var/lib/jerboa-edge/events.db \ +./jerboa-edge +``` + +`EDGE_SECRET` is required for production webhook ingestion. If it is missing, +unsigned webhook requests fail closed. Local development can opt in to unsigned +requests with `EDGE_ALLOW_UNSIGNED=1`. + +Useful endpoints: + +- `POST /hooks/:type` +- `GET /health` +- `GET /metrics` +- `GET /api/stats` +- `GET /api/events/:id` +- `GET /api/dead-letter` +- `POST /api/dead-letter/:id/replay` +- `GET /dashboard` + +## Security + +Release gates: + +```sh +JERBUILD=/path/to/jerbuild make verify +JERBUILD=/path/to/jerbuild make release-evidence +``` + +Release evidence includes Linux runtime/load status under +`dist/release-evidence/soak/`. On non-Linux hosts this records the runtime and +load gates as blocked; production release requires Linux release-host smoke and +sustained load evidence. A Linux release host can record sustained load with +`JEDGE_RUN_RELEASE_SOAK=1 JEDGE_RUN_PRODUCTION_LOAD=1`; externally captured +runtime/load proof can be attached with `JEDGE_TARGET_RUNTIME_PROOF_FILE` and +`JEDGE_TARGET_LOAD_PROOF_FILE`. + +The security posture is documented in: + +- `SECURITY.md` +- `docs/threat-model.md` +- `docs/deployment-security.md` +- `docs/ffi-boundary.md` +- `docs/release-evidence.md` + +## License + +Apache-2.0, matching the Jerboa and Chez Scheme license posture. See `LICENSE`. new file mode 100644 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,43 @@ +# Security Policy + +`jerboa-edge` is a network daemon and must be treated as hostile-input-facing +software. Production releases require the gates in `~/Release-plan.md`, +`~/mine/jerboa-production-readiness.md`, and this repository to pass. + +## Supported Status + +No public production-support commitment exists until a release is cut with +`make release-evidence` from a clean checkout and the generated evidence is +reviewed. + +Required gates: + +- `make verify` passes with the intended `JERBUILD`. +- Medium-or-higher Jerboa security scanner findings are zero. +- `EDGE_SECRET` is configured in production. +- The daemon is reachable only behind external mTLS or an equivalent authenticated ingress. +- `EDGE_ALLOW_UNSIGNED` is unset in production. +- Persistence paths, credentials, and service account permissions are documented by the deployer. +- Dependency and native-code audit output is clean, or accepted risk is recorded in release notes. +- Release evidence includes explicit Linux runtime and sustained load status. + Production evidence must record `linux_runtime_status=current-smoke-recorded` + from a Linux host or a required `target_runtime_proof_status=present` proof + file, and `load_status=release-host-sustained-recorded` or a required + `target_load_proof_status=present` proof file. Any `blocked-*`, `failed`, or + `blocked-not-run` runtime/load status is a production blocker. + +## Production Rules + +- Do not expose `jerboa-edge` directly to the public internet. +- Do not enable the development unsigned-webhook mode in production. +- Do not commit secrets, private keys, tokens, production `.env` files, hostnames, customer data, or database files. +- Do not add in-process TLS without a threat model update, scanner coverage, and cleanup tests for native handles. +- Do not add broad direct `(chezscheme)` imports in project source. +- Do not add string-built SQL, shell interpolation, or bare Scheme `read` on untrusted input. +- Keep restricted user filters and hot handlers behind Jerboa restricted execution and timeouts. + +## Reporting + +Before public release, report issues privately to the repository owner. After a +public release, replace this section with a dedicated advisory contact, +supported versions, and disclosure window. new file mode 100644 --- /dev/null +++ b/docs/deployment-security.md @@ -0,0 +1,63 @@ +# Deployment Security + +## Network + +Run `jerboa-edge` behind an external mTLS terminator, service mesh sidecar, or +reverse proxy that enforces client authentication, request size limits, rate +limits, access logging, and TLS policy. Bind the daemon to a private interface +or loopback listener. + +The daemon intentionally does not link Jerboa native TLS support. This avoids a +second in-process HTTP/TLS parser and keeps TLS certificate/key material out of +the Scheme process. + +Production runtime target is Linux. The static fiber HTTP server uses Jerboa's +epoll-native event loop, so Linux release/CI smoke tests are required before +promotion. Sustained load evidence must also be recorded for the release host +with `JEDGE_RUN_RELEASE_SOAK=1 JEDGE_RUN_PRODUCTION_LOAD=1`, or attached as +reviewable target proof with `JEDGE_TARGET_RUNTIME_PROOF_FILE` and +`JEDGE_TARGET_LOAD_PROOF_FILE`. Local non-Linux evidence keeps the runtime and +load gates blocked unless target proof files are supplied and hashed into the +bundle. + +## Authentication + +Set `EDGE_SECRET` in production. Webhook requests without a valid `x-signature` +are rejected when a secret is configured. If `EDGE_SECRET` is absent, unsigned +webhooks are rejected unless `EDGE_ALLOW_UNSIGNED=1` is explicitly set for local +development. + +Do not set `EDGE_ALLOW_UNSIGNED` in production. + +## Persistence + +`EDGE_DB_PATH` enables jsqlite persistence. The deployer should place this path +on a dedicated directory owned by the service account with no world-readable +permissions. Backups and logs containing payload data should follow the same +data handling policy as webhook payloads. + +## Process Sandboxing + +Use a dedicated service account. For systemd, prefer: + +- `NoNewPrivileges=true` +- `PrivateTmp=true` +- `ProtectSystem=strict` +- `ProtectHome=true` +- `ReadWritePaths=` limited to the `EDGE_DB_PATH` directory +- `RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX` +- memory and file-descriptor limits sized for expected load + +## Operational Checks + +Before promoting a build: + +```sh +JERBUILD=/path/to/jerbuild make verify +JERBUILD=/path/to/jerbuild make release-evidence +``` + +Keep generated `dist/release-evidence` with the release record. +The bundle must include `dist/release-evidence/soak/status.txt`. A +`load_status=blocked-not-run`, `linux_runtime_status=blocked-*`, or +`status=blocked-target-proof` entry is a production blocker. new file mode 100644 --- /dev/null +++ b/docs/ffi-boundary.md @@ -0,0 +1,37 @@ +# FFI Boundary + +`jerboa-edge` builds a standalone jerbuild binary with a custom C launcher and +the bundled Jerboa native Rust archive. + +## Native Features + +The `.jerbuild` configuration enables only: + +- `crypto`: HMAC-SHA256 and constant-time comparison. +- `wasm`: Jerboa restricted execution support. + +The TLS feature is intentionally disabled. Production TLS and client +authentication must be supplied by external ingress. + +## Static Launcher + +`support/static-main.c` embeds the Scheme program boot data in a temporary file +and invokes Chez through `Sscheme_program`. The launcher: + +- sets `JERBOA_STATIC=1` so Jerboa uses linked native symbols, +- uses `mkstemp` for the temporary program path, +- writes boot data with a checked `fwrite_all` loop, +- closes with checked `fclose`, +- unlinks the temporary file after the Scheme program starts. + +## FFI Symbol List + +`support/gen-ffi-symbols.sh` regenerates `support/ffi-symbols.gen` from the +actual platform archive after the first jerbuild pass. The file is ignored +because it is generated and platform-specific. + +## SQLite Boundary + +`jsqlite` is vendored under `vendor/` during builds. Prepared statements are +finalized with `dynamic-wind`, and database handles are closed on failed open +and graceful shutdown. new file mode 100644 --- /dev/null +++ b/docs/release-evidence.md @@ -0,0 +1,74 @@ +# Release Evidence + +`make release-evidence` writes evidence to `dist/release-evidence`. + +The directory includes: + +- git commit, status, and diff summary, +- build environment and native feature summary, +- security, audit, import-check, and smoke-test logs, +- Cargo metadata for the bundled Jerboa native crate when available, +- SBOM/dependency manifests for source, support scripts, vendored jsqlite, + the repo-local no-default `crypto,wasm` native crate copy, binary linkage, + and native artifact hashes, +- repeated clean-build reproducibility reports for the standalone binary, + generated FFI symbol list, native archive, and source manifest, +- `soak/` with Linux runtime smoke, sustained request-load status, optional + target proof files, and SHA-256 hashes of attached proof files. Local + non-Linux evidence records the Linux runtime smoke and load gates as blocked + instead of treating the macOS smoke skip as production-ready evidence, +- binary linkage information, +- SHA-256 hashes for the binary and source files. + +Release evidence is generated from the same gates used by CI: + +```sh +JERBUILD=/path/to/jerbuild make verify +JERBUILD=/path/to/jerbuild make release-evidence +``` + +On non-Linux developer hosts the smoke target records a skip because the static +fiber HTTP runtime requires Linux epoll-native symbols. Production release +evidence must include the Linux CI/release-host smoke result. + +To refresh runtime smoke evidence on a Linux release host: + +```sh +JEDGE_RUN_RELEASE_SOAK=1 JERBUILD=/path/to/jerbuild make soak-evidence +``` + +That records `status=short-smoke-not-production` when the Linux smoke passes. +To record sustained request-load evidence on that Linux host: + +```sh +JEDGE_RUN_RELEASE_SOAK=1 \ +JEDGE_RUN_PRODUCTION_LOAD=1 \ +JEDGE_PRODUCTION_SOAK_MIN_REQUESTS=10000 \ +JERBUILD=/path/to/jerbuild \ +make soak-evidence +``` + +That records `load_status=release-host-sustained-recorded` only after the +daemon accepts at least `JEDGE_PRODUCTION_SOAK_MIN_REQUESTS` sequential webhook +requests with zero failed requests. + +If the Linux smoke or sustained load run is captured by another release-host +job, attach that evidence to the bundle with: + +```sh +JEDGE_TARGET_RUNTIME_PROOF_FILE=/path/to/linux-runtime-status.txt \ +JEDGE_TARGET_LOAD_PROOF_FILE=/path/to/linux-load-status.txt \ +JEDGE_REQUIRE_TARGET_RUNTIME_PROOF=1 \ +JEDGE_REQUIRE_TARGET_LOAD_PROOF=1 \ +make soak-evidence +``` + +The runtime proof must contain `linux_runtime_status=current-smoke-recorded`. +The load proof must contain `load_status=release-host-sustained-recorded`. +Supplied proof files are copied into `dist/soak/` and hashed. Missing, empty, or +marker-mismatched required proof files fail closed with +`status=blocked-target-proof`. + +The target writes evidence before returning failure. A nonzero exit with +`release_evidence_status=blocked` is a release blocker, not an incomplete +evidence bundle. new file mode 100644 --- /dev/null +++ b/docs/threat-model.md @@ -0,0 +1,46 @@ +# jerboa-edge Threat Model + +## Scope + +`jerboa-edge` receives webhooks from a trusted ingress, queues events, runs +filters and handlers, stores optional SQLite-backed state, and exposes query, +metrics, health, dead-letter, and dashboard endpoints. + +## Assets + +- Webhook payloads and event metadata. +- `EDGE_SECRET` and any deployer-managed ingress credentials. +- SQLite state at `EDGE_DB_PATH`. +- Restricted filter and hot-handler code submitted through API endpoints. +- Service availability and worker supervision state. + +## Trust Boundaries + +- External clients terminate at an mTLS ingress before reaching this daemon. +- `jerboa-edge` listens on plain HTTP only inside the trusted runtime boundary. +- HMAC verification authenticates webhook payloads when `EDGE_SECRET` is set. +- User-provided filter and handler code crosses into Jerboa restricted execution. +- jsqlite persistence crosses into a local filesystem/database boundary. +- Jerboa native FFI is limited to `crypto,wasm` for this daemon. + +## Primary Threats And Controls + +- Unauthorized webhook submission: production requires `EDGE_SECRET`; missing secrets fail closed. +- Accidental unsigned mode: `EDGE_ALLOW_UNSIGNED=1` is explicit development-only behavior and is checked by the security gate. +- Public daemon exposure: production requires external mTLS or equivalent authenticated ingress. +- Code execution through filters or hot handlers: code runs through `restricted-eval-string` with Jerboa restricted bindings and timeouts. +- SQL injection: SQL statements are literals and event values are bound parameters. +- SQLite handle leaks: prepared statements are finalized with `dynamic-wind`; database handles close on failed open and shutdown. +- Native handle leaks: in-process TLS is removed; the remaining static launcher uses checked stdio writes and closes. +- Worker crash loss: worker actors run under one-for-one supervision and failed events retry before dead-lettering. + +## Residual Risk + +- The daemon depends on the deployer to enforce mTLS, network ACLs, request size limits, rate limits, and process sandboxing. +- Restricted execution is still hostile-input-facing and must be reviewed whenever new bindings are added. +- The smoke test validates basic runtime behavior on Linux, not sustained load, + fuzzing, or full ingress policy. Release evidence must record explicit + Linux runtime status and sustained request-load status; target proof files + can be attached and hashed, but `load_status=blocked-not-run`, + `linux_runtime_status=blocked-*`, or `status=blocked-target-proof` remains a + production blocker. --- a/edge.ss +++ b/edge.ss @@ -12,7 +12,7 @@ ;;; - Structured JSON logging (greppable by Loki/Splunk) ;;; - Graceful SIGTERM shutdown (drain, flush jsqlite WAL) ;;; - Optional jsqlite persistence (EDGE_DB_PATH) -;;; - Optional TLS termination (EDGE_TLS_CERT + EDGE_TLS_KEY) +;;; - Production deployment behind an external mTLS terminator ;;; ;;; Zero external dependencies. Everything is Jerboa stdlib. ;;; @@ -22,25 +22,17 @@ ;;; curl localhost:8080/api/stats ;;; curl localhost:8080/metrics -(import (except (chezscheme) merge - make-hash-table hash-table? - sort sort! format printf fprintf - iota 1+ 1- - path-extension path-absolute? - with-input-from-string with-output-to-string - make-date partition - atom? meta) - (except (jerboa prelude) - make-time split cpu-count or-else +(import (except (jerboa prelude) + cpu-count or-else json-object->string string->json-object write-json read-json tcp-write-string tcp-write tcp-read tcp-close tcp-accept tcp-listen tcp-connect - sqlite-open sqlite-close sqlite-exec sqlite-execute sqlite-query - sqlite-prepare sqlite-finalize sqlite-step sqlite-bind) + sqlite-open sqlite-close sqlite-finalize sqlite-exec sqlite-execute sqlite-query + sqlite-prepare sqlite-step sqlite-bind) (std net fiber-httpd) (std net fiber-ws) (except (std csp) go go-named) - (std csp clj) + (except (std csp clj) merge split) (std actor) (std stm) (std transducer) @@ -51,16 +43,17 @@ (std misc timeout) (std metrics) (std os signal) + (only (std error conditions) make-supervision-failure) (prefix (jsqlite api) japi:) - (prefix (jsqlite constants) jconst:) - (std net tls-rustls) - (std net tcp-raw)) + (prefix (jsqlite constants) jconst:)) (def sqlite-open japi:sqlite-open) -(def sqlite-close japi:sqlite-close) (def sqlite-exec japi:sqlite-exec) (def sqlite-execute japi:sqlite-exec) +(def (db-close-handle! db) + (japi:sqlite-close db)) ;; sqlite-finalize is handled before connection close + (def (bind-sqlite-args! stmt vals) (let loop ([i 1] [vs vals]) (unless (null? vs) @@ -68,20 +61,26 @@ (loop (+ i 1) (cdr vs))))) (def (sqlite-query db sql . vals) - (let ([stmt (japi:sqlite-prepare db sql)]) - (bind-sqlite-args! stmt vals) - (let ([names (japi:sqlite-columns stmt)]) - (let loop ([acc '()]) - (let ([rc (japi:sqlite-step stmt)]) - (cond - [(= rc jconst:SQLITE_ROW) - (loop (cons (map cons names (japi:sqlite-row stmt)) acc))] - [(= rc jconst:SQLITE_DONE) - (japi:sqlite-finalize stmt) - (reverse acc)] - [else - (japi:sqlite-finalize stmt) - (reverse acc)])))))) + (let ([stmt #f]) + (dynamic-wind + (lambda () (void)) + (lambda () + (set! stmt (japi:sqlite-prepare db sql)) + (bind-sqlite-args! stmt vals) + (let ([names (japi:sqlite-columns stmt)]) + (let loop ([acc '()]) + (let ([rc (japi:sqlite-step stmt)]) + (cond + [(= rc jconst:SQLITE_ROW) + (loop (cons (map cons names (japi:sqlite-row stmt)) acc))] + [(= rc jconst:SQLITE_DONE) + (reverse acc)] + [else + (reverse acc)]))))) + (lambda () + (when stmt + (guard (e [(condition? e) (void)]) + (japi:sqlite-finalize stmt))))))) ;; ═══════════════════════════════════════════════════════════════ ;; Configuration @@ -97,9 +96,8 @@ (let ([v (getenv "EDGE_ALLOW_UNSIGNED")]) (and v (or (string=? v "1") (string=? v "true") (string=? v "yes"))))) (def *db-path* (getenv "EDGE_DB_PATH")) ;; SQLite file path, or #f for in-memory only -(def *tls-cert* (getenv "EDGE_TLS_CERT")) ;; PEM cert path, or #f to disable TLS -(def *tls-key* (getenv "EDGE_TLS_KEY")) ;; PEM key path, or #f to disable TLS -(def *tls-port* (or (and (getenv "EDGE_TLS_PORT") (string->number (getenv "EDGE_TLS_PORT"))) 8443)) +(def (secret-configured?) + (> (string-length *secret*) 0)) ;; ═══════════════════════════════════════════════════════════════ ;; Prometheus Metrics (Phase 3.4) @@ -155,20 +153,21 @@ (def (db-init!) (when *db-path* - (let ([db (sqlite-open *db-path*)]) - (sqlite-exec db "PRAGMA journal_mode=WAL") - (sqlite-exec db - (string-append - "CREATE TABLE IF NOT EXISTS events (" - " id TEXT PRIMARY KEY, type TEXT, status TEXT," - " received TEXT, processed TEXT)")) - (sqlite-exec db - (string-append - "CREATE TABLE IF NOT EXISTS dead_letters (" - " id TEXT PRIMARY KEY, type TEXT, reason TEXT," - " retries INTEGER, ts INTEGER)")) - (set! *db* db) - (jlog "sqlite opened" "path" *db-path*)))) + (let ([db #f]) + (dynamic-wind + (lambda () (void)) + (lambda () + (set! db (sqlite-open *db-path*)) + (sqlite-exec db "PRAGMA journal_mode=WAL") + (sqlite-exec db "CREATE TABLE IF NOT EXISTS events (id TEXT PRIMARY KEY, type TEXT, status TEXT, received TEXT, processed TEXT)") + (sqlite-exec db "CREATE TABLE IF NOT EXISTS dead_letters (id TEXT PRIMARY KEY, type TEXT, reason TEXT, retries INTEGER, ts INTEGER)") + (set! *db* db) + (set! db #f) + (jlog "sqlite opened" "path" *db-path*)) + (lambda () + (when db