mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 16:46:24 +00:00
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.
This commit is contained in:
@@ -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,47 @@ 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
|
||||||
|
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:
|
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 +111,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 +123,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
|
||||||
|
|||||||
@@ -168,6 +168,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)
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user