From 4808b113ec6beffe4c205e707c2fe49b076ba64a Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:55:00 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(frontdoor):=20git-push=20webhook=20?= =?UTF-8?q?=E2=80=94=20the=20fail-closed=20trigger=20that=20deploys=20(#37?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy_flow.on_push is the build->deploy->preview flow, but nothing turned a real `git push` into a deploy. This is that trigger: a governed webhook receiver the git host (Gitea/GitHub) POSTs to on every push. It closes the last named gap in BUILD_DEPLOY.md ("the only remaining piece is the push trigger") — `git push` now literally deploys. It is FAIL-CLOSED at the door: an unsigned or mis-signed push is NEVER built and NEVER deployed. tools/push_webhook.py — a pure, unit-tested core + a thin stdlib http.server wrapper: - verify_signature: constant-time HMAC-SHA256 over the RAW body; accepts GitHub `X-Hub-Signature-256: sha256=…` and Gitea `X-Gitea-Signature: …` (bare hex). Empty secret/body/ header => False. - parse_push_event: normalises the git-host push payload (branch vs tag, delete, changed files). - handle_push: verify FIRST; a bad signature returns `rejected` with NO build started. Tags/deletes are ignored. The full source tree at the pushed SHA is resolved by an injected checkout callback (falls back to the payload's changed files, documented). Emits a SEALED, tamper-evident receipt bound to the exact body digest — the project secret is never sealed, echoed, or written. - serve(): POST /hooks//, per-tenant secret from the sovereign store; 401 rejected / 202 accepted / 422 build-failed. GET only /healthz. Same fail-closed posture as the rest of the stack: the trigger itself is a zero-trust gate, not an open hook — the sovereign answer to an anonymous deploy webhook. Wired: capd/git-push-webhook.mesh.capd.json (caps.dev.git-push-webhook, composes_with git-push-deploy); validate.py (required tool + CapD); Makefile `push-webhook`; portal evidence view surfaces webhook-receipts; docs/BUILD_DEPLOY.md documents the trigger. Tests: +14 = 196 tools tests green (signed deploys, unsigned/forged/body-tampered rejected & never built, tag/delete ignored, resolve_files override, sealed+persisted receipt, secret never leaked). --- Makefile | 5 +- capd/git-push-webhook.mesh.capd.json | 31 ++++ docs/BUILD_DEPLOY.md | 23 ++- tools/portal_server.py | 2 +- tools/push_webhook.py | 245 +++++++++++++++++++++++++++ tools/test_push_webhook.py | 137 +++++++++++++++ tools/validate.py | 3 + 7 files changed, 442 insertions(+), 4 deletions(-) create mode 100644 capd/git-push-webhook.mesh.capd.json create mode 100644 tools/push_webhook.py create mode 100644 tools/test_push_webhook.py diff --git a/Makefile b/Makefile index fa1d497..ce4e734 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # SourceOS Continuum — lifecycle entry points. # Control-plane targets delegate to Makefile.porter (the rehomed Porter control plane). -.PHONY: validate onboard dev-up dev-down shim-test test tools-test rollout promotion-gate portal compute mesh-demo grant commons mcp spine run loop verify lease sphere +.PHONY: validate onboard dev-up dev-down shim-test test tools-test rollout promotion-gate portal compute mesh-demo grant commons mcp spine run loop verify lease sphere push push-webhook edge login provision deploy inference availability validate: ## repo hygiene + CapD validity python3 tools/validate.py @@ -48,6 +48,9 @@ sphere: ## data-sphere demo: immutable dm-verity sphere, construction-tenancy, i push: ## git-push deploy flow demo: build -> deploy -> per-branch preview cd tools && python3 deploy_flow.py +push-webhook: ## git-push webhook demo: signed push -> deploy; forged push -> rejected (fail-closed door). Run a receiver with ARGS="serve 8099" + cd tools && python3 push_webhook.py $(ARGS) + edge: ## edge-worker demo: register the agent-machine into a cloud pool + evolve topology (reversed Giant Swarm) cd tools && python3 edge_worker.py diff --git a/capd/git-push-webhook.mesh.capd.json b/capd/git-push-webhook.mesh.capd.json new file mode 100644 index 0000000..0a3ac3e --- /dev/null +++ b/capd/git-push-webhook.mesh.capd.json @@ -0,0 +1,31 @@ +{ + "capability_id": "caps.dev.git-push-webhook@0.1.0", + "kind": "dev.push-trigger", + "status": "experimental", + "name": "Git-push webhook — the fail-closed trigger that turns a real push into a deploy", + "description": "The trigger half of git-push-deploy: a governed webhook receiver a git host (Gitea/GitHub) POSTs to on every push, which runs deploy_flow.on_push and opens the per-branch preview. It is FAIL-CLOSED at the door — an unsigned or mis-signed push is never built and never deployed. Every delivery is verified in constant time against the project's HMAC-SHA256 secret (GitHub `X-Hub-Signature-256`, Gitea `X-Gitea-Signature`) before any work starts, and yields a sealed, tamper-evident receipt bound to the exact request-body digest; the secret never appears in the decision or the receipt. The full source manifest at the pushed commit is resolved by an injected checkout of the pushed SHA. This closes the last gap in the Vercel/Heroku ergonomic: `git push` now literally deploys, sovereign and governed.", + "links": { + "engine": "tools/push_webhook.py", + "flow": "tools/deploy_flow.py", + "build": "tools/buildpack.py", + "preview": "tools/devspace.py", + "promotion": "tools/promotion_gate.py", + "receipts": "artifacts/webhook-receipts/", + "spec_witness": "docs/BUILD_DEPLOY.md", + "reference_pattern": "GitHub/Gitea signed webhooks (X-Hub-Signature-256 / X-Gitea-Signature, HMAC-SHA256 over the raw body) — met sovereign: constant-time verify, fail-closed, sealed receipt, secret never echoed" + }, + "composes_with": { + "deploy": "caps.dev.git-push-deploy@0.1.0", + "inner_loop": "caps.dev.devspace-inner-loop@0.1.0", + "control_plane": "caps.infra.paas.continuum-local@0.1.0", + "scales_up_to": "caps.infra.cluster-scaleup.hyperswarm@0.1.0" + }, + "policy": { + "availability": "needs-work", + "fail_closed": true, + "signature_required": true, + "constant_time_verify": true, + "secret_never_echoed": true, + "evidence_emitting": true + } +} diff --git a/docs/BUILD_DEPLOY.md b/docs/BUILD_DEPLOY.md index c0efc11..21042ec 100644 --- a/docs/BUILD_DEPLOY.md +++ b/docs/BUILD_DEPLOY.md @@ -47,5 +47,24 @@ git push ──> detect(source) ──> pack build (Paketo) ──> reprod `build_plan()` is content-addressed (same source → same image → reproducible), fail-closed (no buildpack match → refuse, don't guess), and its image flows straight into the executor's k8s manifest -(verified in tests). The only remaining piece is the **push trigger** (a webhook that runs this on a -`git push` and opens the preview) — the ergonomic wrapper over machinery that's now all here. +(verified in tests). + +## The push trigger (`push_webhook.py`) + +The last piece — what actually *calls* `on_push` when a real push lands — is a governed webhook +receiver the git host (Gitea/GitHub) POSTs to. It is **fail-closed at the door**: + +``` +POST /hooks// + ──> verify HMAC-SHA256 over the RAW body (constant-time) ← unsigned/forged ⇒ REJECTED, no build + ──> parse the push event (branch? tag? delete?) ← tags & deletes ⇒ ignored + ──> resolve the source tree at the pushed SHA (checkout) + ──> deploy_flow.on_push() ──> build ──> preview + ──> a SEALED receipt, bound to the exact body digest ← the secret is never echoed +``` + +Every git host signs deliveries (GitHub `X-Hub-Signature-256: sha256=…`, Gitea `X-Gitea-Signature: …`); +we verify against the project's per-tenant secret **before any work starts**. A push that isn't +validly signed is rejected with a sealed receipt and **no build is ever started** — the same +fail-closed posture as the rest of the stack. So `git push` now literally deploys: the Vercel/Heroku +ergonomic, sovereign and governed, with the trigger itself a zero-trust gate rather than an open hook. diff --git a/tools/portal_server.py b/tools/portal_server.py index fc9ed53..abb6a98 100644 --- a/tools/portal_server.py +++ b/tools/portal_server.py @@ -59,7 +59,7 @@ def _lifecycle() -> dict: def _evidence(limit: int = 20) -> dict: bundles = [] - for name in ("gate-decisions", "mcp-receipts"): + for name in ("gate-decisions", "mcp-receipts", "webhook-receipts"): p = _ROOT / "artifacts" / name if p.is_dir(): for f in sorted(p.glob("*.json"), reverse=True)[:limit]: diff --git a/tools/push_webhook.py b/tools/push_webhook.py new file mode 100644 index 0000000..3523c20 --- /dev/null +++ b/tools/push_webhook.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Git-push webhook — the trigger that makes `git push` literally deploy. + +`deploy_flow.on_push` is the build->deploy->preview flow, but something has to *call* it when a real +push lands. That is this: a governed webhook receiver a git host (Gitea, GitHub, Gitea-Actions) POSTs +to on every push. It is the FIRST link in the chain and it is FAIL-CLOSED at the door: + + an unsigned or mis-signed push is NEVER built and NEVER deployed. + +Every git host signs webhook deliveries with an HMAC-SHA256 over the raw body (GitHub: +`X-Hub-Signature-256: sha256=`; Gitea: `X-Gitea-Signature: `). We verify it in constant +time before we do anything. A push that does not carry a valid signature for the project's secret is +rejected with a sealed receipt and no build is ever started — the same posture as the rest of the +stack (sensitive work fails closed, never silently proceeds). + +Design: + * `verify_signature` / `parse_push_event` / `handle_push` are a PURE core (no socket, unit-tested). + * `handle_push` returns a sealed, tamper-evident decision bound to the exact request body digest; + the project secret NEVER appears in the decision or the written receipt. + * the full source manifest at the pushed commit is resolved by an injected `resolve_files(repo, + ref, after)` — in production wired to a checkout of the pushed SHA. Without it we fall back to the + changed files in the payload (honest: that can under-detect a buildpack; production supplies it). + * `serve()` is a thin stdlib http.server wrapper (POST /hooks//), no dependencies. +""" +from __future__ import annotations + +import hashlib +import hmac +import json +import os +from datetime import datetime, timezone +from pathlib import Path + +import deploy_flow as df + +_ROOT = Path(__file__).resolve().parent.parent +_RECEIPTS = _ROOT / "artifacts" / "webhook-receipts" +_ZERO_SHA = "0" * 40 # git's null object — a branch delete pushes "after": 0000... + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _digest(raw_body: bytes) -> str: + return "sha256:" + hashlib.sha256(raw_body).hexdigest() + + +def _seal(body: dict) -> str: + return "sha256:" + hashlib.sha256( + json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() + + +def verify_signature(secret: str, raw_body: bytes, sig_header: str) -> bool: + """Constant-time HMAC-SHA256 check over the RAW request body. Accepts both `sha256=` + (GitHub) and a bare `` (Gitea). Empty secret, body, or header -> False (fail-closed).""" + if not secret or not sig_header or not raw_body: + return False + mac = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() + provided = sig_header.split("=", 1)[1] if sig_header.startswith("sha256=") else sig_header + return hmac.compare_digest(mac, provided.strip()) + + +def parse_push_event(payload: dict) -> dict: + """Normalise a git-host push payload (GitHub/Gitea share this shape) to what the deploy needs.""" + ref = payload.get("ref") or "" + is_branch = ref.startswith("refs/heads/") + after = payload.get("after") + changed = sorted({f + for c in (payload.get("commits") or []) + for key in ("added", "modified") + for f in (c.get(key) or [])}) + return { + "ref": ref, + "is_branch": is_branch, + "branch": ref[len("refs/heads/"):] if is_branch else None, + "repo": (payload.get("repository") or {}).get("name"), + "pusher": (payload.get("pusher") or {}).get("name") or (payload.get("sender") or {}).get("login"), + "after": after, + "deleted": bool(payload.get("deleted")) or after == _ZERO_SHA, + "changed_files": changed, + } + + +def _finish(decision: dict, receipts_dir) -> dict: + """Seal the decision (tamper-evident) and, if a receipts dir is given, persist it. The project + secret is never part of `decision`, so it is never sealed and never written.""" + decision["receipt_digest"] = _seal({k: v for k, v in decision.items() if k != "receipt_digest"}) + if receipts_dir is not None: + d = Path(receipts_dir) + d.mkdir(parents=True, exist_ok=True) + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%f") + (d / f"push-{stamp}-{decision['receipt_digest'][7:19]}.json").write_text( + json.dumps(decision, indent=2, sort_keys=True)) + return decision + + +def handle_push(*, secret: str, sig_header: str, raw_body: bytes, project: dict, + resolve_files=None, receipts_dir=None) -> dict: + """The governed decision for one webhook delivery. Fail-closed: verify the signature FIRST; an + unsigned/mis-signed push returns `rejected` and NO build is started. + + project: {tenant, user, app?, sensitivity?} — resolved per-repo by the caller. + resolve_files(repo, ref, after) -> [paths] — the full source manifest at the pushed commit + (production: a checkout of `after`). Optional. + Returns a sealed decision; status in {rejected, ignored, deployed, build-failed}. + """ + decision = {"surface": "sourceos-continuum.push_webhook.v1", + "received_at": _now(), "body_digest": _digest(raw_body)} + + # 1. THE DOOR — fail-closed. No valid signature => never build, never deploy. + if not verify_signature(secret, raw_body, sig_header): + return _finish({**decision, "status": "rejected", "accepted": False, + "reason": "signature verification failed — an unsigned or mis-signed push is " + "never built or deployed (fail-closed)"}, receipts_dir) + + # 2. a valid signature guarantees an authentic body; now it must be a well-formed push. + try: + payload = json.loads(raw_body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return _finish({**decision, "status": "rejected", "accepted": False, + "reason": "signature valid but body is not JSON — malformed push payload"}, + receipts_dir) + + ev = parse_push_event(payload) + decision["event"] = {"repo": ev["repo"], "branch": ev["branch"], "ref": ev["ref"], + "pusher": ev["pusher"], "after": ev["after"]} + + if not ev["is_branch"] or not ev["repo"]: + return _finish({**decision, "status": "ignored", "accepted": True, + "reason": "not a branch push (tag or other ref) — nothing to deploy"}, + receipts_dir) + if ev["deleted"]: + return _finish({**decision, "status": "ignored", "accepted": True, + "reason": "branch deleted — nothing to build"}, receipts_dir) + + # 3. resolve the full source tree at the pushed commit, then run the real deploy flow. + if resolve_files is not None: + source_files = list(resolve_files(ev["repo"], ev["ref"], ev["after"])) + files_source = "checkout" + else: + source_files = ev["changed_files"] + files_source = "push-payload-changed-files" + + result = df.on_push(tenant=project["tenant"], user=project["user"], repo=ev["repo"], + branch=ev["branch"], source_files=source_files, + sensitivity=project.get("sensitivity", "normal"), app=project.get("app")) + return _finish({**decision, "status": result["status"], + "accepted": result["status"] != "build-failed", + "files_source": files_source, "deploy": result}, receipts_dir) + + +# --- thin HTTP wrapper (stdlib only) ------------------------------------------------------------- + +def _secret_for(tenant: str) -> str: + """Per-tenant webhook secret from the environment (never printed). A per-tenant override wins over + the global secret; production reads these from the sovereign secret store, minted in CI.""" + return (os.environ.get(f"SOURCEOS_WEBHOOK_SECRET_{tenant.upper().replace('-', '_')}") + or os.environ.get("SOURCEOS_WEBHOOK_SECRET", "")) + + +def project_for_path(path: str) -> dict: + """Map the webhook URL to a project. `/hooks//` is the multi-tenant form; anything + else falls back to env defaults. The secret is resolved per-tenant and never returned to a client.""" + parts = [p for p in path.split("?", 1)[0].strip("/").split("/") if p] + if len(parts) >= 2 and parts[0] == "hooks": + tenant, app = parts[1], (parts[2] if len(parts) > 2 else "default") + else: + tenant, app = os.environ.get("SOURCEOS_TENANT", "you"), "default" + return {"tenant": tenant, "user": os.environ.get("SOURCEOS_USER", "dev"), "app": app, + "secret": _secret_for(tenant), + "sensitivity": os.environ.get("SOURCEOS_SENSITIVITY", "normal")} + + +_STATUS_CODE = {"deployed": 202, "ignored": 202, "build-failed": 422, "rejected": 401} + + +def serve(port: int = 8099) -> None: + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + class _Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length > 0 else b"" + sig = (self.headers.get("X-Hub-Signature-256") + or self.headers.get("X-Gitea-Signature") + or self.headers.get("X-SourceOS-Signature") or "") + proj = project_for_path(self.path) + decision = handle_push(secret=proj["secret"], sig_header=sig, raw_body=raw, + project=proj, receipts_dir=_RECEIPTS) + body = json.dumps(decision, indent=2, sort_keys=True).encode("utf-8") + self.send_response(_STATUS_CODE.get(decision["status"], 200)) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + ok = self.path.split("?", 1)[0] == "/healthz" + body = b"ok" if ok else b"this is a POST-only webhook receiver" + self.send_response(200 if ok else 404) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_): + pass + + server = ThreadingHTTPServer(("127.0.0.1", port), _Handler) + print(f"[continuum] push webhook on http://127.0.0.1:{port}/hooks// " + f"(POST, HMAC-signed, fail-closed; Ctrl-C to stop)") + try: + server.serve_forever() + except KeyboardInterrupt: + server.shutdown() + + +if __name__ == "__main__": + import sys + + if len(sys.argv) > 1 and sys.argv[1] == "serve": + serve(int(sys.argv[2]) if len(sys.argv) > 2 else 8099) + raise SystemExit(0) + + # Demo: sign a push the way a git host would, then show the fail-closed door in action. + secret = "demo-webhook-secret" + payload = {"ref": "refs/heads/pr-42", "after": "a" * 40, + "repository": {"name": "productpage"}, "pusher": {"name": "alice"}, + "commits": [{"added": ["package.json"], "modified": ["server.js"]}]} + raw = json.dumps(payload).encode("utf-8") + good_sig = "sha256=" + hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest() + project = {"tenant": "acme", "user": "alice", "app": "shop", "sensitivity": "normal"} + + accepted = handle_push(secret=secret, sig_header=good_sig, raw_body=raw, project=project) + rejected = handle_push(secret=secret, sig_header="sha256=deadbeef", raw_body=raw, project=project) + + print(json.dumps({ + "signed_push": {"status": accepted["status"], "image": accepted["deploy"]["image"], + "preview_route": accepted["deploy"]["preview"]["route_header"], + "receipt": accepted["receipt_digest"]}, + "forged_push": {"status": rejected["status"], "accepted": rejected["accepted"], + "built": "deploy" in rejected, "reason": rejected["reason"]}, + "secret_leaked_in_receipt": secret in json.dumps(accepted), + }, indent=2)) diff --git a/tools/test_push_webhook.py b/tools/test_push_webhook.py new file mode 100644 index 0000000..ced5f2d --- /dev/null +++ b/tools/test_push_webhook.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Tests for the git-push webhook — the fail-closed trigger that turns a push into a deploy.""" +import hashlib +import hmac +import json +import tempfile + +import push_webhook as pw + +_SECRET = "s3cr3t-per-tenant" +_PROJECT = {"tenant": "acme", "user": "alice", "app": "shop", "sensitivity": "normal"} + + +def _payload(ref="refs/heads/pr-42", after="a" * 40, added=("package.json",), modified=("server.js",), + repo="productpage", deleted=False): + return {"ref": ref, "after": after, "deleted": deleted, + "repository": {"name": repo}, "pusher": {"name": "alice"}, + "commits": [{"added": list(added), "modified": list(modified)}]} + + +def _raw(payload): + return json.dumps(payload).encode("utf-8") + + +def _sign(raw, secret=_SECRET, prefix="sha256="): + return prefix + hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest() + + +def test_valid_signed_push_builds_and_deploys(): + raw = _raw(_payload()) + d = pw.handle_push(secret=_SECRET, sig_header=_sign(raw), raw_body=raw, project=_PROJECT) + assert d["status"] == "deployed" and d["accepted"] is True + assert d["deploy"]["language"] == "node" and d["deploy"]["image"].startswith("productpage-pr-42@") + assert d["event"]["branch"] == "pr-42" and d["receipt_digest"].startswith("sha256:") + + +def test_unsigned_push_is_rejected_and_never_builds(): + raw = _raw(_payload()) + d = pw.handle_push(secret=_SECRET, sig_header="", raw_body=raw, project=_PROJECT) + assert d["status"] == "rejected" and d["accepted"] is False + assert "deploy" not in d # THE point: no build was ever started + + +def test_tampered_signature_is_rejected(): + raw = _raw(_payload()) + forged = _sign(raw, secret="wrong-secret") # correct shape, wrong key + d = pw.handle_push(secret=_SECRET, sig_header=forged, raw_body=raw, project=_PROJECT) + assert d["status"] == "rejected" and "deploy" not in d + + +def test_body_tampered_after_signing_is_rejected(): + raw = _raw(_payload()) + sig = _sign(raw) + tampered = _raw(_payload(repo="attacker-owned")) # different body, old signature + d = pw.handle_push(secret=_SECRET, sig_header=sig, raw_body=tampered, project=_PROJECT) + assert d["status"] == "rejected" and "deploy" not in d + + +def test_gitea_bare_hex_signature_form_is_accepted(): + raw = _raw(_payload()) + d = pw.handle_push(secret=_SECRET, sig_header=_sign(raw, prefix=""), raw_body=raw, project=_PROJECT) + assert d["status"] == "deployed" + + +def test_tag_push_is_ignored(): + raw = _raw(_payload(ref="refs/tags/v1.0.0")) + d = pw.handle_push(secret=_SECRET, sig_header=_sign(raw), raw_body=raw, project=_PROJECT) + assert d["status"] == "ignored" and "deploy" not in d + + +def test_branch_delete_is_ignored(): + raw = _raw(_payload(after="0" * 40, deleted=True)) + d = pw.handle_push(secret=_SECRET, sig_header=_sign(raw), raw_body=raw, project=_PROJECT) + assert d["status"] == "ignored" + + +def test_valid_signature_but_non_json_body_is_rejected(): + raw = b"\x00\x01 not json" + d = pw.handle_push(secret=_SECRET, sig_header=_sign(raw), raw_body=raw, project=_PROJECT) + assert d["status"] == "rejected" and "malformed" in d["reason"] + + +def test_no_matching_buildpack_is_build_failed_not_deployed(): + raw = _raw(_payload(added=("README.md",), modified=("LICENSE",))) + d = pw.handle_push(secret=_SECRET, sig_header=_sign(raw), raw_body=raw, project=_PROJECT) + assert d["status"] == "build-failed" and d["accepted"] is False + + +def test_resolve_files_supplies_the_full_tree_at_the_pushed_commit(): + # the payload's changed files wouldn't detect Go, but a checkout of the pushed SHA does. + raw = _raw(_payload(added=("main.go",), modified=("go.sum",))) + d = pw.handle_push(secret=_SECRET, sig_header=_sign(raw), raw_body=raw, project=_PROJECT, + resolve_files=lambda repo, ref, after: ["go.mod", "main.go", "go.sum"]) + assert d["status"] == "deployed" and d["deploy"]["language"] == "go" + assert d["files_source"] == "checkout" + + +def test_receipt_is_sealed_and_persisted(): + raw = _raw(_payload()) + with tempfile.TemporaryDirectory() as td: + d = pw.handle_push(secret=_SECRET, sig_header=_sign(raw), raw_body=raw, project=_PROJECT, + receipts_dir=td) + import pathlib + files = list(pathlib.Path(td).glob("push-*.json")) + assert len(files) == 1 + written = json.loads(files[0].read_text()) + assert written["receipt_digest"] == d["receipt_digest"] + # the receipt is bound to the exact request body + assert written["body_digest"] == pw._digest(raw) + + +def test_secret_never_appears_in_decision_or_receipt(): + raw = _raw(_payload()) + d = pw.handle_push(secret=_SECRET, sig_header=_sign(raw), raw_body=raw, project=_PROJECT) + assert _SECRET not in json.dumps(d) + + +def test_project_for_path_parses_tenant_and_app(): + p = pw.project_for_path("/hooks/acme/shop?x=1") + assert p["tenant"] == "acme" and p["app"] == "shop" + p2 = pw.project_for_path("/hooks/acme") + assert p2["tenant"] == "acme" and p2["app"] == "default" + + +def test_verify_signature_rejects_empties(): + assert pw.verify_signature("", b"body", "sha256=x") is False + assert pw.verify_signature("s", b"", "sha256=x") is False + assert pw.verify_signature("s", b"body", "") is False + + +if __name__ == "__main__": + import sys + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + for fn in fns: + fn() + print(f"ok: {len(fns)} push-webhook tests passed") + sys.exit(0) diff --git a/tools/validate.py b/tools/validate.py index 2f187e6..e8b0304 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -27,6 +27,7 @@ "capd/data-spheres.mesh.capd.json", "capd/sovereign-inference.mesh.capd.json", "capd/git-push-deploy.mesh.capd.json", + "capd/git-push-webhook.mesh.capd.json", "capd/provisioning-plane.mesh.capd.json", "tools/promotion_gate.py", "tools/portal_server.py", @@ -49,6 +50,7 @@ "tools/buildpack.py", "tools/provisioning.py", "tools/deploy_flow.py", + "tools/push_webhook.py", "tools/login.py", "tools/edge_worker.py", ] @@ -64,6 +66,7 @@ "capd/data-spheres.mesh.capd.json": "caps.data.spheres", "capd/sovereign-inference.mesh.capd.json": "caps.inference.sovereign", "capd/git-push-deploy.mesh.capd.json": "caps.dev.git-push-deploy", + "capd/git-push-webhook.mesh.capd.json": "caps.dev.git-push-webhook", "capd/provisioning-plane.mesh.capd.json": "caps.dev.provisioning", } From f8d555e453e54c0a7736386fec8e1a0ddf7cac8a Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:58:03 -0400 Subject: [PATCH 2/2] harden(webhook): reject validly-signed non-object JSON instead of crashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A push whose HMAC verifies but whose body is a JSON non-object ([], 123, "x", null) reached parse_push_event and would raise on .get() — a 500 on authenticated-but-malformed input. handle_push now rejects it cleanly (same as non-JSON), and parse_push_event accesses nested fields defensively (_obj() + isinstance-guarded commits). +1 test = 197 green. Handle any input; never crash the door. --- tools/push_webhook.py | 14 ++++++++++++-- tools/test_push_webhook.py | 7 +++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/tools/push_webhook.py b/tools/push_webhook.py index 3523c20..541ec62 100644 --- a/tools/push_webhook.py +++ b/tools/push_webhook.py @@ -46,6 +46,11 @@ def _digest(raw_body: bytes) -> str: return "sha256:" + hashlib.sha256(raw_body).hexdigest() +def _obj(x) -> dict: + """A dict or {} — defensive access into an authenticated-but-arbitrary JSON payload.""" + return x if isinstance(x, dict) else {} + + def _seal(body: dict) -> str: return "sha256:" + hashlib.sha256( json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() @@ -68,14 +73,15 @@ def parse_push_event(payload: dict) -> dict: after = payload.get("after") changed = sorted({f for c in (payload.get("commits") or []) + if isinstance(c, dict) for key in ("added", "modified") for f in (c.get(key) or [])}) return { "ref": ref, "is_branch": is_branch, "branch": ref[len("refs/heads/"):] if is_branch else None, - "repo": (payload.get("repository") or {}).get("name"), - "pusher": (payload.get("pusher") or {}).get("name") or (payload.get("sender") or {}).get("login"), + "repo": _obj(payload.get("repository")).get("name"), + "pusher": _obj(payload.get("pusher")).get("name") or _obj(payload.get("sender")).get("login"), "after": after, "deleted": bool(payload.get("deleted")) or after == _ZERO_SHA, "changed_files": changed, @@ -121,6 +127,10 @@ def handle_push(*, secret: str, sig_header: str, raw_body: bytes, project: dict, return _finish({**decision, "status": "rejected", "accepted": False, "reason": "signature valid but body is not JSON — malformed push payload"}, receipts_dir) + if not isinstance(payload, dict): + return _finish({**decision, "status": "rejected", "accepted": False, + "reason": "signature valid but body is not a JSON object — malformed push payload"}, + receipts_dir) ev = parse_push_event(payload) decision["event"] = {"repo": ev["repo"], "branch": ev["branch"], "ref": ev["ref"], diff --git a/tools/test_push_webhook.py b/tools/test_push_webhook.py index ced5f2d..aef49a3 100644 --- a/tools/test_push_webhook.py +++ b/tools/test_push_webhook.py @@ -80,6 +80,13 @@ def test_valid_signature_but_non_json_body_is_rejected(): assert d["status"] == "rejected" and "malformed" in d["reason"] +def test_valid_signature_but_non_object_json_is_rejected_not_crashed(): + # a validly-signed but non-object body ([], 123, "x") must reject cleanly, never raise (no 500). + for raw in (b"[]", b"123", b'"a string"', b"null"): + d = pw.handle_push(secret=_SECRET, sig_header=_sign(raw), raw_body=raw, project=_PROJECT) + assert d["status"] == "rejected" and "deploy" not in d, raw + + def test_no_matching_buildpack_is_build_failed_not_deployed(): raw = _raw(_payload(added=("README.md",), modified=("LICENSE",))) d = pw.handle_push(secret=_SECRET, sig_header=_sign(raw), raw_body=raw, project=_PROJECT)