Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/actions-lock-check-self-test.yml
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions .github/workflows/actions-lock-check.yml
Original file line number Diff line number Diff line change
@@ -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[@]}" .
8 changes: 6 additions & 2 deletions .github/workflows/actions.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/semgrep.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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

Expand Down
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>`. The job also runs
`gh actions-lock --verify-local`. Reusable workflow calls (`jobs.<id>.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
Expand All @@ -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@<commit-sha> # no release tags; lock it in actions.lock
with:
model: ${{ vars.PI_PREFERRED_MODEL }}
anthropic-federation-rule-id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }}
Expand All @@ -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@<commit-sha>
with:
stop: true
```
Expand Down
87 changes: 76 additions & 11 deletions scripts/action_pins.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Local composites outside .github/actions skipped

Medium Severity

repo_problems only opens composite files under .github/actions/**, while ref_problem treats every ./ uses: as local and returns immediately. A workflow can uses: ./some-action whose action.yml pins evil@main, and neither the Python check nor onboarding will see that remote ref.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by learned rule: Keep actions.lock; do not rewrite SHA pins

Reviewed by Cursor Bugbot for commit 54ed6cf. Configure here.



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())
89 changes: 89 additions & 0 deletions scripts/test_action_pins.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion scripts/test_semgrep_triage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading