09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
181 lines
6.7 KiB
Python
181 lines
6.7 KiB
Python
"""Assert third-party actions in privileged workflows are SHA-pinned.
|
|
|
|
Pinning third-party GitHub Actions to a 40-character commit SHA (instead
|
|
of a floating tag like ``@v1`` or ``@release/v1``) is a defence against
|
|
upstream tag-hijacking and silent supply-chain compromise: a malicious
|
|
push to a tag we trust would otherwise execute under our secrets on the
|
|
next workflow run. This check is the static gate that keeps the
|
|
SHA-pinning invariant from regressing.
|
|
|
|
Scope:
|
|
|
|
* **Privileged workflows** — the hardcoded list below — run jobs that
|
|
carry deploy keys, OIDC tokens, repo write scopes, or maintainer
|
|
credentials. Every ``uses:`` referencing a third-party repo in those
|
|
workflows must be SHA-pinned.
|
|
* **First-party ``actions/*`` actions** (e.g. ``actions/checkout``,
|
|
``actions/setup-python``) are owned by GitHub and may stay on floating
|
|
tags. Dependabot still bumps them on a weekly cadence (see
|
|
``.github/dependabot.yml``).
|
|
* Non-privileged workflows are out of scope; they don't see secrets and
|
|
can churn at floating-tag speed.
|
|
|
|
Comment convention: when a SHA is pinned, the line should be followed by
|
|
a human-readable tag comment so reviewers can read the intent without
|
|
visiting GitHub. Example::
|
|
|
|
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # @ v1.14.0
|
|
|
|
The comment is advisory — this script does not enforce its presence
|
|
(Dependabot rewrites only the SHA, and a missing comment is a polish
|
|
finding, not a security gap). We enforce only the SHA itself.
|
|
|
|
Usage::
|
|
|
|
python scripts/check_action_pinning.py
|
|
python scripts/check_action_pinning.py --workflow-dir custom/path
|
|
|
|
Exit codes:
|
|
0 Every third-party action in every privileged workflow is SHA-pinned.
|
|
1 One or more violations found (printed to stderr with file:line).
|
|
2 Argument error / privileged workflow file missing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
# Privileged workflows. Each one carries secrets, OIDC, or maintainer-only
|
|
# triggers; a hijacked third-party action in any of these can exfiltrate
|
|
# credentials or push malicious artifacts under our identity. Keep this
|
|
# list synced with the workflows that declare an ``environment:`` gate or
|
|
# reference ``${{ secrets.* }}`` — see ``check_workflow_secret_gates.py``
|
|
# for the runtime companion to this static check.
|
|
PRIVILEGED_WORKFLOWS: tuple[str, ...] = (
|
|
"publish.yml",
|
|
"publish-docker.yml",
|
|
"publish-mcpb.yml",
|
|
"testpypi-publish.yml",
|
|
"claude.yml",
|
|
"rpc-health.yml",
|
|
"nightly.yml",
|
|
"verify-artifacts.yml",
|
|
"verify-package.yml",
|
|
)
|
|
|
|
# ``uses:`` lines we'll inspect. The ref (everything after ``@``) is the
|
|
# focus of the check; we only need owner/repo to decide whether the
|
|
# action is first-party. Composite-action paths (``./.github/...``) and
|
|
# Docker image refs (``docker://...``) are intentionally NOT matched —
|
|
# they're a different trust model and aren't used in this repo today.
|
|
_USES_RE = re.compile(
|
|
r"""
|
|
^\s*-?\s* # optional list dash + leading indent
|
|
uses:\s+ # the ``uses:`` key
|
|
(?P<owner>[A-Za-z0-9._-]+)
|
|
/
|
|
(?P<repo>[A-Za-z0-9._/-]+?) # repo may include path-into-monorepo
|
|
@
|
|
(?P<ref>\S+) # whatever is after ``@`` up to next whitespace
|
|
""",
|
|
re.VERBOSE,
|
|
)
|
|
|
|
# A SHA is a 40-character lowercase hex string. Anything else — including
|
|
# short SHAs (``@cef221``), tags (``@v1``, ``@release/v1``), branches
|
|
# (``@main``) — is treated as a floating ref.
|
|
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
|
|
# Owners we treat as first-party (GitHub-owned, no SHA pin required).
|
|
# Mirrors the ``_ALLOWED_READ_SCOPES`` frozenset convention used by
|
|
# ``check_workflow_permissions.py`` for similar small lookup tables.
|
|
# ``github/*`` (e.g. ``github/codeql-action``) is GitHub-owned too but
|
|
# lives in a separate org; expand this set deliberately if a ``github/*``
|
|
# action ever lands in a privileged workflow.
|
|
_FIRST_PARTY_OWNERS = frozenset({"actions"})
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument(
|
|
"--workflow-dir",
|
|
default=".github/workflows",
|
|
help="Directory containing workflow YAML files (default: .github/workflows)",
|
|
)
|
|
args = ap.parse_args()
|
|
|
|
workflow_dir = Path(args.workflow_dir)
|
|
if not workflow_dir.is_dir():
|
|
print(f"Not a directory: {workflow_dir}", file=sys.stderr)
|
|
return 2
|
|
|
|
missing: list[str] = []
|
|
for name in PRIVILEGED_WORKFLOWS:
|
|
if not (workflow_dir / name).is_file():
|
|
missing.append(name)
|
|
if missing:
|
|
# A privileged workflow disappearing is itself worth a hard fail:
|
|
# either the list is stale or someone deleted security infra.
|
|
# Surface so a maintainer either restores the file or updates
|
|
# PRIVILEGED_WORKFLOWS deliberately.
|
|
for name in missing:
|
|
print(
|
|
f"{workflow_dir / name}: privileged workflow file missing",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
|
|
violations: list[str] = []
|
|
for name in PRIVILEGED_WORKFLOWS:
|
|
path = workflow_dir / name
|
|
for lineno, owner, repo, ref in _iter_uses(path):
|
|
if owner in _FIRST_PARTY_OWNERS:
|
|
# actions/* is GitHub-owned; floating tag is acceptable.
|
|
continue
|
|
if _SHA_RE.match(ref):
|
|
# Third-party + 40-char SHA = pinned. OK.
|
|
continue
|
|
violations.append(
|
|
f"{path}:{lineno}: third-party action not SHA-pinned: "
|
|
f"{owner}/{repo}@{ref} (expected 40-char commit SHA)"
|
|
)
|
|
|
|
if violations:
|
|
for v in violations:
|
|
print(v, file=sys.stderr)
|
|
print(
|
|
f"\n{len(violations)} violation(s). "
|
|
"Replace floating tag with the resolved commit SHA, e.g.:\n"
|
|
" gh api repos/<owner>/<repo>/commits/<tag> --jq .sha\n"
|
|
"Then add a comment with the human-readable tag for review "
|
|
"readability, e.g. ``# @ v1.2.3``.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
print(
|
|
f"OK: all third-party actions in {len(PRIVILEGED_WORKFLOWS)} privileged "
|
|
f"workflows are SHA-pinned"
|
|
)
|
|
return 0
|
|
|
|
|
|
def _iter_uses(path: Path) -> Iterator[tuple[int, str, str, str]]:
|
|
"""Yield ``(lineno, owner, repo, ref)`` for each ``uses:`` line in ``path``.
|
|
|
|
``lineno`` is 1-based to match editor / grep / GitHub line numbering.
|
|
"""
|
|
for lineno, line in enumerate(path.read_text().splitlines(), start=1):
|
|
m = _USES_RE.match(line)
|
|
if not m:
|
|
continue
|
|
yield lineno, m.group("owner"), m.group("repo"), m.group("ref")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|