From 91e536425d1d7fd1743702fbcd120720d16437aa Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:32:03 +0100 Subject: [PATCH] feat: enforce supply chain policy --- .github/workflows/supply-chain.yml | 18 ++++++++++ Dockerfile | 6 ++-- Makefile | 5 ++- docs/SUPPLY-CHAIN.md | 24 +++++++++++++ multiplayer-next.md | 7 ++-- multiplayer-todo.md | 2 +- scripts/verify_supply_chain.py | 53 ++++++++++++++++++++++++++++ server/security/test_supply_chain.py | 36 +++++++++++++++++++ 8 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/supply-chain.yml create mode 100644 docs/SUPPLY-CHAIN.md create mode 100644 scripts/verify_supply_chain.py create mode 100644 server/security/test_supply_chain.py diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml new file mode 100644 index 00000000..66a54964 --- /dev/null +++ b/.github/workflows/supply-chain.yml @@ -0,0 +1,18 @@ +name: Supply Chain Policy + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + repository-policy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Verify immutable image references and secret hygiene + run: make verify-supply-chain + - name: Verify release process is documented + run: test -s docs/SUPPLY-CHAIN.md diff --git a/Dockerfile b/Dockerfile index d282a0b7..8f987462 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ # Local-only dedicated-server build and verification image. Pin the Godot # release family used by project.godot; no image is pushed by this repository. -FROM --platform=linux/amd64 barichello/godot-ci:4.7.1 AS project-imported +# barichello/godot-ci:4.7.1 (linux/amd64), resolved 2026-08-29. +FROM --platform=linux/amd64 barichello/godot-ci@sha256:622e5ca81b54cd8038ecf7de5d157b47efc800d7cf635af2eec18a6aee4bab7e AS project-imported WORKDIR /workspace RUN apt-get update \ && apt-get install -y --no-install-recommends libfontconfig1 \ @@ -28,7 +29,8 @@ RUN sed -i 's|^run/main_scene=.*$|run/main_scene="res://scenes/server_boot.tscn" && mkdir -p /opt/cosmic-clash \ && godot --headless --path Game --export-release "Linux Dedicated Server" /opt/cosmic-clash/CosmicClashServer.x86_64 -FROM --platform=linux/amd64 ubuntu:24.04 AS server +# ubuntu:24.04 multi-architecture index, resolved 2026-08-29. +FROM --platform=linux/amd64 ubuntu@sha256:571c2ab10651ab3a703fcfcb1b06545f5b53085872dcdf68bed17dd7ef4d72db AS server RUN apt-get update && apt-get install -y --no-install-recommends libfontconfig1 libgl1 libstdc++6 && rm -rf /var/lib/apt/lists/* COPY --from=exporter /opt/cosmic-clash/ /opt/cosmic-clash/ COPY deploy/cosmic-clash-server /opt/cosmic-clash/cosmic-clash-server diff --git a/Makefile b/Makefile index 09a4eb86..607fbb3f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: verify-phase6 verify-enet-integration verify-steam-templates +.PHONY: verify-phase6 verify-enet-integration verify-steam-templates verify-supply-chain verify-phase6: bash scripts/verify_phase6.sh @@ -8,3 +8,6 @@ verify-enet-integration: verify-steam-templates: bash scripts/verify_steam_templates.sh + +verify-supply-chain: + python3 scripts/verify_supply_chain.py diff --git a/docs/SUPPLY-CHAIN.md b/docs/SUPPLY-CHAIN.md new file mode 100644 index 00000000..6dee8ae0 --- /dev/null +++ b/docs/SUPPLY-CHAIN.md @@ -0,0 +1,24 @@ +# Multiplayer artifact supply chain + +Container references in the repository are immutable `@sha256:` digests. The +base manifests may contain a zero digest only as a deployment template; a +release overlay must replace it with a registry-resolved digest and run the +checker with `--require-concrete`. + +The release pipeline must, for every image and exported server artifact: + +1. generate and retain an SBOM tied to the exact digest; +2. scan OS and application dependencies and fail on a critical or disallowed + vulnerability; +3. sign the image and provenance with the offline release authority, and + verify both at cluster admission; and +4. publish the digest, SBOM, scan result, signature and provenance as one + immutable release record. + +Critical vulnerability fixes are triaged immediately and a patched release is +cut within 24 hours of confirmation. A release with an unaccepted critical +finding or unverifiable signature is not eligible for admission. + +`python3 scripts/verify_supply_chain.py` is the dependency-free repository +guard. Registry signing/scanning and admission require the release environment +and are intentionally not simulated by this local check. diff --git a/multiplayer-next.md b/multiplayer-next.md index 191e4234..369b617f 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -62,8 +62,11 @@ product policy are in [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md). signer and add volumetric edge defense, WebSocket limits and overload shedding. A provider-neutral restricted Kubernetes baseline and structural policy tests now exist; live edge/data-plane controls remain. -- [ ] Pin, scan, SBOM and sign artifacts; verify signatures at admission and - document the critical vulnerability SLA. +- [ ] **IN PROGRESS:** Pin, scan, SBOM and sign artifacts; verify signatures at + admission and document the critical vulnerability SLA. Repository image + references are now digest-pinned with a static secret-hygiene guard and a + 24-hour critical-fix policy; registry execution and concrete release + provenance remain. ## Phase 8 — queues, playlists and rating diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 31f14560..e57edfe0 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1185,7 +1185,7 @@ the local/CI/community transport, not a silent production fallback. | 8.10 `[D:8.5,8.31]` | **IN PROGRESS.** Dependency-free workload credential policy validates an adapter-verified signature, issuer/audience/time bounds, namespace/service account, pod UID, GameServer UID, allocation ID, match ID and server ID before result submission | `server/domain/workload.go` and adversarial tests reject every binding mutation, missing/unverified signature and time boundary; `server/testkit/pipeline_test.go` carries allocation identity through the offline result path; projected-token/JWT adapter, trusted-cluster verification and live duplicate/conflict alerting remain | | 8.11 `[D:8.1]` | **DONE.** Write the threat model: forged clients/replay/queues/results, floods/bots, pod/insider compromise, gameplay and API DDoS, SDR signing-key theft, dependencies and denial-of-wallet | [`docs/THREAT-MODEL.md`](docs/THREAT-MODEL.md) records prevention, detection/response, owner and residual risk for every threat; it separates offline CA/online signer, client/game-server/PostgreSQL/Redis trust boundaries and recovery behavior | | 8.12 `[D:8.11]` | **IN PROGRESS.** Provider-neutral Kubernetes baseline enforces restricted namespace admission, non-root/read-only/no-capability workloads, separate service accounts, allocator-only RBAC, default-deny networking and explicit edge/data/DNS/Agones flows; application manifests consume externally populated Secret objects | `deploy/k8s/base/` plus `server/security/test_kubernetes_policies.py` cover the static hardening and secret-reference invariants; private-store provisioning, edge DDoS/WAF/origin shielding, WebSocket limits, overload shedding, encrypted backups and live policy/load tests remain | -| 8.13 `[D:8.12]` | Pin images by digest; generate SBOMs, scan dependencies/images, sign artifacts, verify signatures at admission and document a critical-fix SLA | CI blocks a vulnerable/disallowed or unsigned release artifact and records the exact provenance deployed | +| 8.13 `[D:8.12]` | **IN PROGRESS.** Docker/Kubernetes references are digest-pinned, a dependency-free checker rejects mutable tags/plaintext credentials and concrete release overlays can reject template digests; release documentation defines SBOM, dependency/image scanning, signing, admission verification and a 24-hour critical-fix SLA | `scripts/verify_supply_chain.py`, `server/security/test_supply_chain.py`, `docs/SUPPLY-CHAIN.md` and `.github/workflows/supply-chain.yml` cover repository policy and provenance requirements; registry SBOM/scan/sign/admission execution and a concrete production overlay remain | #### 8C — Queueing, matchmaking, playlists and rating diff --git a/scripts/verify_supply_chain.py b/scripts/verify_supply_chain.py new file mode 100644 index 00000000..9e0ebee3 --- /dev/null +++ b/scripts/verify_supply_chain.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Reject mutable container references and checked-in credential values.""" + +from pathlib import Path +import argparse +import re +import sys + + +DIGEST = re.compile(r"^[^\s@]+@sha256:[0-9a-f]{64}$") +FROM = re.compile(r"^\s*FROM(?:\s+--platform=\S+)?\s+(\S+)") +IMAGE = re.compile(r"^\s*image:\s*(\S+)\s*$") +SECRET_VALUE = re.compile(r"^\s*(?:password|token|private[-_ ]?key|publisher[-_ ]?key):\s*\S+", re.I) + + +def check_text(path: Path, text: str, concrete: bool) -> list[str]: + errors = [] + for line_number, line in enumerate(text.splitlines(), 1): + from_match = FROM.match(line) + image_match = IMAGE.match(line) + reference = from_match.group(1) if from_match else image_match.group(1) if image_match else None + if from_match and reference: + reference = reference.split(" AS ", 1)[0].split(" as ", 1)[0] + # A bare name in a later Docker stage is an internal stage alias, not + # an independently fetched image and therefore needs no digest. + internal_stage = bool(from_match and reference and "/" not in reference and "@" not in reference and ":" not in reference) + if reference and not internal_stage and not DIGEST.fullmatch(reference): + errors.append(f"{path}:{line_number}: image is not digest-pinned: {reference}") + if concrete and reference and "@sha256:" in reference: + digest = reference.rsplit("@sha256:", 1)[1] + if set(digest) == {"0"}: + errors.append(f"{path}:{line_number}: template digest is not a release artifact") + if SECRET_VALUE.match(line): + errors.append(f"{path}:{line_number}: possible plaintext credential") + return errors + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--dockerfile", type=Path, default=Path("Dockerfile")) + parser.add_argument("--manifest-dir", type=Path, default=Path("deploy/k8s")) + parser.add_argument("--require-concrete", action="store_true") + args = parser.parse_args() + errors = check_text(args.dockerfile, args.dockerfile.read_text(), args.require_concrete) + for path in sorted(args.manifest_dir.rglob("*.y*ml")): + errors.extend(check_text(path, path.read_text(), args.require_concrete)) + for error in errors: + print(error, file=sys.stderr) + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server/security/test_supply_chain.py b/server/security/test_supply_chain.py new file mode 100644 index 00000000..c93009be --- /dev/null +++ b/server/security/test_supply_chain.py @@ -0,0 +1,36 @@ +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).parents[2] +CHECKER = ROOT / "scripts" / "verify_supply_chain.py" + + +class SupplyChainTest(unittest.TestCase): + def run_checker(self, *args): + return subprocess.run([sys.executable, str(CHECKER), *args], cwd=ROOT, text=True, capture_output=True) + + def test_checked_in_references_are_digest_pinned(self): + result = self.run_checker() + self.assertEqual(result.returncode, 0, result.stderr) + + def test_checker_rejects_tags_plaintext_secrets_and_template_release(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + dockerfile = root / "Dockerfile" + manifests = root / "manifests" + manifests.mkdir() + dockerfile.write_text("FROM example.invalid/game:latest\n") + (manifests / "bad.yaml").write_text("image: example.invalid/game@sha256:" + "0" * 64 + "\npassword: leaked\n") + result = self.run_checker("--dockerfile", str(dockerfile), "--manifest-dir", str(manifests), "--require-concrete") + self.assertNotEqual(result.returncode, 0) + self.assertIn("not digest-pinned", result.stderr) + self.assertIn("plaintext credential", result.stderr) + self.assertIn("not a release artifact", result.stderr) + + +if __name__ == "__main__": + unittest.main()