feat: enforce supply chain policy

This commit is contained in:
Josh Creek
2026-08-31 21:32:03 +01:00
parent 0f1cc17af6
commit 91e536425d
8 changed files with 145 additions and 6 deletions
+18
View File
@@ -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
+4 -2
View File
@@ -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
+4 -1
View File
@@ -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
+24
View File
@@ -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.
+5 -2
View File
@@ -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
+1 -1
View File
@@ -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
+53
View File
@@ -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())
+36
View File
@@ -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()