From daf6eff42dd85034ae091bb604a5fb75cd923ba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kris=20Kr=C3=BCg?= Date: Mon, 20 Jul 2026 10:59:57 -0700 Subject: [PATCH] ci: harden supply chain workflows --- .github/branch-protection.md | 4 + .github/dependabot.yml | 38 ++++++++ .github/workflows/agentic-dev-loop.yml | 2 +- .github/workflows/agentic-issue-quality.yml | 2 +- .github/workflows/agentic-pr-review.yml | 2 +- .github/workflows/agentic-traceability.yml | 2 +- .github/workflows/ci.yml | 10 +-- .github/workflows/codeql.yml | 40 +++++++++ .github/workflows/dependency-review.yml | 21 +++++ tests/agentic/test_workflows.py | 98 +++++++++++++++++++++ 10 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dependency-review.yml diff --git a/.github/branch-protection.md b/.github/branch-protection.md index c2ffa6c..e19d1f9 100644 --- a/.github/branch-protection.md +++ b/.github/branch-protection.md @@ -14,5 +14,9 @@ Recommended protection for `main`: `needs-human` / `blocked` gates. Applying these settings remains a separate, explicitly approved maintainer action. +CodeQL and Dependency Review begin as reporting checks. A maintainer decides +whether to require them only after their initial pull-request and `main` runs +have succeeded. + For autonomous PRs, keep maintainer spot checks enabled until at least 10 Rafiki PRs have passed review, CI, and audit-log recording without scope drift. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d904cd7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,38 @@ +version: 2 + +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + groups: + root-npm: + patterns: + - "*" + + - package-ecosystem: npm + directory: /frontend + schedule: + interval: weekly + groups: + frontend-npm: + patterns: + - "*" + + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + groups: + python: + patterns: + - "*" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/agentic-dev-loop.yml b/.github/workflows/agentic-dev-loop.yml index ff8cd20..e1d8ded 100644 --- a/.github/workflows/agentic-dev-loop.yml +++ b/.github/workflows/agentic-dev-loop.yml @@ -36,7 +36,7 @@ jobs: LOOP_PAUSED: ${{ vars.LOOP_PAUSED || 'false' }} QUALITY_LABELS: ${{ inputs.quality_labels || '' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 0 diff --git a/.github/workflows/agentic-issue-quality.yml b/.github/workflows/agentic-issue-quality.yml index df4285a..6e2a361 100644 --- a/.github/workflows/agentic-issue-quality.yml +++ b/.github/workflows/agentic-issue-quality.yml @@ -29,7 +29,7 @@ jobs: }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - name: Ensure standard labels env: diff --git a/.github/workflows/agentic-pr-review.yml b/.github/workflows/agentic-pr-review.yml index c29e2de..5b593f8 100644 --- a/.github/workflows/agentic-pr-review.yml +++ b/.github/workflows/agentic-pr-review.yml @@ -26,7 +26,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - name: Ensure standard labels run: python3 scripts/agentic/ensure_labels.py diff --git a/.github/workflows/agentic-traceability.yml b/.github/workflows/agentic-traceability.yml index f4ef565..cc057a7 100644 --- a/.github/workflows/agentic-traceability.yml +++ b/.github/workflows/agentic-traceability.yml @@ -21,7 +21,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - name: Fetch PR policy metadata run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83630d7..05716fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,15 +15,15 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: "22.12" - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.11" @@ -52,11 +52,11 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 0 - name: Run gitleaks - uses: gitleaks/gitleaks-action@v2 + uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..908afcd --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,40 @@ +name: CodeQL + +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: "23 9 * * 1" + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: + - javascript-typescript + - python + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - name: Initialize CodeQL + uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + with: + languages: ${{ matrix.language }} + build-mode: none + + - name: Analyze + uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..658ca8f --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,21 @@ +name: Dependency Review + +on: + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - name: Review dependency changes + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + fail-on-severity: high diff --git a/tests/agentic/test_workflows.py b/tests/agentic/test_workflows.py index 20585d5..ea86fd2 100644 --- a/tests/agentic/test_workflows.py +++ b/tests/agentic/test_workflows.py @@ -1,4 +1,5 @@ import json +import re import subprocess from pathlib import Path @@ -194,6 +195,103 @@ def test_ci_names_security_verification_and_runs_it_before_deterministic_checks( ) +def test_active_workflow_actions_use_immutable_release_pins(): + pin = re.compile( + r"^\s*(?:-\s*)?uses:\s+[^@\s]+@(?P[0-9a-f]{40})\s+#\s+" + r"(?Pv\d+\.\d+\.\d+)\s*$" + ) + uses_lines = [] + + for path in sorted((ROOT / ".github" / "workflows").glob("*.yml")): + for line_number, line in enumerate(path.read_text().splitlines(), start=1): + if re.match(r"^\s*(?:-\s*)?uses:", line): + uses_lines.append((path, line_number, line)) + + assert uses_lines + for path, line_number, line in uses_lines: + assert pin.match(line), f"{path.name}:{line_number}: {line}" + + +def test_dependabot_groups_every_supported_dependency_surface_weekly(): + path = ROOT / ".github" / "dependabot.yml" + config = yaml.safe_load(path.read_text()) + updates = { + (entry["package-ecosystem"], entry["directory"]): entry + for entry in config["updates"] + } + + assert config["version"] == 2 + assert set(updates) == { + ("npm", "/"), + ("npm", "/frontend"), + ("pip", "/"), + ("github-actions", "/"), + } + for entry in updates.values(): + assert entry["schedule"] == {"interval": "weekly"} + assert len(entry["groups"]) == 1 + assert next(iter(entry["groups"].values()))["patterns"] == ["*"] + + text = path.read_text().lower() + assert "auto-merge" not in text + assert "automerge" not in text + + +def test_codeql_scans_supported_languages_on_pr_main_and_weekly_schedule(): + workflow = _workflow("codeql.yml") + analyze = workflow["jobs"]["analyze"] + steps = analyze["steps"] + init = next(step for step in steps if step.get("name") == "Initialize CodeQL") + + assert workflow["on"]["push"]["branches"] == ["main"] + assert workflow["on"]["pull_request"]["branches"] == ["main"] + assert workflow["on"]["schedule"] + assert workflow["permissions"] == {"contents": "read"} + assert analyze["permissions"] == { + "contents": "read", + "security-events": "write", + } + assert analyze["strategy"] == { + "fail-fast": False, + "matrix": {"language": ["javascript-typescript", "python"]}, + } + assert init["with"] == { + "languages": "${{ matrix.language }}", + "build-mode": "none", + } + assert any( + step.get("name") == "Analyze" + and step["uses"].startswith("github/codeql-action/analyze@") + for step in steps + ) + + +def test_dependency_review_runs_on_main_pull_requests_with_read_only_access(): + workflow = _workflow("dependency-review.yml") + job = workflow["jobs"]["dependency-review"] + review = next( + step for step in job["steps"] if step.get("name") == "Review dependency changes" + ) + + assert workflow["on"] == {"pull_request": {"branches": ["main"]}} + assert workflow["permissions"] == {"contents": "read"} + assert "permissions" not in job + assert review["with"] == {"fail-on-severity": "high"} + + +def test_security_monitoring_yaml_parses_without_provider_credentials(): + paths = [ROOT / ".github" / "dependabot.yml"] + paths.extend(sorted((ROOT / ".github" / "workflows").glob("*.yml"))) + + for path in paths: + assert isinstance(yaml.safe_load(path.read_text()), dict), path.name + + for name in ("codeql.yml", "dependency-review.yml"): + text = (ROOT / ".github" / "workflows" / name).read_text() + assert "secrets." not in text + assert "API_KEY" not in text + + def test_verify_script_covers_every_deterministic_ci_gate(): package = json.loads((ROOT / "package.json").read_text()) package_lock = json.loads((ROOT / "package-lock.json").read_text())