From 9cae530236fad6c76e6bb7b6d15febd50b588ca9 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 9 Jun 2026 12:31:00 -0400 Subject: [PATCH 1/3] feat: add zizmor for GitHub Actions security Add zizmor (GitHub Actions static analysis) across the three concerns: - repo-review: new GH106 check (uses the zizmor pre-commit hook or the zizmor-action), with tests and regenerated README. - guide: new "Linting your workflows" section in gha_basic.md; the page is now bumped by the pc_bump nox session. - cookiecutter: add the zizmor pre-commit hook and a .github/zizmor.yml (GitHub-CI projects only) that relaxes unpinned-uses to ref-pin so the template stays maintainable via Dependabot. The generated workflows are made zizmor-clean (top-level permissions, persist-credentials: false, enable-cache: false on the wheel build). GH106 is ignored for this repo's own workflows for now. Assisted-by: ClaudeCode:claude-opus-4.8 --- README.md | 1 + docs/pages/guides/gha_basic.md | 21 ++++++++++ noxfile.py | 1 + pyproject.toml | 1 + src/sp_repo_review/checks/github.py | 39 +++++++++++++++++++ tests/test_github.py | 39 +++++++++++++++++++ .../.github/workflows/ci.yml | 12 +++++- ...ter.__type!='compiled' %}cd.yml{% endif %} | 5 +++ ...ter.__type=='compiled' %}cd.yml{% endif %} | 11 ++++++ ...r.__ci == 'github' %}zizmor.yml{% endif %} | 8 ++++ .../.pre-commit-config.yaml | 8 ++++ 11 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 {{cookiecutter.project_name}}/.github/{% if cookiecutter.__ci == 'github' %}zizmor.yml{% endif %} diff --git a/README.md b/README.md index 2aca5c4a..2a966d09 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,7 @@ for family, grp in itertools.groupby(collected.checks.items(), key=lambda x: x[1 - [`GH103`](https://learn.scientific-python.org/development/guides/gha-basic#GH103): At least one workflow with manual dispatch trigger - [`GH104`](https://learn.scientific-python.org/development/guides/gha-wheels#GH104): Use unique names for upload-artifact - [`GH105`](https://learn.scientific-python.org/development/guides/gha-basic#GH105): Use Trusted Publishing instead of token-based publishing on PyPI +- [`GH106`](https://learn.scientific-python.org/development/guides/gha-basic#GH106): Use zizmor to check the GitHub Actions - [`GH200`](https://learn.scientific-python.org/development/guides/gha-basic#GH200): Maintained by Dependabot - [`GH210`](https://learn.scientific-python.org/development/guides/gha-basic#GH210): Maintains the GitHub action versions with Dependabot - [`GH211`](https://learn.scientific-python.org/development/guides/gha-basic#GH211): Do not pin core actions as major versions diff --git a/docs/pages/guides/gha_basic.md b/docs/pages/guides/gha_basic.md index 5529afba..5cb94ca4 100644 --- a/docs/pages/guides/gha_basic.md +++ b/docs/pages/guides/gha_basic.md @@ -91,6 +91,27 @@ run a manual check, like check-manifest, then you can keep it but just use this one check. You can also use `needs: lint` in your other jobs to keep them from running if the lint check does not pass. +### Linting your workflows + +{rr}`GH106` GitHub Actions workflows are a common source of security issues, +such as script injection from untrusted input, overly broad token permissions, +and credentials accidentally persisted by `actions/checkout`. +[zizmor](https://docs.zizmor.sh) is a static analysis tool that audits your +workflows for these problems. The easiest way to run it is as a pre-commit hook: + +```yaml +- repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: "v1.25.2" + hooks: + - id: zizmor +``` + +You can silence individual findings with `# zizmor: ignore[rule]` comments, or +collect them in a [`zizmor.yml`](https://docs.zizmor.sh/configuration/) config +file. If you'd rather keep it out of pre-commit, zizmor also ships the +[`zizmorcore/zizmor-action`](https://github.com/zizmorcore/zizmor-action) +GitHub Action, which can upload results to GitHub's code scanning dashboard. + ### Unit tests Implementing unit tests is also easy. Since you should be following best diff --git a/noxfile.py b/noxfile.py index 60573f2d..42c78578 100755 --- a/noxfile.py +++ b/noxfile.py @@ -426,6 +426,7 @@ def pc_bump(session: nox.Session) -> None: versions = {} pages = [ Path("docs/pages/guides/style.md"), + Path("docs/pages/guides/gha_basic.md"), Path("{{cookiecutter.project_name}}/.pre-commit-config.yaml"), Path(".pre-commit-config.yaml"), ] diff --git a/pyproject.toml b/pyproject.toml index 9fd7904a..320ea26c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -204,6 +204,7 @@ ignore = [ [tool.repo-review.ignore] RTD103 = "Using Ruby instead of Python for docs" +GH106 = "zizmor adoption for this repo's own workflows is tracked separately" [tool.typos.default.extend-words] nd = "nd" diff --git a/src/sp_repo_review/checks/github.py b/src/sp_repo_review/checks/github.py index 1547b238..8f893034 100644 --- a/src/sp_repo_review/checks/github.py +++ b/src/sp_repo_review/checks/github.py @@ -192,6 +192,45 @@ def check(workflows: dict[str, Any]) -> str: return "\n".join(errors) +class GH106(GitHub): + "Use zizmor to check the GitHub Actions" + + requires = {"GH100"} + url = mk_url("gha-basic") + + @staticmethod + def check(precommit: dict[str, Any], workflows: dict[str, Any]) -> bool: + """ + Projects with GitHub Actions should statically analyze their workflows + with [zizmor](https://docs.zizmor.sh), which catches common security + issues such as template injection, excessive permissions, and + credential persistence. The simplest way is to add the pre-commit hook: + + ```yaml + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: v1.25.2 + hooks: + - id: zizmor + ``` + + You can also run it as the `zizmorcore/zizmor-action` GitHub Action. + """ + for repo_item in precommit.get("repos", []): + if ( + repo_item.get("repo", "").lower() + == "https://github.com/zizmorcore/zizmor-pre-commit" + ): + return True + for workflow in workflows.values(): + for job in workflow.get("jobs", {}).values(): + if not isinstance(job, dict): + continue + for step in job.get("steps", []): + if step.get("uses", "").startswith("zizmorcore/zizmor-action"): + return True + return False + + class GH200(GitHub): "Maintained by Dependabot" diff --git a/tests/test_github.py b/tests/test_github.py index 99e3a347..6dcdabf8 100644 --- a/tests/test_github.py +++ b/tests/test_github.py @@ -180,6 +180,45 @@ def test_gh105_token_based_upload() -> None: assert "Token-based publishing" in res.err_msg +def test_gh106_precommit() -> None: + precommit = yaml.safe_load( + """ + repos: + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: v1.22.0 + hooks: + - id: zizmor + """ + ) + assert compute_check("GH106", precommit=precommit, workflows={"ci": {}}).result + + +def test_gh106_action() -> None: + workflows = yaml.safe_load( + """ + zizmor: + jobs: + zizmor: + steps: + - uses: zizmorcore/zizmor-action@v0.5.6 + """ + ) + assert compute_check("GH106", precommit={}, workflows=workflows).result + + +def test_gh106_missing() -> None: + precommit = yaml.safe_load( + """ + repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.16 + hooks: + - id: ruff-check + """ + ) + assert not compute_check("GH106", precommit=precommit, workflows={"ci": {}}).result + + def test_gh200() -> None: dependabot = yaml.safe_load( """ diff --git a/{{cookiecutter.project_name}}/.github/workflows/ci.yml b/{{cookiecutter.project_name}}/.github/workflows/ci.yml index 036e5108..8bda07a4 100644 --- a/{{cookiecutter.project_name}}/.github/workflows/ci.yml +++ b/{{cookiecutter.project_name}}/.github/workflows/ci.yml @@ -7,6 +7,8 @@ on: branches: - main +permissions: {} + concurrency: group: {% raw %}${{ github.workflow }}-${{ github.ref }}{% endraw %} cancel-in-progress: true @@ -21,10 +23,13 @@ jobs: lint: name: Format runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v6 - {%- if cookiecutter.vcs %} with: + persist-credentials: false + {%- if cookiecutter.vcs %} fetch-depth: 0 {%- endif %} @@ -45,6 +50,8 @@ jobs: {%- if cookiecutter.__type == "compiled" %} needs: [lint] {%- endif %} + permissions: + contents: read strategy: fail-fast: false matrix: @@ -57,8 +64,9 @@ jobs: steps: - uses: actions/checkout@v6 - {%- if cookiecutter.vcs %} with: + persist-credentials: false + {%- if cookiecutter.vcs %} fetch-depth: 0 {%- endif %} diff --git a/{{cookiecutter.project_name}}/.github/workflows/{% if cookiecutter.__type!='compiled' %}cd.yml{% endif %} b/{{cookiecutter.project_name}}/.github/workflows/{% if cookiecutter.__type!='compiled' %}cd.yml{% endif %} index bc0c2b45..ad96c913 100644 --- a/{{cookiecutter.project_name}}/.github/workflows/{% if cookiecutter.__type!='compiled' %}cd.yml{% endif %} +++ b/{{cookiecutter.project_name}}/.github/workflows/{% if cookiecutter.__type!='compiled' %}cd.yml{% endif %} @@ -10,6 +10,8 @@ on: types: - published +permissions: {} + concurrency: group: {% raw %}${{ github.workflow }}-${{ github.ref }}{% endraw %} cancel-in-progress: true @@ -23,10 +25,13 @@ jobs: dist: name: Distribution build runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v6 with: + persist-credentials: false fetch-depth: 0 - uses: hynek/build-and-inspect-python-package@v2 diff --git a/{{cookiecutter.project_name}}/.github/workflows/{% if cookiecutter.__type=='compiled' %}cd.yml{% endif %} b/{{cookiecutter.project_name}}/.github/workflows/{% if cookiecutter.__type=='compiled' %}cd.yml{% endif %} index fcac06ff..21b07dfc 100644 --- a/{{cookiecutter.project_name}}/.github/workflows/{% if cookiecutter.__type=='compiled' %}cd.yml{% endif %} +++ b/{{cookiecutter.project_name}}/.github/workflows/{% if cookiecutter.__type=='compiled' %}cd.yml{% endif %} @@ -9,6 +9,8 @@ on: paths: - .github/workflows/cd.yml +permissions: {} + concurrency: group: {% raw %}${{ github.workflow }}-${{ github.ref }}{% endraw %} cancel-in-progress: true @@ -22,9 +24,12 @@ jobs: make_sdist: name: Make SDist runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v6 with: + persist-credentials: false fetch-depth: 0 - name: Build SDist @@ -38,6 +43,8 @@ jobs: build_wheels: name: {% raw %}Wheel on ${{ matrix.os }}{% endraw %} runs-on: {% raw %}${{ matrix.os }}{% endraw %} + permissions: + contents: read strategy: fail-fast: false matrix: @@ -52,9 +59,13 @@ jobs: steps: - uses: actions/checkout@v6 with: + persist-credentials: false fetch-depth: 0 - uses: astral-sh/setup-uv@v8.2.0 + with: + # Disable caching to avoid poisoning published wheels + enable-cache: false - uses: pypa/cibuildwheel@v4.0 diff --git a/{{cookiecutter.project_name}}/.github/{% if cookiecutter.__ci == 'github' %}zizmor.yml{% endif %} b/{{cookiecutter.project_name}}/.github/{% if cookiecutter.__ci == 'github' %}zizmor.yml{% endif %} new file mode 100644 index 00000000..5aae9aaf --- /dev/null +++ b/{{cookiecutter.project_name}}/.github/{% if cookiecutter.__ci == 'github' %}zizmor.yml{% endif %} @@ -0,0 +1,8 @@ +# Configuration for zizmor (https://docs.zizmor.sh) +rules: + unpinned-uses: + config: + # Actions are kept up to date with Dependabot, so a ref (tag) pin is + # sufficient; hash pinning is not required. + policies: + "*": ref-pin diff --git a/{{cookiecutter.project_name}}/.pre-commit-config.yaml b/{{cookiecutter.project_name}}/.pre-commit-config.yaml index 1e8a5961..64f8486f 100644 --- a/{{cookiecutter.project_name}}/.pre-commit-config.yaml +++ b/{{cookiecutter.project_name}}/.pre-commit-config.yaml @@ -114,3 +114,11 @@ repos: - id: check-gitlab-ci {%- endif %} - id: check-readthedocs + +{%- if cookiecutter.__ci == "github" %} + + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: "v1.25.2" + hooks: + - id: zizmor +{%- endif %} From c3db1a98105d317f103bfbe3d441a70388146f5d Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 9 Jun 2026 20:57:04 -0400 Subject: [PATCH 2/3] fix: add cooldown to template dependabot.yml for zizmor compliance Assisted-by: ClaudeCode:claude-sonnet-4-6 --- {{cookiecutter.project_name}}/.github/dependabot.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/{{cookiecutter.project_name}}/.github/dependabot.yml b/{{cookiecutter.project_name}}/.github/dependabot.yml index 6c4b3695..01703d62 100644 --- a/{{cookiecutter.project_name}}/.github/dependabot.yml +++ b/{{cookiecutter.project_name}}/.github/dependabot.yml @@ -5,6 +5,8 @@ updates: directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 7 groups: actions: patterns: From 335e5f66fa2d70a33301bf6003b4eea1618b9a2e Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 10 Jun 2026 00:00:13 -0400 Subject: [PATCH 3/3] chore: now we pass zizmor Signed-off-by: Henry Schreiner --- pyproject.toml | 3 +-- ...{% if cookiecutter.__ci == 'github' %}zizmor.yml{% endif %} | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 320ea26c..f64802c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -203,8 +203,7 @@ ignore = [ "helpers/extensions.py" = ["ANN"] [tool.repo-review.ignore] -RTD103 = "Using Ruby instead of Python for docs" -GH106 = "zizmor adoption for this repo's own workflows is tracked separately" +RTD103 = "Using MystMD instead of Python for docs" [tool.typos.default.extend-words] nd = "nd" diff --git a/{{cookiecutter.project_name}}/.github/{% if cookiecutter.__ci == 'github' %}zizmor.yml{% endif %} b/{{cookiecutter.project_name}}/.github/{% if cookiecutter.__ci == 'github' %}zizmor.yml{% endif %} index 5aae9aaf..ab94a2af 100644 --- a/{{cookiecutter.project_name}}/.github/{% if cookiecutter.__ci == 'github' %}zizmor.yml{% endif %} +++ b/{{cookiecutter.project_name}}/.github/{% if cookiecutter.__ci == 'github' %}zizmor.yml{% endif %} @@ -2,7 +2,6 @@ rules: unpinned-uses: config: - # Actions are kept up to date with Dependabot, so a ref (tag) pin is - # sufficient; hash pinning is not required. + # Feel free to switch to hash pinning, then this can be removed. policies: "*": ref-pin