From ecfdb76dda80b4dfe910e361cfa541a10aaf9ee0 Mon Sep 17 00:00:00 2001 From: Guillaume Tauzin <4648633+gtauzin@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:52:50 +0200 Subject: [PATCH 1/2] feat(docs): add a user-facing security posture page Add docs/pages/explanation/security.md, an explanation-quadrant page that reassures package users by naming every security measure the project runs and what it means for their trust. Gated on repo_visibility so it never claims a control a private repo does not run (no CodeQL/Scorecard section there). Ships to generated projects and the template repo. Bundles two fixes surfaced by the v0.33.0 fan-out canary: - add S301/S110/S311 to the generated tests/** ruff ignore (test code legitimately uses pickle, try/except/pass, and non-crypto random), so repos need no per-repo triage for them - add a code_owner answer (default @gtauzin) for CODEOWNERS, replacing the bare @{github_username} org name that GitHub rejects as an invalid code owner --- copier.yml | 9 +++ docs/pages/explanation/security.md | 65 ++++++++++++++++ mkdocs.yml | 1 + .../references/file-classification.md | 1 + template/.copier-answers.yml.jinja | 1 + .../references/file-classification.md | 1 + template/CODEOWNERS.jinja | 6 +- .../docs/pages/explanation/security.md.jinja | 77 +++++++++++++++++++ template/mkdocs.yml.jinja | 1 + template/pyproject.toml.jinja | 2 +- tests/conftest.py | 1 + tests/test_security_hardening.py | 43 +++++++++++ 12 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 docs/pages/explanation/security.md create mode 100644 template/docs/pages/explanation/security.md.jinja diff --git a/copier.yml b/copier.yml index 7937873..ca2e5bd 100644 --- a/copier.yml +++ b/copier.yml @@ -102,6 +102,15 @@ github_username: help: "GitHub username or organization" default: "stateful-y" +code_owner: + type: str + # Written into CODEOWNERS. A GitHub username (@user) or team (@org/team). Defaults to + # a maintainer rather than github_username, because a bare organization name is not a + # valid code owner (GitHub flags it) -- the owning org is `github_username`, but the + # reviewer must be a person or team. + help: "Default code owner for CODEOWNERS (@user or @org/team)" + default: "@gtauzin" + license: type: str help: "Project license" diff --git a/docs/pages/explanation/security.md b/docs/pages/explanation/security.md new file mode 100644 index 0000000..055258e --- /dev/null +++ b/docs/pages/explanation/security.md @@ -0,0 +1,65 @@ +# Security + +Every package generated from this template inherits a hardened, security-first posture by +default. This page explains the measures a generated project ships with and what each one +means for the people who depend on that package, so adopters know exactly what they are +getting and downstream users know what protects them. + +The template repository itself follows the same posture as its own first adopter, and +these controls are kept current across template releases so they do not drift. + +## How releases reach users safely + +**Trusted publishing (OIDC).** Generated projects publish to PyPI through GitHub's OpenID +Connect trusted-publishing flow. No long-lived PyPI API token exists to be stolen: a +release can only originate from the project's own audited release workflow. + +**Signed artifacts (PEP 740 attestations).** Every published distribution carries a +Sigstore attestation binding it to the exact workflow run that built it, so a consumer can +verify that an installed artifact is the one the project actually released. + +**A software bill of materials (SBOM).** Each release ships a CycloneDX SBOM listing every +dependency it resolves, so a newly disclosed vulnerability in a transitive dependency can +be traced to the affected releases in seconds. + +**Immutable, monitored dependencies.** Third-party GitHub Actions and Python dependencies +are pinned and kept current by Renovate as reviewable pull requests, so nothing is swapped +out from under a release. + +## How the code is checked + +**Static security analysis.** ruff's flake8-bandit (`S`) ruleset scans every change for +insecure patterns (unsafe subprocess use, weak hashing, hardcoded credentials, unsafe +deserialization) before it can merge. It runs on public and private projects alike. + +**Deep analysis (CodeQL).** Public projects additionally run GitHub's CodeQL data-flow +analysis, which catches vulnerability classes such as injection and path traversal that +line-level linting cannot see. + +**Secret scanning.** gitleaks runs as a pre-commit hook and as an un-bypassable CI check, +so credentials never enter a codebase or a release. + +## How the pipeline itself is hardened + +**Least privilege.** Every generated workflow runs with a read-only token by default; only +the jobs that must write are granted more. + +**A single merge gate.** No change lands until the full test suite and the security checks +above all pass, enforced as one required status check. + +**Scoped automation identity.** Release automation uses a short-lived, narrowly-scoped +GitHub App token rather than a broad personal access token. + +## Transparency and disclosure + +**An independent security grade.** Public projects are scored weekly by the +[OpenSSF Scorecard](https://securityscorecards.dev/), a public automated audit of their +security practices, so a project's posture is verifiable rather than merely asserted. + +**Responsible disclosure.** Every project ships a `SECURITY.md` describing how to report a +vulnerability privately, and a `CODEOWNERS` file so changes are reviewed before they merge. + +**Visibility-aware.** A private project does not ship the public-only controls (CodeQL, the +public Scorecard grade); it keeps the static analysis and secret scanning, which run +identically regardless of visibility, so its security page never claims a control it does +not run. diff --git a/mkdocs.yml b/mkdocs.yml index 53ae7b7..be23645 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -90,6 +90,7 @@ nav: - Explanation: - Architecture Overview: pages/explanation/architecture.md - Release Process: pages/explanation/release-process.md + - Security: pages/explanation/security.md # Reference is last on purpose: it is the section you arrive at knowing what # you want, so it earns its place by being findable, not by being passed # through -- learn, do, understand, then look things up. Same order the diff --git a/template/.claude/skills/update-from-template/references/file-classification.md b/template/.claude/skills/update-from-template/references/file-classification.md index 374cf8d..f5004b2 100644 --- a/template/.claude/skills/update-from-template/references/file-classification.md +++ b/template/.claude/skills/update-from-template/references/file-classification.md @@ -90,6 +90,7 @@ justfile docs/index.md docs/pages/reference/api.md docs/pages/how-to/contribute.md +docs/pages/explanation/security.md .github/workflows/tests.yml # conditional: include_actions .github/workflows/pr-title.yml # conditional: include_actions .github/workflows/publish-release.yml # conditional: include_actions diff --git a/template/.copier-answers.yml.jinja b/template/.copier-answers.yml.jinja index 274999d..fda7c4e 100644 --- a/template/.copier-answers.yml.jinja +++ b/template/.copier-answers.yml.jinja @@ -5,6 +5,7 @@ _commit: {{ _copier_conf.vcs_ref_hash }} _src_path: gh:stateful-y/python-package-copier author_email: {{ author_email }} author_name: {{ author_name }} +code_owner: '{{ code_owner }}' description: {{ description }} github_username: {{ github_username }} include_actions: {{ include_actions }} diff --git a/template/.github/skills/update-from-template/references/file-classification.md b/template/.github/skills/update-from-template/references/file-classification.md index 374cf8d..f5004b2 100644 --- a/template/.github/skills/update-from-template/references/file-classification.md +++ b/template/.github/skills/update-from-template/references/file-classification.md @@ -90,6 +90,7 @@ justfile docs/index.md docs/pages/reference/api.md docs/pages/how-to/contribute.md +docs/pages/explanation/security.md .github/workflows/tests.yml # conditional: include_actions .github/workflows/pr-title.yml # conditional: include_actions .github/workflows/publish-release.yml # conditional: include_actions diff --git a/template/CODEOWNERS.jinja b/template/CODEOWNERS.jinja index 1aa9224..1ceb02c 100644 --- a/template/CODEOWNERS.jinja +++ b/template/CODEOWNERS.jinja @@ -1,6 +1,6 @@ # Code owners for {{ project_name }}. # # Each line maps a path pattern to the owner(s) requested for review on changes. -# For an organization repo, replace the owner below with a team (e.g. @{{ github_username }}/maintainers), -# since an org name alone is not a valid code owner. -* @{{ github_username }} +# `code_owner` is a GitHub username (@user) or a team (@org/team); a bare +# organization name is not a valid code owner. +* {{ code_owner }} diff --git a/template/docs/pages/explanation/security.md.jinja b/template/docs/pages/explanation/security.md.jinja new file mode 100644 index 0000000..061ed20 --- /dev/null +++ b/template/docs/pages/explanation/security.md.jinja @@ -0,0 +1,77 @@ +# Security + +{{ project_name }} is built on a hardened, security-first release pipeline. This page +explains the measures that protect you as a *user* of the package and what each one means +in practice, so you can depend on it with confidence. + +Every control described here is inherited from the +[python-package-copier](https://github.com/stateful-y/python-package-copier) template and +kept current across releases, so the posture does not quietly drift as the project evolves. + +## How releases reach you safely + +**Trusted publishing (OIDC).** Releases are published to PyPI through GitHub's OpenID +Connect trusted-publishing flow. No long-lived PyPI API token exists anywhere to be stolen +and used to push a malicious release: a release can only originate from this project's own +audited release workflow. + +**Signed artifacts (PEP 740 attestations).** Every published distribution carries a +cryptographic attestation, produced with Sigstore, binding the artifact to the exact +workflow run that built it. You can verify that what you install is what this project +actually released, not a tampered copy. + +**A software bill of materials (SBOM).** Each release ships a CycloneDX SBOM enumerating +every dependency it resolves. When a vulnerability is disclosed in a transitive +dependency, you can tell in seconds whether a given release is affected. + +**Immutable, monitored dependencies.** Third-party GitHub Actions and Python dependencies +are pinned, and Renovate keeps them current as reviewable pull requests. A dependency +cannot be swapped out from under a release, and updates arrive as visible changes rather +than silent drift. + +## How the code is checked + +**Static security analysis.** Every change is scanned with ruff's flake8-bandit (`S`) +ruleset, which flags insecure patterns (unsafe subprocess use, weak hashing, hardcoded +credentials, unsafe deserialization) before they can merge. +{% if repo_visibility == 'public' %} +**Deep analysis (CodeQL).** GitHub's CodeQL runs data-flow analysis on every change and on +a weekly schedule, catching vulnerability classes such as injection and path traversal that +line-level linting cannot see. +{% endif %} +**Secret scanning.** gitleaks runs both as a pre-commit hook and as a CI check that cannot +be bypassed, so credentials and keys never enter the codebase or a release. +{% if repo_visibility != 'public' %} +!!! note + + This is a private repository. GitHub-hosted deep analysis (CodeQL) and the public + OpenSSF Scorecard grade are available only for public repositories, so they are not + enabled here. The static security analysis and secret scanning described above run + identically regardless of visibility. +{% endif %} +## How the pipeline itself is hardened + +**Least privilege.** Every automation workflow runs with a read-only token by default; +only the specific jobs that must write are granted more. A compromised step cannot quietly +rewrite the repository. + +**A single merge gate.** No change lands on the main branch until the full test suite and +the security checks above all pass, enforced as one required status check. + +**Scoped automation identity.** Release automation authenticates with a short-lived, +narrowly-scoped GitHub App token rather than a broad personal access token, so there is no +long-lived automation credential to leak. + +## Transparency and disclosure +{% if repo_visibility == 'public' %} +**An independent security grade.** This repository is scored weekly by the +[OpenSSF Scorecard](https://securityscorecards.dev/), an external automated audit of its +security practices (pinned dependencies, token permissions, branch protection, and more). +The result is public, so the posture is verifiable rather than merely asserted. +{% endif %} +**Responsible disclosure.** If you find a vulnerability, the +[security policy](https://github.com/{{ github_username }}/{{ project_slug }}/security/policy) +explains how to report it privately, before any public disclosure. + +**Reviewed changes.** Code ownership is declared in `CODEOWNERS`, so changes are reviewed +before they merge. diff --git a/template/mkdocs.yml.jinja b/template/mkdocs.yml.jinja index bf422f6..0485e5f 100644 --- a/template/mkdocs.yml.jinja +++ b/template/mkdocs.yml.jinja @@ -240,6 +240,7 @@ nav: {% endif %} - Explanation: - pages/explanation/index.md - Concepts: pages/explanation/concepts.md + - Security: pages/explanation/security.md # Reference is last on purpose: it is the section you arrive at knowing what # you want, so it earns its place by being findable, not by being passed # through. Everything above it is read in roughly the order a newcomer needs diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index fea3431..882546e 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -188,7 +188,7 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] -"tests/**/*" = ["ARG", "S101", "S603", "S607", "T201", "PLR2004"] # Allow assert statements, subprocess calls, prints, and magic values in tests +"tests/**/*" = ["ARG", "S101", "S110", "S301", "S311", "S603", "S607", "T201", "PLR2004"] # Allow asserts, try/except/pass, pickle, non-crypto random, subprocess, prints, and magic values in tests # The template's documentation build tooling lives in docs_build/. Every script # there prints build progress and may hold per-build caches. # diff --git a/tests/conftest.py b/tests/conftest.py index b2824d0..99f5bb7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,6 +26,7 @@ def copy(self, extra_answers: dict | None = None): "author_name": "Test Author", "author_email": "test@example.com", "github_username": "testuser", + "code_owner": "@testowner", "version": "0.1.0", "min_python_version": "3.11", "max_python_version": "3.14", diff --git a/tests/test_security_hardening.py b/tests/test_security_hardening.py index f3cce40..7f13d5c 100644 --- a/tests/test_security_hardening.py +++ b/tests/test_security_hardening.py @@ -139,3 +139,46 @@ def test_publish_action_is_not_a_branch_ref(copie): publish = _read(result.project_dir, ".github/workflows/publish-release.yml") assert "gh-action-pypi-publish@release/v1" not in publish assert "gh-action-pypi-publish@v" in publish + + +def test_security_posture_page_present_and_in_nav(copie): + """A user-facing security posture page ships and is listed in the docs nav.""" + result = copie.copy() + page = result.project_dir / "docs/pages/explanation/security.md" + assert page.exists(), "no security posture explanation page" + assert "trusted-publishing" in page.read_text(encoding="utf-8").lower() or "OIDC" in page.read_text( + encoding="utf-8" + ) + nav = _read(result.project_dir, "mkdocs.yml") + assert "pages/explanation/security.md" in nav, "security page missing from the docs nav" + + +def test_security_page_gates_public_only_controls(copie): + """The page presents CodeQL/Scorecard as active for public repos and not for private.""" + public = _read( + copie.copy(extra_answers={"repo_visibility": "public"}).project_dir, + "docs/pages/explanation/security.md", + ) + assert "Deep analysis (CodeQL)" in public + assert "independent security grade" in public + + private = _read( + copie.copy(extra_answers={"repo_visibility": "private", "include_codecov": False}).project_dir, + "docs/pages/explanation/security.md", + ) + assert "Deep analysis (CodeQL)" not in private + assert "independent security grade" not in private + # The page still describes the controls a private repo DOES run. + assert "Static security analysis" in private + assert "Secret scanning" in private + + +def test_codeowners_uses_a_valid_owner(copie): + """CODEOWNERS ships a concrete owner, not a bare org name GitHub rejects.""" + result = copie.copy() + codeowners = _read(result.project_dir, "CODEOWNERS") + owner_line = next(line for line in codeowners.splitlines() if line.startswith("*")) + owner = owner_line.split()[1] + assert owner.startswith("@"), "code owner should be an @user or @org/team" + # A bare "@org" with no team is the invalid form; a user handle or @org/team is fine. + assert owner != "@" and len(owner) > 1 From fa5b0bbc74e619e52f5d4c22e771e0b29439e3d8 Mon Sep 17 00:00:00 2001 From: Guillaume Tauzin <4648633+gtauzin@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:56:53 +0200 Subject: [PATCH 2/2] docs(security): drop the template-inheritance note from the security page --- template/docs/pages/explanation/security.md.jinja | 4 ---- 1 file changed, 4 deletions(-) diff --git a/template/docs/pages/explanation/security.md.jinja b/template/docs/pages/explanation/security.md.jinja index 061ed20..035e668 100644 --- a/template/docs/pages/explanation/security.md.jinja +++ b/template/docs/pages/explanation/security.md.jinja @@ -4,10 +4,6 @@ explains the measures that protect you as a *user* of the package and what each one means in practice, so you can depend on it with confidence. -Every control described here is inherited from the -[python-package-copier](https://github.com/stateful-y/python-package-copier) template and -kept current across releases, so the posture does not quietly drift as the project evolves. - ## How releases reach you safely **Trusted publishing (OIDC).** Releases are published to PyPI through GitHub's OpenID