Skip to content
Merged
39 changes: 34 additions & 5 deletions .github/workflows/publish-docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ on:
push:
tags:
- "v*"
# Break-glass. The release gate below can fail mid-release, and this
# workflow's only other trigger is the tag push itself — so a blocked scan
# leaves the tag existing with no image and no signature, and the pull
# command in the README 404s. Without a manual re-run the recovery is
# deleting a tag or editing .grype.yaml under release pressure, which is
# exactly how a suppression gets rubber-stamped. WOR-871.
workflow_dispatch:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

# Workflow-scoped minimum. Per-job permissions widen as needed.
permissions:
Expand Down Expand Up @@ -104,16 +111,38 @@ jobs:
tags: worthless-proxy:scan-${{ github.sha }}
labels: ${{ steps.meta.outputs.labels }}

# Both release-gate scans below read .grype.yaml and honour every
# suppression in it. Grype silently drops the unknown `expiry` key, so
# without this step a lapsed suppression keeps suppressing forever on
# the one path that reaches users. docker-security.yml has run this
# since WOR-852; the release path did not. WOR-871.
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
with:
# Caching is on by default and this workflow PUBLISHES a signed
# artifact — a poisoned cache would reach the image we cosign.
# zizmor flags it High here and is right to. This step parses ~50
# lines of YAML, so there is nothing worth caching anyway.
enable-cache: false
- name: Grype ignores are dated and current
# --no-project: parsing one small YAML file does not justify syncing
# the whole project venv.
run: uv run --no-project --with pyyaml python scripts/hooks/check_grype_ignore_expiry.py
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

# Grype (Anchore) replaces Trivy (Aqua) after the TeamPCP supply-chain
# compromise of aquasecurity/trivy-action in Feb-Mar 2026. Different
# vendor, different codebase, different CI pipeline — limits blast
# radius of any one scanner repo being compromised. Same archetype:
# OSS, OS-pkg + language-dep coverage, fails on fixable CRITICAL.
- name: Grype release-gate amd64 (fail on critical)
# OSS, OS-pkg + language-dep coverage.
#
# Cutoff MATCHES docker-security.yml. It sat at `critical` while the PR
# gate moved to `medium`, so a fixable High could be published to users
# while a Medium blocked a branch — the strict gate guarding the
# proposal, the loose one guarding the artifact. WOR-871.
- name: Grype release-gate amd64 (fail on MEDIUM+)
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
with:
image: worthless-proxy:scan-${{ github.sha }}
severity-cutoff: critical
severity-cutoff: medium
Comment thread
oblangatas marked this conversation as resolved.
only-fixed: true
fail-build: true

Expand All @@ -133,13 +162,13 @@ jobs:
tags: worthless-proxy:scan-arm64-${{ github.sha }}
labels: ${{ steps.meta.outputs.labels }}

- name: Grype release-gate arm64 (fail on critical)
- name: Grype release-gate arm64 (fail on MEDIUM+)
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
with:
# docker-archive: scheme tells Grype to read the tarball directly
# (via its internal libs). No daemon load needed for foreign arch.
image: docker-archive:${{ env.ARM64_TAR }}
severity-cutoff: critical
severity-cutoff: medium
only-fixed: true
fail-build: true

Expand Down
4 changes: 3 additions & 1 deletion docs/install-docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ description: "Pull a pre-built, signed multi-arch image from GHCR."

# Install -- Docker (from GHCR)

Pull a pre-built, multi-arch image from the GitHub Container Registry. No clone, no build. Every image is vulnerability-scanned with [Grype](https://github.com/anchore/grype) on both architectures and signed with cosign before publish.
Pull a pre-built, multi-arch image from the GitHub Container Registry. No clone, no build. Every image is scanned with [Grype](https://github.com/anchore/grype) on both architectures and signed with cosign before publish. The release fails on any **fixable Medium-or-higher** vulnerability — the same bar a pull request has to clear.

"Scanned" is not "zero known CVEs". The image carries a small number of CPython vulnerabilities inherited from the base image that have no stable upstream fix, and each one is listed in [`.grype.yaml`](https://github.com/shacharm2/worthless/blob/main/.grype.yaml) with a written argument for why it is not reachable from the proxy and a date by which it must be re-examined. Read that file if you want to check our reasoning rather than take our word for it.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

```bash
docker run -d --name worthless -p 127.0.0.1:8787:8787 \
Expand Down
65 changes: 65 additions & 0 deletions tests/test_grype_ignore_expiry.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
REPO = Path(__file__).resolve().parents[1]
HOOK = REPO / "scripts" / "hooks" / "check_grype_ignore_expiry.py"
WORKFLOW = REPO / ".github" / "workflows" / "docker-security.yml"
PUBLISH_WORKFLOW = REPO / ".github" / "workflows" / "publish-docker.yml"
INFORMATIONAL_CONFIG = REPO / ".grype-informational.yaml"


Expand Down Expand Up @@ -247,3 +248,67 @@ def test_the_scan_reruns_when_its_own_config_changes() -> None:
# the nested location — the same blind spot CONFIGS covers above. An
# ignore parked there would otherwise never re-trigger the scan.
assert ".grype/config.yaml" in paths, f"{event}: nested grype config not in paths filter"


# --- the RELEASE gate (WOR-871) --------------------------------------------
# Everything above guards the PR path. The artifact users actually `docker
# pull` is gated by a different workflow, and nothing kept the two in step.


# Grype severities, loosest gate first. A cutoff further right blocks more.
_STRICTNESS = ("negligible", "low", "medium", "high", "critical")


def _gate_cutoffs(workflow: Path) -> list[str]:
"""Every build-failing anchore/scan-action cutoff in a workflow."""
wf = yaml.safe_load(workflow.read_text())
return [
step["with"]["severity-cutoff"]
for job in wf["jobs"].values()
for step in job.get("steps", [])
if "anchore/scan-action" in str(step.get("uses", ""))
and step.get("with", {}).get("fail-build") is True
]
Comment thread
oblangatas marked this conversation as resolved.
Outdated


def test_the_release_gate_is_never_looser_than_the_pr_gate() -> None:
"""The published image must not be held to a weaker standard than a branch.

A PR is a proposal; the GHCR image is what users run against real keys.
Gating the proposal harder than the artifact is backwards, and it drifted
that way silently — WOR-852 tightened the PR gate and nothing flagged that
the release gate had been left two tiers behind.
"""
pr = _gate_cutoffs(WORKFLOW)
release = _gate_cutoffs(PUBLISH_WORKFLOW)
assert pr and release, "expected build-failing scan steps in both workflows"
weakest_pr = min(_STRICTNESS.index(c) for c in pr)
for cutoff in release:
assert _STRICTNESS.index(cutoff) <= weakest_pr, (
f"release gate `{cutoff}` is looser than the PR gate `{_STRICTNESS[weakest_pr]}`"
)


def test_the_release_gate_enforces_ignore_expiry() -> None:
"""Suppressions are time-boxed only where something checks the date.

Grype drops the unknown `expiry` key silently, so the hook is the sole
enforcement. The PR gate runs it; without this the release path honours
every suppression in .grype.yaml with nothing policing whether they have
lapsed — on the one path that reaches users.
"""
assert "check_grype_ignore_expiry.py" in PUBLISH_WORKFLOW.read_text(), (
"release workflow never validates .grype.yaml expiry dates"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated


def test_the_release_gate_can_be_rerun_by_hand() -> None:
"""A hardened gate must not be able to strand a release.

Publish fires on `v*` tag push only. If the scan blocks mid-release the tag
already exists while the image does not, and the documented pull command
404s. A manual trigger is the difference between re-running a release and
deleting a tag under pressure.
"""
triggers = yaml.safe_load(PUBLISH_WORKFLOW.read_text())[True]
assert "workflow_dispatch" in triggers, "release workflow has no break-glass trigger"
Loading