diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 94697c5..2545d05 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -28,10 +28,10 @@ on: - '.github/workflows/docs.yml' workflow_dispatch: +# Least-privilege default; the write scopes GitHub Pages needs are granted +# per job, to the two jobs that actually publish. permissions: contents: read - pages: write - id-token: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -42,8 +42,14 @@ jobs: name: Build docs runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: read + # actions/configure-pages enables the Pages site on push to main. + pages: write steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Setup Python uses: actions/setup-python@v6 @@ -86,6 +92,11 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.DEPLOY_GITHUB_PAGES == 'true' runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: read + # actions/deploy-pages publishes the artifact using an OIDC token. + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index 4a8387d..627b0f3 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -36,6 +36,8 @@ jobs: contents: read steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Check links with lychee id: lychee diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 210d429..519b612 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,6 +51,7 @@ jobs: steps: - uses: actions/checkout@v6 with: + persist-credentials: false fetch-depth: 0 - name: Setup Python @@ -115,6 +116,7 @@ jobs: steps: - uses: actions/checkout@v6 with: + persist-credentials: false fetch-depth: 0 - name: Simulate fresh merge with base branch (PR only) @@ -214,6 +216,8 @@ jobs: ) steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Setup Python uses: actions/setup-python@v6 @@ -272,7 +276,7 @@ jobs: - name: Upload coverage to Codecov if: env.CODECOV_TOKEN != '' - uses: codecov/codecov-action@v7 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 @ 2026-06-07 with: files: ${{ steps.python_layout.outputs.root }}/coverage.xml token: ${{ env.CODECOV_TOKEN }} @@ -300,6 +304,8 @@ jobs: ) steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Setup Python uses: actions/setup-python@v6 @@ -366,6 +372,7 @@ jobs: steps: - uses: actions/checkout@v6 with: + persist-credentials: false fetch-depth: 0 - name: Set up Python @@ -453,6 +460,8 @@ jobs: if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Detect Dockerfile id: dockerfile @@ -501,6 +510,7 @@ jobs: steps: - uses: actions/checkout@v6 with: + persist-credentials: false fetch-depth: 0 - name: Setup Python @@ -573,7 +583,7 @@ jobs: - name: Publish to PyPI if: steps.version_check.outputs.should_release == 'true' - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 @ 2026-07-29 - name: Smoke test published package if: steps.version_check.outputs.should_release == 'true' @@ -620,8 +630,13 @@ jobs: contents: write id-token: write steps: + # The only checkout in this repository that keeps credentials in + # .git/config: scripts/version_and_commit.py runs `git push origin main` + # to publish the version bump, which needs the token wired into the + # remote. Every other checkout sets persist-credentials: false. - uses: actions/checkout@v6 with: + persist-credentials: true fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -705,7 +720,7 @@ jobs: - name: Publish to PyPI if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 @ 2026-07-29 with: packages-dir: ${{ steps.python_layout.outputs.dist_dir }} @@ -755,6 +770,8 @@ jobs: RELEASE_VERSION: ${{ needs.auto-release.outputs.version || needs.manual-release.outputs.version }} steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Check Docker publish configuration id: config @@ -793,6 +810,8 @@ jobs: contents: read steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -889,6 +908,8 @@ jobs: docker-publish-build, docker-publish] steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Fail the run when a required job was cancelled or failed env: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index a252a4a..1cf1e9c 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -20,6 +20,8 @@ jobs: cancel-in-progress: true steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Setup Python uses: actions/setup-python@v6 @@ -45,6 +47,8 @@ jobs: language: [python, actions] steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Initialize CodeQL uses: github/codeql-action/init@v4 @@ -67,6 +71,8 @@ jobs: pull-requests: write steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Review dependency changes uses: actions/dependency-review-action@v5 diff --git a/.github/workflows/workflows.yml b/.github/workflows/workflows.yml index 56819a4..3e99061 100644 --- a/.github/workflows/workflows.yml +++ b/.github/workflows/workflows.yml @@ -31,6 +31,8 @@ jobs: contents: read steps: - uses: actions/checkout@v6 + with: + persist-credentials: false # The Docker image bundles shellcheck and pyflakes, so this lints every # `run:` block too. A native actionlint binary without shellcheck on @@ -40,3 +42,33 @@ jobs: uses: docker://rhysd/actionlint:1.7.7 with: args: -color + + # actionlint validates workflow schema and shell; it does not detect + # credential persistence, template injection or unpinned actions. zizmor + # covers exactly those, so both linters run on every change under .github/. + # Reproduce locally with: + # pipx run zizmor==1.30.0 --min-confidence medium --persona regular \ + # --config .github/zizmor.yml .github/workflows + zizmor: + name: Audit Workflows + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-zizmor + cancel-in-progress: true + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + # Annotations instead of SARIF: forks of this template do not necessarily + # have code scanning enabled, and the job should fail loudly either way. + - name: Audit workflow files + uses: zizmorcore/zizmor-action@v0.6.2 + with: + advanced-security: false + annotations: true + config: .github/zizmor.yml + min-confidence: medium diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 0000000..2c65dd9 --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,15 @@ +# zizmor configuration - https://docs.zizmor.sh/configuration/ +rules: + unpinned-uses: + config: + policies: + # These publishers are trusted at tag granularity: their release tags + # are the reference this template is meant to read at a glance. + # Everything else must be pinned to a full commit hash. + actions/*: ref-pin + github/*: ref-pin + docker/*: ref-pin + astral-sh/*: ref-pin + lycheeverse/*: ref-pin + zizmorcore/*: ref-pin + '*': hash-pin diff --git a/.gitkeep b/.gitkeep index 2a80916..cebc2de 100644 --- a/.gitkeep +++ b/.gitkeep @@ -1,2 +1,3 @@ # .gitkeep file auto-generated at 2026-08-20T04:56:54.559Z for PR creation at branch issue-60-1431435e7081 for issue https://github.com/link-foundation/python-ai-driven-development-pipeline-template/issues/60 -# Updated: 2026-08-28T10:49:28.004Z \ No newline at end of file +# Updated: 2026-08-28T10:49:28.004Z +# Updated: 2026-09-03T19:44:32.240Z \ No newline at end of file diff --git a/changelog.d/20260903_issue_64_workflow_audit.md b/changelog.d/20260903_issue_64_workflow_audit.md new file mode 100644 index 0000000..9109d33 --- /dev/null +++ b/changelog.d/20260903_issue_64_workflow_audit.md @@ -0,0 +1,24 @@ +### Added + +- Added a `zizmor` job to the `Workflows` workflow and a `.github/zizmor.yml` + policy file. `actionlint` validates workflow schema and shell; it does not + detect credential persistence, template injection or unpinned actions, so + those defects previously shipped into every repository generated from this + template (closes #64). The policy tag-pins the publishers whose release tags + the template is meant to read at a glance (`actions/*`, `github/*`, + `docker/*`, `astral-sh/*`, `lycheeverse/*`, `zizmorcore/*`) and requires a + full commit hash for everything else. + +### Fixed + +- Set `persist-credentials: false` on every `actions/checkout` step except the + one in `manual-release`, which pushes the version bump commit and therefore + needs the token in `.git/config`. Previously all 17 checkouts wrote the + `GITHUB_TOKEN` into the working tree, where any later step in the same job + could read it. +- Hash-pinned `pypa/gh-action-pypi-publish` (was `@release/v1`, a mutable + branch executing in the PyPI trusted-publishing job) and + `codecov/codecov-action`, each annotated with the tag and date pinned. +- Moved `pages: write` and `id-token: write` in `docs.yml` from the workflow + level to the two jobs that publish, so the build job no longer carries write + scopes it does not use. diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 763050f..7b5b6f7 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -121,6 +121,24 @@ def assert_action_pin_absent(workflow: str, action: str, version: str) -> None: assert not re.search(pattern, workflow) +def assert_action_hash_pin(workflow: str, action: str, count: int) -> str: + """Assert an action is pinned to a full commit hash annotated with its tag. + + zizmor's ``unpinned-uses`` audit demands a hash pin for every publisher not + listed in ``.github/zizmor.yml``. A bare hash is unreadable, so the pin + carries a ``# @ `` comment that says what was pinned and when. + """ + pattern = ( + rf"uses:\s+{re.escape(action)}@([0-9a-f]{{40}})" + r"\s+#\s+(v[0-9][^\s]*) @ (\d{4}-\d{2}-\d{2})" + ) + matches = re.findall(pattern, workflow) + assert ( + len(matches) == count + ), f"expected {count} hash-pinned {action} reference(s), found {len(matches)}" + return matches[0][0] + + def test_workflow_run_blocks_do_not_interpolate_untrusted_inputs() -> None: """Contributor-controlled inputs must reach shell scripts through env vars.""" unsafe_expression = re.compile( @@ -361,10 +379,15 @@ def test_release_workflow_action_versions_are_current() -> None: assert_action_pin_count(release_workflow, "actions/setup-python", "v6", 7) assert_action_pin_count(release_workflow, "actions/upload-artifact", "v7", 2) assert_action_pin_count(release_workflow, "actions/download-artifact", "v7", 2) - assert_action_pin_count(release_workflow, "codecov/codecov-action", "v7", 1) + assert_action_hash_pin(release_workflow, "codecov/codecov-action", 1) + assert_action_hash_pin(release_workflow, "pypa/gh-action-pypi-publish", 2) assert_action_pin_absent(release_workflow, "actions/setup-python", "v5") assert_action_pin_absent(release_workflow, "codecov/codecov-action", "v4") + # A mutable branch pin executes whatever that branch holds at run time. + assert_action_pin_absent( + release_workflow, "pypa/gh-action-pypi-publish", "release/v1" + ) def test_release_workflow_sets_git_default_branch_before_checkout() -> None: @@ -391,7 +414,7 @@ def test_release_workflow_gates_codecov_upload_on_token() -> None: assert "if: env.CODECOV_TOKEN == ''" in skip_step assert "::notice::" in skip_step assert "if: env.CODECOV_TOKEN != ''" in upload_step - assert "uses: codecov/codecov-action@v7" in upload_step + assert_action_hash_pin(upload_step, "codecov/codecov-action", 1) assert "files: ${{ steps.python_layout.outputs.root }}/coverage.xml" in upload_step assert "\n file:" not in upload_step assert "token: ${{ env.CODECOV_TOKEN }}" in upload_step @@ -852,3 +875,92 @@ def test_workflow_lint_job_validates_every_workflow() -> None: # The Docker image bundles shellcheck and pyflakes; a bare binary without # shellcheck on PATH skips the shell checks and still exits 0. assert "docker://rhysd/actionlint:" in job + + +def test_workflow_audit_job_runs_zizmor() -> None: + """zizmor has to run in CI next to actionlint, or its findings never surface. + + actionlint validates workflow schema and shell. It does not detect + credential persistence, template injection or unpinned actions -- zizmor + audits exactly those (issue #64). + """ + workflow = read_workflow("workflows.yml") + job = workflow_job_block(workflow, "zizmor") + + assert "paths:" in workflow and "'.github/**'" in workflow + assert "timeout-minutes:" in job + assert "uses: zizmorcore/zizmor-action@" in job + assert "config: .github/zizmor.yml" in job + assert "min-confidence: medium" in job + # SARIF upload needs code scanning, which forks of this template do not + # necessarily have; annotations fail the job either way. + assert "advanced-security: false" in job + assert "annotations: true" in job + + +def test_zizmor_config_requires_hash_pins_by_default() -> None: + """Unlisted publishers must be hash-pinned; trusted ones may stay tag-pinned.""" + config = (ROOT / ".github" / "zizmor.yml").read_text(encoding="utf-8") + + assert "unpinned-uses:" in config + policies = dict( + re.findall(r"^\s+'?([A-Za-z0-9_*/-]+)'?:\s*((?:hash|ref)-pin)$", config, re.M) + ) + + assert policies["*"] == "hash-pin" + for publisher in ("actions/*", "github/*", "docker/*", "zizmorcore/*"): + assert policies[publisher] == "ref-pin", publisher + # Anything that publishes releases or artifacts is deliberately absent + # here, so the catch-all hash-pin rule applies to it. + assert not any(key.startswith(("pypa/", "codecov/")) for key in policies) + + +def test_every_checkout_declares_credential_persistence() -> None: + """actions/checkout writes the token into .git/config unless told not to. + + Any later step in the same job can read it from there, so each checkout has + to make the choice explicit rather than inherit the credential-persisting + default (zizmor's ``artipacked`` audit). + """ + checkouts = 0 + persisting: list[str] = [] + + for path in sorted(WORKFLOWS.glob("*.y*ml")): + lines = path.read_text(encoding="utf-8").splitlines() + for index, line in enumerate(lines): + if not re.match(r"^\s*- uses: actions/checkout@", line): + continue + checkouts += 1 + step = "\n".join(lines[index : index + 6]) + assert "persist-credentials:" in step, ( + f"{path.name}:{index + 1} checkout does not set " "persist-credentials" + ) + if "persist-credentials: true" in step: + persisting.append(f"{path.name}:{index + 1}") + + assert checkouts == 18, f"expected 18 checkouts, found {checkouts}" + # Only the job that pushes the version bump commit needs the token wired + # into the remote; every other checkout only reads the tree. + assert ( + len(persisting) == 1 + ), f"only the pushing checkout may persist credentials, saw {persisting}" + manual_release = workflow_job_block(read_workflow("release.yml"), "manual-release") + assert "persist-credentials: true" in manual_release + + +def test_write_permissions_are_granted_per_job() -> None: + """Workflow-level write scopes leak into every job, including read-only ones.""" + for path in sorted(WORKFLOWS.glob("*.y*ml")): + lines = path.read_text(encoding="utf-8").splitlines() + for index, line in enumerate(lines): + if line != "permissions:": + continue + for scope in lines[index + 1 :]: + if not scope.startswith(" ") or not scope.strip(): + break + if scope.lstrip().startswith("#"): + continue + assert scope.strip().endswith(("read", "none")), ( + f"{path.name} grants '{scope.strip()}' to every job; move " + "write scopes to the jobs that need them" + )