diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 136b7ca6..214b6c59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,8 @@ # edit this workflow itself, so these guards catch accidents and casual # attempts, not a determined author - branch protection with required checks # and human review of workflow/settings diffs remain the real backstop. -# Actions are pinned to commit SHAs; the token is read-only. +# Actions are pinned to commit SHAs; the token is read-only, and no checkout +# leaves it in .git/config (persist-credentials: false) for later steps to read. name: CI @@ -41,6 +42,7 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: + persist-credentials: false fetch-depth: 0 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: @@ -56,6 +58,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" @@ -70,6 +74,8 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: ${{ matrix.python-version }} @@ -89,6 +95,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Probe Dependency graph availability id: graph run: | @@ -128,6 +136,8 @@ jobs: container: debian:bookworm steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Install apt-packaged TeX Live (bookworm leg) if: matrix.leg.name == 'debian-bookworm' # --no-install-recommends keeps the leg lean, so the two font packages @@ -196,6 +206,8 @@ jobs: tools: ${{ steps.list.outputs.tools }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - id: list run: | tools=$(find .agents/skills -mindepth 3 -maxdepth 3 -path '*/cli/package.json' \ @@ -214,17 +226,23 @@ jobs: tool: ${{ fromJSON(needs.discover-clis.outputs.tools) }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - run: bun install working-directory: .agents/skills/${{ matrix.tool }}/cli - run: bun run typecheck working-directory: .agents/skills/${{ matrix.tool }}/cli - name: Run fixture/mock tests when present + # The tool name is a directory name a pull request controls, so it + # reaches the shell as data through env, never as expanded script text. + env: + TOOL: ${{ matrix.tool }} run: | if find tests -type f -name '*.test.ts' | grep -q .; then bun test else - echo "No Bun tests found for ${{ matrix.tool }}; skipping" + echo "No Bun tests found for $TOOL; skipping" fi working-directory: .agents/skills/${{ matrix.tool }}/cli @@ -234,6 +252,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Tracked template files must keep their placeholder tokens run: | fail=0 diff --git a/.github/workflows/upstream-watch.yml b/.github/workflows/upstream-watch.yml index 32d21006..e63a4cfd 100644 --- a/.github/workflows/upstream-watch.yml +++ b/.github/workflows/upstream-watch.yml @@ -32,7 +32,6 @@ on: permissions: contents: read - issues: write jobs: triage: @@ -41,10 +40,17 @@ jobs: # tests/test_upstream_triage.py so a template clone never runs it by surprise. if: github.repository != 'MadsLorentzen/ai-job-search' runs-on: ubuntu-latest + # Issue write is granted to this one job, not the workflow, so a job added + # later starts read-only. + permissions: + contents: read + issues: write steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 + # The fetch below reads a public remote; nothing here pushes. + persist-credentials: false - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" diff --git a/CHANGELOG.md b/CHANGELOG.md index dc72b1ca..44a921ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -193,6 +193,21 @@ per-file diff commands. web search only for that last escalation step, finding the employer's own copy of the posting; company research and salary lookups stay out of `/rank`. +### Security + +- **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a + script** (#658, `.github/workflows/ci.yml`, `.github/workflows/upstream-watch.yml`, + `tests/test_workflow_hardening.py`) - all nine `actions/checkout` steps persisted the job token + for every later step, including `bun install` and `bun test` in CLI code a pull request brings + (zizmor `artipacked`); each now sets `persist-credentials: false`, which nothing needed. The + `cli-checks` test step expanded `${{ matrix.tool }}` - a directory name `discover-clis` reads + from the PR's tree - straight into its shell script (zizmor `template-injection`); it now + arrives through `env`. `upstream-watch.yml` grants `issues: write` to its one job instead of + the whole workflow, so a job added later starts read-only. The container images stay on tags: + the `texlive/texlive:latest` leg exists to track current TeX Live, and a digest pin without + automated updates would freeze both legs. The test reads the workflow text with the stdlib, so + it needs neither zizmor nor PyYAML. + ## [1.7.3] - 2026-10-19 ### Added diff --git a/tests/test_workflow_hardening.py b/tests/test_workflow_hardening.py new file mode 100644 index 00000000..c17f49d1 --- /dev/null +++ b/tests/test_workflow_hardening.py @@ -0,0 +1,61 @@ +"""Pin the workflow settings zizmor checks, without needing zizmor or PyYAML. + +- Every `actions/checkout` sets `persist-credentials: false`. Otherwise the job + token stays in `.git/config` for every later step, including the CLI code a + pull request brings (`artipacked`). +- A matrix value that comes from repository content - `cli-checks` discovers + `matrix.tool` from directory names, which a pull request controls - never + appears expanded inside a `run:` script (`template-injection`). +""" +import unittest +from pathlib import Path + +WORKFLOWS = sorted((Path(__file__).resolve().parent.parent / ".github" / "workflows").glob("*.yml")) + + +def indent(line: str) -> int: + return len(line) - len(line.lstrip()) + + +def key_indent(line: str) -> int: + """Column of a step's keys: two past the dash on a `- key:` line.""" + return indent(line) + (2 if line.lstrip().startswith("- ") else 0) + + +class WorkflowHardeningTests(unittest.TestCase): + def test_workflows_found(self): + self.assertTrue(WORKFLOWS) + + def test_every_checkout_drops_its_credentials(self): + for path in WORKFLOWS: + lines = path.read_text(encoding="utf-8").splitlines() + for i, line in enumerate(lines): + if "uses: actions/checkout@" not in line: + continue + keys = key_indent(line) + block = [] + for following in lines[i + 1 :]: + if following.strip() and indent(following) < keys: + break + block.append(following.strip()) + with self.subTest(workflow=path.name, line=i + 1): + self.assertIn("persist-credentials: false", block) + + def test_discovered_matrix_values_stay_out_of_run_scripts(self): + for path in WORKFLOWS: + lines = path.read_text(encoding="utf-8").splitlines() + run_keys = None # indent of the active `run:` key, None outside one + for i, line in enumerate(lines, 1): + if run_keys is not None and line.strip() and indent(line) <= run_keys: + run_keys = None + stripped = line.strip() + if run_keys is None and stripped.startswith(("run:", "- run:")): + run_keys = key_indent(line) + script = stripped.split("run:", 1)[1] + else: + script = line if run_keys is not None else "" + with self.subTest(workflow=path.name, line=i): + self.assertNotIn("${{ matrix.tool }}", script) + +if __name__ == "__main__": + unittest.main()