Skip to content

ci: add zizmor workflow linting and harden GitHub Actions#2055

Open
Pouyanpi wants to merge 5 commits into
developfrom
pouyanpi/ci/zizmor-workflow-linting
Open

ci: add zizmor workflow linting and harden GitHub Actions#2055
Pouyanpi wants to merge 5 commits into
developfrom
pouyanpi/ci/zizmor-workflow-linting

Conversation

@Pouyanpi

@Pouyanpi Pouyanpi commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Description

Adds zizmor as a pre-commit hook (also enforced in CI via
lint.yml's make pre-commit) to lint GitHub Actions workflows, runs it at zizmor's
default severity, and resolves every finding it surfaces. Our workflows use privileged
triggers (pull_request_target, workflow_run), publish to PyPI, and push tags, so gating
them catches credential-persistence, template-injection, excessive-permission, and
cache-poisoning mistakes before they ship.

Tooling

  • New zizmor pre-commit hook (pinned v1.25.2), at default severity.
  • New .github/zizmor.yml: unpinned-uses: ref-pin so tag pins (@v6, kept current by
    Dependabot) satisfy the linter instead of requiring SHA pins.

Least-privilege permissions (excessive-permissions)

  • Scoped permissions: blocks added to workflows that relied on the default token:
    contents: read where a checkout runs (incl. callers of the reusable _test.yml),
    permissions: {} for pure status / install-from-PyPI / token-publish jobs, and
    contents: write + pull-requests: read for docs-remove-stale-reviews (its reusable
    workflow pushes to gh-pages).

Injection / credential hardening

  • Event-derived ${{ … }} values moved out of run: bodies into env: / shell vars
    (template-injection).
  • persist-credentials: false on checkouts that don't push (artipacked).
  • package-manager-cache: false on setup-node in docs-build (closes a reachable
    cache-poisoning vector — build-docs runs on fork PRs).
  • pr-tests passes only CODECOV_TOKEN to _test.yml instead of secrets: inherit
    (secrets-inherit); _test.yml declares it as an optional workflow_call secret.

Documented suppressions (inline # zizmor: ignore[...], each with rationale)

  • dangerous-triggers ×3 (triage-label, pr-merge-guidance, publish-pypi-approval) —
    none check out or execute PR-head code; event data read as data; workflow_run upstream
    has no PR trigger.
  • cache-poisoning (test-and-build-wheel) — no pull_request trigger; trusted-ref-only
    runs, so fork PRs can't write the cache these runs restore.
  • artipacked (create-tag) — needs the checkout's persisted credentials to git push
    the release tag; uploads no artifacts.
  • use-trusted-publishing (publish-wheel) — intentional API-token publish; OIDC migration
    tracked separately.

Areas needing careful review: the permissions: scopes (esp. docs-remove-stale-reviews,
which must keep contents: write for its gh-pages push) and the secrets-inherit change
to the _test.yml interface.

Verification

  • Rebased on the latest develop. uvx zizmor .github/workflows/ (default severity) →
    No findings to report across the full workflow set, including workflows develop
    recently added.
  • pre-commit run --all-files passes (incl. the new zizmor hook).
  • Beyond CI / could not run locally: the secrets-inherit change can only be confirmed
    on the first CI run — verify the Codecov upload still succeeds on the coverage job
    (Python 3.11). Logic is backward-compatible; residual risk is low.

AI Assistance

  • No AI tools were used.
  • AI tools were used; a human reviewed and can explain every change (tool: Claude Code).

Checklist

  • I've read the CONTRIBUTING guidelines.
  • This PR links to a triaged issue assigned to me.
  • My PR title follows the project commit convention.
  • I've updated the documentation if applicable.
  • I've added tests if applicable.
  • I've noted any verification beyond CI and any checks I couldn't run.
  • I did not update generated changelog files manually.
  • I addressed all CodeRabbit, Greptile, and other review comments, or replied with why no change is needed.
  • @mentions of the person or team responsible for reviewing proposed changes.

Summary by CodeRabbit

Release Notes

  • Security

    • Enhanced GitHub Actions workflows with explicit permissions configuration
    • Improved credential handling in deployment pipelines
  • Infrastructure

    • Added automated code scanning integration for improved code quality
    • Refactored CI/CD workflows for better maintainability and reliability

Pouyanpi added 5 commits June 17, 2026 14:27
Adopt zizmor as a pre-commit hook (.github/zizmor.yml) gating on high
severity. The unpinned-uses policy is relaxed to ref-pin to match the
repo's version-tag pinning convention (kept current via Dependabot).

Resolve the high-severity findings zizmor surfaced in existing
workflows:

- Move attacker-controllable template expansions into env vars in
  release.yml and publish-pypi-approval.yml.
- Scope triage-label.yml permissions to the job level.
- Add documented zizmor ignores for the intentional
  pull_request_target / workflow_run / cache-poisoning patterns in
  triage-label.yml, pr-merge-guidance.yml, publish-pypi-approval.yml,
  and test-and-build-wheel.yml.

Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com>
Align the pre-commit zizmor pin with the current release. v1.25.2's
cache-poisoning audit flags actions/setup-node, which defaults
package-manager-cache to true and restores an npm cache when package.json
declares npm. docs-build runs on pull_request and publishes previews and
staging docs, so set package-manager-cache: false to keep cache restoration
out of the publishing path and keep the --min-severity high gate green.
…ction

Apply zizmor's lower-severity fixes (below the high gate enforced in
pre-commit). Set persist-credentials: false on checkouts that do not push
with the persisted token, so the GITHUB_TOKEN is not written to .git/config
where it could leak via uploaded artifacts (artipacked). Move
step-output and context expansions out of run-block bodies into env
variables referenced as shell vars, so untrusted values are passed as data
rather than interpolated into the script (template-injection).

create-tag keeps its persisted credentials because it pushes the release
tag with git push origin; it uploads no artifacts, so the token is not
exposed.
The previous comment claimed the cached .venv is not an input to the
published wheel, which is inaccurate: build.sh builds the wheel inside that
.venv, so a poisoned cache could alter the artifact. State the actual reason
the suppression is safe instead: this workflow has no pull_request trigger
(push to main/develop/tags and schedule only) and GitHub scopes caches by
ref, so fork PRs cannot write a cache these trusted runs restore. Comment
only; no behavior change.
Drop --min-severity high from the zizmor pre-commit hook so the gate matches
the tool default (and pydantic-ai's setup), then resolve everything it
surfaces:

- excessive-permissions: add least-privilege permissions: blocks to the
  workflows that previously relied on the default token. contents: read where
  a checkout runs (directly or via the reusable _test.yml), {} for pure
  status/install-from-PyPI/publish jobs, and contents: write + pull-requests:
  read for docs-remove-stale-reviews whose reusable workflow pushes to
  gh-pages.
- secrets-inherit: pr-tests passes only CODECOV_TOKEN to _test.yml instead of
  inheriting all secrets; _test.yml declares it as an optional workflow_call
  secret (backward compatible with callers that pass nothing).
- artipacked (create-tag) and use-trusted-publishing (publish-wheel) are
  suppressed inline with rationale: the tag push needs the checkout's
  persisted credentials, and the API-token publish is intentional pending a
  separate OIDC trusted-publishing migration.
@github-actions github-actions Bot added the status: needs triage New issues that have not yet been reviewed or categorized. label Jun 22, 2026
@Pouyanpi Pouyanpi changed the title Pouyanpi/ci/zizmor workflow linting ci: add zizmor workflow linting and harden GitHub Actions Jun 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

@codecov

codecov Bot commented Jun 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Pouyanpi Pouyanpi added status: triaged Triaged by a maintainer; eligible for automated review (CodeRabbit/Greptile). and removed status: needs triage New issues that have not yet been reviewed or categorized. labels Jun 22, 2026
@greptile-apps

greptile-apps Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR integrates zizmor as a pre-commit and CI lint gate, then resolves every finding it surfaces across the entire workflow set — covering template injection, excessive permissions, credential persistence, cache poisoning, and secrets scoping.

  • Template injection: all event-derived ${{ }} expressions in run: blocks moved to env: variables across create-tag.yml, release.yml, publish-pypi-approval.yml, test-and-build-wheel.yml, and test-docker.yml.
  • Least-privilege permissions: top-level or job-level permissions: blocks added to twelve workflows; pr-tests.yml replaces secrets: inherit with an explicit CODECOV_TOKEN pass-through, and _test.yml declares it as an optional workflow_call secret.
  • Credential / cache hardening: persist-credentials: false added to checkouts that don't need to push (with a documented suppression for create-tag.yml which does), and package-manager-cache: false applied to setup-node in docs-build.yaml to close a cache-poisoning vector on fork PRs.

Confidence Score: 4/5

The changes are CI/workflow-only and safe to merge; the hardening is correct and well-documented.

The template-injection fixes, permission scoping, credential hardening, and secrets-inherit removal are all correct. Two small gaps remain: triage-label.yml still uses a mutable @v7 tag for actions/github-script in a pull_request_target context (while the analogous pr-merge-guidance.yml was updated to a SHA pin in this very PR), and release.yml / publish-pypi-approval.yml are missing a top-level permissions: block unlike the rest of the hardened workflows.

triage-label.yml (mutable action pin in privileged trigger) and release.yml / publish-pypi-approval.yml (no top-level permissions block).

Important Files Changed

Filename Overview
.github/workflows/_test.yml Added optional CODECOV_TOKEN secret declaration and persist-credentials: false to checkout — correct and complete
.github/workflows/create-tag.yml Template injection fixed (tag name moved to env vars), artipacked suppression correctly documented; job-level permissions: contents: write present
.github/workflows/release.yml Template injection fixed (version/INPUT_VERSION moved to env vars), persist-credentials: false added; workflow lacks a top-level permissions block unlike most other hardened workflows
.github/workflows/publish-pypi-approval.yml Template injection fixed, persist-credentials: false added, dangerous-triggers suppressed with rationale; workflow lacks top-level permissions block unlike most other hardened workflows
.github/workflows/triage-label.yml Permissions correctly moved from workflow-level to per-job scope; actions/github-script@v7 tag pin not upgraded to SHA unlike the same action in the analogous pr-merge-guidance.yml
.github/workflows/pr-tests.yml secrets: inherit replaced with explicit CODECOV_TOKEN pass-through; top-level contents: read added
.github/workflows/pr-merge-guidance.yml dangerous-triggers suppression added with correct rationale; job uses SHA-pinned github-script and never checks out PR head code
.github/workflows/docs-build.yaml package-manager-cache: false added to three setup-node steps, closing a cache-poisoning vector on fork-PR-triggered builds
.github/workflows/test-and-build-wheel.yml cache-poisoning suppression correctly documented (no pull_request trigger), persist-credentials: false added, contents: read scoped
.github/zizmor.yml New zizmor config allowing ref-pin (tag) for all actions, matching Dependabot-maintained tag-pin convention
.pre-commit-config.yaml zizmor pre-commit hook added at pinned v1.25.2 — gating CI via make pre-commit in lint.yml is correct

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Dev as Developer / PR Author
    participant GH as GitHub Actions
    participant PreCommit as pre-commit (zizmor)
    participant LintCI as lint.yml (make pre-commit)
    participant Workflows as Hardened Workflows

    Dev->>PreCommit: git commit / pre-commit run
    PreCommit->>PreCommit: zizmor v1.25.2 scans .github/workflows/
    alt findings present
        PreCommit-->>Dev: Block commit, report violations
    else clean
        PreCommit-->>Dev: Pass
    end

    Dev->>GH: Push / open PR
    GH->>LintCI: Trigger lint.yml
    LintCI->>PreCommit: make pre-commit (includes zizmor)
    PreCommit-->>LintCI: No findings (default severity)
    LintCI-->>GH: Pass

    GH->>Workflows: Trigger workflow (pull_request / push / workflow_run)
    note over Workflows: permissions scoped per job<br/>persist-credentials: false<br/>${{ }} in run: moved to env: vars<br/>secrets: inherit replaced with explicit token
    Workflows-->>GH: Executes with least-privilege token
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Dev as Developer / PR Author
    participant GH as GitHub Actions
    participant PreCommit as pre-commit (zizmor)
    participant LintCI as lint.yml (make pre-commit)
    participant Workflows as Hardened Workflows

    Dev->>PreCommit: git commit / pre-commit run
    PreCommit->>PreCommit: zizmor v1.25.2 scans .github/workflows/
    alt findings present
        PreCommit-->>Dev: Block commit, report violations
    else clean
        PreCommit-->>Dev: Pass
    end

    Dev->>GH: Push / open PR
    GH->>LintCI: Trigger lint.yml
    LintCI->>PreCommit: make pre-commit (includes zizmor)
    PreCommit-->>LintCI: No findings (default severity)
    LintCI-->>GH: Pass

    GH->>Workflows: Trigger workflow (pull_request / push / workflow_run)
    note over Workflows: permissions scoped per job<br/>persist-credentials: false<br/>${{ }} in run: moved to env: vars<br/>secrets: inherit replaced with explicit token
    Workflows-->>GH: Executes with least-privilege token
Loading

Comments Outside Diff (1)

  1. .github/workflows/release.yml, line 20-26 (link)

    P2 No top-level permissions: block on release.yml

    Most workflows in this PR received a top-level permissions: block (e.g. contents: read or permissions: {}) in addition to any job-level scope. release.yml only has a job-level declaration (contents: write, pull-requests: write) and no workflow-level restriction. Without the top-level block, any hypothetical future job added to this file without explicit permissions would inherit the repository default token — which could be broad. The same pattern is absent from publish-pypi-approval.yml. Adding permissions: {} at the workflow level (letting each job override with only what it needs) would be consistent with the rest of the hardening applied here.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: .github/workflows/release.yml
    Line: 20-26
    
    Comment:
    **No top-level `permissions:` block on `release.yml`**
    
    Most workflows in this PR received a top-level `permissions:` block (e.g. `contents: read` or `permissions: {}`) in addition to any job-level scope. `release.yml` only has a job-level declaration (`contents: write, pull-requests: write`) and no workflow-level restriction. Without the top-level block, any hypothetical future job added to this file without explicit permissions would inherit the repository default token — which could be broad. The same pattern is absent from `publish-pypi-approval.yml`. Adding `permissions: {}` at the workflow level (letting each job override with only what it needs) would be consistent with the rest of the hardening applied here.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
.github/workflows/triage-label.yml:32
**Tag-pinned `github-script` in `pull_request_target` context**

`actions/github-script@v7` is a mutable tag — its resolved SHA can change if the upstream tag is moved. The analogous `pr-merge-guidance.yml` (also `pull_request_target` with write permissions) was updated to use `actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3` — a SHA pin. For consistency with that hardening, this workflow should pin the action to a specific commit SHA as well, so a tag-move in the upstream repo cannot silently change the code executing with the PR write token.

### Issue 2 of 2
.github/workflows/release.yml:20-26
**No top-level `permissions:` block on `release.yml`**

Most workflows in this PR received a top-level `permissions:` block (e.g. `contents: read` or `permissions: {}`) in addition to any job-level scope. `release.yml` only has a job-level declaration (`contents: write, pull-requests: write`) and no workflow-level restriction. Without the top-level block, any hypothetical future job added to this file without explicit permissions would inherit the repository default token — which could be broad. The same pattern is absent from `publish-pypi-approval.yml`. Adding `permissions: {}` at the workflow level (letting each job override with only what it needs) would be consistent with the rest of the hardening applied here.

Reviews (1): Last reviewed commit: "ci: run zizmor at default severity and r..." | Re-trigger Greptile

Comment thread .github/workflows/triage-label.yml
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: d1fea995-cc74-4eab-8afe-3fb0e44c0e08

📥 Commits

Reviewing files that changed from the base of the PR and between 7d5035e and 51ba8ae.

📒 Files selected for processing (19)
  • .github/workflows/_test.yml
  • .github/workflows/create-tag.yml
  • .github/workflows/docs-build.yaml
  • .github/workflows/docs-remove-stale-reviews.yaml
  • .github/workflows/full-tests.yml
  • .github/workflows/latest-deps-tests.yml
  • .github/workflows/lint.yml
  • .github/workflows/pr-merge-guidance.yml
  • .github/workflows/pr-tests-skip.yml
  • .github/workflows/pr-tests.yml
  • .github/workflows/publish-pypi-approval.yml
  • .github/workflows/publish-wheel.yml
  • .github/workflows/release.yml
  • .github/workflows/test-and-build-wheel.yml
  • .github/workflows/test-docker.yml
  • .github/workflows/test-published-dist.yml
  • .github/workflows/triage-label.yml
  • .github/zizmor.yml
  • .pre-commit-config.yaml

📝 Walkthrough

Walkthrough

Applies repository-wide GitHub Actions security hardening: introduces zizmor as a pre-commit linter with tag-based pinning policy, adds explicit permissions blocks to 13 workflows, sets persist-credentials: false on checkout steps, refactors step-output expressions inside shell run blocks into step-level env variables to prevent expression injection, and explicitly scopes secrets instead of using secrets: inherit.

Changes

GitHub Actions Security Hardening

Layer / File(s) Summary
Zizmor linter setup and configuration
.pre-commit-config.yaml, .github/zizmor.yml
Adds the zizmor pre-commit hook pinned to v1.25.2 and configures zizmor to enforce tag/branch-based ref pinning for all GitHub Actions uses via the unpinned-uses rule.
Workflow-level permissions hardening
.github/workflows/full-tests.yml, .github/workflows/latest-deps-tests.yml, .github/workflows/lint.yml, .github/workflows/pr-tests.yml, .github/workflows/pr-tests-skip.yml, .github/workflows/publish-wheel.yml, .github/workflows/test-published-dist.yml, .github/workflows/test-and-build-wheel.yml, .github/workflows/test-docker.yml, .github/workflows/docs-remove-stale-reviews.yaml, .github/workflows/triage-label.yml, .github/workflows/pr-merge-guidance.yml, .github/workflows/publish-pypi-approval.yml
Adds explicit permissions blocks across all workflows: empty {} where no token access is needed, contents: read where only checkout is required, job-scoped pull-requests: write on triage-label jobs replacing the prior global grant, and security rationale comments on pull_request_target workflows.
Checkout credential persistence and secret scoping
.github/workflows/_test.yml, .github/workflows/lint.yml, .github/workflows/release.yml, .github/workflows/test-and-build-wheel.yml, .github/workflows/test-docker.yml, .github/workflows/publish-pypi-approval.yml, .github/workflows/create-tag.yml, .github/workflows/pr-tests.yml, .github/workflows/docs-build.yaml
Sets persist-credentials: false on checkout steps in six workflows. Adds CODECOV_TOKEN as an optional declared secret in _test.yml's workflow_call interface and replaces secrets: inherit with an explicit CODECOV_TOKEN mapping in pr-tests.yml. Disables package-manager-cache on all three setup-node calls in docs-build.yaml.
Expression injection prevention via env var refactoring
.github/workflows/create-tag.yml, .github/workflows/release.yml, .github/workflows/publish-pypi-approval.yml, .github/workflows/test-and-build-wheel.yml, .github/workflows/test-docker.yml
Replaces direct ${{ steps.*.outputs.* }} and ${{ github.event.* }} interpolation inside shell run blocks with step-level env mappings throughout tag creation, version determination, changelog generation, PyPI approval, artifact naming, and Docker tag composition steps.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

  • NVIDIA-NeMo/Guardrails#1971: Both PRs modify .github/workflows/pr-merge-guidance.yml, with the related PR introducing the workflow and this PR adding security rationale comments to its pull_request_target trigger block.
🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: adding zizmor workflow linting and hardening GitHub Actions security, which aligns with the PR's core objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Test Results For Major Changes ✅ Passed PR contains only infrastructure/configuration changes with no application code modifications; includes documented verification (zizmor linting + pre-commit passing) in explicit Verification section.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pouyanpi/ci/zizmor-workflow-linting

Comment @coderabbitai help to get the list of available commands and usage tips.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size: M status: triaged Triaged by a maintainer; eligible for automated review (CodeRabbit/Greptile).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant