name: Security Scan # The single deterministic security scan for a PR. Runs ONCE per PR and produces # the `Security Scan` check; the per-workflow gate jobs (security-gate.yml) don't # re-scan, they poll THIS check and mirror its result, so the work happens once # while still gating every CI workflow. # # It only STATICALLY analyses the diff/head (semgrep, grep, diff-read) with NO # secrets on fork PRs, so it never executes untrusted code. The scanner is always # checked out from `main` and the scanned code sits in a separate `pr/` dir, so a # PR can't edit its own scan. # # Blocking: any detector that finds something fails this check; the per-workflow # pollers mirror the failure and skip the dependent CI jobs (no PR-code checkout # / uv sync / test). Detectors run fail-fast -- the first finding fails the job, # so a clean PR must pass every one. # # Trust tiers (should-scan.sh): trusted (OWNER/MEMBER/COLLABORATOR, or an author # in the MAINTAINERS list -- covers maintainers with private org membership) and # non-PR events aren't scanned; returning contributors are; first-timers are held # by GitHub's native fork-approval gate first. on: pull_request: # labeled/unlabeled so applying or removing the skip label # (skip-security-scan) re-runs the scan and flips this check. The waiver is # label-only (should-scan.sh): applying it needs Triage permission, so the # label alone is the maintainer gate -- no separate approval, hence no # pull_request_review trigger. types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] permissions: contents: read pull-requests: read # read PR labels for the skip waiver concurrency: group: security-scan-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: scan: name: Security Scan runs-on: ubuntu-latest timeout-minutes: 10 env: # Route uv at PyPI for the semgrep fetch. UV_INDEX_URL: https://pypi.org/simple steps: - name: Check out scanner from main uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: main # trusted; never the PR head sparse-checkout: | .github/scripts/security-scan .github/scripts/merge-ready .github/security persist-credentials: false - name: Load maintainers id: maintainers env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} run: bash .github/scripts/merge-ready/load-maintainers.sh - name: Trust gate id: gate env: EVENT_NAME: ${{ github.event_name }} AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }} # For the skip-security-scan label waiver + author check (read-only). GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR: ${{ github.event.pull_request.number }} MAINTAINERS: ${{ steps.maintainers.outputs.list }} run: | # Before this lands on main the scripts are absent there -- proceed # (fail-open) so the introducing PR is not bricked. if [ ! -f .github/scripts/security-scan/should-scan.sh ]; then echo "::warning::security scanner not present on main yet; proceeding without scan (bootstrap)." echo "scan=false" >> "$GITHUB_OUTPUT" echo "reason=scanner absent on main (bootstrap)" >> "$GITHUB_OUTPUT" exit 0 fi bash .github/scripts/security-scan/should-scan.sh - name: Fetch PR diff if: ${{ steps.gate.outputs.scan == 'true' }} env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR: ${{ github.event.pull_request.number }} run: | gh pr diff "$PR" --repo "$REPO" > "$GITHUB_WORKSPACE/pr.diff" gh pr diff "$PR" --repo "$REPO" --name-only > "$GITHUB_WORKSPACE/changed.txt" echo "Changed files:"; cat "$GITHUB_WORKSPACE/changed.txt" - name: Secret scan (added lines) if: ${{ steps.gate.outputs.scan == 'true' }} env: DIFF_FILE: ${{ github.workspace }}/pr.diff run: python3 .github/scripts/security-scan/secret-scan.py - name: Exfil scan (added lines) if: ${{ steps.gate.outputs.scan == 'true' }} env: DIFF_FILE: ${{ github.workspace }}/pr.diff run: python3 .github/scripts/security-scan/exfil-scan.py - name: Sensitive-path guard if: ${{ steps.gate.outputs.scan == 'true' }} env: CHANGED_FILES: ${{ github.workspace }}/changed.txt run: bash .github/scripts/security-scan/sensitive-paths.sh - name: Check out PR head for static analysis if: ${{ steps.gate.outputs.scan == 'true' }} uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.pull_request.head.sha }} # untrusted: only statically scanned path: pr persist-credentials: false - name: Workflow misuse lint if: ${{ steps.gate.outputs.scan == 'true' }} working-directory: pr env: CHANGED_FILES: ${{ github.workspace }}/changed.txt run: python3 "$GITHUB_WORKSPACE/.github/scripts/security-scan/lint-workflow-misuse.py" - name: Install uv if: ${{ steps.gate.outputs.scan == 'true' }} uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v3 - name: OSV advisory scan (uv.lock) # Checks every package version pinned in the PR's uv.lock against the # OSV advisory database, which covers known-malicious, typosquatted, # and CVE-flagged versions. Only fires when uv.lock is in the changeset # to avoid blocking PRs when main's baseline lockfile already has open # advisories on main. if: ${{ steps.gate.outputs.scan == 'true' }} working-directory: pr run: | if ! grep -qxF 'uv.lock' "$GITHUB_WORKSPACE/changed.txt"; then echo "uv.lock not changed; skipping OSV scan." exit 0 fi # Drop editable local packages (the project itself + sdks/*) before # auditing. pip-audit can't hash an editable path requirement and # errors out when one is present, so without this filter any PR that # actually changes uv.lock fails here. We only want to audit # third-party pinned packages anyway — OSV has no advisories for # local source. Filtering all `-e` lines (rather than naming each # workspace member) keeps this correct if members are added later. uv export --frozen --format requirements-txt --all-extras \ > /tmp/uv-req-full.txt grep -v '^-e ' /tmp/uv-req-full.txt > /tmp/uv-req.txt uvx pip-audit --requirement /tmp/uv-req.txt --no-deps - name: Semgrep (changed files, local rules) if: ${{ steps.gate.outputs.scan == 'true' }} env: RULES: ${{ github.workspace }}/.github/security/semgrep-rules.yml run: | # Scan only PR-changed files present in the head tree, so a # contributor is never failed for pre-existing findings. : > targets.txt while IFS= read -r f; do [ -n "$f" ] && [ -f "pr/$f" ] && printf 'pr/%s\n' "$f" >> targets.txt done < "$GITHUB_WORKSPACE/changed.txt" if [ ! -s targets.txt ]; then echo "No changed files to semgrep."; exit 0 fi echo "Semgrep targets:"; cat targets.txt # Informational pass (warnings never block). uvx semgrep scan --config "$RULES" --severity=WARNING \ --metrics=off --quiet $(cat targets.txt) || true # Gating pass: ERROR-severity rules fail the scan. uvx semgrep scan --config "$RULES" --severity=ERROR --error \ --metrics=off --quiet $(cat targets.txt) # Surfaced on ANY detector failure above (sensitive-path / secret / exfil # / workflow-misuse / semgrep): the detectors say WHAT they found; this # says HOW a maintainer can waive it. The waiver is label-only: applying # the 'skip-security-scan' label needs Triage permission, so the label is # itself the maintainer gate (see should-scan.sh). Applying it re-runs this # scan via the labeled trigger above. - name: Explain the maintainer waiver (on failure) if: ${{ failure() }} run: | MSG="A maintainer can skip the Security Scan by applying the 'skip-security-scan' label (this requires Triage permission, so a fork author cannot self-waive). Applying the label re-runs this scan automatically." echo "::error::$MSG" { echo "### Security Scan failed" echo echo "$MSG" } >> "$GITHUB_STEP_SUMMARY"