name: Supply Chain Audit # Narrow, high-signal scanner. Only fires on critical indicators of supply # chain attacks (e.g. the litellm-style payloads). Low-signal heuristics # (plain base64, plain exec/eval, dependency/Dockerfile/workflow edits, # Actions version unpinning, outbound POST/PUT) were intentionally # removed — they fired on nearly every PR and trained reviewers to ignore # the scanner. Keep this file's checks ruthlessly narrow: if you find # yourself adding WARNING-tier patterns here again, make a separate # advisory-only workflow instead. # # Path-gating is handled centrally by the ``ci.yml`` orchestrator's # ``detect`` job. The orchestrator passes ``scan`` / ``deps`` booleans as # inputs; this workflow's jobs gate on those inputs instead of re-computing # the diff. MCP catalog review was previously here but has moved to # ``review-labels.yml`` so it can be rerun independently. # # Outputs: # review_status — JSON array of status objects consumed by the review # comment assembler (scripts/ci/assemble_review_comment.py). # critical_findings — "true" when the narrow critical-pattern scan found # something. The review-label gate consumes this and # owns the action-required result, so adding # ``ci-reviewed`` can heal the run on rerun. on: workflow_call: inputs: event_name: description: The event name from the calling orchestrator. type: string required: true scan: description: Whether supply-chain-relevant files changed. type: boolean required: true deps: description: Whether pyproject.toml changed. type: boolean required: true outputs: review_status: description: JSON array of review status objects for the review comment assembler. value: ${{ jobs.aggregate.outputs.review_status }} critical_findings: description: Whether the critical-pattern scan found a risk requiring maintainer review. value: ${{ jobs.aggregate.outputs.critical_findings }} permissions: pull-requests: write contents: read jobs: scan: name: Scan PR for critical supply chain risks if: inputs.scan runs-on: ubuntu-latest timeout-minutes: 15 outputs: review_status: ${{ steps.emit-status.outputs.review_status }} critical_findings: ${{ steps.scan.outputs.found }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Scan diff for critical patterns id: scan env: GH_TOKEN: ${{ github.token }} CI_REVIEWED: ${{ contains(github.event.pull_request.labels.*.name, 'ci-reviewed') }} run: | set -euo pipefail BASE="${{ github.event.pull_request.base.sha }}" HEAD="${{ github.event.pull_request.head.sha }}" # Added lines only, excluding lockfiles. # Three-point diff (base...head) diffs from the merge base to HEAD, # so only changes introduced by this PR are included — not changes # that landed on main after the PR branched off. DIFF=$(git diff "$BASE"..."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true) FINDINGS="" # --- .pth files (auto-execute on Python startup) --- # The exact mechanism used in the litellm supply chain attack: # https://github.com/BerriAI/litellm/issues/24512 PTH_FILES=$(git diff --diff-filter=d --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true) if [ -n "$PTH_FILES" ]; then FINDINGS="${FINDINGS} ### 🚨 CRITICAL: .pth file added or modified Python \`.pth\` files in \`site-packages/\` execute automatically when the interpreter starts — no import required. **Files:** \`\`\` ${PTH_FILES} \`\`\` " fi # --- base64 decode + exec/eval on the same line (the litellm attack pattern) --- B64_EXEC_HITS=$(echo "$DIFF" | grep -n '^+' | grep -iE 'base64\.(b64decode|decodebytes|urlsafe_b64decode)' | grep -iE 'exec\(|eval\(' | head -10 || true) if [ -n "$B64_EXEC_HITS" ]; then FINDINGS="${FINDINGS} ### 🚨 CRITICAL: base64 decode + exec/eval combo Base64-decoded strings passed directly to exec/eval — the signature of hidden credential-stealing payloads. **Matches:** \`\`\` ${B64_EXEC_HITS} \`\`\` " fi # --- subprocess with encoded/obfuscated command argument --- PROC_HITS=$(echo "$DIFF" | grep -n '^+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|\\x[0-9a-f]{2}|chr\(' | head -10 || true) if [ -n "$PROC_HITS" ]; then FINDINGS="${FINDINGS} ### 🚨 CRITICAL: subprocess with encoded/obfuscated command Subprocess calls whose command strings are base64- or hex-encoded are a strong indicator of payload execution. **Matches:** \`\`\` ${PROC_HITS} \`\`\` " fi # --- Install-hook files (setup.py/sitecustomize/usercustomize/__init__.pth) --- # These execute during pip install or interpreter startup. # Anchored at repo root: only the top-level setup.py/setup.cfg run during # `pip install`, and only top-level sitecustomize.py/usercustomize.py are # auto-loaded by the interpreter via site.py. Any nested file with the # same name (e.g. hermes_cli/setup.py — the CLI setup wizard) is unrelated # and produced false positives that trained reviewers to ignore the scanner. SETUP_HITS=$(git diff --diff-filter=d --name-only "$BASE"..."$HEAD" | grep -E '^(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true) # A maintainer-applied ci-reviewed label records the manual review # required for intentional changes to an install hook. The scanner # still blocks every unreviewed addition or modification. if [ -n "$SETUP_HITS" ] && [ "$CI_REVIEWED" != "true" ]; then FINDINGS="${FINDINGS} ### 🚨 CRITICAL: Install-hook file added or modified These files can execute code during package installation or interpreter startup. **Files:** \`\`\` ${SETUP_HITS} \`\`\` " fi if [ -n "$FINDINGS" ]; then echo "found=true" >> "$GITHUB_OUTPUT" echo "$FINDINGS" > /tmp/findings.md else echo "found=false" >> "$GITHUB_OUTPUT" fi - name: Emit review_status id: emit-status if: always() env: FOUND: ${{ steps.scan.outputs.found }} run: | python3 - <<'PYEOF' import json, os # The review-label gate renders and blocks critical findings. Keep # this scan a fact-finder so adding ci-reviewed can rerun the gate # without requiring the scanner itself to fail again. status = [] with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: f.write(f"review_status={json.dumps(status)}\n") PYEOF dep-bounds: name: Check PyPI dependency upper bounds if: inputs.deps runs-on: ubuntu-latest timeout-minutes: 15 outputs: review_status: ${{ steps.emit-status.outputs.review_status }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Check for unbounded PyPI deps id: bounds run: | set -euo pipefail BASE="${{ github.event.pull_request.base.sha }}" HEAD="${{ github.event.pull_request.head.sha }}" # Only check added lines in pyproject.toml ADDED=$(git diff "$BASE"..."$HEAD" -- pyproject.toml | grep '^+' | grep -v '^+++' || true) if [ -z "$ADDED" ]; then echo "found=false" >> "$GITHUB_OUTPUT" exit 0 fi # Match PyPI dep specs that have >= and no < ceiling. # Pattern: "package>=version" without a following ",<" bound. # Excludes git+ URLs (which use commit SHAs) and comments. UNBOUNDED=$(echo "$ADDED" | grep -oE '"[a-zA-Z0-9_-]+(\[[^\]]*\])?>=[ 0-9.]+"' | grep -v ',<' || true) if [ -n "$UNBOUNDED" ]; then echo "found=true" >> "$GITHUB_OUTPUT" echo "$UNBOUNDED" > /tmp/unbounded.txt else echo "found=false" >> "$GITHUB_OUTPUT" fi - name: Emit review_status id: emit-status if: always() env: FOUND: ${{ steps.bounds.outputs.found }} run: | python3 - <<'PYEOF' import json, os found = os.environ.get("FOUND", "") == "true" if found: with open("/tmp/unbounded.txt", encoding="utf-8") as f: detail = f.read() status = [{ "source": "supply chain", "results": [{ "kind": "action_required", "title": "Unbounded PyPI dependencies", "summary": "This PR adds PyPI dependencies without upper bounds.", "detail": detail, "how_to_fix": 'Add a `=1.2.0,<2"`. See CONTRIBUTING.md dependency pinning policy.' }] }] else: status = [] with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: f.write(f"review_status={json.dumps(status)}\n") PYEOF - name: Fail on unbounded deps if: steps.bounds.outputs.found == 'true' run: | echo "::error::PyPI dependencies without upper bounds detected. Add