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..f64802c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -203,7 +203,7 @@ ignore = [ "helpers/extensions.py" = ["ANN"] [tool.repo-review.ignore] -RTD103 = "Using Ruby instead of Python for docs" +RTD103 = "Using MystMD instead of Python for docs" [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/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: 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..ab94a2af --- /dev/null +++ b/{{cookiecutter.project_name}}/.github/{% if cookiecutter.__ci == 'github' %}zizmor.yml{% endif %} @@ -0,0 +1,7 @@ +# Configuration for zizmor (https://docs.zizmor.sh) +rules: + unpinned-uses: + config: + # Feel free to switch to hash pinning, then this can be removed. + 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 %}