Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
65 changes: 65 additions & 0 deletions docs/pages/explanation/security.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions template/.copier-answers.yml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions template/CODEOWNERS.jinja
Original file line number Diff line number Diff line change
@@ -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 }}
73 changes: 73 additions & 0 deletions template/docs/pages/explanation/security.md.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# 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.

## 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.
1 change: 1 addition & 0 deletions template/mkdocs.yml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion template/pyproject.toml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
43 changes: 43 additions & 0 deletions tests/test_security_hardening.py
Original file line number Diff line number Diff line change
Expand Up @@ -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