diff --git a/README.md b/README.md index 944a7790..854cf543 100644 --- a/README.md +++ b/README.md @@ -420,6 +420,10 @@ Will not show up if no `.readthedocs.yml`/`.readthedocs.yaml` file is present. - `RF201`: Avoid using deprecated config settings - `RF202`: Use (new) lint config section +### Security + +- [`SEC001`](https://learn.scientific-python.org/development/guides/security#SEC001): Use zizmor to check the GitHub Actions + ### Setuptools Config Will not show up if no `setup.cfg` file is present. diff --git a/docs/guides/index.md b/docs/guides/index.md index c3ecc900..f49187ec 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -18,7 +18,8 @@ A section on CI follows, with a [general setup guide][gha_basic], and then two choices for using CI to distribute your package, one for [pure Python][gha_pure], and one for [compiled extensions][gha_wheels]. You can read about setting up good tests on the [pytest page][pytest], with -[coverage][]. There's also a page on setting up [docs][], as well. +[coverage][]. There's also a page on setting up [docs][], as well as a page on +[security][] best practices. :::{tip} New project template Once you have completed the guidelines, there is a @@ -45,6 +46,7 @@ WebAssembly! All checks point to a linked badge in the guide. [gha_basic]: guides/gha-basic [gha_pure]: guides/gha-pure [gha_wheels]: guides/gha-wheels +[security]: guides/security [pytest]: guides/pytest [right in the guide]: guides/repo-review diff --git a/docs/guides/security.md b/docs/guides/security.md new file mode 100644 index 00000000..d51e7eff --- /dev/null +++ b/docs/guides/security.md @@ -0,0 +1,110 @@ +--- +short_title: Security +--- + +# Security + +Supply-chain and CI security are increasingly important for scientific Python +projects; new attacks are targeting smaller packages than ever before thanks to +the ease with which exploits can be found and utilized with AI. The first six +months of 2026 had 4.5x the malitoius package volume of _all_ of 2025[^1]. + +[^1]: + +Most of these attacks strung together smaller vulerabilties into something +exploitable, often in CI like GitHub Actions. Once in, the attacks upload +malitious packages that spread the attack via PyPI or NPM. + +This page has recommendations for keeping your repository and its automation +secure. This will never be complete, but even a few small steps can make your +code much more secure. + +## GitHub Actions + +{rr}`SEC001` GitHub Actions workflows are a common source of security issues, +due to how commonly it is used, and it's original design being focused on ease +of use and convenience. + +Common security problems: + +* Action moving references, like `@v1`, or tags, like `@v1.0.1`, can be pushed + if an attacker comprimizes the action repository you are using. If you use + full 40 character SHA's, these cannot be modified. (Official actions are + likely okay, but important for third party actions). There's even a GitHub + setting to require this. It's conventional to include the tag as a trailing + comment. +* Action SHA references can be added by a fork. If you make a fork of + `actions/checkout`, you can reference _your_ SHA via + `actions/checkout@`. Only accept SHAs you have verified or a tool (like + dependabot) produce. If you use Zizmor, it can also verify that an SHA matches + a tag, tags cannot be pulled from a fork. +* Caching is dangerous. Attackers can poison an unrelated cache. Avoid caching + in your release jobs. +* `pull_request_target` is really dangerous. Attackers can use it to poison + caches, for example. +* Tighten default permissions. A job should not have permissions to do anything + it doesn't need. Set the default in settings to read-only, then explicitly + grant required permissions. +* Don't build code in your release job. The release job should do _as little as + possible_. +* Use trusted publishing. There's no long-lived token to steal. + +:::{note} + +This guide and cookiecutter does _not_ use SHA pinning to make it easier to +read and maintain. You can convert to SHA with tools like +[`npx actions-up`](https://github.com/azat-io/actions-up). + +::: + +### Zizmor + +[zizmor](https://docs.zizmor.sh) is a static analysis tool that audits your +workflows for common problems, including many of the ones above. You can run +it as a pre-commit hook or as a GitHub Action: + +::::{tab-set} +:::{tab-item} pre-commit + +```yaml +- repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: "v1.26.1" + hooks: + - id: zizmor +``` + +::: +:::{tab-item} GitHub Actions + +```yaml +name: zizmor + +on: + push: + branches: [main] + pull_request: + +permissions: {} + +jobs: + zizmor: + runs-on: ubuntu-latest + permissions: + security-events: write + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: zizmorcore/zizmor-action@v0.5.7 +``` + +The [`zizmorcore/zizmor-action`](https://github.com/zizmorcore/zizmor-action) +GitHub Action can upload its findings to GitHub's code scanning dashboard. + +::: +:::: + +You can silence individual findings with `# zizmor: ignore[rule]` comments, or +collect them in a [`zizmor.yml`](https://docs.zizmor.sh/configuration/) config +file. diff --git a/docs/myst.yml b/docs/myst.yml index 0a470973..ed00f642 100644 --- a/docs/myst.yml +++ b/docs/myst.yml @@ -30,6 +30,7 @@ project: - file: guides/gha_basic.md - file: guides/gha_pure.md - file: guides/gha_wheels.md + - file: guides/security.md - file: guides/tasks.md - file: principles/index.md children: diff --git a/noxfile.py b/noxfile.py index 0e100000..34e4f958 100755 --- a/noxfile.py +++ b/noxfile.py @@ -426,6 +426,7 @@ def pc_bump(session: nox.Session) -> None: versions = {} pages = [ Path("docs/guides/style.md"), + Path("docs/guides/security.md"), Path("{{cookiecutter.project_name}}/.pre-commit-config.yaml"), Path(".pre-commit-config.yaml"), ] diff --git a/pyproject.toml b/pyproject.toml index 9fd7904a..99c9ec74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,7 @@ precommit = "sp_repo_review.checks.precommit:repo_review_checks" ruff = "sp_repo_review.checks.ruff:repo_review_checks" mypy = "sp_repo_review.checks.mypy:repo_review_checks" github = "sp_repo_review.checks.github:repo_review_checks" +security = "sp_repo_review.checks.security:repo_review_checks" readthedocs = "sp_repo_review.checks.readthedocs:repo_review_checks" setupcfg = "sp_repo_review.checks.setupcfg:repo_review_checks" noxfile = "sp_repo_review.checks.noxfile:repo_review_checks" diff --git a/src/sp_repo_review/checks/security.py b/src/sp_repo_review/checks/security.py new file mode 100644 index 00000000..b11de41c --- /dev/null +++ b/src/sp_repo_review/checks/security.py @@ -0,0 +1,55 @@ +# SEC: Security +## SEC0xx: GitHub Actions security + +from __future__ import annotations + +from typing import Any + +from . import mk_url + + +class Security: + family = "security" + + +class SEC001(Security): + "Use zizmor to check the GitHub Actions" + + requires = {"GH100"} + url = mk_url("security") + + @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.26.1 + 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 + + +def repo_review_checks() -> dict[str, Security]: + return {p.__name__: p() for p in Security.__subclasses__()} diff --git a/src/sp_repo_review/families.py b/src/sp_repo_review/families.py index b5297cbe..2dbbf5c1 100644 --- a/src/sp_repo_review/families.py +++ b/src/sp_repo_review/families.py @@ -114,6 +114,9 @@ def get_families( "github": Family( name="GitHub Actions", ), + "security": Family( + name="Security", + ), "pre-commit": Family( name="Pre-commit", readme_note="Will not show up if using lefthook instead of pre-commit/prek.", diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 00000000..63f5bc98 --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,41 @@ +import yaml +from repo_review.testing import compute_check + + +def test_sec001_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("SEC001", precommit=precommit, workflows={"ci": {}}).result + + +def test_sec001_action() -> None: + workflows = yaml.safe_load( + """ + zizmor: + jobs: + zizmor: + steps: + - uses: zizmorcore/zizmor-action@v0.5.6 + """ + ) + assert compute_check("SEC001", precommit={}, workflows=workflows).result + + +def test_sec001_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("SEC001", precommit=precommit, workflows={"ci": {}}).result 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 59e969f7..32dc428d 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@v7 - {%- 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@v7 - {%- 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 20766cb3..9d33754e 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@v7 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 83b3f783..1385ce05 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@v7 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@v7 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.1 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 30181c5e..e729bbf3 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.26.1" + hooks: + - id: zizmor +{%- endif %}