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 `<next_major` upper bound, e.g. `"package>=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 <next_major ceiling per CONTRIBUTING.md policy."
          exit 1

  aggregate:
    name: Aggregate review statuses
    needs: [scan, dep-bounds]
    if: always()
    runs-on: ubuntu-latest
    timeout-minutes: 15
    outputs:
      review_status: ${{ steps.merge.outputs.review_status }}
      critical_findings: ${{ steps.merge.outputs.critical_findings }}
    steps:
      - name: Merge review statuses
        id: merge
        env:
          SCAN_STATUS: ${{ needs.scan.outputs.review_status }}
          DEP_STATUS: ${{ needs.dep-bounds.outputs.review_status }}
          CRITICAL_FINDINGS: ${{ needs.scan.outputs.critical_findings }}
        run: |
          python3 - <<'PYEOF'
          import json, os

          merged = []
          for key in ("SCAN_STATUS", "DEP_STATUS"):
              raw = os.environ.get(key, "")
              if not raw:
                  continue
              try:
                  data = json.loads(raw)
              except (json.JSONDecodeError, TypeError):
                  continue
              if isinstance(data, list):
                  merged.extend(data)

          with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
              f.write(f"review_status={json.dumps(merged)}\n")
              f.write("critical_findings=" + os.environ.get("CRITICAL_FINDINGS", "false") + "\n")
          PYEOF
