From 6d279b43b7cc50079e648bf0a6c869b0d4285333 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 3 Sep 2026 19:44:32 +0000 Subject: [PATCH 1/2] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-foundation/python-ai-driven-development-pipeline-template/issues/64 --- .gitkeep | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From aad2ea61a946b500e592aeb8330e03d7787620f9 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 3 Sep 2026 19:50:57 +0000 Subject: [PATCH 2/2] ci: audit workflows with zizmor and harden checkouts and action pins actionlint validates workflow schema and shell, but does not detect credential persistence, template injection or unpinned actions, so those defects shipped silently into every repository generated from this template (#64). - Add a zizmor job to workflows.yml and .github/zizmor.yml, which tag-pins trusted publishers and hash-pins everything else. - Set persist-credentials: false on every checkout except manual-release, which pushes the version bump and needs the token in .git/config. - Hash-pin pypa/gh-action-pypi-publish (was the mutable release/v1 branch) and codecov/codecov-action, annotated with tag and date. - Grant docs.yml Pages write scopes per job instead of workflow-wide. - Cover all four with regression tests in tests/test_workflows.py. --- .github/workflows/docs.yml | 15 ++- .github/workflows/links.yml | 2 + .github/workflows/release.yml | 27 +++- .github/workflows/security.yml | 6 + .github/workflows/workflows.yml | 32 +++++ .github/zizmor.yml | 15 +++ .../20260903_issue_64_workflow_audit.md | 24 ++++ tests/test_workflows.py | 116 +++++++++++++++++- 8 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 .github/zizmor.yml create mode 100644 changelog.d/20260903_issue_64_workflow_audit.md 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/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" + )