diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ffb4ade..ff2c222 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,7 @@ "name": "Heath Stewart" }, "metadata": { - "version": "0.6.0" + "version": "0.6.1" }, "plugins": [ { @@ -35,7 +35,7 @@ "name": "security", "source": "./plugins/security", "description": "Skills and tools for supply chain security", - "version": "0.2.0" + "version": "0.2.1" } ] } diff --git a/plugins/security/.claude-plugin/plugin.json b/plugins/security/.claude-plugin/plugin.json index 46bbcb4..7da0066 100644 --- a/plugins/security/.claude-plugin/plugin.json +++ b/plugins/security/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "security", "description": "Skills and tools for supply chain security", - "version": "0.2.0", + "version": "0.2.1", "author": { "name": "Heath Stewart" }, diff --git a/plugins/security/skills/pin-github-actions/SKILL.md b/plugins/security/skills/pin-github-actions/SKILL.md index 4aa91bb..b6ab1e7 100644 --- a/plugins/security/skills/pin-github-actions/SKILL.md +++ b/plugins/security/skills/pin-github-actions/SKILL.md @@ -5,26 +5,15 @@ description: Run when adding or updating GitHub Actions workflow steps. Pin ever # Pin GitHub Actions -Pin every `uses:` step in a workflow to an exact commit SHA with the resolved version tag -as a trailing comment so the intent is clear. +Use the script as the source of truth. Run it first; only fall back to manual reasoning +if the script fails or reports unresolved references. -**Format:** +The **skill directory** is the directory containing this SKILL.md file. The +**plugin directory** is two levels above the skill directory (the parent of `skills/`). -```yaml -uses: owner/action@ # vX.Y.Z -``` - -**Example:** - -```yaml -- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 -``` +## Default flow -## Setup - -This skill uses a Python script. The **skill directory** is the directory containing -this SKILL.md file. The **plugin directory** is two levels above the skill directory -(the parent of `skills/`). +Run from the root of the repository whose workflows you want to pin. Create the plugin venv once if it does not already exist: @@ -33,25 +22,26 @@ python -m venv /.venv /.venv/bin/pip install -r /scripts/requirements.txt ``` -## Running +If `python` is unavailable, retry the same setup command with `python3`. -Run from the root of the repository whose workflows you want to pin. - -Pin all workflows under `.github/workflows/`: +Prefer passing the specific workflow files you already know about: ```bash -/.venv/bin/python /scripts/pin_github_actions.py +/.venv/bin/python /scripts/pin_github_actions.py .github/workflows/ci.yml ``` -Pin specific workflow files: +Pin every workflow under `.github/workflows/` only when you need a broader sweep: ```bash -/.venv/bin/python /scripts/pin_github_actions.py .github/workflows/ci.yml +/.venv/bin/python /scripts/pin_github_actions.py ``` +Do not read `pin_github_actions.py` or `requirements.txt` unless the command fails. +Do not hand-edit `uses:` lines unless the script cannot complete the change. +Treat a zero exit status as success. + ## Rules -- Never reference a mutable tag (e.g., `@v4`, `@main`). -- Never omit the version comment — it is the only human-readable clue to the pinned version. -- Dependabot keeps SHAs current; do not manually update SHAs unless Dependabot cannot reach the action. +- Every non-local `uses:` step must end as `owner/action@<40-char-sha> # vX.Y.Z`. +- Never leave a mutable ref such as `@v4` or `@main`. - Internal workflow references (`uses: ./.github/workflows/...`) are exempt and are not modified by the script. diff --git a/plugins/security/skills/pin-github-actions/scripts/pin_github_actions.py b/plugins/security/skills/pin-github-actions/scripts/pin_github_actions.py index b158e01..38923ea 100644 --- a/plugins/security/skills/pin-github-actions/scripts/pin_github_actions.py +++ b/plugins/security/skills/pin-github-actions/scripts/pin_github_actions.py @@ -19,6 +19,7 @@ import re import subprocess +from dataclasses import dataclass, field from pathlib import Path @@ -39,6 +40,12 @@ _tag_cache: dict[tuple[str, str, str], str] = {} +@dataclass +class PinResult: + changed: bool = False + unresolved: list[str] = field(default_factory=list) + + def _run_ls_remote(repo_url: str, *patterns: str) -> str | None: try: result = subprocess.run( @@ -130,7 +137,7 @@ def semver_key(t: str) -> tuple[int, ...]: return best -def _pin_match(m: re.Match) -> str: +def _pin_match(path: Path, unresolved: list[str], m: re.Match) -> str: """Return a pinned replacement for a single uses: regex match.""" prefix = m.group(1) action = m.group(2) @@ -148,30 +155,35 @@ def _pin_match(m: re.Match) -> str: # Already pinned to a SHA — re-resolve using the version in the comment stripped = comment.strip() if not stripped.startswith("#"): - return m.group(0) # no version comment; cannot re-resolve + unresolved.append( + f"{path}: {action}@{ref} is pinned to a SHA but missing a version comment" + ) + return m.group(0) version = stripped.lstrip("#").strip() new_sha = resolve_to_sha(repo_url, version) if not new_sha: + unresolved.append(f"{path}: could not re-resolve {action} from comment {version}") return m.group(0) exact_tag = find_exact_tag(repo_url, version, new_sha) return f"{prefix}{action}@{new_sha} # {exact_tag}" else: sha = resolve_to_sha(repo_url, ref) if not sha: - print(f" Warning: could not resolve {action}@{ref}", file=sys.stderr) + unresolved.append(f"{path}: could not resolve {action}@{ref}") return m.group(0) exact_tag = find_exact_tag(repo_url, ref, sha) return f"{prefix}{action}@{sha} # {exact_tag}" -def pin_workflow(path: Path) -> bool: - """Pin all GitHub Actions in a workflow file. Returns True if the file changed.""" +def pin_workflow(path: Path) -> PinResult: + """Pin all GitHub Actions in a workflow file.""" original = path.read_text(encoding="utf-8") - updated = USES_RE.sub(_pin_match, original) + unresolved: list[str] = [] + updated = USES_RE.sub(lambda m: _pin_match(path, unresolved, m), original) if updated != original: path.write_text(updated, encoding="utf-8") - return True - return False + return PinResult(changed=True, unresolved=unresolved) + return PinResult(unresolved=unresolved) def main() -> None: @@ -188,18 +200,28 @@ def main() -> None: sys.exit("No workflow files found.") changed = 0 + unresolved_count = 0 for p in paths: if not p.exists(): print(f"Skipping (not found): {p}", file=sys.stderr) + unresolved_count += 1 continue print(f"Processing {p} ...", end=" ", flush=True) - if pin_workflow(p): + result = pin_workflow(p) + if result.changed: print("updated") changed += 1 + elif result.unresolved: + print("needs attention") else: print("no changes") + for warning in result.unresolved: + print(f" Warning: {warning}", file=sys.stderr) + unresolved_count += len(result.unresolved) print(f"\n{changed} file(s) updated.") + if unresolved_count: + sys.exit(f"{unresolved_count} reference(s) still need manual attention.") if __name__ == "__main__":