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..56ea890f --- /dev/null +++ b/docs/guides/security.md @@ -0,0 +1,58 @@ +--- +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. This page +collects recommendations for keeping your repository and its automation secure. +This is a work in progress; expect it to grow over time. + +## GitHub Actions + +{rr}`SEC001` 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.26.1" + hooks: + - id: zizmor +``` + +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 its findings to GitHub's code scanning +dashboard: + +```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 +``` + +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 %}