Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions .github/scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Dependency update tooling

Weekly automation that refreshes every pinned dependency in this repository —
container image tags, `uv.lock` files, and GitHub Action SHAs — behind a
supply-chain cooldown and an upstream-provenance check.

Driven by [`.github/workflows/update.yml`](../workflows/update.yml) every Tuesday
at 03:16 UTC. Run it yourself the same way CI does:

```bash
bash .github/scripts/update-pins.sh
```

It needs `uv` and a GitHub token; without `GITHUB_TOKEN` set it falls back to
`gh auth token`, and without either it runs unauthenticated into GitHub's
60-requests-per-hour limit almost immediately.

## What it guarantees

**A 7-day cooldown on every adopted version.** Nothing published in the last
week is adopted, so a compromised upstream release has time to be noticed by
someone else first ([context](https://github.com/aquasecurity/trivy/discussions/10425)).
Candidates are walked newest-first and the first one past the window is taken —
a 3-day-old release is skipped while an 8-day-old one is still picked up, rather
than the whole pin stalling. For Python dependencies the cooldown is declarative
instead: `exclude-newer = "7 days"` in each project's `[tool.uv]`, which the
resolver enforces for `uv lock --upgrade` and for a reader's `uv sync` alike.

**Provenance for every GitHub-sourced version.** The tag must exist as a ref in
the repository it claims to come from, and must resolve (through an annotated
tag, if there is one) to a commit. Git refs are repo-scoped — a fork's tags never
appear in the parent's refs — which is what the
[imposter-commit attack](https://github.com/aquasecurity/trivy/security/advisories/GHSA-69fq-xp46-6x23)
lacks, since it references a bare SHA with no ref behind it.

**Hash-pinned Actions, verified twice.** `pinact run -u` re-pins to the newest
release past its own 7-day cooldown; `pinact run --check --verify-comment`
confirms each `# vX.Y.Z` comment still names the pinned SHA; and
`verify_action_pins.py` confirms that SHA is reachable from a branch upstream,
which is the check pinact has no equivalent for. That last one also fails on any
`uses:` that is not SHA-pinned at all, so it doubles as the gate a per-PR
`zizmor unpinned-uses` rule would otherwise provide.

## Layout

| Path | Purpose |
| --- | --- |
| `update-pins.sh` | The pin list. One commented block per pin — start here. |
| `update_file.py` | Resolve a version, gate it, rewrite it into files. |
| `verify_action_pins.py` | Confirm pinned Action commits exist upstream. |
| `check_doc_drift.py` | Report docs quoting a superseded version. |
| `supply_chain/` | Cooldown, GitHub verification, Docker Hub tag discovery. |

`supply_chain/` is a trimmed vendoring of `zenable_monorepo` from
`Zenable-io/next-gen-governance`, which is private while this repository is
public. The security-relevant logic is kept behaviourally close to upstream so a
fix there ports as a readable diff. **If a third repository needs this, extract
these modules into a package all three consume rather than vendoring again.**

## Adding a pin

Append a block to `update-pins.sh`. Both regexes are required — there is no
inferred default, because every pin here is a bespoke line in a compose file or
Dockerfile and a guessed pattern is how an update silently becomes a no-op.

```bash
"${UPDATE_FILE[@]}" \
--file "${GIT_ROOT}/labs/example/compose.yaml" \
--source-type github-release \
--github-owner someone --github-repo something \
--pin-level patch \
--no-downgrade \
--search-pattern 'ghcr\.io/someone/something:v[0-9]+\.[0-9]+\.[0-9]+' \
--replacement-pattern 'ghcr.io/someone/something:{version}'
```

Prefer `--source-type github-release` even when the image lives on quay.io or
ghcr.io, as both Keycloak and agentgateway do here: GitHub releases carry a real
publish date and a verifiable tag, and those registries offer neither.

Use `--source-type docker-hub` when there is no GitHub release to read, and pass
`--version-line` whenever a major bump would mean a *different artifact* rather
than a newer one — `jaegertracing/all-in-one` is held to `1` because Jaeger v2
is a different image with a different CLI, and an unrestricted pin would
"update" that lab into something that does not start.

## Known gaps

These are deliberate. Each is a decision, not an oversight.

- **`labs/ema-mcp/run.sh` is digest-pinned, not automated.**
`ceposta/keycloak:id-jag` is a mutable tag on a third party's personal Docker
Hub account, with no version in it and no release feed behind it, so there is
nothing for a cooldown to measure or an update to move it to. It is pinned by
digest so the bytes are at least fixed; refreshing it is a manual decision
about whether the new contents are trustworthy, and the command to resolve a
new digest is in the comment above the pin.
- **`FROM python:3.13-slim` is not automated.** A floating tag: rebuilt whenever
its base OS changes, so the labs already get those patches on every build, and
its `last_updated` is perpetually a day old — which makes a release cooldown
structurally unmeasurable. What the pin actually controls is 3.13 versus 3.14,
a compatibility decision that belongs in a human PR that re-runs the labs.
- **Recorded output is reported, never rewritten.** READMEs and `evidence/` hold
captured terminal transcripts. Regex-editing one to name a version nobody ran
would be fabricating evidence, and `docker compose ps` column alignment would
not survive it. `check_doc_drift.py` annotates them instead, and names which of
the two homes applies — see below. It matches two shapes: a full `repo:tag`
reference, and a bare product name plus version (`kc.sh --version` prints
`Keycloak 26.7.2`, which has no registry path to match on). The second only
applies to plain-semver pins, because a templated tag like python's
`3.13-slim` has no clean version to compare against and the labs legitimately
print a fuller `Python 3.13.12`.
- **`labs/*/README.md` is not editable here.** Each one is generated from that
lab's `.mdx` in `Zenable-io/next-gen-governance`
(`services/ui_frontend/src/lib/labs/content/labs/`) and carries a "Do not edit
by hand" banner. A stale transcript in a README is fixed *there*, then
re-exported with
`node services/ui_frontend/scripts/export-lab-readme.js --all <this repo>`.
An edit committed here is silently reverted by the next export. `evidence/`,
the rig code, and the compose/Dockerfile pins the automation touches are all
owned by this repository, which is why they are safe to rewrite.
- **Nothing here runs the labs.** These PRs are not proof a bump works. That is
`next-gen-governance`'s `task e2e`.
- **Branch reachability is advisory for tag-sourced pins**, and a hard failure
only for Action SHAs. Release tooling routinely tags a commit that never lands
on a branch — Keycloak's `Set version to 26.7.2` is tagged and then superseded
on `release/26.7` — so failing on it would block correctly-published releases.
The tag-ref check above is what carries the guarantee there; for an Action pin
there is no ref to lean on, so reachability stays mandatory.
181 changes: 181 additions & 0 deletions .github/scripts/check_doc_drift.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""Report documentation that quotes a superseded image version.

Every lab's README carries recorded terminal output, and `evidence/` holds the
captured runs behind it. Both name image versions. When the automation bumps a
pin, that recorded output becomes wrong.

It is not fixed by a regex. A transcript rewritten to name a version nobody ran
is fabricated evidence, and the column alignment of `docker compose ps` output
would not survive the edit anyway. So this reports the drift and leaves the
files alone: a human regenerates them by running the lab, which is the same
thing that produced them in the first place.

Two different homes, which is why the report says which one applies. `evidence/`
belongs to this repository and comes from each lab's `scripts/capture-evidence.sh`.
Every `README.md` under `labs/` is GENERATED from that lab's `.mdx` in
Zenable-io/next-gen-governance and carries a "Do not edit by hand" banner saying
so — a fix committed here is silently reverted by the next export.

Config files are the source of truth. Anything found in a `.md` or under
`evidence/` naming a different version for the same image is reported.
"""

import re
import subprocess
import sys
from collections import defaultdict
from pathlib import Path

from supply_chain import annotations

# `image: repo:tag` in compose, and `FROM repo:tag` in a Dockerfile. The tag is
# captured separately so a digest suffix or a trailing comment does not join it.
_IMAGE_RE = re.compile(
r"(?:image:\s*|FROM\s+)(?P<repo>[a-z0-9][a-z0-9._/-]*[a-z0-9]):(?P<tag>[\w][\w.-]*)",
re.IGNORECASE,
)

# The same reference as it appears in prose or recorded output, where it is not
# introduced by an `image:` or `FROM` key.
_MENTION_RE = re.compile(r"(?P<repo>[a-z0-9][a-z0-9._/-]*[a-z0-9]):(?P<tag>[\w][\w.-]*)")

# Recorded output often names a product rather than an image: `kc.sh --version`
# prints `Keycloak 26.7.1`, with no registry path and no colon, which the
# reference pattern above cannot see. Matched against the repository's last path
# segment, so `quay.io/keycloak/keycloak` also covers "Keycloak <version>".
_PRODUCT_RE_TEMPLATE = r"\b{name}[ :v]+(?P<tag>\d+\.\d+(?:\.\d+)*)\b"

# Only plain-semver pins take part in the product-name check. A templated tag
# like python's `3.13-slim` has no clean version to compare against, and the
# labs legitimately print a fuller `Python 3.13.12` for the same pin — which
# would read as drift on every single run.
_PLAIN_VERSION_RE = re.compile(r"^v?\d+\.\d+(?:\.\d+)*$")

# `*Dockerfile*` rather than `*Dockerfile`, because a lab that builds more than
# one image suffixes them — `Dockerfile.get-started`, `Dockerfile.tickets`. The
# unsuffixed glob silently skipped both, which reads as "no drift" rather than
# as an error.
_CONFIG_GLOBS = ("*compose.yml", "*compose.yaml", "*Dockerfile*")
_DOC_GLOBS = ("*.md", "*.txt", "*.json")


def _tracked_files(root: Path, globs: tuple[str, ...]) -> list[Path]:
"""Git-tracked files matching any of ``globs``.

Uses the index rather than a filesystem walk so a stray `.venv/` or a
build artifact can never be mistaken for repository content.
"""
result = subprocess.run(
["git", "-C", str(root), "ls-files", "-z", *globs],
capture_output=True,
text=True,
check=True,
)
return [root / name for name in result.stdout.split("\0") if name]


def collect_pinned_versions(root: Path) -> dict[str, dict[str, set[Path]]]:
"""Map each image repository to the tags the config files pin it at.

:return: ``{repo: {tag: {file, ...}}}``
"""
pins: dict[str, dict[str, set[Path]]] = defaultdict(lambda: defaultdict(set))
for path in _tracked_files(root, _CONFIG_GLOBS):
for match in _IMAGE_RE.finditer(path.read_text(encoding="utf-8")):
pins[match["repo"]][match["tag"]].add(path)
return pins


def find_drift(root: Path, pins: dict[str, dict[str, set[Path]]]) -> list[str]:
"""Find documentation naming a tag the config files no longer use."""
findings: list[str] = []
config_files = {p.resolve() for p in _tracked_files(root, _CONFIG_GLOBS)}

for path in _tracked_files(root, _DOC_GLOBS):
if path.resolve() in config_files:
continue
try:
content = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue

seen: set[tuple[str, str]] = set()

def record(repo: str, tag: str, shown: str, *, strip_v: bool = False) -> None:
known_tags = pins.get(repo)
# A bare `word:word` is far too common in prose to treat as an image
# reference, so only repositories the config files actually pin count.
if not known_tags:
return
# The product-name form captures the digits only, so `agentgateway:v1.4.1`
# reaches here as `1.4.1` and would otherwise read as drift against its
# own pin. The reference form keeps the tag verbatim, where a stray `v`
# really is a different tag.
comparable = (
{t.lstrip("vV") for t in known_tags} if strip_v else known_tags
)
if (tag.lstrip("vV") if strip_v else tag) in comparable:
return
if (repo, tag) in seen:
return
seen.add((repo, tag))
current = ", ".join(sorted(known_tags))
findings.append(
f"{path.relative_to(root)}: names {shown}, but the "
f"configuration now pins {repo}:{current}"
)

for match in _MENTION_RE.finditer(content):
record(match["repo"], match["tag"], f"{match['repo']}:{match['tag']}")

for repo, known_tags in pins.items():
if not any(_PLAIN_VERSION_RE.match(t) for t in known_tags):
continue
product = repo.rsplit("/", 1)[-1]
product_re = re.compile(
_PRODUCT_RE_TEMPLATE.format(name=re.escape(product)), re.IGNORECASE
)
for match in product_re.finditer(content):
record(repo, match["tag"], f"{product} {match['tag']}", strip_v=True)

return findings


def main() -> int:
root = Path(
subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
)

findings = find_drift(root, collect_pinned_versions(root))
if not findings:
print("No documentation drift found.")
return 0

print(f"{len(findings)} file reference(s) now disagree with the pinned versions:")
for finding in findings:
print(f" - {finding}")
annotations.warning(finding, title="doc-quotes-superseded-version")

print(
"\nRegenerate the recorded output rather than editing it by hand. Where it "
"lives depends on the file:\n"
" evidence/** is owned here — re-run the lab's scripts/capture-evidence.sh.\n"
" README.md is GENERATED from the lab's .mdx in Zenable-io/next-gen-governance\n"
" (services/ui_frontend/src/lib/labs/content/labs/). Fix the transcript\n"
" there, then re-export with\n"
" `node services/ui_frontend/scripts/export-lab-readme.js --all <this repo>`.\n"
" Editing README.md here is undone by the next export."
)
# Advisory, not a gate. The update PR should still open; a human decides
# whether the transcripts are worth re-recording this week.
return 0


if __name__ == "__main__":
sys.exit(main())
16 changes: 16 additions & 0 deletions .github/scripts/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[project]
name = "labs-update-tooling"
version = "0"
description = "Cooldown-gated, integrity-verified dependency pinning for Zenable Labs"
requires-python = ">=3.11"
dependencies = [
"requests>=2.32",
"PyYAML>=6.0",
]

[tool.uv]
package = false
# The same 7-day supply-chain cooldown this tooling enforces on the labs, applied
# to the tooling itself. Tooling that adopts a same-day release while telling the
# repo to wait a week is not a control, it is a suggestion.
exclude-newer = "7 days"
13 changes: 13 additions & 0 deletions .github/scripts/supply_chain/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Supply-chain primitives for the Zenable Labs dependency automation.

A deliberately small subset of ``zenable_monorepo`` from Zenable-io/next-gen-governance,
carrying only what this repository's pins need: a release cooldown, GitHub release
and tag->commit integrity verification, Docker Hub tag discovery, and CI annotations.

Vendored rather than depended on because next-gen-governance is private and this
repository is public. The security-relevant logic — the cooldown window, the
release -> tag ref -> commit -> reachable-from-branch chain — is kept behaviourally
identical to upstream so a fix there ports here as a readable diff. When a third
repository needs this, extract these modules into a package both can consume
rather than vendoring a second copy.
"""
Loading