ci: add supply-chain guards — permission allowlist, gitignore rules, manifest checks, pinned actions (#84)

* 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.

* ci: scope dependency-review to upstream PRs - forks lack Dependency graph by default

Verified on a fork: the action fails with 'Dependency review is not
supported on this repository' until Dependency graph is manually enabled,
and forks don't inherit it. Guarded with the same
github.repository == upstream condition the other upstream-only jobs use.
With the graph enabled the action passes, so the config itself is sound.

* ci: probe Dependency graph before dependency-review - warn and pass when unavailable

The upstream PR run showed Dependency graph is disabled on the upstream
repo too (the action hard-fails: 'Dependency review is not supported on
this repository'), not just on forks. Only the repo owner can enable it,
so a hard red X here is friction, not signal.

The job now probes the dependency-graph SBOM endpoint with the workflow
token first: HTTP 200 runs the real review; anything else emits a
::warning:: naming the setting to flip (Settings -> Advanced Security ->
Dependency graph) and passes. Same graceful-skip pattern the workflow
uses for optional tools - the check self-activates the moment the graph
is enabled, no workflow change needed.
This commit is contained in:
Ayobami Adegoke
2026-07-09 06:03:01 +02:00
committed by GitHub
parent 9e26de2c67
commit e16afac7b9
3 changed files with 201 additions and 6 deletions
+60 -6
View File
@@ -10,6 +10,16 @@
# real portals (network-flaky, and the linkedin-search skill is personal-use # real portals (network-flaky, and the linkedin-search skill is personal-use
# only per its own ToS warning - CI-automated requests would violate that). # only per its own ToS warning - CI-automated requests would violate that).
# CLIs get typechecked instead; live testing stays a local, on-demand step. # 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 name: CI
@@ -19,24 +29,68 @@ on:
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
lint: lint:
name: Lint skills, commands, settings name: Lint skills, commands, settings
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-python@v5 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version: "3.12" python-version: "3.12"
- run: pip install pyyaml - run: pip install pyyaml
- run: python tools/lint_skills.py - 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 (upstream PRs only)
# Requires the repo's Dependency graph, which forks never inherit and
# which may be disabled upstream - so: upstream PRs only, and the
# graph is probed first. If it is unavailable, the job warns and
# passes instead of hard-failing (the same graceful-skip pattern the
# workflow uses for optional tools). Enabling Dependency graph under
# Settings -> Advanced Security activates the real check.
if: github.event_name == 'pull_request' && github.repository == 'MadsLorentzen/ai-job-search'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Probe Dependency graph availability
id: graph
run: |
code=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer ${{ github.token }}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${{ github.repository }}/dependency-graph/sbom")
if [ "$code" = "200" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::warning::Dependency graph is not enabled on this repository (HTTP $code). Dependency review was skipped - enable Dependency graph under Settings -> Advanced Security to activate this check."
fi
- name: Dependency review
if: steps.graph.outputs.enabled == 'true'
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
with:
fail-on-severity: high
latex-smoke: latex-smoke:
name: Compile example CV and cover letter name: Compile example CV and cover letter
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: texlive/texlive:latest container: texlive/texlive:latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Compile CV example (lualatex) - name: Compile CV example (lualatex)
run: | run: |
cd cv cd cv
@@ -78,8 +132,8 @@ jobs:
- jobnet-search - jobnet-search
- linkedin-search - linkedin-search
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@v2 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- run: bun install - run: bun install
working-directory: .agents/skills/${{ matrix.tool }}/cli working-directory: .agents/skills/${{ matrix.tool }}/cli
- run: bun run typecheck - run: bun run typecheck
@@ -90,7 +144,7 @@ jobs:
if: github.repository == 'MadsLorentzen/ai-job-search' if: github.repository == 'MadsLorentzen/ai-job-search'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Tracked template files must keep their placeholder tokens - name: Tracked template files must keep their placeholder tokens
run: | run: |
fail=0 fail=0
+1
View File
@@ -186,6 +186,7 @@ ai-job-search/
├── tools/ ├── tools/
│ ├── convert_salary_excel.py # Convert salary Excel to JSON │ ├── convert_salary_excel.py # Convert salary Excel to JSON
│ ├── lint_skills.py # CI lint for skills, commands, settings.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 │ └── README_SALARY_TOOL.md # Salary tool setup instructions
├── job_scraper/ # Scraper state (seen jobs, results) ├── job_scraper/ # Scraper state (seen jobs, results)
├── upskill/ # /upskill report output (markdown reports per run) ├── upskill/ # /upskill report output (markdown reports per run)
+140
View File
@@ -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())