From 1412a25f5feedc2bc8bd7cacde61c737f15f7aa4 Mon Sep 17 00:00:00 2001 From: ayobamiseun Date: Wed, 8 Jul 2026 18:00:02 +0100 Subject: [PATCH] ci: add supply-chain guards - permission allowlist, gitignore rules, manifest checks, pinned actions This template's threat model is unusual: it ships pre-approved Claude Code permissions (.claude/settings.json) and CLI code that every fork user executes via those permissions. A plausible-looking PR could therefore ship risk to every forker: widen a permission to Bash(*), weaken the personal-data gitignore rules, or smuggle code execution into bun install via a lifecycle script. Nothing checked for these mechanically. New job security-guards runs tools/security_guards.py (stdlib only): - settings.json: every permissions.allow entry must be in an exact, in-repo allowlist. The guard makes permission changes loud, not impossible - a PR that intentionally widens permissions must update the allowlist in the same diff, so the widening is explicit and reviewable - .gitignore: the personal-data rules (tracker, documents/**, cv/main_*, salary data, seen_jobs) must all still be present - the mirror image of the placeholder-integrity job - .agents/**/package.json: no lifecycle scripts (preinstall/install/ postinstall/prepare/prepack) and no trustedDependencies, which would execute arbitrary code during bun install on users' machines New job dependency-review (PRs only): actions/dependency-review-action flags newly introduced vulnerable or malicious dependencies, fail-on-severity high. Workflow hardening: explicit top-level permissions: contents: read (least-privilege token), and all actions pinned to commit SHAs resolved from the same major tags already in use (checkout v4, setup-python v5, setup-bun v2), with the tag recorded in a comment. Honest limit, recorded in the workflow header: a PR can 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. Verified locally: positive run passes; injecting Bash(*) into settings.json, deleting the tracker gitignore rule, and adding a postinstall script each fail the guard with the intended message, and reverting restores a clean pass. --- .github/workflows/ci.yml | 45 +++++++++++-- README.md | 1 + tools/security_guards.py | 140 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 tools/security_guards.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b6b607..fc2381c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,16 @@ # real portals (network-flaky, and the linkedin-search skill is personal-use # only per its own ToS warning - CI-automated requests would violate that). # CLIs get typechecked instead; live testing stays a local, on-demand step. +# +# Security posture: this template ships pre-approved Claude Code permissions +# and CLI code that every fork user executes, so the security-guards job +# fails PRs that widen settings.json permissions, weaken the personal-data +# gitignore rules, or add package lifecycle scripts; dependency-review flags +# newly introduced vulnerable/malicious dependencies. Honest limit: a PR can +# 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. name: CI @@ -19,24 +29,47 @@ on: pull_request: workflow_dispatch: +permissions: + contents: read + jobs: lint: name: Lint skills, commands, settings runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" - run: pip install pyyaml - run: python tools/lint_skills.py + security-guards: + name: Security guards (permissions, gitignore, manifests) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + - run: python tools/security_guards.py + + dependency-review: + name: Dependency review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 + with: + fail-on-severity: high + latex-smoke: name: Compile example CV and cover letter runs-on: ubuntu-latest container: texlive/texlive:latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Compile CV example (lualatex) run: | cd cv @@ -78,8 +111,8 @@ jobs: - jobnet-search - linkedin-search steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - run: bun install working-directory: .agents/skills/${{ matrix.tool }}/cli - run: bun run typecheck @@ -90,7 +123,7 @@ jobs: if: github.repository == 'MadsLorentzen/ai-job-search' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Tracked template files must keep their placeholder tokens run: | fail=0 diff --git a/README.md b/README.md index 49d8b0a..bedd576 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,7 @@ ai-job-search/ ├── tools/ │ ├── convert_salary_excel.py # Convert salary Excel to JSON │ ├── lint_skills.py # CI lint for skills, commands, settings.json +│ ├── security_guards.py # CI guards: permission allowlist, gitignore rules, manifests │ └── README_SALARY_TOOL.md # Salary tool setup instructions ├── job_scraper/ # Scraper state (seen jobs, results) ├── upskill/ # /upskill report output (markdown reports per run) diff --git a/tools/security_guards.py b/tools/security_guards.py new file mode 100644 index 0000000..22802f6 --- /dev/null +++ b/tools/security_guards.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Supply-chain guards for the template's riskiest surfaces. + +Run from anywhere: python tools/security_guards.py + +This repo ships pre-approved Claude Code permissions and CLI code that every +fork user executes. These guards make the dangerous changes LOUD, not +impossible: a PR that intentionally needs one of them must update the +allowlists in this file in the same diff, so the change is explicit and +reviewable rather than buried. + +Checks: +1. .claude/settings.json — every permissions.allow entry must be in the exact + allowlist below. Catches permission widening (e.g. Bash(*), Bash(curl:*)), + which would auto-approve commands on every fork. +2. .gitignore — the personal-data ignore rules must all still be present. + Catches weakening that would make future users silently commit their + tracker, profile exports, or application archives. +3. .agents/**/package.json — no npm/bun lifecycle scripts (preinstall, + install, postinstall, prepare, prepack) and no trustedDependencies. + Catches code execution smuggled into `bun install`. + +Stdlib only. Exit 0 on success, 1 with a failure list otherwise. +""" + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +errors: list[str] = [] + +# The exact permission entries the template ships. A PR that adds or changes +# an entry must add it here too - that is the point: the diff shows both. +ALLOWED_PERMISSIONS = { + "Skill(job-application-assistant)", + "Bash(bun run:*)", + "Bash(python salary_lookup.py:*)", + "Bash(python3 salary_lookup.py:*)", + "Bash(pdftotext:*)", +} + +# Personal-data ignore rules that must never disappear from .gitignore. +REQUIRED_IGNORE_RULES = [ + "salary_data.json", + "job_scraper/seen_jobs.json", + "cv/main_*.tex", + "!cv/main_example.tex", + "cover_letters/cover_*.tex", + "documents/cv/**", + "documents/linkedin/**", + "documents/diplomas/**", + "documents/references/**", + "documents/applications/**", + "job_search_tracker.csv", +] + +FORBIDDEN_SCRIPTS = {"preinstall", "install", "postinstall", "prepare", "prepack"} + + +def check_permissions() -> None: + path = ROOT / ".claude" / "settings.json" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + errors.append(f".claude/settings.json: unreadable or invalid JSON: {exc}") + return + allow = data.get("permissions", {}).get("allow", []) + for entry in allow: + if entry not in ALLOWED_PERMISSIONS: + errors.append( + f".claude/settings.json: permission not in the reviewed allowlist: {entry!r}. " + "Pre-approved permissions run without prompting on every fork. If this entry is " + "intentional, add it to ALLOWED_PERMISSIONS in tools/security_guards.py in the " + "same PR so the widening is explicit and reviewable." + ) + for entry in ALLOWED_PERMISSIONS - set(allow): + # Not an error: settings may legitimately drop an entry. But an + # allowlist entry that no longer exists should be pruned. + print(f"note: allowlisted permission not present in settings.json: {entry!r}") + + +def check_gitignore() -> None: + path = ROOT / ".gitignore" + try: + rules = {line.strip() for line in path.read_text(encoding="utf-8").splitlines()} + except OSError as exc: + errors.append(f".gitignore: unreadable: {exc}") + return + for rule in REQUIRED_IGNORE_RULES: + if rule not in rules: + errors.append( + f".gitignore: required personal-data rule missing: {rule!r}. " + "These rules keep fork users from committing personal data. If the rule moved " + "or was renamed intentionally, update REQUIRED_IGNORE_RULES in " + "tools/security_guards.py in the same PR." + ) + + +def check_package_manifests() -> None: + manifests = [ + p for p in ROOT.glob(".agents/**/package.json") if "node_modules" not in p.parts + ] + if not manifests: + errors.append(".agents: no package.json files found - glob roots are wrong or the tree moved") + for manifest in manifests: + relpath = manifest.relative_to(ROOT) + try: + data = json.loads(manifest.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + errors.append(f"{relpath}: unreadable or invalid JSON: {exc}") + continue + bad = FORBIDDEN_SCRIPTS & set(data.get("scripts", {})) + if bad: + errors.append( + f"{relpath}: lifecycle script(s) {sorted(bad)} are forbidden - they execute " + "arbitrary code during `bun install` on every fork user's machine." + ) + if "trustedDependencies" in data: + errors.append( + f"{relpath}: trustedDependencies is forbidden - it re-enables dependency " + "lifecycle scripts that bun blocks by default." + ) + + +def main() -> int: + check_permissions() + check_gitignore() + check_package_manifests() + if errors: + print(f"security_guards: {len(errors)} failure(s)") + for err in errors: + print(f" - {err}") + return 1 + print("security_guards: OK (permissions allowlist, gitignore rules, package manifests)") + return 0 + + +if __name__ == "__main__": + sys.exit(main())