diff --git a/.github/workflows/actions-lock-check-self-test.yml b/.github/workflows/actions-lock-check-self-test.yml new file mode 100644 index 0000000..c4b81d4 --- /dev/null +++ b/.github/workflows/actions-lock-check-self-test.yml @@ -0,0 +1,24 @@ +name: Actions Lock Check Self-Test + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.4.0 + with: + persist-credentials: false + - run: | + python3 -m venv /tmp/action-pins-tests + /tmp/action-pins-tests/bin/pip install PyYAML==6.0.3 + /tmp/action-pins-tests/bin/python -m unittest discover -s scripts -p test_action_pins.py + + check: + uses: ./.github/workflows/actions-lock-check.yml diff --git a/.github/workflows/actions-lock-check.yml b/.github/workflows/actions-lock-check.yml new file mode 100644 index 0000000..2fafdcd --- /dev/null +++ b/.github/workflows/actions-lock-check.yml @@ -0,0 +1,51 @@ +name: Actions lock check + +# Checks the caller's workflows against the org convention: every step-level action is +# an exact vX.Y.Z release (or a full SHA for a commit with no release tag), and +# actions.lock pins it. The job holds no secrets because it runs a third-party binary. + +on: + workflow_call: + inputs: + allow-sha-pins: + description: Accept SHA pins of tagged releases, for workflows a generator owns + type: boolean + required: false + default: false + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@v4.4.0 + with: + persist-credentials: false + + # --verify-local makes no API calls. Online --verify reads every action's repository + # with this token, and orgs with an IP allow list (aquasecurity) reject runner IPs. + # The runner itself rejects a locked commit that isn't reachable from its tag. + - name: Verify actions.lock coverage + env: + GH_TOKEN: ${{ github.token }} + run: | + gh extension install github/gh-actions-lock --pin v0.4.1 + gh actions-lock --verify-local + + - name: Check action refs + env: + ALLOW_SHA_PINS: ${{ inputs.allow-sha-pins }} + run: | + if [ "$GITHUB_REPOSITORY" = kernel/security-workflows ]; then + cp scripts/action_pins.py "$RUNNER_TEMP/action_pins.py" # self-test runs the PR's copy + else + curl -fsSL https://raw.githubusercontent.com/kernel/security-workflows/main/scripts/action_pins.py \ + -o "$RUNNER_TEMP/action_pins.py" + fi + python3 -m venv "$RUNNER_TEMP/venv" + "$RUNNER_TEMP/venv/bin/pip" install -q PyYAML==6.0.3 + flags=() + if [ "$ALLOW_SHA_PINS" = true ]; then flags+=(--allow-sha-pins); fi + "$RUNNER_TEMP/venv/bin/python" -I "$RUNNER_TEMP/action_pins.py" "${flags[@]}" . diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index cba9759..fd5540e 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -3,6 +3,10 @@ # Docs: https://gh.io/actions-lockfile version: 'v0.0.3' workflows: + '.github/workflows/actions-lock-check-self-test.yml': + - 'actions/checkout@v4.4.0' + '.github/workflows/actions-lock-check.yml': + - 'actions/checkout@v4.4.0' '.github/workflows/breakglass-merge.yml': - 'actions/create-github-app-token@v1.12.0' '.github/workflows/breakglass-tests.yml': @@ -41,7 +45,7 @@ workflows: - 'actions/download-artifact@v4.3.0' - 'actions/setup-node@v4.4.0' - 'actions/upload-artifact@v4.6.2' - - 'kernel/security-workflows@main' + - 'kernel/security-workflows@af490855f90e83f89dcaf4ea18da962ec294350e' '.github/workflows/vuln-remediation-self-test.yml': - 'actions/checkout@v4.4.0' '.github/workflows/vuln-remediation.yml': @@ -141,7 +145,7 @@ dependencies: commit: 'sha1-7474bc4690e29a8392af63c5b98e7449536d5c3a' owner_id: 57468012 repo_id: 220391287 - 'kernel/security-workflows@main': + 'kernel/security-workflows@af490855f90e83f89dcaf4ea18da962ec294350e': ref: 'main' commit: 'sha1-af490855f90e83f89dcaf4ea18da962ec294350e' owner_id: 194616459 diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index 55c0b38..367ecb3 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -186,7 +186,7 @@ jobs: pi --version - name: Model credentials - uses: kernel/security-workflows/.github/actions/llm-credentials@main + uses: kernel/security-workflows/.github/actions/llm-credentials@af490855f90e83f89dcaf4ea18da962ec294350e with: model: ${{ vars.PI_PREFERRED_MODEL }} anthropic-federation-rule-id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} @@ -217,7 +217,7 @@ jobs: - name: Stop model credentials if: ${{ always() }} - uses: kernel/security-workflows/.github/actions/llm-credentials@main + uses: kernel/security-workflows/.github/actions/llm-credentials@af490855f90e83f89dcaf4ea18da962ec294350e with: stop: true diff --git a/README.md b/README.md index 57728dd..c63c781 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,32 @@ short-lived provider credentials, which is why the caller grants `id-token: writ ## Actions +### `actions-lock-check.yml` + +Fails a pull request when a workflow can run an action that wasn't reviewed. Every +step-level `uses:` must be an exact release tag (`actions/checkout@v4.4.0`), or a full +commit SHA when the commit has no release tag, and `.github/workflows/actions.lock` must pin +it. Same-repository actions use `$/.github/actions/`. The job also runs +`gh actions-lock --verify-local`. Reusable workflow calls (`jobs..uses`) aren't covered by the +lockfile and aren't checked. + +To fix a failure, run `gh actions-lock`, then check that no SHA pin of an untagged commit +was rewritten to a branch such as `@main`. + +```yaml +# In your repo's .github/workflows/actions-lock-check.yml +name: Actions lock check +on: + pull_request: +permissions: + contents: read +jobs: + check: + uses: kernel/security-workflows/.github/workflows/actions-lock-check.yml@main + # with: + # allow-sha-pins: true # only for workflows a code generator owns +``` + ### `llm-credentials` action Gives pi short-lived Anthropic, OpenAI or OpenRouter credentials through workload identity federation, so a @@ -120,7 +146,7 @@ permissions: contents: read id-token: write steps: - - uses: kernel/security-workflows/.github/actions/llm-credentials@main + - uses: kernel/security-workflows/.github/actions/llm-credentials@ # no release tags; lock it in actions.lock with: model: ${{ vars.PI_PREFERRED_MODEL }} anthropic-federation-rule-id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} @@ -133,7 +159,7 @@ steps: PI_PREFERRED_MODEL: ${{ vars.PI_PREFERRED_MODEL }} run: pi --print --model "$PI_PREFERRED_MODEL" ... # no ANTHROPIC_API_KEY or OPENAI_API_KEY - if: always() - uses: kernel/security-workflows/.github/actions/llm-credentials@main + uses: kernel/security-workflows/.github/actions/llm-credentials@ with: stop: true ``` diff --git a/scripts/action_pins.py b/scripts/action_pins.py index 689aa89..db4d14f 100644 --- a/scripts/action_pins.py +++ b/scripts/action_pins.py @@ -1,7 +1,14 @@ -"""Checks that workflow `uses:` refs are exact releases recorded in actions.lock.""" +"""Checks that workflow `uses:` refs are exact releases recorded in actions.lock. + +Run as a script to check every workflow and in-repo action of a checkout: + + python3 action_pins.py [--allow-sha-pins] [ROOT] +""" from __future__ import annotations +import argparse import re +import sys from pathlib import Path import yaml @@ -12,23 +19,81 @@ SHA = re.compile(r"[0-9a-f]{40}") -def lock_problem(workflow: Path, uses: str) -> str | None: - """Return why `uses` in `workflow` isn't an exact, locked ref, or None if it is. +def ref_problem(uses: str, entries: list[str] | None, lock: dict, allow_sha_pins: bool = False) -> str | None: + """Return why `uses` isn't an exact, locked ref, or None if it is. - A ref must be an exact vX.Y.Z tag, or a full commit SHA for a commit with no release - tag. Either way the workflow must be onboarded in actions.lock with an entry for that - exact ref. `$/` actions always run at the workflow's own commit. + `entries` is the file's list under `workflows:` in actions.lock, or None for an in-repo + action file, whose dependencies are recorded under the workflows that call it. """ - if uses.startswith("$/"): + if uses.startswith(("$/", "./", "docker://")): return None name, _, ref = uses.partition("@") if not (EXACT_TAG.fullmatch(ref) or SHA.fullmatch(ref)): return f"{uses}: ref must be an exact vX.Y.Z tag or a full commit SHA" - lock = yaml.safe_load(LOCKFILE.read_text()) key = "/".join(name.split("/")[:2]) + "@" + ref - if key not in lock["workflows"].get(str(workflow.relative_to(ROOT)), []): - return f"{uses}: {workflow.name} has no actions.lock entry for {key}" + if key not in (lock.get("dependencies") or {}) or (entries is not None and key not in entries): + return f"{uses}: no actions.lock entry for {key}" locked_ref = lock["dependencies"][key]["ref"] - if SHA.fullmatch(ref) and EXACT_TAG.fullmatch(locked_ref): + if SHA.fullmatch(ref) and EXACT_TAG.fullmatch(locked_ref) and not allow_sha_pins: return f"{uses}: {ref[:7]} is release {locked_ref}; reference the tag instead" return None + + +def lock_problem(workflow: Path, uses: str) -> str | None: + """Return why `uses` in `workflow` isn't an exact, locked ref, or None if it is. + + A ref must be an exact vX.Y.Z tag, or a full commit SHA for a commit with no release + tag. Either way the workflow must be onboarded in actions.lock with an entry for that + exact ref. `$/` actions always run at the workflow's own commit. + """ + if uses.startswith("$/"): + return None + lock = yaml.safe_load(LOCKFILE.read_text()) + entries = lock["workflows"].get(str(workflow.relative_to(ROOT)), []) + return ref_problem(uses, entries, lock) + + +def step_uses(doc: dict) -> list[str]: + """Every step-level `uses:` in a workflow or composite action document.""" + steps = [s for job in (doc.get("jobs") or {}).values() for s in job.get("steps") or []] + steps += (doc.get("runs") or {}).get("steps") or [] + return [s["uses"] for s in steps if isinstance(s, dict) and isinstance(s.get("uses"), str)] + + +def repo_problems(root: Path, allow_sha_pins: bool = False) -> list[str]: + """Check every workflow and in-repo action under `root`.""" + lockfile = root / ".github/workflows/actions.lock" + lock = yaml.safe_load(lockfile.read_text()) if lockfile.exists() else {} + workflows = lock.get("workflows") or {} + files = sorted(p for p in (root / ".github/workflows").glob("*.y*ml")) + actions = sorted((root / ".github").glob("actions/**/action.y*ml")) + problems = [] + for path in files + actions: + rel = str(path.relative_to(root)) + uses = step_uses(yaml.safe_load(path.read_text()) or {}) + external = [u for u in uses if not u.startswith(("$/", "./", "docker://"))] + is_workflow = path in files + if is_workflow and external and rel not in workflows: + problems.append(f"{rel}: not onboarded in actions.lock; run `gh actions-lock`") + continue + entries = workflows.get(rel) if is_workflow else None + for u in uses: + if problem := ref_problem(u, entries, lock, allow_sha_pins): + problems.append(f"{rel}: {problem}") + return problems + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("root", nargs="?", default=".", type=Path) + parser.add_argument("--allow-sha-pins", action="store_true", + help="accept SHA pins of tagged releases (generator-owned workflows)") + args = parser.parse_args() + problems = repo_problems(args.root.resolve(), args.allow_sha_pins) + for p in problems: + print(f"::error::{p}") + return 1 if problems else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_action_pins.py b/scripts/test_action_pins.py new file mode 100644 index 0000000..d882e97 --- /dev/null +++ b/scripts/test_action_pins.py @@ -0,0 +1,89 @@ +import tempfile +import textwrap +import unittest +from pathlib import Path + +from action_pins import repo_problems + +TAGGED = "11d5960a326750d5838078e36cf38b85af677262" +UNTAGGED = "af490855f90e83f89dcaf4ea18da962ec294350e" +LOCK = f""" +version: 'v0.0.3' +workflows: + '.github/workflows/ci.yml': + - 'actions/checkout@v4.4.0' + - 'actions/checkout@{TAGGED}' + - 'kernel/security-workflows@{UNTAGGED}' + '.github/workflows/caller.yml': [] +dependencies: + 'actions/checkout@v4.4.0': + ref: 'v4.4.0' + commit: 'sha1-{TAGGED}' + 'actions/checkout@{TAGGED}': + ref: 'v4.4.0' + commit: 'sha1-{TAGGED}' + 'kernel/security-workflows@{UNTAGGED}': + ref: 'main' + commit: 'sha1-{UNTAGGED}' +""" + + +class RepoProblemsTest(unittest.TestCase): + def check(self, uses, lock=LOCK, allow_sha_pins=False, path=".github/workflows/ci.yml"): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".github/workflows").mkdir(parents=True) + if lock is not None: + (root / ".github/workflows/actions.lock").write_text(lock) + (root / ".github/workflows/caller.yml").write_text( + "on: push\njobs:\n x:\n uses: kernel/security-workflows/.github/workflows/semgrep.yml@main\n" + ) + target = root / path + target.parent.mkdir(parents=True, exist_ok=True) + steps = "".join(f"\n - uses: {u}" for u in uses) + if path.endswith("action.yml"): + target.write_text(f"runs:\n using: composite\n steps:{steps.replace(chr(10) + ' ', chr(10) + ' ')}\n") + else: + target.write_text(f"on: push\njobs:\n x:\n runs-on: ubuntu-latest\n steps:{steps}\n") + return repo_problems(root, allow_sha_pins) + + def test_accepts_exact_tags_untagged_shas_and_local_actions(self): + uses = [ + "actions/checkout@v4.4.0", + f"kernel/security-workflows/.github/actions/llm-credentials@{UNTAGGED}", + "$/.github/actions/build", + "./.github/actions/build", + "docker://alpine:3", + ] + self.assertEqual(self.check(uses), []) + + def test_rejects_floating_branch_unlocked_and_tagged_sha_refs(self): + for uses in ( + "actions/checkout@v4", + "actions/checkout@main", + "actions/checkout@v4.5.0", + f"actions/checkout@{TAGGED}", + ): + self.assertEqual(len(self.check([uses])), 1, uses) + + def test_allow_sha_pins_accepts_locked_tagged_shas_only(self): + self.assertEqual(self.check([f"actions/checkout@{TAGGED}"], allow_sha_pins=True), []) + self.assertEqual(len(self.check(["actions/checkout@v4"], allow_sha_pins=True)), 1) + + def test_workflows_must_be_onboarded(self): + (problem,) = self.check(["actions/checkout@v4.4.0"], path=".github/workflows/new.yml") + self.assertIn("not onboarded", problem) + (problem,) = self.check(["actions/checkout@v4.4.0"], lock=None) + self.assertIn("not onboarded", problem) + + def test_in_repo_actions_need_a_locked_exact_ref(self): + path = ".github/actions/build/action.yml" + self.assertEqual(self.check(["actions/checkout@v4.4.0"], path=path), []) + self.assertEqual(len(self.check(["actions/checkout@v4"], path=path)), 1) + + def test_reusable_workflow_calls_are_ignored(self): + self.assertEqual(self.check([]), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_semgrep_triage.py b/scripts/test_semgrep_triage.py index 3e2420a..c441066 100644 --- a/scripts/test_semgrep_triage.py +++ b/scripts/test_semgrep_triage.py @@ -277,7 +277,7 @@ def test_model_credentials_exist_only_while_the_agent_runs(self): agent = self.names.index("Triage findings") self.assertEqual(self.names[agent - 1], "Model credentials") self.assertEqual(self.names[agent + 1], "Stop model credentials") - action = "kernel/security-workflows/.github/actions/llm-credentials@main" + action = "kernel/security-workflows/.github/actions/llm-credentials@af490855f90e83f89dcaf4ea18da962ec294350e" self.assertEqual(self.step("Model credentials")["uses"], action) self.assertEqual(self.step("Stop model credentials")["with"], {"stop": True}) jobs = yaml.safe_load(WORKFLOW.read_text())["jobs"]