Skip to content

ci: add SECURITY.md, restrict workflow permissions and pin actions to SHAs - #304

Merged
allen0099 merged 4 commits into
masterfrom
ci/harden-workflows
Sep 27, 2026
Merged

allen0099 merged 4 commits into
masterfrom
ci/harden-workflows

Conversation

@allen0099

@allen0099 allen0099 commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Closes #300

Summary

SECURITY.md (docs commit)

  • New SECURITY.md at the repo root. It says to report privately through GitHub private vulnerability reporting and not in public issues. It also covers supported versions (the latest 0.3.x release only, currently 0.3.8), what to put in a report, and what to expect. It makes no SLA promises: reports get an acknowledgement and a follow-up, and a confirmed issue gets a 0.3.x patch plus a published advisory.
  • Linked from docs/CONTRIBUTING.md (new "Reporting a Security Vulnerability" section), from the README resource list (which is also the docs home page), and from the zh-TW mirror (i18n/zh-TW/docs/CONTRIBUTING.md and index.md). The links are absolute GitHub URLs because SECURITY.md is outside docs/.

Workflow hardening (ci commit)

  • Permissions: added top-level permissions: contents: read to lint.yml, docs.yml and renovate-validate.yml. None of them writes to the repo, uploads artifacts or calls the API.

  • persist-credentials: false: added to every checkout that does not push: lint, docs, renovate-validate, test, lowest, coverage (coverage job), and coverage (badge job).

    • badge job: JamesIves/github-pages-deploy-action does not use the checkout's credentials. In CI it runs git config --local --unset-all http.https://github.com/.extraheader, then pushes to https://x-access-token:${token}@github.com/<repo>.git, built from its own token input (default: the job's GITHUB_TOKEN). See src/git.ts and src/util.ts at v4.9.0. Its README also asks for persist-credentials: false when a different token is used, to avoid auth conflicts.
    • release.yml build job already had persist-credentials: false.
    • Unchanged: the release.yml release job's checkout keeps its credentials because it runs git push for the release commit and tag. I added a comment saying so. zizmor flags it as artipacked (low confidence), and that finding is expected.
  • SHA pinning: every uses: now points to a full commit SHA with a # vX.Y.Z comment. Annotated tags were dereferenced to their commits. pypa/gh-action-pypi-publish@release/v1 is pinned to v1.14.2, which is also the current head of release/v1.

  • Renovate: added helpers:pinGitHubActionDigests to extends so the pins keep updating.

  • Renovate release-age delay (third commit): non-major and digest updates automerge. Without a delay, a compromised upstream action release could be merged as soon as CI passed. That includes pypa/gh-action-pypi-publish, which release.yml runs. The new rule:

    {
      description: "Wait 3 days before raising (and automerging) GitHub Actions updates",
      matchManagers: ["github-actions"],
      minimumReleaseAge: "3 days",
      minimumReleaseAgeBehaviour: "timestamp-required",
      internalChecksFilter: "strict",
    }

    Automerge still applies once the 3 days have passed, and the github-actions group rule is unchanged.

    Timestamps. Per Renovate's minimum release age docs:

    • github-actions updates come from the github-tags datasource, which provides a release timestamp: the committedDate of the commit the tag points to.
    • Digest updates are aged against the timestamp of the newest version matching the current value. They are neither exempt nor blocked for good.
    • timestamp-required has been the default since Renovate 42. I set it explicitly so that an update with no timestamp stays pending instead of going through at once, whatever Renovate version runs.
    • internalChecksFilter: "strict" (also the default) means no branch is created until the age check passes. Held updates appear under "Pending Status Checks" on the Dependency Dashboard, where they can be forced.

    Limitation. The committer sets committedDate, and a force-pushed tag is aged against its original date. So this delays ordinary releases but cannot guarantee protection against a determined attacker. The config comment says the same.

  • release.yml: only uses: refs and one comment changed. Its logic, inputs and job structure are the same.

Validation

  • npx --yes --package renovate -- renovate-config-validator --strict (same command as renovate-validate.yml): Config validated successfully for .github/renovate.json5 (Renovate 44.115.12). I re-ran it after the release-age rule was added.
  • actionlint 1.7.12 (with shellcheck) on all 7 files in .github/workflows/: 0 errors.
  • zizmor 1.30.1 (online audits) on all 7 files in .github/workflows/: 2 unsuppressed findings, both expected:
    • artipacked (low): the release.yml release job checkout, which has to push with git.
    • superfluous-actions (info): softprops/action-gh-release could be gh release. I left it alone because changing it would change release logic.
  • uv run pre-commit run --all-files: all passed.
  • uv run pytest: 857 passed, 191 skipped (the live Redis/Memcached suites are opt-in).
  • zensical build --strict for the English and zh-TW sites: no issues.

CHANGELOG

The entry is in changelog.d/300.added.md and is merged into CHANGELOG.md at release time.

The fragments have no Documentation section, so the SECURITY.md entry is filed under Added. The workflow changes are CI-only, so there is no entry for them.

SECURITY.md names the supported release line (latest 0.3.x), where to
report (GitHub private vulnerability reporting, not public issues), what
to include and what to expect. Linked from CONTRIBUTING (English and
zh-TW), the README and the zh-TW home page.

Refs #300
- lint.yml, docs.yml and renovate-validate.yml get a top-level
  permissions: contents: read; none of them writes anything.
- Every actions/checkout that does not push sets persist-credentials:
  false, including the coverage badge job: the deploy action unsets the
  checkout's auth header and pushes with its own token input. The
  release job's checkout keeps its credentials, since it pushes the
  release commit and tag with git.
- Every action is pinned to a full commit SHA with a version comment;
  pypa/gh-action-pypi-publish@release/v1 is pinned to v1.14.2, the
  current head of that branch.
- Renovate extends helpers:pinGitHubActionDigests so the pins keep
  updating.

Refs #300
@allen0099 allen0099 added documentation Improvements or additions to documentation github-actions Legacy Dependabot label for Actions update PRs; Renovate uses 'dependencies'. Use 'ci' for CI issues security Security vulnerability or hardening labels Sep 27, 2026
Actions are pinned to SHAs and non-major updates automerge, so a
compromised upstream release would be merged as soon as CI passed.
minimumReleaseAge 3 days (timestamp-required, internalChecksFilter
strict) delays them; automerge still applies afterwards.

Refs #300
@allen0099
allen0099 merged commit 783ed76 into master Sep 27, 2026
13 checks passed
@allen0099
allen0099 deleted the ci/harden-workflows branch September 27, 2026 13:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation github-actions Legacy Dependabot label for Actions update PRs; Renovate uses 'dependencies'. Use 'ci' for CI issues security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add SECURITY.md and harden workflow permissions and action pinning

1 participant