mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
ci: centralize path-gating behind single orchestrator + all-checks-pass gate Replace the scattered per-workflow detect-changes pattern with a single ci.yml orchestrator that runs the classifier once, then conditionally calls sub-workflows via workflow_call based on lane outputs. A final all-checks-pass job (if: always()) aggregates all results so branch protection only needs to require one check. Changes: - New .github/workflows/ci.yml orchestrator (detect + conditional calls + all-checks-pass gate) - Extend classify_changes.py with scan/deps/mcp_catalog lanes, absorbing supply-chain-audit's internal changes job - Update detect-changes/action.yml to expose the new lane outputs - Convert all 10 PR-gated sub-workflows to workflow_call-only triggers, removing their push/pull_request triggers and per-step detect-changes guards (gating now happens at the orchestrator level) - lint.yml + supply-chain-audit.yml receive event_name as a workflow_call input to replace github.event_name (which is "workflow_call" inside called workflows) - supply-chain-audit.yml: remove internal changes job + *-gate jobs (orchestrator handles gating, booleans arrive as inputs) - contributor-check.yml: remove internal filter step - Update test_classify_changes.py for 6-lane output + new supply-chain test cases
264 lines
10 KiB
YAML
264 lines
10 KiB
YAML
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`` /
|
|
# ``mcp_catalog`` booleans as inputs; this workflow's jobs gate on those
|
|
# inputs instead of re-computing the diff.
|
|
|
|
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
|
|
mcp_catalog:
|
|
description: Whether the MCP catalog / installer changed.
|
|
type: boolean
|
|
required: true
|
|
|
|
permissions:
|
|
pull-requests: write
|
|
contents: read
|
|
|
|
jobs:
|
|
scan:
|
|
name: Scan PR for critical supply chain risks
|
|
if: inputs.scan
|
|
runs-on: ubuntu-latest
|
|
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: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
BASE="${{ github.event.pull_request.base.sha }}"
|
|
HEAD="${{ github.event.pull_request.head.sha }}"
|
|
|
|
# Added lines only, excluding lockfiles.
|
|
# Three-dot 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 --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 --name-only "$BASE"..."$HEAD" | grep -E '^(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
|
|
if [ -n "$SETUP_HITS" ]; 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: Post critical finding comment
|
|
if: steps.scan.outputs.found == 'true'
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
BODY="## 🚨 CRITICAL Supply Chain Risk Detected
|
|
|
|
This PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging.
|
|
|
|
$(cat /tmp/findings.md)
|
|
|
|
---
|
|
*Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting.*"
|
|
|
|
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
|
|
|
|
- name: Fail on critical findings
|
|
if: steps.scan.outputs.found == 'true'
|
|
run: |
|
|
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
|
|
exit 1
|
|
|
|
dep-bounds:
|
|
name: Check PyPI dependency upper bounds
|
|
if: inputs.deps
|
|
runs-on: ubuntu-latest
|
|
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 >= but 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: Post unbounded dep warning
|
|
if: steps.bounds.outputs.found == 'true'
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
BODY="## ⚠️ Unbounded PyPI Dependency Detected
|
|
|
|
This PR adds PyPI dependencies without a \`<next_major\` upper bound. Per our [supply chain policy](../blob/main/CONTRIBUTING.md#dependency-pinning-policy-supply-chain-hardening), all PyPI deps must be pinned as \`>=floor,<next_major\`.
|
|
|
|
**Unbounded specs found:**
|
|
\`\`\`
|
|
$(cat /tmp/unbounded.txt)
|
|
\`\`\`
|
|
|
|
**Fix:** Add an upper bound, e.g. \`"package>=1.2.0,<2"\`
|
|
|
|
---
|
|
*See PR #2810 and CONTRIBUTING.md for the full policy rationale.*"
|
|
|
|
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
|
|
|
|
- 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
|
|
|
|
mcp-catalog-review:
|
|
name: MCP catalog security review
|
|
if: inputs.mcp_catalog
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Require explicit MCP catalog review label
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
PR="${{ github.event.pull_request.number }}"
|
|
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
|
|
if echo "$LABELS" | grep -Fxq 'mcp-catalog-reviewed'; then
|
|
echo "MCP catalog review label present."
|
|
exit 0
|
|
fi
|
|
|
|
BODY="## ⚠️ MCP catalog security review required
|
|
|
|
This PR changes the bundled MCP catalog or MCP catalog installer code. MCP entries can define local commands that users later install into \`mcp_servers\`, so this needs explicit maintainer review before merge.
|
|
|
|
A maintainer should verify:
|
|
- any new/changed \`optional-mcps/**/manifest.yaml\` command and args are expected,
|
|
- stdio transports do not use shell+egress/exfiltration payloads,
|
|
- git install refs are pinned and bootstrap commands are minimal,
|
|
- requested env vars/secrets match the upstream MCP's documented needs.
|
|
|
|
After review, add the \`mcp-catalog-reviewed\` label and re-run this check."
|
|
|
|
gh pr comment "$PR" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
|
|
echo "::error::MCP catalog changes require the mcp-catalog-reviewed label."
|
|
exit 1
|