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 %}