From ab35c4f0a6c142a67cb2443f2821889f52da92e9 Mon Sep 17 00:00:00 2001 From: Alex Ultra Date: Mon, 15 Jun 2026 12:52:58 +0200 Subject: [PATCH 1/4] chore(rig): apply rig.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffold rig.yaml and add rig's additive security/quality CI gates (codeql self-gate, dependency-review, leftover-grep, review-threads, secret-scan) plus their ci/ companion scripts. Existing ci.yml (ruff/pytest/mypy) is untouched — rig's workflows are separate files, no collision. Global skills/hooks already installed machine-wide. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/codeql.yml | 187 ++++++++++++++++++++++++ .github/workflows/dependency-review.yml | 50 +++++++ .github/workflows/leftover-grep.yml | 63 ++++++++ .github/workflows/review-threads.yml | 56 +++++++ .github/workflows/secret-scan.yml | 52 +++++++ ci/dependency-review/dep-audit.sh | 91 ++++++++++++ ci/leftover-grep/leftover-grep.sh | 98 +++++++++++++ ci/review-threads/review-threads.sh | 63 ++++++++ ci/secret-scan/secret-scan.sh | 81 ++++++++++ rig.yaml | 70 +++++++++ 10 files changed, 811 insertions(+) create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dependency-review.yml create mode 100644 .github/workflows/leftover-grep.yml create mode 100644 .github/workflows/review-threads.yml create mode 100644 .github/workflows/secret-scan.yml create mode 100755 ci/dependency-review/dep-audit.sh create mode 100755 ci/leftover-grep/leftover-grep.sh create mode 100755 ci/review-threads/review-threads.sh create mode 100755 ci/secret-scan/secret-scan.sh create mode 100644 rig.yaml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..87fa3e0 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,187 @@ +# CodeQL SELF-GATE — for a PRIVATE repo WITHOUT GitHub Advanced Security. +# +# Without GHAS, the Code Scanning dashboard is unavailable: uploading SARIF 403s +# ("Advanced Security must be enabled"), so the normal `codeql/workflow.yml` can't gate. +# This variant runs the SAME analysis but keeps the SARIF LOCAL and gates on it in-job — +# a required check that fails on findings, with $0 of GHAS and no branch-protection UI. +# +# INSTALL: copy to .github/workflows/codeql.yml (use INSTEAD of workflow.yml, not both). +# Give the job a DISTINCT `name:` if your repo also has an orphaned UI "Default Setup" +# CodeQL whose hollow checks you exempt elsewhere — a distinct name moves THIS gate out +# of that exemption so your merge gate (e.g. ci/ship) requires it. +# +# SEVERITY FLOOR (knob): GATE_LEVELS below selects which SARIF result levels FAIL the job. +# error — only the highest-severity findings gate (loosest) +# "error warning" — error + warning gate (recommended; this is the default) +# Notes (recommendations) are always reported, never gating. +# +# IN-SOURCE SUPPRESSION: a finding is suppressed iff the flagged line OR the line directly +# above it carries `// codeql[]`. Keeps suppressions greppable, reviewable, +# and reasoned — not a silent baseline. (When `upload:false`, codeql-action does NOT run +# its own alert-suppression queries, so this gate implements suppression itself.) +# +# LANGUAGES / PINNING: same as ci/codeql/workflow.yml — edit the matrix; codeql-action is +# pinned by major tag per GitHub convention. + +name: CodeQL Self-Gate + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '0 3 * * 1' + workflow_dispatch: + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +# No `security-events: write` — this gate does NOT upload to Code Scanning (no GHAS). +permissions: + actions: read + contents: read + +env: + # Severity floor — which SARIF levels fail the job. See header. + GATE_LEVELS: 'error warning' + +jobs: + analyze: + name: CodeQL Self-Gate (${{ matrix.language }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - language: javascript-typescript + build-mode: none + - language: actions + build-mode: none + # - language: python + # build-mode: none + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: security-extended + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: '/language:${{ matrix.language }}' + # No GHAS → the dashboard upload 403s. Keep the SARIF, gate on it below. + upload: false + output: sarif-results + + - name: Upload SARIF artifact + # Publish the raw SARIF even on a gate failure, so it's downloadable + # (`gh run download`) for triage. + if: always() + uses: actions/upload-artifact@v4 + with: + name: codeql-sarif-${{ matrix.language }} + path: sarif-results/*.sarif + if-no-files-found: error + retention-days: 30 + + - name: Gate on SARIF findings + if: always() + shell: bash + env: + LANGUAGE: ${{ matrix.language }} + run: | + set -euo pipefail + shopt -s nullglob + files=(sarif-results/*.sarif) + if [ ${#files[@]} -eq 0 ]; then + echo "::error::No SARIF produced for ${LANGUAGE} — analysis did not emit results." >&2 + exit 1 + fi + + # Which levels gate (from GATE_LEVELS env). Build a quick membership test. + is_gated_level() { + local lvl="$1" g + for g in ${GATE_LEVELS:-error warning}; do + [ "$lvl" = "$g" ] && return 0 + done + return 1 + } + + # is_source_suppressed -> rc 0 if suppressed. + # Honors `// codeql[]` on the flagged line or the line directly above. + is_source_suppressed() { + local rid="$1" src="$2" line="$3" above tag="// codeql[$1]" + [ -f "$src" ] || return 1 + [ "$line" -gt 0 ] 2>/dev/null || return 1 + sed -n "${line}p" "$src" 2>/dev/null | grep -qF "$tag" && return 0 + if [ "$line" -gt 1 ]; then + above=$((line - 1)) + sed -n "${above}p" "$src" 2>/dev/null | grep -qF "$tag" && return 0 + fi + return 1 + } + + gated=0; notes=0; suppressed=0 + echo "=== CodeQL findings for ${LANGUAGE} ===" + + for f in "${files[@]}"; do + # Resolve each result's effective level (per-result level overrides the rule + # default; default to "warning" per the SARIF spec when unspecified). + # TSV: \t\t\t\t\t + jq -r ' + .runs[] as $run + | ( [ $run.tool.driver.rules[]? + | { key: .id, value: ( .defaultConfiguration.level // "warning" ) } ] + | from_entries ) as $ruleLevel + | $run.results[]? + | ( .level // $ruleLevel[.ruleId] // "warning" ) as $lvl + | ( ( ( .suppressions // [] ) | length > 0 ) | tostring ) as $sarifSup + | ( .locations[0].physicalLocation as $pl + | [ $sarifSup, $lvl, (.ruleId // "?"), + ( $pl.artifactLocation.uri // "?" ), + ( ($pl.region.startLine // 0) | tostring ), + ( .message.text // "" | gsub("[\t\n]"; " ") ) ] ) + | @tsv + ' "$f" > findings.tsv || { echo "::error::Failed to parse $f" >&2; exit 1; } + + while IFS=$'\t' read -r sarifSup lvl rule uri line msg; do + [ -z "${lvl:-}" ] && continue + loc="${uri}:${line}" + if [ "$sarifSup" = "true" ] || is_source_suppressed "$rule" "$uri" "$line"; then + suppressed=$((suppressed+1)) + echo " suppr. [$lvl] $rule $loc — $msg (in-source suppression)" + elif is_gated_level "$lvl"; then + gated=$((gated+1)) + echo "::error file=${uri},line=${line}::[$lvl] $rule — $msg" + echo " GATED [$lvl] $rule $loc — $msg" + elif [ "$lvl" = "note" ]; then + notes=$((notes+1)) + echo " note [$lvl] $rule $loc — $msg" + else + echo " other [$lvl] $rule $loc — $msg" + fi + done < findings.tsv + done + + echo "=== Summary (${LANGUAGE}): gated=$gated, suppressed=$suppressed, notes=$notes ===" + { + echo "### CodeQL — ${LANGUAGE}" + echo "" + echo "- Gated (${GATE_LEVELS}): **$gated**" + echo "- Suppressed (in-source, justified): $suppressed" + echo "- Notes (recommendation): $notes" + } >> "$GITHUB_STEP_SUMMARY" + + if [ "$gated" -gt 0 ]; then + echo "::error::CodeQL gate FAILED for ${LANGUAGE}: $gated gated finding(s). See annotations." >&2 + exit 1 + fi + echo "CodeQL gate PASSED for ${LANGUAGE}: 0 gated ($suppressed suppressed, $notes note(s))." diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..f4f9e96 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,50 @@ +# Dependency review — block a PR that ADDS a vulnerable or disallowed-license dependency. +# +# Standard engine = actions/dependency-review-action (GitHub's official). It diffs the +# dependency manifests/lockfiles in the PR and fails on (a) newly introduced dependencies +# with known vulnerabilities at/above a severity threshold, and (b) dependencies whose +# license is not allowed. This is the PR-time, "don't let it IN" complement to the audit +# scanners in ../sast/ and ../secret-scan/ (which scan what's already there). +# +# AVAILABILITY: dependency-review needs the GitHub Dependency Graph. It's ON for PUBLIC +# repos; for PRIVATE repos it requires GitHub Advanced Security (Settings -> Code security +# -> Dependency graph). Without it, this action errors — use `bun audit` / `npm audit` / +# `pip-audit` / `cargo audit` in a plain CI step instead (see README). +# +# INSTALL: copy to .github/workflows/dependency-review.yml. +# +# KNOBS: fail-on-severity, and license allow/deny lists below. +# +# Triggered by `pull_request` (read-only token) — safe; the action reads the dependency +# diff from the PR, runs no PR code. + +name: dependency-review + +on: + pull_request: + +permissions: + contents: read + # `comment-summary-in-pr` posts a summary comment — that needs write. Drop this to + # `contents: read` only if you also set `comment-summary-in-pr: never`. + pull-requests: write + +jobs: + dependency-review: + name: dependency-review + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Dependency review + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + # Fail if a newly added dependency has a vuln at/above this severity. + fail-on-severity: high + # License policy — set ONE of these (allow-list is stricter). Examples: + # allow-licenses: MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC + deny-licenses: AGPL-3.0, GPL-2.0, GPL-3.0 + # To waive a specific known advisory, list its GHSA id(s): + # allow-ghsas: GHSA-xxxx-xxxx-xxxx + comment-summary-in-pr: on-failure diff --git a/.github/workflows/leftover-grep.yml b/.github/workflows/leftover-grep.yml new file mode 100644 index 0000000..80cb6ff --- /dev/null +++ b/.github/workflows/leftover-grep.yml @@ -0,0 +1,63 @@ +# Leftover-marker gate: fail the PR on debugging/forbidden leftovers in the ADDED code. +# +# Catches the "left it in" class: focused tests (.only/fdescribe/fit), `debugger`, stray +# console.log/debug, TODO/FIXME without a tracker reference, and merge-conflict markers. +# Scans only the lines the PR ADDS (vs the base) so it doesn't punish pre-existing debt. +# +# INSTALL: copy to .github/workflows/leftover-grep.yml AND keep the gate script at +# ci/leftover-grep/leftover-grep.sh (vendor the ci/ dir, or copy + adjust the run: path). +# +# KNOBS (env on the step): LEFTOVER_INCLUDE / LEFTOVER_EXCLUDE (which files), TICKET_REGEX +# (what makes a TODO "tracked"), ALLOW_CONSOLE=1 (console.log -> warning not failure). +# +# TAMPER-RESISTANCE — `pull_request_target`: a merge-BLOCKING gate must run the trusted +# BASE-branch copy of its script (a PR could otherwise edit the script to bypass its own +# gate). Unlike the metadata-only gates, this one needs the PR's CODE — but only to READ it: +# we fetch the PR head SHA and `git diff`/grep it as DATA (`git diff` never runs PR code). +# The trusted base script runs; the PR's content is only scanned. The token is read-only. +# HARD RULE: do NOT add a build/test/install step here — that WOULD execute PR code under +# the privileged trigger. This job only diffs + greps. +# Caveat: it does NOT run on the PR that first adds it (base has no gate yet). + +name: leftover-grep + +on: + pull_request_target: + workflow_dispatch: + inputs: + head_sha: + description: 'Commit SHA to scan (required for manual runs)' + required: true + type: string + +permissions: + contents: read + +jobs: + leftover-grep: + name: leftover-grep + runs-on: ubuntu-latest + steps: + - name: Checkout BASE branch (trusted script) with full history + # Default ref under pull_request_target = base. The gate SCRIPT that runs is the + # trusted base copy, not the PR's. fetch-depth: 0 so the base ref resolves. + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Fetch the PR head commit as DATA (not checked out / not executed) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || inputs.head_sha }} + run: | + # Fetch the PR head object into the local repo so `git diff` can reach it. We do + # NOT check it out and NOT run anything from it — it's only diffed + grepped. + git fetch --no-tags --depth=1 origin "$HEAD_SHA" + + - name: Scan added lines for leftovers + env: + LEFTOVER_BASE: origin/${{ github.event.pull_request.base.ref || github.event.repository.default_branch }} + LEFTOVER_HEAD: ${{ github.event.pull_request.head.sha || inputs.head_sha }} + # TICKET_REGEX: 'ABC-[0-9]+|#[0-9]+' # your tracker's id format + # ALLOW_CONSOLE: '0' + run: bash ci/leftover-grep/leftover-grep.sh diff --git a/.github/workflows/review-threads.yml b/.github/workflows/review-threads.yml new file mode 100644 index 0000000..9c347f4 --- /dev/null +++ b/.github/workflows/review-threads.yml @@ -0,0 +1,56 @@ +# Merge gate: block the PR while it has UNRESOLVED review threads. +# +# GitHub's native equivalent is the branch-protection toggle "Require conversation +# resolution before merging" — but that's admin-only and can't be set from inside the repo. +# This workflow gives you the same gate as a required STATUS CHECK that any maintainer can +# add to branch protection (Settings -> Branches -> required checks -> "review-threads"). +# +# INSTALL: copy to .github/workflows/review-threads.yml AND keep the gate script at +# ci/review-threads/review-threads.sh (vendor the ci/ dir, or copy the script and adjust +# the run: path). To make it a HARD block, add the check "review-threads" to branch +# protection (admin-only, one-time). +# +# TAMPER-RESISTANCE — why `pull_request_target`: +# A merge-BLOCKING gate must not run a script the PR itself can edit (a PR could weaken the +# very gate it has to pass). Under `pull_request_target` the BASE-branch (trusted) copy of +# this workflow AND of the checked-out gate script run — the PR's version is ignored. The +# job reads ONLY the PR NUMBER from the event payload; it never checks out or executes +# PR-head code, and `permissions` stay read-only. +# Caveat: like any base-run gate, it does NOT run on the PR that first introduces it. +# +# Re-evaluates: resolving a thread emits no webhook, so we also re-run on `synchronize` and +# offer a manual `workflow_dispatch` (which REQUIRES a pr_number input — there's no PR in the +# event payload on a manual run). Or rely on the ship-time preflight in ci/ship. + +name: review-threads + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to re-check (required — manual runs have no PR in the event payload)' + required: true + type: string + +permissions: + contents: read + pull-requests: read + +jobs: + review-threads: + name: review-threads + runs-on: ubuntu-latest + steps: + - name: Checkout (base branch — trusted; default ref under pull_request_target) + # No `ref:` — under pull_request_target the default checkout is the BASE branch, so + # the gate script that runs is the trusted base copy, NOT the PR's version. + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Fail on unresolved review threads + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # PR number: from the event on pull_request_target, else the manual input. + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + run: bash ci/review-threads/review-threads.sh diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 0000000..dea78b3 --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,52 @@ +# Secret-scan CI workflow (GitHub Actions) — gitleaks, pinned. +# +# Secret-scanning standard = gitleaks. This is the drop-in CI backstop for the same +# guard that runs as a git pre-commit hook locally (see ../../git-hooks/no-secrets-scan +# and the `secret-scanning` skill). The git-hook catches it on the committer's machine; +# CI catches anyone whose local hook is missing or bypassed. +# +# INSTALL: copy this file to .github/workflows/secret-scan.yml in your repo. +# +# TIERS: this workflow is the BLOCK tier — a high-confidence finding FAILS the job. +# The warn tier is a local-hook nicety; in CI, "fail on any high-confidence secret" is +# the right default (a warn-only CI check is ignored and worthless). If you want CI to +# surface low-confidence findings without failing, run a second non-required job with +# GITLEAKS_CONFIG pointed at gitleaks-warn.toml and `continue-on-error: true`. +# +# EXTEND / ALLOWLIST: gitleaks reads `.gitleaks.toml` at the repo root automatically. +# Add custom [[rules]] and an [allowlist] there. For an org-wide baseline, point +# GITLEAKS_CONFIG at a checked-in shared config. False positives: inline `gitleaks:allow` +# comment, or an [allowlist] entry. See the `secret-scanning` skill for the full story. +# +# PINNED: gitleaks/gitleaks-action is pinned to a commit SHA (supply-chain hygiene — +# a moving tag like @v3 can be repointed at malicious code). Bump deliberately. + +name: secret-scan + +on: + pull_request: + push: + branches: ["**"] + +permissions: + contents: read + +jobs: + gitleaks: + name: gitleaks (block on secrets) + runs-on: ubuntu-latest + steps: + - name: Checkout (full history — gitleaks scans commits, not just the tree) + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: gitleaks + uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 + env: + # Optional: a checked-in org/repo config. Defaults to repo-root .gitleaks.toml. + # GITLEAKS_CONFIG: .gitleaks.toml + # + # GITLEAKS_LICENSE is only required for gitleaks-action in GitHub *organizations* + # (not personal repos). Set it as a repo/org secret if your account needs it: + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} diff --git a/ci/dependency-review/dep-audit.sh b/ci/dependency-review/dep-audit.sh new file mode 100755 index 0000000..f84cdbd --- /dev/null +++ b/ci/dependency-review/dep-audit.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Dependency vulnerability audit — generic fallback for any CI or a repo WITHOUT GitHub's +# Dependency Graph (where actions/dependency-review-action can't run). Auto-detects the +# package manager and runs its native audit, failing on high/critical advisories. +# +# This is the "what's already in the tree" audit. The PR-time "don't let a new bad dep IN" +# gate is workflow.yml (dependency-review-action) — prefer that on public/GHAS repos. +# +# Detects, in order: bun, npm/pnpm/yarn (node), pip-audit (python), cargo-audit (rust), +# govulncheck (go). Runs every ecosystem it finds a manifest for. +# +# Knobs (env): +# DEP_AUDIT_LEVEL minimum severity to FAIL on: low|moderate|high|critical (default high). +# DEP_AUDIT_ALLOW_MISSING "1" = DON'T fail when a manifest is found but its scanner isn't +# installed (fail-OPEN). Default 0 = fail CLOSED: a detected +# ecosystem with no usable scanner is a gate failure, not a silent +# skip — otherwise "no audit ran" masquerades as "no vulns". +# +# Usage: sh ci/dependency-review/dep-audit.sh +set -eu + +LEVEL="${DEP_AUDIT_LEVEL:-high}" +ALLOW_MISSING="${DEP_AUDIT_ALLOW_MISSING:-0}" +rc=0 +ran=0 +missing=0 + +note() { echo "[dep-audit] $*" >&2; } +# A detected manifest whose scanner is absent: fail closed unless explicitly allowed. +miss() { + if [ "$ALLOW_MISSING" = "1" ]; then + note "$* — skipping (DEP_AUDIT_ALLOW_MISSING=1)." + else + note "$* — FAILING (no audit performed; set DEP_AUDIT_ALLOW_MISSING=1 to allow)." + missing=$((missing+1)) + fi +} + +if [ -f bun.lock ] || [ -f bun.lockb ]; then + if command -v bun >/dev/null 2>&1; then + ran=1; note "bun audit --audit-level=$LEVEL" + bun audit --audit-level="$LEVEL" || rc=1 + else + miss "bun lockfile present but bun not installed" + fi +elif [ -f package.json ]; then + if [ -f pnpm-lock.yaml ] && command -v pnpm >/dev/null 2>&1; then + ran=1; note "pnpm audit --audit-level $LEVEL"; pnpm audit --audit-level "$LEVEL" || rc=1 + elif [ -f yarn.lock ] && command -v yarn >/dev/null 2>&1; then + ran=1; note "yarn npm audit (yarn berry) — failing on $LEVEL+"; yarn npm audit --severity "$LEVEL" || rc=1 + elif command -v npm >/dev/null 2>&1; then + ran=1; note "npm audit --audit-level=$LEVEL"; npm audit --audit-level="$LEVEL" || rc=1 + else + miss "package.json present but no usable node package manager (npm/pnpm/yarn)" + fi +fi + +if [ -f requirements.txt ] || [ -f pyproject.toml ] || [ -f poetry.lock ]; then + if command -v pip-audit >/dev/null 2>&1; then + ran=1; note "pip-audit"; pip-audit || rc=1 + else + miss "python manifest present but pip-audit not installed (pipx install pip-audit)" + fi +fi + +if [ -f Cargo.lock ]; then + if command -v cargo-audit >/dev/null 2>&1 || cargo audit --version >/dev/null 2>&1; then + ran=1; note "cargo audit"; cargo audit || rc=1 + else + miss "Cargo.lock present but cargo-audit not installed (cargo install cargo-audit)" + fi +fi + +if [ -f go.mod ]; then + if command -v govulncheck >/dev/null 2>&1; then + ran=1; note "govulncheck ./..."; govulncheck ./... || rc=1 + else + miss "go.mod present but govulncheck not installed (go install golang.org/x/vuln/cmd/govulncheck@latest)" + fi +fi + +if [ "$ran" = "0" ] && [ "$missing" = "0" ]; then + note "no supported manifest/lockfile found — nothing to audit." + exit 0 +fi +if [ "$missing" -gt 0 ]; then + note "FAIL — $missing detected ecosystem(s) had no usable scanner (fail-closed). Install the tool(s) above, or set DEP_AUDIT_ALLOW_MISSING=1." + exit 1 +fi +[ "$rc" = "0" ] && note "PASS — no advisories at $LEVEL+." || note "FAIL — advisories at $LEVEL+ above." +exit "$rc" diff --git a/ci/leftover-grep/leftover-grep.sh b/ci/leftover-grep/leftover-grep.sh new file mode 100755 index 0000000..2fe1208 --- /dev/null +++ b/ci/leftover-grep/leftover-grep.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Leftover-marker gate: fail if the CODE introduces debugging/forbidden leftovers. +# +# Catches the classic "oops, left it in" mistakes before they merge: +# • focused tests — .only( / fdescribe / fit( (silently skip the rest of a suite) +# • debugger statements — `debugger;` +# • stray console logs — console.log/debug (configurable; warn vs block) +# • untracked TODOs — TODO/FIXME WITHOUT an issue reference (TODO(ABC-123) is ok) +# • merge conflict markers — <<<<<<< / ======= / >>>>>>> +# +# By default it scans only the lines ADDED in the PR diff (so it doesn't punish you for +# pre-existing debt), falling back to a full-tree scan when no base ref is available. +# +# Knobs (env): +# LEFTOVER_BASE diff base. Default origin/main -> main -> full-tree scan. +# LEFTOVER_INCLUDE ERE of file paths to scan. Default: source-ish extensions. +# LEFTOVER_EXCLUDE ERE of paths to skip. Default: vendored/build/lock dirs. +# TICKET_REGEX what makes a TODO "tracked". Default: TODO/FIXME followed by +# (ABC-123) or (#123) or a URL. Customize for your tracker. +# ALLOW_CONSOLE "1" = console.log is a WARNING, not a failure (default: block). +# LEFTOVER_FULLTREE "1" = always scan the whole tree, ignore the diff. +# LEFTOVER_HEAD head ref/SHA to diff against the base. Default HEAD. Under a +# tamper-resistant pull_request_target setup this is the PR head SHA, +# fetched as DATA — `git diff` + grep only READ those lines, they +# never execute PR code — so the trusted base script still gates. +# +# Usage: sh ci/leftover-grep/leftover-grep.sh +set -euo pipefail + +LEFTOVER_BASE="${LEFTOVER_BASE:-origin/main}" +LEFTOVER_HEAD="${LEFTOVER_HEAD:-HEAD}" +LEFTOVER_INCLUDE="${LEFTOVER_INCLUDE:-\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|rb|java|kt|c|h|cpp|hpp|cs|php|swift|sh)$}" +LEFTOVER_EXCLUDE="${LEFTOVER_EXCLUDE:-(^|/)(node_modules|dist|build|out|vendor|\.git|coverage|__snapshots__)/|\.min\.(js|css)$|lock$}" +TICKET_REGEX="${TICKET_REGEX:-[A-Z]+-[0-9]+|#[0-9]+|https?://}" +ALLOW_CONSOLE="${ALLOW_CONSOLE:-0}" +LEFTOVER_FULLTREE="${LEFTOVER_FULLTREE:-0}" + +# Resolve a base ref or empty (-> full-tree scan). +base="" +if [ "$LEFTOVER_FULLTREE" != "1" ]; then + if git rev-parse --verify --quiet "$LEFTOVER_BASE" >/dev/null 2>&1; then base="$LEFTOVER_BASE" + elif git rev-parse --verify --quiet main >/dev/null 2>&1; then base="main"; fi +fi + +# Collect (file, lineno, line) tuples for ADDED lines (diff) or all lines (full tree). +# Output format: \t\t +emit_lines() { + if [ -n "$base" ]; then + # Parse `git diff` unified output, tracking the new-file line number, emitting only '+' + # lines (added). Robust enough for a gate without extra deps. + git diff --no-color --unified=0 "$base...$LEFTOVER_HEAD" -- . \ + | awk ' + /^\+\+\+ / { f=$2; sub(/^b\//,"",f); next } + /^@@ / { match($0, /\+[0-9]+/); ln=substr($0,RSTART+1,RLENGTH-1)+0; next } + /^\+/ && f!="" { t=substr($0,2); printf "%s\t%d\t%s\n", f, ln, t; ln++; next } + ' + else + # Full-tree scan of tracked files. + git ls-files | while IFS= read -r f; do + [ -f "$f" ] || continue + grep -nH '' "$f" 2>/dev/null | sed 's/:/\t/; s/:/\t/' || true + done + fi +} + +violations=0 +warnings=0 +report() { # + if [ "$1" = "WARN" ]; then warnings=$((warnings+1)); echo " warn [$4] $2:$3 $5" >&2 + else violations=$((violations+1)); echo "::error file=$2,line=$3::[$4] $5"; echo " BLOCK [$4] $2:$3 $5" >&2; fi +} + +if [ -n "$base" ]; then echo "[leftover] scanning diff vs ${base} ..." >&2; else echo "[leftover] scanning full tree ..." >&2; fi + +while IFS=$'\t' read -r file ln text; do + [ -n "${file:-}" ] || continue + printf '%s' "$file" | grep -qE "$LEFTOVER_INCLUDE" || continue + printf '%s' "$file" | grep -qE "$LEFTOVER_EXCLUDE" && continue + + # focused tests + printf '%s' "$text" | grep -qE '\.only\(|(^|[^a-zA-Z])f(describe|it|test)\(' && report BLOCK "$file" "$ln" "focused-test" "$text" + # debugger + printf '%s' "$text" | grep -qE '(^|[^a-zA-Z])debugger;?\s*$' && report BLOCK "$file" "$ln" "debugger" "$text" + # merge conflict markers + printf '%s' "$text" | grep -qE '^(<{7}|={7}|>{7})( |$)' && report BLOCK "$file" "$ln" "merge-marker" "$text" + # console.log/debug + if printf '%s' "$text" | grep -qE 'console\.(log|debug)\('; then + [ "$ALLOW_CONSOLE" = "1" ] && report WARN "$file" "$ln" "console" "$text" || report BLOCK "$file" "$ln" "console" "$text" + fi + # TODO/FIXME without a tracker reference + if printf '%s' "$text" | grep -qE '(TODO|FIXME)'; then + printf '%s' "$text" | grep -qE "($TICKET_REGEX)" || report BLOCK "$file" "$ln" "untracked-todo" "$text" + fi +done < <(emit_lines) + +echo "[leftover] $violations blocking, $warnings warning(s)." >&2 +[ "$violations" = "0" ] || { echo "[leftover] FAIL — remove the leftovers above (or reference a ticket on the TODO)." >&2; exit 1; } +echo "[leftover] PASS." diff --git a/ci/review-threads/review-threads.sh b/ci/review-threads/review-threads.sh new file mode 100755 index 0000000..6cf8ef2 --- /dev/null +++ b/ci/review-threads/review-threads.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Merge gate: FAIL if a PR has unresolved review threads. +# +# GitHub's "require conversation resolution" branch-protection toggle is the native way to +# do this, but it's admin-only and invisible from inside the repo. This script gives you +# the same gate as a portable CI check (and a pre-merge preflight in a ship script): it +# counts unresolved review threads via the GraphQL API and exits non-zero if any remain. +# +# Requires: gh (authenticated). Reads the PR number from $1 or $PR_NUMBER. +# +# Knobs (env): +# PR_NUMBER PR number (or pass as $1). +# GH_REPO owner/repo (default: gh's current repo; honored by gh automatically). +# +# Usage: +# sh ci/review-threads/review-threads.sh 123 +# PR_NUMBER=123 sh ci/review-threads/review-threads.sh +set -euo pipefail + +PR="${1:-${PR_NUMBER:-}}" +[ -n "$PR" ] || { echo "Usage: $0 (or set PR_NUMBER)" >&2; exit 2; } +command -v gh >/dev/null 2>&1 || { echo "gh CLI not found" >&2; exit 2; } + +# Paginate reviewThreads and count the unresolved ones. Using {owner}/{repo} placeholders +# lets gh fill the current repo; override with GH_REPO / --repo if needed. +QUERY='query($owner:String!,$name:String!,$pr:Int!,$endCursor:String){ + repository(owner:$owner,name:$name){ + pullRequest(number:$pr){ + reviewThreads(first:100,after:$endCursor){ + pageInfo{hasNextPage endCursor} + nodes{isResolved isOutdated} + } + } + } +}' + +# Count unresolved threads (sum across pages). Outdated-but-unresolved still count by +# default — set IGNORE_OUTDATED=1 to skip threads GitHub marked outdated. +IGNORE_OUTDATED="${IGNORE_OUTDATED:-0}" +if [ "$IGNORE_OUTDATED" = "1" ]; then + JQ='[.data.repository.pullRequest.reviewThreads.nodes[] | select((.isResolved|not) and (.isOutdated|not))] | length' +else + JQ='[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved|not)] | length' +fi + +# Fail-CLOSED on an API error: a silent 0 would let an unresolved PR merge. Check gh's +# exit status, not just the parsed count. +if RAW=$(gh api graphql --paginate \ + -F owner='{owner}' -F name='{repo}' -F pr="$PR" \ + -f query="$QUERY" --jq "$JQ" 2>/dev/null); then + UNRESOLVED=$(printf '%s' "$RAW" | awk '{s+=$1} END{print s+0}') +else + echo "::error::could not query review threads for PR #$PR (gh api failed) — failing closed." >&2 + echo "FAIL: review-thread query failed for PR #$PR." >&2 + exit 1 +fi + +if [ "${UNRESOLVED:-0}" != "0" ]; then + echo "::error::PR #$PR has $UNRESOLVED unresolved review thread(s) — resolve every thread before merging." >&2 + echo "FAIL: $UNRESOLVED unresolved review thread(s) on PR #$PR." >&2 + exit 1 +fi +echo "PASS: PR #$PR has no unresolved review threads." diff --git a/ci/secret-scan/secret-scan.sh b/ci/secret-scan/secret-scan.sh new file mode 100755 index 0000000..1ff8c59 --- /dev/null +++ b/ci/secret-scan/secret-scan.sh @@ -0,0 +1,81 @@ +#!/bin/sh +# secret-scan.sh — generic, CI-agnostic secret scan with gitleaks. +# +# For any CI that runs a shell step (GitLab CI, Jenkins, Buildkite, CircleCI, Drone, +# bare cron, a Makefile target). On GitHub Actions prefer ./secret-scan.yml (the pinned +# action). Secret-scanning standard = gitleaks; this is the same engine, scripted. +# +# WHAT IT DOES +# - Installs gitleaks if missing (brew | apt download | go install), else fails clearly. +# - BLOCK tier (default): scans the repo; a high-confidence finding => exit 1 (CI red). +# - WARN tier (SECRET_SCAN_WARN_CONFIG set): a second pass that only prints findings +# and never fails — for surfacing low-confidence cases without blocking the pipeline. +# +# CONFIG / EXTEND +# - Repo-root .gitleaks.toml is auto-detected by gitleaks (add [[rules]] / [allowlist]). +# - SECRET_SCAN_CONFIG=path -> explicit block-tier config (overrides .gitleaks.toml). +# - SECRET_SCAN_WARN_CONFIG=path -> enable the warn pass with this config. +# - SECRET_SCAN_SCOPE=full|staged -> "full" (default in CI) scans all history; "staged" +# scans only staged changes (for a local/hook reuse). +# +# FALSE POSITIVES: inline `gitleaks:allow` comment, or an [allowlist] entry. Never paper +# over a real finding by deleting the step. +set -eu + +GITLEAKS_VERSION="${GITLEAKS_VERSION:-8.30.1}" +SCOPE="${SECRET_SCAN_SCOPE:-full}" + +log() { printf '%s\n' "secret-scan: $*" >&2; } + +ensure_gitleaks() { + if command -v gitleaks >/dev/null 2>&1; then return 0; fi + log "gitleaks not found — attempting install (v$GITLEAKS_VERSION)." + if command -v brew >/dev/null 2>&1; then + brew install gitleaks && return 0 + fi + if command -v go >/dev/null 2>&1; then + GOBIN="${GOBIN:-$HOME/go/bin}" go install "github.com/gitleaks/gitleaks/v8@v$GITLEAKS_VERSION" \ + && export PATH="${GOBIN:-$HOME/go/bin}:$PATH" && return 0 + fi + # Last resort: download the release tarball for linux amd64. + if command -v curl >/dev/null 2>&1 && command -v tar >/dev/null 2>&1; then + url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + tmp="$(mktemp -d)" + if curl -fsSL "$url" -o "$tmp/gl.tgz" && tar -xzf "$tmp/gl.tgz" -C "$tmp" gitleaks; then + install -m 0755 "$tmp/gitleaks" /usr/local/bin/gitleaks 2>/dev/null \ + || { mkdir -p "$HOME/.local/bin"; install -m 0755 "$tmp/gitleaks" "$HOME/.local/bin/gitleaks"; export PATH="$HOME/.local/bin:$PATH"; } + rm -rf "$tmp"; return 0 + fi + rm -rf "$tmp" + fi + log "could not install gitleaks automatically — install it and re-run: https://github.com/gitleaks/gitleaks" + return 1 +} + +scan() { # $1 = optional config path + cfg="${1:-}" + set -- --redact --no-banner -v + [ -n "$cfg" ] && set -- "$@" -c "$cfg" + case "$SCOPE" in + staged) gitleaks git --staged "$@" ;; + *) gitleaks git "$@" ;; # full history + esac +} + +ensure_gitleaks + +# --- WARN tier first (optional, never fails the build) --- +if [ -n "${SECRET_SCAN_WARN_CONFIG:-}" ]; then + if ! scan "$SECRET_SCAN_WARN_CONFIG"; then + log "WARNING — suspicious string(s) found (not failing the build). Review them above." + fi +fi + +# --- BLOCK tier (fails the build on a high-confidence finding) --- +if ! scan "${SECRET_SCAN_CONFIG:-}"; then + log "BLOCKED — a high-confidence secret was found. Remove it, rotate it, and re-push." + log " false positive? add a 'gitleaks:allow' comment or an [allowlist] entry in .gitleaks.toml." + exit 1 +fi + +log "clean — no high-confidence secrets found." diff --git a/rig.yaml b/rig.yaml new file mode 100644 index 0000000..d73047d --- /dev/null +++ b/rig.yaml @@ -0,0 +1,70 @@ +# rig.yaml — declarative setup for this repo, applied by `rig apply`. +# COMMITTED BY DEFAULT: this file is the reproducible source of truth. +# Global defaults live at ~/.config/rig/config.yaml; this file overrides them. +# See: rig status (drift), rig apply (converge). Schema: docs/config-schema.md + +version: 1 +scope: both +defaults: + skills_target: ~/.agents/skills + hooks_target: ~/.claude/hooks + ci_target: .github/workflows + mcp_target: ~/.claude/mcp + on_conflict: backup +skills: + enabled: true + target: ~/.agents/skills + universal: + all: true + disable: [] + by_type: + enable: + - cli +agent_hooks: + enabled: true + target: ~/.claude/hooks + target_kind: claude-code + all: true +git_hooks: + dispatcher: + enabled: true + dir: ~/.config/git/global-hooks.d + runner: ~/.config/git/run-global-hooks + set_global_hooks_path: true + install_local_retrofit_script: true + fragments: + secret-scan: + enabled: true +ci: + enabled: true + target: .github/workflows + all: false + items: + secret-scan: + enabled: true + tier: block + codeql: + enabled: true + tier: block + variant: selfgate + dependency-review: + enabled: true + tier: block + leftover-grep: + enabled: true + tier: block + review-threads: + enabled: true + tier: block + ship: + enabled: true + install_to: ~/bin + gh_alias: true +mcp: + enabled: true + target: ~/.claude/mcp + items: {} +harness: + enabled: true + kind: claude-code + auto_mode: true From ead791ee71567166aae74d2f330df892a6e677a8 Mon Sep 17 00:00:00 2001 From: Alex Ultra Date: Mon, 15 Jun 2026 12:55:35 +0200 Subject: [PATCH 2/4] docs(agents): drop now-self-advertised generic rules Remove generic engineering rules from AGENTS.md that the rig-installed agent-tools skills now self-advertise (atomic-commits, ai-review-before-commit, pre-commit-gate, push-regularly, no-type-escape-hatches). Collapse the "Commit discipline" section to only the 3d-cli-specific overrides: direct-to-main workflow, the project's review model roster, and the Co-Authored-By trailer. Drop the standalone "Zero warnings" bullet (now covered by pre-commit-gate + no-type-escape-hatches); keep the project's exact lint command folded into the Typed bullet. Conservative pass: all 3d-cli specifics kept (bin/3d dispatcher contract, self-registering command modules, pyrun/venv tiers, OpenSCAD/mesh/render pipeline, 3d test gate, mypy.ini bindings, bootstrap marker, help/docs sync paths, fit-camera proof requirements, trusted tg policy). No survivors reworded; CLAUDE.md symlink and internal refs intact. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 85 ++++++++++++------------------------------------------- 1 file changed, 18 insertions(+), 67 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e999faa..20e9815 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,13 +175,8 @@ or delete the worktree. ``` Third-party libs without stubs (trimesh, manifold3d, open3d, cv2, scipy, pyvista) are handled by `mypy.ini` (`ignore_missing_imports` per module) — never a blanket - `ignore_errors`, which would fake "clean". -- **Zero warnings.** Both `mypy` and `ruff` must run clean with **no warnings** before any - commit. Treat every linter warning as an error — fix it immediately. Do not add blanket - ignores or noqa markers to silence real issues. Run the full lint gate: - ```bash - uv run ruff check lib/ tests/ && uv run mypy lib/ tests/ - ``` + `ignore_errors`, which would fake "clean". The full lint gate is `uv run ruff check + lib/ tests/ && uv run mypy lib/ tests/` (also run by `3d test`). - **Async where it genuinely helps.** Independent OpenSCAD renders (multi-angle batches, fit-camera candidate evals, match-loop candidate evals) run concurrently via `asyncio` + `asyncio.create_subprocess_exec` / `gather`, bounded by a semaphore (~`os.cpu_count()`). @@ -211,66 +206,22 @@ if offline** (must never block `render`/`help`). `OPENSCADPATH` is auto-exported `libs/` by `cli/env.export_openscadpath()` (called in the dispatcher before any subprocess) so `include ` resolves with no manual step. -## Commit discipline (mandatory, every change) - -1. **Atomic commits** — one logical change each. Message form: `: ` - (e.g. `render: compute --view camera from bounding box`). No "update"/"fix" vagueness. -2. **Before every commit** run multi-model review in parallel, then iterate. Use the - shared read-only review runner as `review` on `$PATH`; install/update it from - `https://github.com/alex-mextner/review-cli` when missing: - ```bash - review -m codex -m gemini -m oc:fireworks/accounts/fireworks/routers/kimi-k2p6-turbo - ``` - Equivalent comma-separated form: - ```bash - review -m codex,gemini,oc:fireworks/accounts/fireworks/routers/kimi-k2p6-turbo - ``` - To force a narrower review question, pass an explicit prompt: - ```bash - review -m codex -m gemini -m oc:fireworks/accounts/fireworks/routers/kimi-k2p6-turbo --prompt "Review the current uncommitted diff for bugs, regressions, security issues, and missing tests. Return only actionable findings." - ``` - Optional extra reviewers run in addition to the baseline when available: - ```bash - review -m codex -m gemini -m oc:fireworks/accounts/fireworks/routers/kimi-k2p6-turbo -m claude-p -m oc:deepseek/deepseek-reasoner - ``` - Run this before staging. If changes are already staged, run the same command with - `--staged`; if both staged and unstaged changes exist, review both diffs separately. - `review` supports repeated or comma-separated `-m` values, runs reviewers in parallel, - and has a per-review timeout (`--timeout`, default 1200s). Backends are read-only by - design; keep the exact backend mechanics in the `review-cli` README - (`https://github.com/alex-mextner/review-cli`) rather than duplicating them here. Do - not print, paste into logs, or commit provider API keys. If `review` is unavailable, - install/update it from `https://github.com/alex-mextner/review-cli`, ensure it is on - `$PATH`, or fall back to equivalent direct read-only reviewer commands. The fallback - path must still attempt Codex plus the best available independent non-Codex reviewer: - ```bash - timeout 1200 codex exec review --uncommitted - gemini -p "Review the current uncommitted diff for bugs, regressions, security issues, and missing tests. Return only actionable findings." - # If Gemini CLI is unavailable or blocked by location/tier, use Gemini API directly: - git diff --no-ext-diff | timeout 300 uv run --with google-genai python -c 'import os, sys; from google import genai; diff = sys.stdin.read(); base = "Review the current uncommitted diff for bugs, regressions, security issues, and missing tests. Return only actionable findings."; print(genai.Client().models.generate_content(model=os.environ.get("GEMINI_MODEL", "gemini-2.5-flash"), contents=base + "\n\n" + diff).text)' - ``` - The staged/unstaged split applies to fallback commands too; use - `git diff --cached --no-ext-diff` for staged Gemini API fallback review. - Gemini CLI must already be configured. The direct Gemini API fallback requires - `GEMINI_API_KEY` or `GOOGLE_API_KEY` in the environment; never print either value. - The API fallback defaults to `gemini-2.5-flash` for parity with the current `review` - backend and speed; override with `GEMINI_MODEL` when a different Gemini model is - required. - READ every finding, fix the real issues, and run another review iteration after fixes. The minimum - acceptable pre-commit bar is Codex plus at least one independent non-Codex reviewer - from a different provider or model family; a second Codex run does not count. If a - configured reviewer is down, replace it with the best available independent model - first. Only when no independent non-Codex reviewer is available may the work fall below - that bar, and the provider-wide blocker must be recorded; do not silently treat - single-review work as fully reviewed. -3. **Push regularly — do NOT let work sit only on your local machine.** This project works - directly on `main` (that is where all history lives — no feature-branch dance). After each - commit or small batch, push: `git push origin main`. Pushing often means the work survives a - crash and is always visible. Never end a working session with unpushed commits — finish with - local `main` level with `origin/main`. -4. Don't mix unrelated changes in one commit. - -Co-Authored-By trailer on commits: `Co-Authored-By: Claude Opus 4.8 (1M context) ` +## Commit discipline (project specifics) + +General commit hygiene — atomic commits, AI review before commit, the pre-commit +lint/type/test gate, and pushing regularly — is self-advertised by the agent-tools +skills (`atomic-commits`, `ai-review-before-commit`, `pre-commit-gate`, +`push-regularly`). Only the 3d-cli-specific overrides live here: + +- **Direct-to-`main` workflow.** This project works directly on `main` — no + feature-branch dance; that is where all history lives. After each commit or small + batch, `git push origin main`. (This overrides the skills' default "push to a + feature branch, open a PR".) +- **Review model roster.** The pre-commit `review` runner's baseline for this repo is + `review -m codex -m gemini -m oc:fireworks/accounts/fireworks/routers/kimi-k2p6-turbo` + (install/update from `https://github.com/alex-mextner/review-cli`). +- **Co-Authored-By trailer** on commits: + `Co-Authored-By: Claude Opus 4.8 (1M context) ` ## Command surface (post-refactor) From 01c828f057bac4576ebed184c309b4022422b27a Mon Sep 17 00:00:00 2001 From: Alex Ultra Date: Mon, 15 Jun 2026 13:21:39 +0200 Subject: [PATCH 3/4] ci: make security gates degrade gracefully + harden own CI workflow Pull in the fixed agent-tools CI gate templates so the new gates stop hard-failing on a fresh private repo: - dependency-review: preflight probes the Dependency Graph and skips cleanly (with a notice + enable link) when off, instead of erroring with "Dependency review is not supported on this repository". Blocks normally once enabled. - secret-scan (gitleaks): pass GITHUB_TOKEN, now required to scan PRs. - codeql self-gate: skip a matrix language cleanly when the repo has no source for it. Suppress the two by-design actions findings in leftover-grep.yml (data-only PR-head fetch) with justified markers. Also fix the two real CodeQL actions findings in this repo's own ci.yml so the self-gate goes green legitimately (not by suppression): - add permissions: contents: read (least-privilege GITHUB_TOKEN). - pin astral-sh/setup-uv@v5 to its commit SHA (unpinned 3rd-party action). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 6 ++- .github/workflows/codeql.yml | 66 ++++++++++++++++++++++--- .github/workflows/dependency-review.yml | 38 ++++++++++++-- .github/workflows/leftover-grep.yml | 10 ++++ .github/workflows/secret-scan.yml | 5 ++ 5 files changed, 113 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f79551..c1f587d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [main] +# Least-privilege default token (CodeQL actions/missing-workflow-permissions). +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest @@ -16,7 +20,7 @@ jobs: with: python-version: "3.11" - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 with: version: "latest" - name: Install system deps diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 87fa3e0..6541d81 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -22,6 +22,16 @@ # # LANGUAGES / PINNING: same as ci/codeql/workflow.yml — edit the matrix; codeql-action is # pinned by major tag per GitHub convention. +# +# LANGUAGE NOT PRESENT IN THE REPO (resilience): the matrix ships with a broad default set +# (javascript-typescript + actions). If a repo has NO source for a matrix language (e.g. a +# Python-only CLI has zero JS/TS), CodeQL's buildless extractor errors with "No source code +# seen" (exit 32) and the analyze step HARD-FAILS — which would block the gate on a repo it +# was never meant to scan. To stay resilient, a preflight step detects whether the repo +# actually contains source for the matrix language and SKIPS analysis cleanly when it does +# not (a clear notice, job stays green). Where source IS present the gate runs and BLOCKS +# normally — nothing is silently disabled. Trim the matrix to your real stack to skip the +# probe entirely. name: CodeQL Self-Gate @@ -66,7 +76,42 @@ jobs: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Detect source for this language + id: detect + shell: bash + env: + LANGUAGE: ${{ matrix.language }} + run: | + set -uo pipefail + # Does the repo actually contain source for this matrix language? If not, CodeQL's + # buildless extractor would fatally error ("No source code seen", exit 32). We probe + # the tracked files up front and skip cleanly for an absent language instead — so the + # broad default matrix doesn't hard-fail on a repo that simply isn't that language. + # Where source IS present this is a no-op and the real gate runs below. + case "$LANGUAGE" in + javascript-typescript) + pattern='\.(js|jsx|mjs|cjs|ts|tsx|mts|cts|vue|svelte)$' ;; + python) + pattern='\.py$' ;; + actions) + # CodeQL's "actions" language analyzes workflow files under .github/workflows. + pattern='^\.github/workflows/.*\.(yml|yaml)$' ;; + *) + # Unknown/compiled language — don't guess; let CodeQL decide (no skip). + echo "has_source=true" >> "$GITHUB_OUTPUT" + echo "No presence heuristic for '$LANGUAGE'; running analysis unconditionally." + exit 0 ;; + esac + if git ls-files | grep -qiE "$pattern"; then + echo "has_source=true" >> "$GITHUB_OUTPUT" + echo "Found ${LANGUAGE} source — running CodeQL." + else + echo "has_source=false" >> "$GITHUB_OUTPUT" + echo "::notice title=CodeQL ${LANGUAGE} skipped::No ${LANGUAGE} source found in this repo — skipping CodeQL for this language (nothing to scan). This is a clean skip, not a failure. Remove '${LANGUAGE}' from the matrix to drop this notice, or add source to enable the gate." + fi + - name: Initialize CodeQL + if: steps.detect.outputs.has_source == 'true' uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} @@ -74,6 +119,7 @@ jobs: queries: security-extended - name: Perform CodeQL Analysis + if: steps.detect.outputs.has_source == 'true' uses: github/codeql-action/analyze@v4 with: category: '/language:${{ matrix.language }}' @@ -83,8 +129,8 @@ jobs: - name: Upload SARIF artifact # Publish the raw SARIF even on a gate failure, so it's downloadable - # (`gh run download`) for triage. - if: always() + # (`gh run download`) for triage. Skipped when the language wasn't analyzed. + if: always() && steps.detect.outputs.has_source == 'true' uses: actions/upload-artifact@v4 with: name: codeql-sarif-${{ matrix.language }} @@ -93,7 +139,8 @@ jobs: retention-days: 30 - name: Gate on SARIF findings - if: always() + # Skip entirely when the language is absent (handled by the detect step above). + if: always() && steps.detect.outputs.has_source == 'true' shell: bash env: LANGUAGE: ${{ matrix.language }} @@ -116,15 +163,20 @@ jobs: } # is_source_suppressed -> rc 0 if suppressed. - # Honors `// codeql[]` on the flagged line or the line directly above. + # Honors `codeql[]` on the flagged line or the line directly above, in + # either comment style: `// codeql[...]` (JS/TS) or `# codeql[...]` (YAML for the + # `actions` language, Python, shell). The marker is matched as a regex so both + # styles work without separate passes; keeps suppressions greppable + reviewable. is_source_suppressed() { - local rid="$1" src="$2" line="$3" above tag="// codeql[$1]" + local rid="$1" src="$2" line="$3" above + # Match `//` or `#` (with optional space), then `codeql[]`. + local re="(//|#)[[:space:]]*codeql\[$(printf '%s' "$rid" | sed 's/[][\.*^$/]/\\&/g')\]" [ -f "$src" ] || return 1 [ "$line" -gt 0 ] 2>/dev/null || return 1 - sed -n "${line}p" "$src" 2>/dev/null | grep -qF "$tag" && return 0 + sed -n "${line}p" "$src" 2>/dev/null | grep -qE "$re" && return 0 if [ "$line" -gt 1 ]; then above=$((line - 1)) - sed -n "${above}p" "$src" 2>/dev/null | grep -qF "$tag" && return 0 + sed -n "${above}p" "$src" 2>/dev/null | grep -qE "$re" && return 0 fi return 1 } diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index f4f9e96..7f25725 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -6,10 +6,19 @@ # license is not allowed. This is the PR-time, "don't let it IN" complement to the audit # scanners in ../sast/ and ../secret-scan/ (which scan what's already there). # -# AVAILABILITY: dependency-review needs the GitHub Dependency Graph. It's ON for PUBLIC -# repos; for PRIVATE repos it requires GitHub Advanced Security (Settings -> Code security -# -> Dependency graph). Without it, this action errors — use `bun audit` / `npm audit` / -# `pip-audit` / `cargo audit` in a plain CI step instead (see README). +# AVAILABILITY (READ THIS): dependency-review needs the GitHub Dependency Graph. It's ON +# for PUBLIC repos; for PRIVATE repos it must be enabled (Settings -> Code security -> +# Dependency graph). The graph itself is FREE — it does NOT require GitHub Advanced +# Security (GHAS is only needed for the Code Scanning *dashboard*, not the graph). On a +# private repo where it is still off, the action HARD-FAILS with "Dependency review is not +# supported on this repository", which would block every PR on a fresh repo. +# +# This workflow DEGRADES GRACEFULLY: a preflight step probes the Dependency Graph (the +# SBOM endpoint 200s when enabled, 404s when not). The gate runs and BLOCKS only when the +# graph is available; when it is OFF, the job SKIPS the gate with a clear notice and the +# enable path — it does NOT hard-fail. The moment you enable the graph the gate goes live +# automatically, no edit here. For coverage on graph-less repos in the meantime, run +# ci/dependency-review/dep-audit.sh in a plain CI step (see README). # # INSTALL: copy to .github/workflows/dependency-review.yml. # @@ -37,7 +46,28 @@ jobs: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Preflight — is the Dependency Graph enabled? + id: preflight + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -uo pipefail + # The SBOM endpoint returns 200 (an SPDX document) when the Dependency Graph is + # enabled for this repo, and 404 when it is not. We use that as the support probe + # so the gate only runs where dependency-review-action can actually work — instead + # of letting the action hard-fail on an unsupported repo. + code="$(gh api "repos/${GITHUB_REPOSITORY}/dependency-graph/sbom" \ + --silent -i 2>/dev/null | awk 'NR==1{print $2}')" + if [ "${code:-}" = "200" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "Dependency Graph is ENABLED — running the dependency-review gate." + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::notice title=dependency-review skipped::Dependency Graph is not enabled on ${GITHUB_REPOSITORY} (SBOM probe returned HTTP ${code:-none}). The dependency-review gate is SKIPPED, not failed. Enable it at https://github.com/${GITHUB_REPOSITORY}/settings/security_analysis (free; no GHAS needed) and this gate goes live automatically. Until then, audit existing deps with ci/dependency-review/dep-audit.sh." + fi + - name: Dependency review + if: steps.preflight.outputs.enabled == 'true' uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: # Fail if a newly added dependency has a vuln at/above this severity. diff --git a/.github/workflows/leftover-grep.yml b/.github/workflows/leftover-grep.yml index 80cb6ff..84cc6ef 100644 --- a/.github/workflows/leftover-grep.yml +++ b/.github/workflows/leftover-grep.yml @@ -45,6 +45,12 @@ jobs: with: fetch-depth: 0 + # codeql[actions/untrusted-checkout/medium] + # Justified: under pull_request_target this fetches the PR head as a git OBJECT only — + # it is never checked out and never executed (see the HARD RULE in the header: no + # build/test/install step). The PR content is read as DATA by `git diff`/grep. The + # CodeQL `actions` self-gate flags any PR-head fetch in a privileged trigger; this one + # is safe by construction, so it is suppressed greppably rather than left to block. - name: Fetch the PR head commit as DATA (not checked out / not executed) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -54,6 +60,10 @@ jobs: # NOT check it out and NOT run anything from it — it's only diffed + grepped. git fetch --no-tags --depth=1 origin "$HEAD_SHA" + # codeql[actions/cache-poisoning/poisonable-step] + # Justified: same as above — this step only runs the trusted BASE-branch gate script + # (`git diff` + grep over PR content as data). It executes no PR-controlled code and + # writes no cache, so it cannot poison a cache. Suppressed greppably, not left to block. - name: Scan added lines for leftovers env: LEFTOVER_BASE: origin/${{ github.event.pull_request.base.ref || github.event.repository.default_branch }} diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index dea78b3..6cd3a47 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -44,6 +44,11 @@ jobs: - name: gitleaks uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 env: + # REQUIRED for `pull_request` runs: gitleaks-action v2+ refuses to scan a PR + # without GITHUB_TOKEN ("🛑 GITHUB_TOKEN is now required to scan pull requests"). + # The automatically-provisioned token is enough — no PAT, no secret to create. + # (On `push` events it is unused, but always passing it keeps both triggers green.) + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Optional: a checked-in org/repo config. Defaults to repo-root .gitleaks.toml. # GITLEAKS_CONFIG: .gitleaks.toml # From 6e9f06aa065f1f1cdf89ac837c8dcb332b0b4868 Mon Sep 17 00:00:00 2001 From: Alex Ultra Date: Mon, 15 Jun 2026 13:24:02 +0200 Subject: [PATCH 4/4] ci(codeql): match suppression marker anywhere in the comment block above a finding Same fix as the template: widen is_source_suppressed to scan the contiguous comment block above a flagged line so the justified # codeql[...] markers in leftover-grep.yml actually suppress the by-design actions findings. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/codeql.yml | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6541d81..917cbe3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -163,21 +163,31 @@ jobs: } # is_source_suppressed -> rc 0 if suppressed. - # Honors `codeql[]` on the flagged line or the line directly above, in - # either comment style: `// codeql[...]` (JS/TS) or `# codeql[...]` (YAML for the - # `actions` language, Python, shell). The marker is matched as a regex so both - # styles work without separate passes; keeps suppressions greppable + reviewable. + # Honors a `codeql[]` marker on the flagged line OR in the contiguous block + # of comment lines directly above it (so a multi-line justification works — the + # marker need only be somewhere in the attached comment block, not on one exact + # line). Both comment styles match: `// codeql[...]` (JS/TS) or `# codeql[...]` + # (YAML for the `actions` language, Python, shell). The scan walks upward and stops + # at the first non-comment / blank line so a marker can't leak across steps. Keeps + # suppressions greppable + reviewable — not a silent baseline. is_source_suppressed() { - local rid="$1" src="$2" line="$3" above - # Match `//` or `#` (with optional space), then `codeql[]`. + local rid="$1" src="$2" line="$3" n txt + # Match `//` or `#` (with optional indent), then `codeql[]`. local re="(//|#)[[:space:]]*codeql\[$(printf '%s' "$rid" | sed 's/[][\.*^$/]/\\&/g')\]" + # Any line whose first non-space char is `#` or `//` counts as a comment line. + local comment_re='^[[:space:]]*(#|//)' [ -f "$src" ] || return 1 [ "$line" -gt 0 ] 2>/dev/null || return 1 + # Flagged line itself. sed -n "${line}p" "$src" 2>/dev/null | grep -qE "$re" && return 0 - if [ "$line" -gt 1 ]; then - above=$((line - 1)) - sed -n "${above}p" "$src" 2>/dev/null | grep -qE "$re" && return 0 - fi + # Walk upward through the contiguous comment block directly above the flagged line. + n=$((line - 1)) + while [ "$n" -ge 1 ]; do + txt="$(sed -n "${n}p" "$src" 2>/dev/null)" + printf '%s' "$txt" | grep -qE "$comment_re" || break # stop at first non-comment + printf '%s' "$txt" | grep -qE "$re" && return 0 + n=$((n - 1)) + done return 1 }