diff --git a/.changeset/feat-spectre-dep-doctor.md b/.changeset/feat-spectre-dep-doctor.md new file mode 100644 index 0000000..5a09afc --- /dev/null +++ b/.changeset/feat-spectre-dep-doctor.md @@ -0,0 +1,10 @@ +--- +"layne": major +--- + +Replace Pi Agent with Spectre and add Dep Doctor scanner. + +- **Spectre** replaces Pi Agent as the multi-provider LLM malicious-intent scanner. It makes a single direct LLM call per file (no agent session) and supports Anthropic, OpenAI, Google, Mistral, and Amazon Bedrock via `@mariozechner/pi-ai`. Configurable file cap, diff line cap, min severity, skip paths/extensions, and concurrency. +- **Dep Doctor** is a new dependency health scanner that fires when a lockfile changes. It detects newly-added packages with known CVEs (via OSV-Scanner), abandoned packages, and deprecated packages. Supports npm, PyPI, and Go lockfiles. +- PR comments now use GitHub alert blocks (`[!CAUTION]` / `[!WARNING]`) with a severity-sorted findings table linking directly to the affected file and line. The `{{findings}}` and `{{severitySummary}}` template variables are now available for custom templates. +- `Dockerfile` now installs `osv-scanner` alongside trufflehog and semgrep. diff --git a/CHANGELOG.md b/CHANGELOG.md index 801fd31..f559b46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,20 +26,6 @@ ### Minor Changes -- [#27](https://github.com/RocketChat/layne/pull/27) [`25248cc`](https://github.com/RocketChat/layne/commit/25248cca3658ecbf5aae3cdfd3eff0187af7cb3f) Thanks [@julio-rocketchat](https://github.com/julio-rocketchat)! - Adds a new diff_only mode and allows mode to be configured - -- [#35](https://github.com/RocketChat/layne/pull/35) [`23f1b32`](https://github.com/RocketChat/layne/commit/23f1b32cfb0092330d2fe467e7d6c45993bd7a83) Thanks [@julio-rocketchat](https://github.com/julio-rocketchat)! - Rewrites the codebase from JavaScript to TypeScript for improved type safety and developer experience. No behavioral changes; deployment, configuration schema, and all external interfaces are identical. - -- [#22](https://github.com/RocketChat/layne/pull/22) [`294d984`](https://github.com/RocketChat/layne/commit/294d9840a732154610e9be8141609a85732f4d63) Thanks [@julio-rocketchat](https://github.com/julio-rocketchat)! - Adds a new feature that allows exceptions to be approved by specific teams or people - -- [#29](https://github.com/RocketChat/layne/pull/29) [`2427573`](https://github.com/RocketChat/layne/commit/242757385abd1b45607b8b97ccf7987c4e0cd3e1) Thanks [@julio-rocketchat](https://github.com/julio-rocketchat)! - Makes timeouts configurable on global and per repo levels - -- [#34](https://github.com/RocketChat/layne/pull/34) [`8a4126f`](https://github.com/RocketChat/layne/commit/8a4126f07c33621b1b8c58cf57cb7707a61ac67b) Thanks [@julio-rocketchat](https://github.com/julio-rocketchat)! - Adds support for warnings for commenter as well as rule names - -## 1.2.0 - -### Minor Changes - - **Exception Approvals**: Configure specific users or teams who can approve PRs that would otherwise fail the security scan. When an authorized approver approves a PR, Layne automatically re-runs the scan and passes it with a clear audit trail. Features include: - Automatic re-run on `pull_request_review` webhook when authorized approver approves - Team membership resolution via GitHub API @@ -59,20 +45,6 @@ ### Minor Changes -- [#15](https://github.com/RocketChat/layne/pull/15) [`e000196`](https://github.com/RocketChat/layne/commit/e00019655f2e9f5ade9e9be07ff92a176fa93d93) Thanks [@julio-rocketchat](https://github.com/julio-rocketchat)! - Adds support for creating comments in the PRs - -- [#13](https://github.com/RocketChat/layne/pull/13) [`4c19ba0`](https://github.com/RocketChat/layne/commit/4c19ba0c4f758c90ab6fb1161f8999a97510865f) Thanks [@julio-rocketchat](https://github.com/julio-rocketchat)! - Adds a new suppressor feature to ignore findings with a "// SECURITY: XYZ" comment - -## 1.0.0 - -### Major Changes - -- [#6](https://github.com/RocketChat/layne/pull/6) [`a11a412`](https://github.com/RocketChat/layne/commit/a11a412371973a812e90a563152cf7ac6c0c7f43) Thanks [@julio-rocketchat](https://github.com/julio-rocketchat)! - Adds support for workflow jobs alongside workflow runs - -- [#8](https://github.com/RocketChat/layne/pull/8) [`62af89e`](https://github.com/RocketChat/layne/commit/62af89e72a98c597d8524e8d88bcf67c29954a16) Thanks [@julio-rocketchat](https://github.com/julio-rocketchat)! - Fixes a bug in which Layne ends up scanning files that are unrelated to the PR - -- [#10](https://github.com/RocketChat/layne/pull/10) [`cafaf75`](https://github.com/RocketChat/layne/commit/cafaf7584beb145977aa5ebcce7672273098fdee) Thanks [@julio-rocketchat](https://github.com/julio-rocketchat)! - Fixes an issue that wouldn't reschedule a Layne scan if there's an existing failed scan - - [`a88504d`](https://github.com/RocketChat/layne/commit/a88504d436bc0aafca94c94ee388560ca89c57c7) Thanks [@julio-rocketchat](https://github.com/julio-rocketchat)! - Changes the documentation to add security architecture and PR guidelines - [#4](https://github.com/RocketChat/layne/pull/4) [`f2de34f`](https://github.com/RocketChat/layne/commit/f2de34f83c26dd5738cc86b82573496f4e3c565f) Thanks [@julio-rocketchat](https://github.com/julio-rocketchat)! - Adds a new trigger for Layne: workflow_run diff --git a/CLAUDE.md b/CLAUDE.md index e890a67..13ac618 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -Layne is a self-hosted GitHub App that centralises security scanning across repositories. It receives `pull_request` webhooks, enqueues scan jobs via BullMQ/Redis, posts results back as GitHub Check Run annotations, manages PR labels, and sends chat notifications. It runs four scanners: Semgrep (SAST), Trufflehog (secret detection), Claude (malicious intent detection), and Pi Agent (agentic deep code review). +Layne is a self-hosted GitHub App that centralises security scanning across repositories. It receives `pull_request` webhooks, enqueues scan jobs via BullMQ/Redis, posts results back as GitHub Check Run annotations, manages PR labels, and sends chat notifications. It runs five scanners: Semgrep (SAST), Trufflehog (secret detection), Claude (malicious intent detection), Spectre (multi-provider LLM scanning), and Dep Doctor (dependency health). ## Commands @@ -52,7 +52,13 @@ Optional: ``` REDIS_URL # defaults to redis://localhost:6379 PORT # defaults to 3000 -ANTHROPIC_API_KEY # required when any repo has claude.enabled: true +ANTHROPIC_API_KEY # required when any repo has claude.enabled: true or spectre with anthropic provider +OPENAI_API_KEY # required when any repo uses spectre with openai provider +GEMINI_API_KEY # required when any repo uses spectre with google provider +MISTRAL_API_KEY # required when any repo uses spectre with mistral provider +AWS_ACCESS_KEY_ID # required when any repo uses spectre with bedrock provider +AWS_SECRET_ACCESS_KEY # required when any repo uses spectre with bedrock provider +AWS_REGION # required when any repo uses spectre with bedrock provider METRICS_ENABLED # set to "true" to enable Prometheus metrics METRICS_PORT # worker metrics server port, defaults to 9091 DOMAIN # used for Rocket.Chat icon_url and TLS @@ -108,8 +114,8 @@ Two separate Node.js processes: - `claude.ts` - calls the Anthropic API to detect malicious intent; **disabled by default**, opt in per repo; skips binary files; caps files at 50 KB; batches at 100 KB per API call; errors are caught and logged without failing the scan. Supports two modes (configured per-repo in `config/layne.json`): - **Prompt mode** (default): single `messages.create` call with a system prompt; use `claude.prompt` to override - **Skill mode**: uses the Anthropic [API Skills beta](https://platform.claude.com/docs/en/build-with-claude/skills-guide) - adds a `code_execution` tool + an uploaded skill to each batch call, enabling runtime decoding, registry lookups, and richer static analysis; set `claude.skill: { id, version }` to enable; handles `pause_turn` continuations automatically (up to 10 turns per batch) -- `pi-agent.ts` - agentic scanner using `@mariozechner/pi-coding-agent`; **disabled by default**, opt in per repo; runs a full agent session with confined read-only file tools (read, grep, find, ls) so the model can follow imports across file boundaries; configurable thinking level (off/minimal/low/medium/high/xhigh), timeout (default 3 min), and AI provider; supports Anthropic, OpenAI, Google, Mistral, and Amazon Bedrock; ruleId prefix `pi_agent/`; **note:** non-deterministic - the same code may produce different ruleIds or line numbers across runs, which can affect exception approval stability -- `pi-agent-tools.ts` - workspace-confined file exploration tools for Pi Agent; `createConfinedTools()` wraps read/grep/find/ls with path validation to prevent the agent from escaping the workspace via `confinePath()` +- `spectre.ts` - malicious intent scanner; **disabled by default**, opt in per repo; makes a single direct LLM call per file (no agent session, no tools, no import following); supports Anthropic, OpenAI, Google, Mistral, and Amazon Bedrock via `@mariozechner/pi-ai`; tier-prioritised file cap (manifest/CI files always scanned first); configurable per-repo skip paths/extensions, file cap, diff line cap, min severity, and concurrency; ruleId prefix `spectre/` +- `dep-doctor.ts` - dependency health scanner; **disabled by default**, opt in per repo; only fires when a lockfile is changed by the PR; diffs the changed lockfile against the merge-base version (via `git show`) to identify newly-added packages only; runs OSV-Scanner for CVE detection (ruleId `dep-doctor/`); checks npm/PyPI registry APIs for abandoned (ruleId `dep-doctor/abandoned`) and deprecated (ruleId `dep-doctor/deprecated`) packages; supported lockfiles: `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `requirements.txt`, `Pipfile.lock`, `poetry.lock`, `uv.lock`, `go.sum`; registry health checks for npm and PyPI only (Go skipped); errors caught and logged without failing the scan; requires `osv-scanner` in PATH - `helpers.ts` - shared adapter utility functions **Common finding shape:** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6fc942e..61d417e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,11 +8,13 @@ Thanks for your interest in contributing. This document covers the branch model, | Branch | Purpose | |---|---| -| `develop` | Default branch. All PRs and releases flow through here. | +| `develop` | Default branch. All PRs target here. | +| `main` | Releases only. Never commit directly. | **Release flow:** -1. As PRs merge to `develop`, the changeset bot opens and maintains a **"chore: release vX.Y.Z"** PR targeting `develop` -2. Merging that PR to `develop` creates a GitHub release automatically +1. As PRs merge to `develop`, the changeset bot opens and maintains a **"chore: release vX.Y.Z"** PR targeting `main` +2. Merging that PR to `main` creates a GitHub release automatically +3. A **"chore: sync main → develop"** PR is then opened automatically — merge it to keep `develop` up to date --- diff --git a/Dockerfile b/Dockerfile index 6e9592f..24ef4a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,21 +18,29 @@ RUN npm run build FROM node:22-alpine AS runtime # Pin tool versions for reproducible builds. Update periodically and verify in staging. -# trufflehog and semgrep are the only external binaries Layne shells out to. +# trufflehog, semgrep, and osv-scanner are the external binaries Layne shells out to. RUN apk add --no-cache \ git \ python3 \ py3-pip \ wget \ + ripgrep \ && python3 -m pip install --break-system-packages semgrep==1.154.0 \ && ARCH="$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')" \ && wget -qO- "https://github.com/trufflesecurity/trufflehog/releases/download/v3.93.7/trufflehog_3.93.7_linux_${ARCH}.tar.gz" \ | tar -xz -C /usr/local/bin trufflehog \ - && chmod +x /usr/local/bin/trufflehog + && chmod +x /usr/local/bin/trufflehog \ + && wget -qO /usr/local/bin/osv-scanner "https://github.com/google/osv-scanner/releases/download/v2.3.8/osv-scanner_linux_${ARCH}" \ + && chmod +x /usr/local/bin/osv-scanner # Run as a non-root user so a compromised container cannot write to the host. RUN addgroup -S layne && adduser -S layne -G layne +# Expose ripgrep at the path pi-coding-agent expects (~/.pi/agent/bin/rg). +RUN mkdir -p /home/layne/.pi/agent/bin \ + && ln -s /usr/bin/rg /home/layne/.pi/agent/bin/rg \ + && chown -R layne:layne /home/layne/.pi + WORKDIR /app COPY --from=deps /app/node_modules ./node_modules diff --git a/README.md b/README.md index 309d037..6e83870 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This tool was based on [Reddit's Implementation](https://web.archive.org/web/202 ``` ┌─────────────────────────────────┐ - │ GITHUB PULL REQUEST │ ◀──────────────────────┐ + │ GITHUB PULL REQUEST │◀────────────────────────┐ │ (OPEN, SYNC, REOPEN) │ │ └─────────────────────────────────┘ │ │ Check run │ @@ -29,32 +29,36 @@ This tool was based on [Reddit's Implementation](https://web.archive.org/web/202 │┌─────────────┐ │ Schedules job ┌────────────────┐ ┌────────────┐ │ │ ││ LAYNE │◀─┘ ─────────────────▶│ REDIS │───▶│ TRUFFLEHOG │──┐ │ │ ││ SERVER │ │ (BULLMQ) │ │ └────────────┘ │ │ │ -│└─────────────┘ └────────────────┘ │ ┌────────────┐ │ ┌──────────┐│ -│ │─▶│ SEMGREP │──┼─▶│ REPORTER ││ +│└─────────────┘ └────────────────┘ │ ┌────────────┐ │ │ │ +│ │─▶│ SEMGREP │──┤ │ │ +│ │ └────────────┘ │ │ │ +│ │ ┌────────────┐ │ ┌──────────┐│ +│ ├─▶│ CLAUDE │──┼─▶│ REPORTER ││ │ │ └────────────┘ │ └──────────┘│ │ │ ┌────────────┐ │ │ -│ ├─▶│ CLAUDE │──┤ │ +│ ├─▶│ SPECTRE │──┤ │ │ │ └────────────┘ │ │ │ │ ┌────────────┐ │ │ -│ └─▶│ PI AGENT │──┘ │ +│ └─▶│ DEP DOCTOR │──┘ │ │ └────────────┘ │ └──────────────────────────────────────────────────────────────────────────────────────────┘ ``` -When a PR is opened or updated - or after a workflow/job runs, depending on your configured trigger -, GitHub sends a webhook to Layne. The server immediately enqueues a scan job and returns `200 OK` to GitHub. A worker picks up the job, clones exactly the commit that triggered the event, hands the changed files off to each configured scanner (Semgrep, Trufflehog, Claude, Pi Agent), collects their findings, and posts the results as inline annotations on the Check Run. +When a PR is opened or updated - or after a workflow/job runs, depending on your configured trigger -, GitHub sends a webhook to Layne. The server immediately enqueues a scan job and returns `200 OK` to GitHub. A worker picks up the job, clones exactly the commit that triggered the event, hands the changed files off to each configured scanner (Semgrep, Trufflehog, Claude, Spectre, Dep Doctor), collects their findings, and posts the results as inline annotations on the Check Run. Only the files modified in the PR are passed to each scanner. Findings in files you did not touch are never reported. ### Scanners -Layne ships with four built-in scanners. You can enable, disable, or configure each one per repository in `config/layne.json`. +Layne ships with five built-in scanners. You can enable, disable, or configure each one per repository in `config/layne.json`. | Scanner | What it detects | Notes | |---|---|---| | [Semgrep](https://semgrep.dev) | SAST - bugs, vulnerabilities, insecure patterns | Runs `semgrep scan --config auto` by default; fully configurable via `extraArgs` | | [Trufflehog](https://github.com/trufflesecurity/trufflehog) | Secrets, API keys and credentials | Runs `trufflehog filesystem`; use `--only-verified` to reduce noise | -| [Claude](https://www.anthropic.com) | Bugs, vulnerabilities, backdoors, obfuscated payloads, supply-chain attacks (you can define a system prompt or a skill to use) | Disabled by default; opt in per repo; requires `ANTHROPIC_API_KEY` | -| [Pi Agent](https://pi.dev) | Agentic deep code review - autonomously traverses imports and follows suspicious patterns across file boundaries | Disabled by default; opt in per repo; supports multiple AI providers | +| [Claude](https://www.anthropic.com) | Malicious intent, backdoors, obfuscated payloads, supply-chain attacks | Disabled by default; opt in per repo; requires `ANTHROPIC_API_KEY` | +| Spectre | Malicious intent via single LLM call per file | Disabled by default; opt in per repo; supports Anthropic, OpenAI, Google, Mistral, Bedrock | +| Dep Doctor | CVEs, abandoned and deprecated dependencies | Disabled by default; opt in per repo; requires `osv-scanner` in PATH | You can also add your own scanners. See [Extending Layne](website/docs/extending.md). diff --git a/config/layne.json b/config/layne.json index 553a4fa..aedd34b 100644 --- a/config/layne.json +++ b/config/layne.json @@ -10,8 +10,7 @@ "onFailure": ["needs-security-review"], "removeOnSuccess": ["needs-security-review"], "onSuccess": [], - "removeOnFailure": [], - "onException": ["security-exception-used"] + "removeOnFailure": [] } }, "acme/frontend": { @@ -47,19 +46,13 @@ "webhookUrl": "$PAYMENTS_ROCKETCHAT_WEBHOOK_URL", "template": ":rotating_light: *Payment system alert — {{repo}} PR #{{prNumber}}*\n{{total}} finding(s): {{critical}} critical, {{high}} high, {{medium}} medium, {{low}} low" } - }, - "exceptionApprovers": { - "users": ["payments-security-lead"], - "teams": ["acme/payments-security"] } }, "RocketChat/security": { "claude": { - "enabled": false - }, - "piAgent": { "enabled": true, - "model": "claude-haiku-4-5-20251001" + "model": "claude-sonnet-4-6", + "skill": { "id": "skill_0187mWg6hVHB3eJHkeDmi2Eg", "version": "latest" } } }, "RocketChat/Rocket.Chat.ReactNative": { diff --git a/src/__tests__/adapters/dep-doctor.test.ts b/src/__tests__/adapters/dep-doctor.test.ts new file mode 100644 index 0000000..29529e7 --- /dev/null +++ b/src/__tests__/adapters/dep-doctor.test.ts @@ -0,0 +1,709 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { DepDoctorConfig } from '../../types.js'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockExecFile = vi.fn(); +vi.mock('child_process', () => ({ execFile: mockExecFile })); + +const mockReadFile = vi.fn(); +const mockWriteFile = vi.fn(); +const mockUnlink = vi.fn(); +const mockMkdir = vi.fn(); +vi.mock('fs/promises', () => ({ + readFile: mockReadFile, + writeFile: mockWriteFile, + unlink: mockUnlink, + mkdir: mockMkdir, +})); + +vi.mock('../../config.js', () => ({ + DEFAULT_CONFIG: Object.freeze({ + depDoctor: Object.freeze({ + enabled: false, + minCveSeverity: 'high', + checkAbandoned: true, + abandonedDays: 730, + checkDeprecated: true, + extraArgs: [], + }), + }), +})); + +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +const { runDepDoctor } = await import('../../adapters/dep-doctor.js'); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const WORKSPACE = '/tmp/ws'; +const BASE_SHA = 'base-sha-abc'; +const LOCKFILE = 'package-lock.json'; + +const ENABLED: DepDoctorConfig = { + enabled: true, + minCveSeverity: 'high', + checkAbandoned: true, + abandonedDays: 730, + checkDeprecated: true, + extraArgs: [], +}; + +// --------------------------------------------------------------------------- +// Stub helpers +// --------------------------------------------------------------------------- + +type ExecCb = (err: Error | null, stdout: string, stderr: string) => void; + +function stubGitShow(content: string) { + mockExecFile.mockImplementationOnce( + (_cmd: string, _args: string[], _opts: unknown, cb: ExecCb) => cb(null, content, '') + ); +} + +function stubGitShowMissing() { + mockExecFile.mockImplementationOnce( + (_cmd: string, _args: string[], _opts: unknown, cb: ExecCb) => + cb(new Error('fatal: Path not found in commit'), '', 'fatal: Path not found in commit') + ); +} + +function stubOsv(output: string, exitCode = 0) { + const err = exitCode !== 0 ? Object.assign(new Error(`exit ${exitCode}`), { code: exitCode }) : null; + mockExecFile.mockImplementationOnce( + (_cmd: string, _args: string[], _opts: unknown, cb: ExecCb) => cb(err, output, '') + ); +} + +function stubOsvNotFound() { + mockExecFile.mockImplementationOnce( + (_cmd: string, _args: string[], _opts: unknown, cb: ExecCb) => + cb(Object.assign(new Error('spawn osv-scanner ENOENT'), { code: 'ENOENT' }), '', '') + ); +} + +function buildOsvOutput(packages: Array<{ + name: string; version: string; ecosystem: string; + vulns?: Array<{ id: string; severity?: string }>; +}>) { + return JSON.stringify({ + results: [{ + source: { path: WORKSPACE, type: 'lockfile' }, + packages: packages.map(p => ({ + package: { name: p.name, version: p.version, ecosystem: p.ecosystem }, + vulnerabilities: (p.vulns ?? []).map(v => ({ + id: v.id, + database_specific: { severity: v.severity ?? 'HIGH' }, + })), + })), + }], + }); +} + +function stubNpmRegistry(name: string, opts: { + deprecated?: string; + lastPublishDaysAgo?: number; + ok?: boolean; +} = {}) { + mockFetch.mockImplementationOnce(async (url: string) => { + if (!url.includes(encodeURIComponent(name)) && !url.includes(name)) { + return { ok: false }; + } + if (opts.ok === false) return { ok: false }; + + const now = Date.now(); + const versions: Record = { + '1.0.0': opts.deprecated ? { deprecated: opts.deprecated } : {}, + }; + const lastPublish = opts.lastPublishDaysAgo !== undefined + ? new Date(now - opts.lastPublishDaysAgo * 24 * 60 * 60 * 1000).toISOString() + : new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(); + + return { + ok: true, + json: async () => ({ + time: { '1.0.0': lastPublish, created: '2010-01-01T00:00:00Z', modified: new Date().toISOString() }, + versions, + }), + }; + }); +} + +function stubPypiRegistry(name: string, opts: { + inactive?: boolean; + lastPublishDaysAgo?: number; + ok?: boolean; +} = {}) { + mockFetch.mockImplementationOnce(async () => { + if (opts.ok === false) return { ok: false }; + + const now = Date.now(); + const lastPublish = opts.lastPublishDaysAgo !== undefined + ? new Date(now - opts.lastPublishDaysAgo * 24 * 60 * 60 * 1000).toISOString() + : new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(); + + return { + ok: true, + json: async () => ({ + info: { classifiers: opts.inactive ? ['Development Status :: 7 - Inactive'] : [] }, + releases: { '1.0.0': [{ upload_time_iso_8601: lastPublish }] }, + }), + }; + }); +} + +// --------------------------------------------------------------------------- +// Lockfile content builders +// --------------------------------------------------------------------------- + +function buildPkgLock(packages: Array<{ name: string; version: string }>) { + const pkgs: Record = { '': {} as { version: string } }; + for (const { name, version } of packages) { + pkgs[`node_modules/${name}`] = { version }; + } + return JSON.stringify({ packages: pkgs }); +} + +function buildRequirementsTxt(packages: Array<{ name: string; version: string }>) { + return packages.map(({ name, version }) => `${name}==${version}`).join('\n'); +} + +// --------------------------------------------------------------------------- +// Guard clause tests +// --------------------------------------------------------------------------- + +// Set default resolved values for fs mocks so tests that trigger temp-file +// handling don't throw when they don't explicitly configure these mocks. +function setupFsMocks() { + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined); + mockUnlink.mockResolvedValue(undefined); +} + +describe('runDepDoctor() — guard clauses', () => { + beforeEach(() => { vi.clearAllMocks(); setupFsMocks(); }); + + it('returns empty array when disabled', async () => { + const findings = await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [LOCKFILE], + baseSha: BASE_SHA, + toolConfig: { ...ENABLED, enabled: false }, + }); + expect(findings).toEqual([]); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it('returns empty array when changedFiles is null', async () => { + const findings = await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: null, + baseSha: BASE_SHA, + toolConfig: ENABLED, + }); + expect(findings).toEqual([]); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it('returns empty array when changedFiles is empty', async () => { + const findings = await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [], + baseSha: BASE_SHA, + toolConfig: ENABLED, + }); + expect(findings).toEqual([]); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it('returns empty array when no lockfile is in changedFiles', async () => { + const findings = await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: ['src/app.js', 'src/utils.ts'], + baseSha: BASE_SHA, + toolConfig: ENABLED, + }); + expect(findings).toEqual([]); + expect(mockExecFile).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// CVE detection +// --------------------------------------------------------------------------- + +describe('runDepDoctor() — CVE detection', () => { + beforeEach(() => { vi.clearAllMocks(); setupFsMocks(); }); + + it('returns a finding for a new package with a HIGH CVE', async () => { + stubGitShow(buildOsvOutput([])); // base lockfile content (no packages) + stubOsv(buildOsvOutput([{ // head scan: lodash has CVE + name: 'lodash', version: '4.17.11', ecosystem: 'npm', + vulns: [{ id: 'CVE-2020-8203', severity: 'HIGH' }], + }]), 1); + stubOsv(buildOsvOutput([])); // base OSV scan: no vulns + stubNpmRegistry('lodash'); // health check: ok + mockReadFile.mockResolvedValue(buildPkgLock([{ name: 'lodash', version: '4.17.11' }])); + + const findings = await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [LOCKFILE], + baseSha: BASE_SHA, + toolConfig: ENABLED, + }); + + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + tool: 'dep-doctor', + ruleId: 'CVE-2020-8203', + severity: 'high', + file: LOCKFILE, + }); + expect(findings[0]!.message).toContain('lodash@4.17.11'); + }); + + it('does NOT flag a package that already existed in the base lockfile', async () => { + const existing = buildOsvOutput([{ + name: 'lodash', version: '4.17.11', ecosystem: 'npm', + vulns: [{ id: 'CVE-2020-8203', severity: 'HIGH' }], + }]); + stubGitShow(existing); // base lockfile has the same package + stubOsv(existing, 1); // head scan: same package with same CVE + stubOsv(existing); // base OSV scan: same package + + const findings = await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [LOCKFILE], + baseSha: BASE_SHA, + toolConfig: { ...ENABLED, checkAbandoned: false, checkDeprecated: false }, + }); + + expect(findings).toEqual([]); + }); + + it('filters out CVEs below minCveSeverity', async () => { + stubGitShow(buildOsvOutput([])); + stubOsv(buildOsvOutput([{ + name: 'lodash', version: '4.17.11', ecosystem: 'npm', + vulns: [{ id: 'CVE-2020-1', severity: 'MEDIUM' }], + }]), 1); + stubOsv(buildOsvOutput([])); + stubNpmRegistry('lodash'); + mockReadFile.mockResolvedValue(buildPkgLock([{ name: 'lodash', version: '4.17.11' }])); + + const findings = await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [LOCKFILE], + baseSha: BASE_SHA, + toolConfig: { ...ENABLED, minCveSeverity: 'high' }, + }); + + const cveFinding = findings.find(f => f.ruleId.startsWith('CVE')); + expect(cveFinding).toBeUndefined(); + }); + + it('treats all packages as new when the base lockfile does not exist', async () => { + stubGitShowMissing(); // no base lockfile + stubOsv(buildOsvOutput([{ + name: 'express', version: '4.18.0', ecosystem: 'npm', + vulns: [{ id: 'CVE-2024-1', severity: 'CRITICAL' }], + }]), 1); + stubNpmRegistry('express'); + mockReadFile.mockResolvedValue(buildPkgLock([{ name: 'express', version: '4.18.0' }])); + + const findings = await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [LOCKFILE], + baseSha: BASE_SHA, + toolConfig: { ...ENABLED, minCveSeverity: 'critical' }, + }); + + expect(findings.some(f => f.ruleId === 'CVE-2024-1')).toBe(true); + }); + + it('deduplicates findings when the same CVE appears multiple times for the same package', async () => { + const output = JSON.stringify({ + results: [ + { + source: { path: WORKSPACE, type: 'lockfile' }, + packages: [ + { + package: { name: 'lodash', version: '4.17.11', ecosystem: 'npm' }, + vulnerabilities: [ + { id: 'CVE-2020-8203', database_specific: { severity: 'HIGH' } }, + { id: 'CVE-2020-8203', database_specific: { severity: 'HIGH' } }, // duplicate + ], + }, + ], + }, + ], + }); + stubGitShow(buildOsvOutput([])); + stubOsv(output, 1); + stubOsv(buildOsvOutput([])); + stubNpmRegistry('lodash'); + mockReadFile.mockResolvedValue(buildPkgLock([{ name: 'lodash', version: '4.17.11' }])); + + const findings = await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [LOCKFILE], + baseSha: BASE_SHA, + toolConfig: ENABLED, + }); + + const cveFindings = findings.filter(f => f.ruleId === 'CVE-2020-8203'); + expect(cveFindings).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// OSV-Scanner error handling +// --------------------------------------------------------------------------- + +describe('runDepDoctor() — osv-scanner error handling', () => { + beforeEach(() => { vi.clearAllMocks(); setupFsMocks(); }); + + it('returns empty array (does not throw) when osv-scanner is not installed', async () => { + stubGitShow(buildOsvOutput([])); + stubOsvNotFound(); // ENOENT for head scan + mockReadFile.mockResolvedValue(''); + + const findings = await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [LOCKFILE], + baseSha: BASE_SHA, + toolConfig: { ...ENABLED, checkAbandoned: false, checkDeprecated: false }, + }); + + expect(findings).toEqual([]); + }); + + it('still runs health checks even when osv-scanner fails', async () => { + stubGitShow(buildOsvOutput([])); + stubOsvNotFound(); + // Health checks should still run since we passed changedFiles with a lockfile + + const findings = await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [LOCKFILE], + baseSha: BASE_SHA, + toolConfig: { ...ENABLED, checkDeprecated: false, checkAbandoned: false }, + }); + + expect(findings).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Abandoned / deprecated health checks +// --------------------------------------------------------------------------- + +describe('runDepDoctor() — abandoned/deprecated checks', () => { + beforeEach(() => { vi.clearAllMocks(); setupFsMocks(); }); + + it('returns an abandoned finding for an npm package with no publish in 2+ years', async () => { + stubGitShow(buildOsvOutput([])); + stubOsv(buildOsvOutput([{ name: 'old-lib', version: '1.0.0', ecosystem: 'npm' }])); + stubOsv(buildOsvOutput([])); + stubNpmRegistry('old-lib', { lastPublishDaysAgo: 800 }); + mockReadFile.mockResolvedValue(buildPkgLock([{ name: 'old-lib', version: '1.0.0' }])); + + const findings = await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [LOCKFILE], + baseSha: BASE_SHA, + toolConfig: ENABLED, + }); + + expect(findings.some(f => f.ruleId === 'abandoned/deprecated')).toBe(true); + expect(findings.find(f => f.ruleId === 'abandoned/deprecated')?.severity).toBe('medium'); + }); + + it('returns a deprecated finding for a deprecated npm package', async () => { + stubGitShow(buildOsvOutput([])); + stubOsv(buildOsvOutput([{ name: 'deprecated-pkg', version: '1.0.0', ecosystem: 'npm' }])); + stubOsv(buildOsvOutput([])); + stubNpmRegistry('deprecated-pkg', { deprecated: 'Use new-pkg instead' }); + mockReadFile.mockResolvedValue(buildPkgLock([{ name: 'deprecated-pkg', version: '1.0.0' }])); + + const findings = await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [LOCKFILE], + baseSha: BASE_SHA, + toolConfig: ENABLED, + }); + + const f = findings.find(f => f.ruleId === 'abandoned/deprecated'); + expect(f).toBeDefined(); + expect(f?.message).toContain('deprecated-pkg'); + }); + + it('does NOT return an abandoned finding when checkAbandoned is false', async () => { + stubGitShow(buildOsvOutput([])); + stubOsv(buildOsvOutput([{ name: 'old-lib', version: '1.0.0', ecosystem: 'npm' }])); + stubOsv(buildOsvOutput([])); + stubNpmRegistry('old-lib', { lastPublishDaysAgo: 800 }); + mockReadFile.mockResolvedValue(buildPkgLock([{ name: 'old-lib', version: '1.0.0' }])); + + const findings = await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [LOCKFILE], + baseSha: BASE_SHA, + toolConfig: { ...ENABLED, checkAbandoned: false }, + }); + + expect(findings.some(f => f.ruleId === 'abandoned/deprecated')).toBe(false); + }); + + it('does NOT return a deprecated finding when checkDeprecated is false', async () => { + stubGitShow(buildOsvOutput([])); + stubOsv(buildOsvOutput([{ name: 'deprecated-pkg', version: '1.0.0', ecosystem: 'npm' }])); + stubOsv(buildOsvOutput([])); + stubNpmRegistry('deprecated-pkg', { deprecated: 'Use something else' }); + mockReadFile.mockResolvedValue(buildPkgLock([{ name: 'deprecated-pkg', version: '1.0.0' }])); + + const findings = await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [LOCKFILE], + baseSha: BASE_SHA, + toolConfig: { ...ENABLED, checkDeprecated: false }, + }); + + expect(findings.some(f => f.ruleId === 'abandoned/deprecated')).toBe(false); + }); + + it('does NOT check registry for packages pre-existing in the base lockfile', async () => { + const lockContent = buildPkgLock([{ name: 'lodash', version: '4.17.11' }]); + const osvExisting = buildOsvOutput([{ name: 'lodash', version: '4.17.11', ecosystem: 'npm' }]); + stubGitShow(lockContent); // base lockfile has lodash + stubOsv(osvExisting); // head CVE scan + stubOsv(osvExisting); // base CVE scan + mockReadFile.mockResolvedValue(lockContent); // head lockfile has same lodash + + await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [LOCKFILE], + baseSha: BASE_SHA, + toolConfig: ENABLED, + }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns empty array (does not throw) when npm registry returns non-200', async () => { + stubGitShow(buildOsvOutput([])); + stubOsv(buildOsvOutput([{ name: 'some-pkg', version: '1.0.0', ecosystem: 'npm' }])); + stubOsv(buildOsvOutput([])); + stubNpmRegistry('some-pkg', { ok: false }); + mockReadFile.mockResolvedValue(buildPkgLock([{ name: 'some-pkg', version: '1.0.0' }])); + + await expect(runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [LOCKFILE], + baseSha: BASE_SHA, + toolConfig: ENABLED, + })).resolves.not.toThrow(); + }); + + it('returns empty array (does not throw) when fetch throws', async () => { + stubGitShow(buildOsvOutput([])); + stubOsv(buildOsvOutput([{ name: 'some-pkg', version: '1.0.0', ecosystem: 'npm' }])); + stubOsv(buildOsvOutput([])); + mockFetch.mockRejectedValueOnce(new Error('network error')); + mockReadFile.mockResolvedValue(buildPkgLock([{ name: 'some-pkg', version: '1.0.0' }])); + + await expect(runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [LOCKFILE], + baseSha: BASE_SHA, + toolConfig: ENABLED, + })).resolves.not.toThrow(); + }); + + it('returns an abandoned finding for a PyPI package with no publish in 2+ years', async () => { + stubGitShow(buildOsvOutput([])); + stubOsv(buildOsvOutput([{ name: 'old-pylib', version: '1.0.0', ecosystem: 'PyPI' }])); + stubOsv(buildOsvOutput([])); + stubPypiRegistry('old-pylib', { lastPublishDaysAgo: 800 }); + mockReadFile.mockResolvedValue(buildRequirementsTxt([{ name: 'old-pylib', version: '1.0.0' }])); + + const findings = await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: ['requirements.txt'], + baseSha: BASE_SHA, + toolConfig: ENABLED, + }); + + expect(findings.some(f => f.ruleId === 'abandoned/deprecated')).toBe(true); + }); + + it('returns a deprecated finding for a PyPI package with inactive classifier', async () => { + stubGitShow(buildOsvOutput([])); + stubOsv(buildOsvOutput([{ name: 'inactive-lib', version: '1.0.0', ecosystem: 'PyPI' }])); + stubOsv(buildOsvOutput([])); + stubPypiRegistry('inactive-lib', { inactive: true }); + mockReadFile.mockResolvedValue(buildRequirementsTxt([{ name: 'inactive-lib', version: '1.0.0' }])); + + const findings = await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: ['requirements.txt'], + baseSha: BASE_SHA, + toolConfig: ENABLED, + }); + + expect(findings.some(f => f.ruleId === 'abandoned/deprecated')).toBe(true); + }); + + it('does NOT call registry API for Go ecosystem packages (unsupported lockfile format)', async () => { + stubGitShow(buildOsvOutput([])); + stubOsv(buildOsvOutput([{ name: 'github.com/foo/bar', version: 'v1.0.0', ecosystem: 'Go' }])); + stubOsv(buildOsvOutput([])); + mockReadFile.mockResolvedValue('github.com/foo/bar v1.0.0 h1:abc=\n'); + + await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: ['go.sum'], + baseSha: BASE_SHA, + toolConfig: ENABLED, + }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Line number lookup +// --------------------------------------------------------------------------- + +describe('runDepDoctor() — line number lookup', () => { + beforeEach(() => { vi.clearAllMocks(); setupFsMocks(); }); + + it('finds the correct line in package-lock.json', async () => { + stubGitShow(buildOsvOutput([])); + stubOsv(buildOsvOutput([{ + name: 'lodash', version: '4.17.11', ecosystem: 'npm', + vulns: [{ id: 'CVE-2020-8203', severity: 'HIGH' }], + }]), 1); + stubOsv(buildOsvOutput([])); + stubNpmRegistry('lodash'); + // Use raw string so parseLockfilePackages sees invalid JSON (returns []) while + // findLineInLockfile can still locate "node_modules/lodash" at line 2. + mockReadFile.mockResolvedValue( + 'line 1\n' + + ' "node_modules/lodash": {\n' + // line 2 + ' "version": "4.17.11"\n' + + ' }\n' + ); + + const findings = await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [LOCKFILE], + baseSha: BASE_SHA, + toolConfig: ENABLED, + }); + + const cveFinding = findings.find(f => f.ruleId === 'CVE-2020-8203'); + expect(cveFinding?.line).toBe(2); + }); + + it('falls back to line 1 when the package name is not found', async () => { + stubGitShow(buildOsvOutput([])); + stubOsv(buildOsvOutput([{ + name: 'unknown-pkg', version: '1.0.0', ecosystem: 'npm', + vulns: [{ id: 'CVE-2024-99', severity: 'HIGH' }], + }]), 1); + stubOsv(buildOsvOutput([])); + stubNpmRegistry('unknown-pkg'); + mockReadFile.mockResolvedValue('{}'); // no match for package name + + const findings = await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [LOCKFILE], + baseSha: BASE_SHA, + toolConfig: ENABLED, + }); + + const cveFinding = findings.find(f => f.ruleId === 'CVE-2024-99'); + expect(cveFinding?.line).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// extraArgs +// --------------------------------------------------------------------------- + +describe('runDepDoctor() — extraArgs', () => { + beforeEach(() => { vi.clearAllMocks(); setupFsMocks(); }); + + it('passes extraArgs to both the head and base osv-scanner invocations', async () => { + stubGitShow(buildOsvOutput([])); + stubOsv(buildOsvOutput([])); + stubOsv(buildOsvOutput([])); + mockReadFile.mockResolvedValue(''); + + await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [LOCKFILE], + baseSha: BASE_SHA, + toolConfig: { ...ENABLED, extraArgs: ['--experimental-all-packages'] }, + }); + + const calls = (mockExecFile as ReturnType).mock.calls as Array<[string, string[]]>; + const osvCalls = calls.filter(([, args]) => args.includes('scan')); + expect(osvCalls).toHaveLength(2); + for (const [, args] of osvCalls) { + expect(args).toContain('--experimental-all-packages'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Base temp file lifecycle +// --------------------------------------------------------------------------- + +describe('runDepDoctor() — base temp file lifecycle', () => { + beforeEach(() => { vi.clearAllMocks(); setupFsMocks(); }); + + it('writes the base lockfile to a temp path then deletes it', async () => { + const pkgOutput = buildOsvOutput([{ name: 'lodash', version: '4.17.11', ecosystem: 'npm' }]); + stubGitShow(pkgOutput); // base content exists (same package as head) + stubOsv(pkgOutput); // head scan: same package (no new packages) + stubOsv(pkgOutput); // base scan on temp file + mockReadFile.mockResolvedValue(''); + + await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [LOCKFILE], + baseSha: BASE_SHA, + toolConfig: { ...ENABLED, checkAbandoned: false, checkDeprecated: false }, + }); + + expect(mockWriteFile).toHaveBeenCalledOnce(); + expect(mockUnlink).toHaveBeenCalledOnce(); + + const writePath = (mockWriteFile as ReturnType).mock.calls[0]![0] as string; + expect(writePath).toContain(WORKSPACE); + }); + + it('still deletes the temp file even when the base osv-scanner call throws', async () => { + const pkgOutput = buildOsvOutput([{ name: 'lodash', version: '4.17.11', ecosystem: 'npm' }]); + stubGitShow(pkgOutput); // base content exists + stubOsv(pkgOutput); // head scan ok + stubOsvNotFound(); // base scan fails with ENOENT + mockReadFile.mockResolvedValue(''); + + await runDepDoctor({ + workspacePath: WORKSPACE, + changedFiles: [LOCKFILE], + baseSha: BASE_SHA, + toolConfig: { ...ENABLED, checkAbandoned: false, checkDeprecated: false }, + }); + + expect(mockUnlink).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/__tests__/adapters/pi-agent-tools.test.ts b/src/__tests__/adapters/pi-agent-tools.test.ts deleted file mode 100644 index ec9e485..0000000 --- a/src/__tests__/adapters/pi-agent-tools.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -const mockReadFile = vi.fn(); -const mockAccess = vi.fn(); -const mockStat = vi.fn(); -const mockReaddir = vi.fn(); -const mockMkdir = vi.fn(); -const mockWriteFile = vi.fn(); -const mockGlob = vi.fn(); -const mockExecFile = vi.fn(); - -vi.mock('child_process', () => ({ - execFile: (...args: unknown[]) => mockExecFile(...args), -})); - -vi.mock('fs/promises', () => ({ - default: { - readFile: (...args: unknown[]) => mockReadFile(...args), - access: (...args: unknown[]) => mockAccess(...args), - stat: (...args: unknown[]) => mockStat(...args), - readdir: (...args: unknown[]) => mockReaddir(...args), - mkdir: (...args: unknown[]) => mockMkdir(...args), - writeFile: (...args: unknown[]) => mockWriteFile(...args), - }, - readFile: (...args: unknown[]) => mockReadFile(...args), - access: (...args: unknown[]) => mockAccess(...args), - stat: (...args: unknown[]) => mockStat(...args), - readdir: (...args: unknown[]) => mockReaddir(...args), - mkdir: (...args: unknown[]) => mockMkdir(...args), - writeFile: (...args: unknown[]) => mockWriteFile(...args), -})); - -vi.mock('tinyglobby', () => ({ - glob: (...args: unknown[]) => mockGlob(...args), -})); - -vi.mock('@mariozechner/pi-coding-agent', () => ({ - createReadTool: vi.fn((_cwd: string, opts: { operations: unknown }) => ({ name: 'read', ops: opts?.operations })), - createGrepTool: vi.fn((_cwd: string, opts: { operations: unknown }) => ({ name: 'grep', ops: opts?.operations })), - createFindTool: vi.fn((_cwd: string, opts: { operations: unknown }) => ({ name: 'find', ops: opts?.operations })), - createLsTool: vi.fn((_cwd: string, opts: { operations: unknown }) => ({ name: 'ls', ops: opts?.operations })), -})); - -const { createConfinedTools } = await import('../../adapters/pi-agent-tools.js'); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const WS = '/workspace/repo'; -const HEAD_SHA = 'abc123def456'; - -type ToolRecord = { name: string; ops: Record }; - -function getOps(toolName: string) { - const tools = createConfinedTools(WS) as unknown as ToolRecord[]; - return tools.find(t => t.name === toolName)!.ops; -} - -function getOpsWithImports(toolName: string, followImports = true) { - const tools = createConfinedTools(WS, { headSha: HEAD_SHA, followImports }) as unknown as ToolRecord[]; - return tools.find(t => t.name === toolName)!.ops; -} - -// Simulate execFile calling its callback for git cat-file or git show -function stubExecFile({ catFileType, showContent, showError }: { - catFileType?: string; - showContent?: Buffer; - showError?: Error; -}) { - mockExecFile.mockImplementation((_cmd: string, args: string[], optsOrCb: unknown, maybeCb?: unknown) => { - const cb = (typeof optsOrCb === 'function' ? optsOrCb : maybeCb) as Function; - if (args.includes('cat-file')) { - cb(catFileType ? null : new Error('not found'), catFileType ? `${catFileType}\n` : '', ''); - } else if (args.includes('show')) { - if (showError) cb(showError, null, ''); - else cb(null, showContent ?? Buffer.from('fetched content'), ''); - } - }); -} - -// --------------------------------------------------------------------------- -// confinePath - tested indirectly through operations -// --------------------------------------------------------------------------- - -describe('createConfinedTools', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockMkdir.mockResolvedValue(undefined); - mockWriteFile.mockResolvedValue(undefined); - }); - - it('returns four tools', () => { - const tools = createConfinedTools(WS); - expect(tools).toHaveLength(4); - }); - - it('returns four tools when options are provided', () => { - const tools = createConfinedTools(WS, { headSha: HEAD_SHA, followImports: true }); - expect(tools).toHaveLength(4); - }); - - // ------------------------------------------------------------------------- - // read operations — baseline (no lazy fetching) - // ------------------------------------------------------------------------- - - describe('read operations', () => { - it('allows reading a file inside the workspace', async () => { - mockReadFile.mockResolvedValue(Buffer.from('hello')); - const ops = getOps('read'); - await expect(ops.readFile(`${WS}/src/foo.ts`)).resolves.toBeDefined(); - expect(mockReadFile).toHaveBeenCalledWith(`${WS}/src/foo.ts`); - }); - - it('blocks reading a file outside the workspace', async () => { - const ops = getOps('read'); - await expect(ops.readFile('/etc/passwd')).rejects.toThrow('access denied'); - expect(mockReadFile).not.toHaveBeenCalled(); - }); - - it('blocks path traversal via ..', async () => { - const ops = getOps('read'); - await expect(ops.readFile(`${WS}/../../../etc/passwd`)).rejects.toThrow('access denied'); - expect(mockReadFile).not.toHaveBeenCalled(); - }); - - it('blocks a path that is a prefix match but not inside the workspace', async () => { - const ops = getOps('read'); - // /workspace/repo-evil should not match /workspace/repo - await expect(ops.readFile('/workspace/repo-evil/secret')).rejects.toThrow('access denied'); - expect(mockReadFile).not.toHaveBeenCalled(); - }); - - it('allows access check inside the workspace', async () => { - mockAccess.mockResolvedValue(undefined); - const ops = getOps('read'); - await expect(ops.access(`${WS}/package.json`)).resolves.toBeUndefined(); - expect(mockAccess).toHaveBeenCalledWith(`${WS}/package.json`); - }); - - it('blocks access check outside the workspace', async () => { - const ops = getOps('read'); - await expect(ops.access('/home/user/.ssh/id_rsa')).rejects.toThrow('access denied'); - expect(mockAccess).not.toHaveBeenCalled(); - }); - }); - - // ------------------------------------------------------------------------- - // read operations — lazy fetching (followImports: true) - // ------------------------------------------------------------------------- - - describe('read operations — lazy fetching', () => { - it('readFile: falls back to git show on ENOENT when followImports is true', async () => { - const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - mockReadFile.mockRejectedValue(enoent); - stubExecFile({ showContent: Buffer.from('fetched content') }); - - const ops = getOpsWithImports('read'); - const result = await ops.readFile(`${WS}/src/unchanged.ts`); - - expect(result).toEqual(Buffer.from('fetched content')); - // should have written to disk - expect(mockMkdir).toHaveBeenCalled(); - expect(mockWriteFile).toHaveBeenCalledWith(`${WS}/src/unchanged.ts`, Buffer.from('fetched content')); - // git show should have been called with relative path - expect(mockExecFile).toHaveBeenCalledWith( - 'git', - expect.arrayContaining(['show', `${HEAD_SHA}:src/unchanged.ts`]), - expect.any(Object), - expect.any(Function), - ); - }); - - it('readFile: re-throws original ENOENT when git show also fails', async () => { - const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - mockReadFile.mockRejectedValue(enoent); - stubExecFile({ showError: new Error('not in repo') }); - - const ops = getOpsWithImports('read'); - await expect(ops.readFile(`${WS}/src/missing.ts`)).rejects.toThrow('ENOENT'); - expect(mockWriteFile).not.toHaveBeenCalled(); - }); - - it('readFile: does not call git when followImports is false', async () => { - const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - mockReadFile.mockRejectedValue(enoent); - - const ops = getOpsWithImports('read', false); - await expect(ops.readFile(`${WS}/src/unchanged.ts`)).rejects.toThrow('ENOENT'); - expect(mockExecFile).not.toHaveBeenCalled(); - }); - - it('readFile: does not call git for non-ENOENT errors', async () => { - const permError = Object.assign(new Error('EACCES'), { code: 'EACCES' }); - mockReadFile.mockRejectedValue(permError); - - const ops = getOpsWithImports('read'); - await expect(ops.readFile(`${WS}/src/locked.ts`)).rejects.toThrow('EACCES'); - expect(mockExecFile).not.toHaveBeenCalled(); - }); - - it('readFile: confinement still blocks paths outside workspace even with followImports', async () => { - const ops = getOpsWithImports('read'); - await expect(ops.readFile('/etc/shadow')).rejects.toThrow('access denied'); - expect(mockReadFile).not.toHaveBeenCalled(); - expect(mockExecFile).not.toHaveBeenCalled(); - }); - - it('readFile: disk write failure is non-fatal — content still returned', async () => { - const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - mockReadFile.mockRejectedValue(enoent); - stubExecFile({ showContent: Buffer.from('content') }); - mockWriteFile.mockRejectedValue(new Error('disk full')); - - const ops = getOpsWithImports('read'); - const result = await ops.readFile(`${WS}/src/unchanged.ts`); - expect(result).toEqual(Buffer.from('content')); - }); - - it('access: returns void when git cat-file reports blob', async () => { - const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - mockAccess.mockRejectedValue(enoent); - stubExecFile({ catFileType: 'blob' }); - - const ops = getOpsWithImports('read'); - await expect(ops.access(`${WS}/src/unchanged.ts`)).resolves.toBeUndefined(); - expect(mockExecFile).toHaveBeenCalledWith( - 'git', - expect.arrayContaining(['cat-file', '-t', `${HEAD_SHA}:src/unchanged.ts`]), - expect.any(Function), - ); - }); - - it('access: re-throws ENOENT when git cat-file reports tree (directory)', async () => { - const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - mockAccess.mockRejectedValue(enoent); - stubExecFile({ catFileType: 'tree' }); - - const ops = getOpsWithImports('read'); - await expect(ops.access(`${WS}/src`)).rejects.toThrow('ENOENT'); - }); - - it('access: re-throws ENOENT when git cat-file fails (path not in repo)', async () => { - const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - mockAccess.mockRejectedValue(enoent); - stubExecFile({ catFileType: undefined }); - - const ops = getOpsWithImports('read'); - await expect(ops.access(`${WS}/src/nowhere.ts`)).rejects.toThrow('ENOENT'); - }); - - it('access: does not call git when followImports is false', async () => { - const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - mockAccess.mockRejectedValue(enoent); - - const ops = getOpsWithImports('read', false); - await expect(ops.access(`${WS}/src/unchanged.ts`)).rejects.toThrow('ENOENT'); - expect(mockExecFile).not.toHaveBeenCalled(); - }); - }); - - // ------------------------------------------------------------------------- - // grep operations - // ------------------------------------------------------------------------- - - describe('grep operations', () => { - it('allows isDirectory inside the workspace', async () => { - mockStat.mockResolvedValue({ isDirectory: () => true }); - const ops = getOps('grep'); - await expect(ops.isDirectory(`${WS}/src`)).resolves.toBe(true); - }); - - it('blocks isDirectory outside the workspace', async () => { - const ops = getOps('grep'); - await expect(ops.isDirectory('/etc')).rejects.toThrow('access denied'); - expect(mockStat).not.toHaveBeenCalled(); - }); - - it('allows readFile inside the workspace', async () => { - mockReadFile.mockResolvedValue('code'); - const ops = getOps('grep'); - await expect(ops.readFile(`${WS}/src/app.ts`)).resolves.toBe('code'); - }); - - it('blocks readFile outside the workspace', async () => { - const ops = getOps('grep'); - await expect(ops.readFile('/Users/julio/layne/.env')).rejects.toThrow('access denied'); - expect(mockReadFile).not.toHaveBeenCalled(); - }); - }); - - // ------------------------------------------------------------------------- - // find operations - // ------------------------------------------------------------------------- - - describe('find operations', () => { - it('allows exists check inside the workspace', async () => { - mockAccess.mockResolvedValue(undefined); - const ops = getOps('find'); - await expect(ops.exists(`${WS}/src`)).resolves.toBe(true); - }); - - it('blocks exists check outside the workspace', async () => { - const ops = getOps('find'); - await expect(ops.exists('/etc/passwd')).rejects.toThrow('access denied'); - expect(mockAccess).not.toHaveBeenCalled(); - }); - - it('allows glob with cwd inside the workspace', async () => { - mockGlob.mockResolvedValue([`${WS}/src/foo.ts`, `${WS}/src/bar.ts`]); - const ops = getOps('find'); - const results = await ops.glob('**/*.ts', `${WS}/src`, { ignore: [], limit: 100 }); - expect(results).toHaveLength(2); - expect(mockGlob).toHaveBeenCalledWith('**/*.ts', expect.objectContaining({ cwd: `${WS}/src` })); - }); - - it('blocks glob with cwd outside the workspace', async () => { - const ops = getOps('find'); - await expect(ops.glob('**/*', '/etc', { ignore: [], limit: 100 })).rejects.toThrow('access denied'); - expect(mockGlob).not.toHaveBeenCalled(); - }); - - it('filters out glob results that escape the workspace', async () => { - mockGlob.mockResolvedValue([`${WS}/src/foo.ts`, '/etc/passwd', `${WS}/src/bar.ts`]); - const ops = getOps('find'); - const results = await ops.glob('**/*.ts', WS, { ignore: [], limit: 100 }); - expect(results).toEqual([`${WS}/src/foo.ts`, `${WS}/src/bar.ts`]); - }); - - it('respects the limit after filtering', async () => { - mockGlob.mockResolvedValue([`${WS}/a.ts`, `${WS}/b.ts`, `${WS}/c.ts`]); - const ops = getOps('find'); - const results = await ops.glob('**/*.ts', WS, { ignore: [], limit: 2 }); - expect(results).toHaveLength(2); - }); - }); - - // ------------------------------------------------------------------------- - // ls operations - // ------------------------------------------------------------------------- - - describe('ls operations', () => { - it('allows exists check inside the workspace', async () => { - mockAccess.mockResolvedValue(undefined); - const ops = getOps('ls'); - await expect(ops.exists(`${WS}/src`)).resolves.toBe(true); - }); - - it('blocks exists check outside the workspace', async () => { - const ops = getOps('ls'); - await expect(ops.exists('/var/log')).rejects.toThrow('access denied'); - expect(mockAccess).not.toHaveBeenCalled(); - }); - - it('allows stat inside the workspace', async () => { - const fakeStat = { isDirectory: () => true }; - mockStat.mockResolvedValue(fakeStat); - const ops = getOps('ls'); - await expect(ops.stat(`${WS}/src`)).resolves.toBe(fakeStat); - }); - - it('blocks stat outside the workspace', async () => { - const ops = getOps('ls'); - await expect(ops.stat('/root')).rejects.toThrow('access denied'); - expect(mockStat).not.toHaveBeenCalled(); - }); - - it('allows readdir inside the workspace', async () => { - mockReaddir.mockResolvedValue(['foo.ts', 'bar.ts']); - const ops = getOps('ls'); - await expect(ops.readdir(`${WS}/src`)).resolves.toEqual(['foo.ts', 'bar.ts']); - }); - - it('blocks readdir outside the workspace', async () => { - const ops = getOps('ls'); - await expect(ops.readdir('/etc')).rejects.toThrow('access denied'); - expect(mockReaddir).not.toHaveBeenCalled(); - }); - - it('allows access to the workspace root itself', async () => { - mockAccess.mockResolvedValue(undefined); - const ops = getOps('ls'); - await expect(ops.exists(WS)).resolves.toBe(true); - }); - }); -}); diff --git a/src/__tests__/adapters/spectre.test.ts b/src/__tests__/adapters/spectre.test.ts new file mode 100644 index 0000000..99031f4 --- /dev/null +++ b/src/__tests__/adapters/spectre.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockCompleteSimple = vi.fn(); +const mockGetModel = vi.fn(() => ({})); +const mockReadFile = vi.fn(); + +vi.mock('@mariozechner/pi-ai', () => ({ + getModel: mockGetModel, + completeSimple: mockCompleteSimple, +})); + +vi.mock('fs/promises', () => ({ + readFile: mockReadFile, +})); + +vi.mock('../../config.js', () => ({ + DEFAULT_CONFIG: Object.freeze({ + spectre: Object.freeze({ + enabled: false, + model: 'claude-haiku-4-5-20251001', + fileCap: 20, + secondaryFileCap: 20, + maxDiffLines: 400, + minSeverity: 'high', + concurrency: 5, + skipPaths: [], + skipExtensions: [], + prompt: null, + boostPatterns: [], + }), + }), +})); + +const { runSpectre } = await import('../../adapters/spectre.js'); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const WORKSPACE = '/tmp/ws'; + +function enabledConfig(overrides: Record = {}) { + return { + enabled: true, + provider: 'anthropic', + model: 'claude-haiku-4-5-20251001', + fileCap: 20, + secondaryFileCap: 20, + maxDiffLines: 400, + minSeverity: 'high' as const, + concurrency: 1, + skipPaths: [], + skipExtensions: [], + prompt: null, + boostPatterns: [], + ...overrides, + }; +} + +// Clean LLM response — no findings. +function noFindings() { + return { + content: [{ type: 'text', text: '{"findings":[]}' }], + }; +} + +// Generate file names for testing. +function files(prefix: string, count: number): string[] { + return Array.from({ length: count }, (_, i) => `${prefix}${i + 1}.js`); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('runSpectre()', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetModel.mockReturnValue({}); + mockCompleteSimple.mockResolvedValue(noFindings()); + // By default files have no suspicious keywords → tier3 + mockReadFile.mockResolvedValue('const x = 1;'); + }); + + it('returns empty when disabled', async () => { + const findings = await runSpectre({ + workspacePath: WORKSPACE, + changedFiles: ['src/a.js'], + toolConfig: enabledConfig({ enabled: false }), + }); + expect(findings).toEqual([]); + expect(mockCompleteSimple).not.toHaveBeenCalled(); + }); + + it('returns empty when no provider configured', async () => { + const findings = await runSpectre({ + workspacePath: WORKSPACE, + changedFiles: ['src/a.js'], + toolConfig: enabledConfig({ provider: undefined }), + }); + expect(findings).toEqual([]); + expect(mockCompleteSimple).not.toHaveBeenCalled(); + }); + + it('scans only up to fileCap files when all files are tier3 (no keywords)', async () => { + const changedFiles = files('src/file', 30); + await runSpectre({ workspacePath: WORKSPACE, changedFiles, toolConfig: enabledConfig() }); + // 30 tier3 files, fileCap 20, no secondary (tier2 overflow is empty) + expect(mockCompleteSimple).toHaveBeenCalledTimes(20); + }); + + it('secondary batch picks up keyword-matching overflow files', async () => { + // 25 files contain a suspicious keyword → all tier2 + const keywordContent = 'require("child_process").execSync("ls")'; + mockReadFile.mockResolvedValue(keywordContent); + + const changedFiles = files('src/kw', 25); + await runSpectre({ workspacePath: WORKSPACE, changedFiles, toolConfig: enabledConfig() }); + + // primary: 20 tier2 files (fills the cap) + // secondary: 5 remaining tier2 files (overflow), capped at secondaryFileCap=20 + expect(mockCompleteSimple).toHaveBeenCalledTimes(25); + }); + + it('secondary batch is capped at secondaryFileCap', async () => { + // 40 keyword-matching files + mockReadFile.mockResolvedValue('require("child_process").execSync("ls")'); + const changedFiles = files('src/kw', 40); + + await runSpectre({ + workspacePath: WORKSPACE, + changedFiles, + toolConfig: enabledConfig({ fileCap: 20, secondaryFileCap: 10 }), + }); + + // primary: 20, secondary: min(20 overflow, cap=10) = 10 → total 30 + expect(mockCompleteSimple).toHaveBeenCalledTimes(30); + }); + + it('secondaryFileCap: 0 disables secondary batch entirely', async () => { + mockReadFile.mockResolvedValue('require("child_process").execSync("ls")'); + const changedFiles = files('src/kw', 30); + + await runSpectre({ + workspacePath: WORKSPACE, + changedFiles, + toolConfig: enabledConfig({ secondaryFileCap: 0 }), + }); + + // Only primary 20 scanned, no secondary + expect(mockCompleteSimple).toHaveBeenCalledTimes(20); + }); + + it('tier3 overflow files are never included in the secondary batch', async () => { + // Mix: 5 keyword files + 25 ordinary files + mockReadFile.mockImplementation(async (path: string) => { + const fname = String(path).split('/').pop() ?? ''; + return fname.startsWith('kw') ? 'execSync("ls")' : 'const x = 1;'; + }); + + const changedFiles = [ + ...files('src/kw', 5), // tier2 + ...files('src/plain', 25), // tier3 + ]; + + await runSpectre({ workspacePath: WORKSPACE, changedFiles, toolConfig: enabledConfig() }); + + // primary: 5 tier2 + 15 tier3 = 20; secondary overflow tier2: 0 → total 20 + expect(mockCompleteSimple).toHaveBeenCalledTimes(20); + }); + + it('tier1 files (manifests) consume primary slots before tier2/tier3', async () => { + // 5 package.json-style tier1 files + 20 keyword files + mockReadFile.mockResolvedValue('execSync("ls")'); + + const tier1Files = Array.from({ length: 5 }, (_, i) => `pkg${i}/package.json`); + const tier2Files = files('src/kw', 20); + const changedFiles = [...tier1Files, ...tier2Files]; + + await runSpectre({ workspacePath: WORKSPACE, changedFiles, toolConfig: enabledConfig() }); + + // primary: 5 tier1 + 15 tier2 = 20 + // secondary: remaining 5 tier2, capped at 20 → 5 + // total: 25 + expect(mockCompleteSimple).toHaveBeenCalledTimes(25); + }); +}); diff --git a/src/__tests__/commenter.test.ts b/src/__tests__/commenter.test.ts index 9937af0..ca75383 100644 --- a/src/__tests__/commenter.test.ts +++ b/src/__tests__/commenter.test.ts @@ -25,6 +25,7 @@ const BASE = { repo: 'frontend', prNumber: 42, installationId: 1, + headSha: 'abc1234567890', commentConfig: { enabled: true, template: null as string | null }, }; @@ -68,13 +69,14 @@ describe('postComment()', () => { expect(body).toContain(COMMENT_MARKER); }); - it('includes the finding count in the created body', async () => { + it('includes the finding count and caution alert in the created body', async () => { const { createComment } = makeOctokit({ existingComments: [] }); await postComment({ ...BASE, findings: [FINDING_HIGH], conclusion: 'failure' }); const { body } = createComment.mock.calls[0][0] as { body: string }; - expect(body).toContain('1 finding(s)'); + expect(body).toContain('View 1 finding(s)'); + expect(body).toContain('[!CAUTION]'); }); }); @@ -149,14 +151,15 @@ describe('postComment()', () => { })); }); - it('includes the comment marker and warning count in the body', async () => { + it('includes the comment marker and warning alert in the body', async () => { const { createComment } = makeOctokit({ existingComments: [] }); await postComment({ ...BASE, findings: [FINDING_MEDIUM], conclusion: 'success' }); const { body } = createComment.mock.calls[0][0] as { body: string }; expect(body).toContain(COMMENT_MARKER); - expect(body).toContain('1 warning(s)'); + expect(body).toContain('[!WARNING]'); + expect(body).toContain('View 1 finding(s)'); }); }); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index c801950..3067d9c 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -383,10 +383,10 @@ describe('loadScanConfig()', () => { // --- timeoutMinutes --- - it('returns timeoutMinutes 10 by default', async () => { + it('returns timeoutMinutes 15 by default', async () => { vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({})); const config = await loadScanConfig({ owner: 'org', repo: 'repo' }); - expect(config.timeoutMinutes).toBe(10); + expect(config.timeoutMinutes).toBe(15); }); it('inherits $global timeoutMinutes when the repo has no timeoutMinutes', async () => { @@ -407,33 +407,74 @@ describe('loadScanConfig()', () => { expect(config.timeoutMinutes).toBe(30); }); - // --- piAgent defaults --- + // --- depDoctor global inheritance --- - it('DEFAULT_CONFIG.piAgent has timeoutMinutes 10', () => { - expect((DEFAULT_CONFIG.piAgent as Record).timeoutMinutes).toBe(10); + it('inherits $global depDoctor when the repo has no depDoctor block', async () => { + vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({ + '$global': { depDoctor: { enabled: true } }, + 'acme/frontend': { semgrep: { extraArgs: ['--config', 'auto'] } }, + })); + const config = await loadScanConfig({ owner: 'acme', repo: 'frontend' }); + expect((config.depDoctor as Record).enabled).toBe(true); }); - it('DEFAULT_CONFIG.piAgent has followImports true', () => { - expect((DEFAULT_CONFIG.piAgent as Record).followImports).toBe(true); + it('repo-level depDoctor overrides $global depDoctor', async () => { + vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({ + '$global': { depDoctor: { enabled: true } }, + 'acme/payments': { depDoctor: { enabled: false } }, + })); + const config = await loadScanConfig({ owner: 'acme', repo: 'payments' }); + expect((config.depDoctor as Record).enabled).toBe(false); + }); + + // --- spectre defaults --- + + it('DEFAULT_CONFIG.spectre has fileCap 20', () => { + expect((DEFAULT_CONFIG.spectre as Record).fileCap).toBe(20); + }); + + it('DEFAULT_CONFIG.spectre has minSeverity high', () => { + expect((DEFAULT_CONFIG.spectre as Record).minSeverity).toBe('high'); + }); + + it('DEFAULT_CONFIG.spectre has maxDiffLines 400', () => { + expect((DEFAULT_CONFIG.spectre as Record).maxDiffLines).toBe(400); + }); + + it('repo can override spectre.fileCap', async () => { + vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({ + 'acme/backend': { + spectre: { enabled: true, provider: 'anthropic', model: 'claude-haiku-4-5-20251001', fileCap: 10 }, + }, + })); + const config = await loadScanConfig({ owner: 'acme', repo: 'backend' }); + expect((config.spectre as Record).fileCap).toBe(10); }); - it('repo can override piAgent.followImports to false', async () => { + it('repo can override spectre.minSeverity', async () => { vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({ 'acme/backend': { - piAgent: { enabled: true, provider: 'anthropic', followImports: false }, + spectre: { enabled: true, provider: 'anthropic', model: 'claude-haiku-4-5-20251001', minSeverity: 'medium' }, }, })); const config = await loadScanConfig({ owner: 'acme', repo: 'backend' }); - expect((config.piAgent as Record).followImports).toBe(false); + expect((config.spectre as Record).minSeverity).toBe('medium'); }); - it('repo piAgent.timeoutMinutes override propagates correctly', async () => { + it('repo can configure spectre skipPaths and skipExtensions', async () => { vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({ 'acme/backend': { - piAgent: { enabled: true, provider: 'anthropic', timeoutMinutes: 15 }, + spectre: { + enabled: true, + provider: 'bedrock', + model: 'anthropic.claude-haiku-4-5-20251001', + skipPaths: ['vendor/', 'generated/'], + skipExtensions: ['.generated.ts'], + }, }, })); const config = await loadScanConfig({ owner: 'acme', repo: 'backend' }); - expect((config.piAgent as Record).timeoutMinutes).toBe(15); + expect((config.spectre as Record).skipPaths).toEqual(['vendor/', 'generated/']); + expect((config.spectre as Record).skipExtensions).toEqual(['.generated.ts']); }); }); diff --git a/src/__tests__/dispatcher.test.ts b/src/__tests__/dispatcher.test.ts index ef06aa5..f44b37a 100644 --- a/src/__tests__/dispatcher.test.ts +++ b/src/__tests__/dispatcher.test.ts @@ -13,26 +13,32 @@ vi.mock('../adapters/claude.js', () => ({ runClaude: vi.fn().mockResolvedValue([]), })); -vi.mock('../adapters/pi-agent.js', () => ({ - runPiAgent: vi.fn().mockResolvedValue([]), +vi.mock('../adapters/spectre.js', () => ({ + runSpectre: vi.fn().mockResolvedValue([]), +})); + +vi.mock('../adapters/dep-doctor.js', () => ({ + runDepDoctor: vi.fn().mockResolvedValue([]), })); vi.mock('../config.js', () => ({ DEFAULT_CONFIG: { - piAgent: { enabled: false, model: 'claude-opus-4-6', thinkingLevel: 'medium', timeoutMinutes: 3 }, + spectre: { enabled: false, model: 'claude-haiku-4-5-20251001', fileCap: 20, minSeverity: 'high' }, }, loadScanConfig: vi.fn().mockResolvedValue({ semgrep: { enabled: true, extraArgs: ['--config', 'auto'] }, trufflehog: { enabled: true, extraArgs: [] }, claude: { enabled: false, model: 'claude-haiku-4-5-20251001' }, - piAgent: { enabled: false, model: 'claude-opus-4-6', thinkingLevel: 'medium', timeoutMinutes: 3 }, + spectre: { enabled: false, model: 'claude-haiku-4-5-20251001', fileCap: 20, minSeverity: 'high' }, + depDoctor: { enabled: false, minCveSeverity: 'high', checkAbandoned: true, abandonedDays: 730, checkDeprecated: true, extraArgs: [] }, }), })); const { runTrufflehog } = await import('../adapters/trufflehog.js'); const { runSemgrep } = await import('../adapters/semgrep.js'); const { runClaude } = await import('../adapters/claude.js'); -const { runPiAgent } = await import('../adapters/pi-agent.js'); +const { runSpectre } = await import('../adapters/spectre.js'); +const { runDepDoctor } = await import('../adapters/dep-doctor.js'); const { loadScanConfig } = await import('../config.js'); const { dispatch } = await import('../dispatcher.js'); @@ -40,6 +46,7 @@ const BASE_SCAN_CONTEXT: ScanContext = { mode: 'changed_files', contextLines: 8, headSha: 'abc123', + baseSha: 'def456', repoWorkspacePath: '/tmp/ws', scanWorkspacePath: '/tmp/ws', scanFiles: ['src/app.js', 'src/utils.js'], @@ -57,12 +64,13 @@ const BASE = { describe('dispatch()', () => { beforeEach(() => vi.clearAllMocks()); - it('calls all four adapters', async () => { + it('calls all five adapters', async () => { await dispatch(BASE); expect(runTrufflehog).toHaveBeenCalledOnce(); expect(runSemgrep).toHaveBeenCalledOnce(); expect(runClaude).toHaveBeenCalledOnce(); - expect(runPiAgent).toHaveBeenCalledOnce(); + expect(runSpectre).toHaveBeenCalledOnce(); + expect(runDepDoctor).toHaveBeenCalledOnce(); }); it('passes scanFiles and scanWorkspacePath to the trufflehog adapter', async () => { @@ -81,12 +89,12 @@ describe('dispatch()', () => { })); }); - it('returns an empty array when both adapters return no findings', async () => { + it('returns an empty array when all adapters return no findings', async () => { const findings = await dispatch(BASE); expect(findings).toEqual([]); }); - it('merges findings from both adapters into a single array', async () => { + it('merges findings from trufflehog and semgrep into a single array', async () => { const th = { file: 'a.js', line: 1, severity: 'high', message: 'secret', ruleId: 'trufflehog/aws', tool: 'trufflehog' }; const sg = { file: 'b.py', line: 5, severity: 'medium', message: 'eval', ruleId: 'python/eval', tool: 'semgrep' }; (runTrufflehog as ReturnType).mockResolvedValueOnce([th]); @@ -166,34 +174,36 @@ describe('dispatch()', () => { }); it('merges findings from all four adapters', async () => { - const th = { file: 'a.js', line: 1, severity: 'high', message: 'secret', ruleId: 'trufflehog/aws', tool: 'trufflehog' }; - const sg = { file: 'b.py', line: 5, severity: 'medium', message: 'eval', ruleId: 'python/eval', tool: 'semgrep' }; - const cl = { file: 'c.sh', line: 3, severity: 'high', message: 'backdoor', ruleId: 'claude/reverse-shell', tool: 'claude' }; - const pa = { file: 'd.js', line: 9, severity: 'high', message: 'exfil', ruleId: 'pi_agent/data-exfiltration', tool: 'pi_agent' }; + const th = { file: 'a.js', line: 1, severity: 'high', message: 'secret', ruleId: 'trufflehog/aws', tool: 'trufflehog' }; + const sg = { file: 'b.py', line: 5, severity: 'medium', message: 'eval', ruleId: 'python/eval', tool: 'semgrep' }; + const cl = { file: 'c.sh', line: 3, severity: 'high', message: 'backdoor', ruleId: 'claude/reverse-shell', tool: 'claude' }; + const sp = { file: 'd.js', line: 9, severity: 'high', message: 'exfil', ruleId: 'credential-exfiltration', tool: 'spectre' }; (runTrufflehog as ReturnType).mockResolvedValueOnce([th]); (runSemgrep as ReturnType).mockResolvedValueOnce([sg]); (runClaude as ReturnType).mockResolvedValueOnce([cl]); - (runPiAgent as ReturnType).mockResolvedValueOnce([pa]); + (runSpectre as ReturnType).mockResolvedValueOnce([sp]); const findings = await dispatch(BASE); expect(findings).toHaveLength(4); expect(findings).toContainEqual(th); expect(findings).toContainEqual(sg); expect(findings).toContainEqual(cl); - expect(findings).toContainEqual(pa); + expect(findings).toContainEqual(sp); }); - it('passes toolConfig.piAgent to runPiAgent', async () => { + it('passes toolConfig.spectre, changedLineRanges, and promptFiles to runSpectre', async () => { await dispatch(BASE); - expect(runPiAgent).toHaveBeenCalledWith(expect.objectContaining({ - toolConfig: { enabled: false, model: 'claude-opus-4-6', thinkingLevel: 'medium', timeoutMinutes: 3 }, + expect(runSpectre).toHaveBeenCalledWith(expect.objectContaining({ + toolConfig: { enabled: false, model: 'claude-haiku-4-5-20251001', fileCap: 20, minSeverity: 'high' }, + changedLineRanges: new Map([['src/app.js', [{ start: 2, end: 4 }]]]), + promptFiles: [], })); }); - it('passes headSha from scanContext to runPiAgent', async () => { - await dispatch(BASE); - expect(runPiAgent).toHaveBeenCalledWith(expect.objectContaining({ - headSha: 'abc123', - })); + it('passes promptFiles from scan context to runSpectre in diff_only mode', async () => { + const promptFiles = [{ file: 'src/app.js', content: '@@ -1,3 +1,4 @@\n foo\n+bar' }]; + const diffContext: ScanContext = { ...BASE_SCAN_CONTEXT, mode: 'diff_only', promptFiles }; + await dispatch({ ...BASE, scanContext: diffContext }); + expect(runSpectre).toHaveBeenCalledWith(expect.objectContaining({ promptFiles })); }); }); diff --git a/src/__tests__/fetcher.test.ts b/src/__tests__/fetcher.test.ts index 896ffba..82f690c 100644 --- a/src/__tests__/fetcher.test.ts +++ b/src/__tests__/fetcher.test.ts @@ -4,7 +4,7 @@ const mockMkdtemp = vi.fn().mockResolvedValue('/tmp/layne-job-1-xyz'); const mockRm = vi.fn().mockResolvedValue(undefined); const mockRealpath = vi.fn(async (path: string) => path); const mockStat = vi.fn().mockResolvedValue({ isFile: () => true }); -const mockExecFile = vi.fn((cmd: string, args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(null, '', '')); +const mockExecFile = vi.fn((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(null, '', '')); vi.mock('fs/promises', () => ({ mkdtemp: mockMkdtemp, @@ -107,7 +107,7 @@ describe('setupRepo()', () => { }); it('throws if any git step fails', async () => { - mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => + mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(new Error('Repository not found'), '', '') ); await expect(setupRepo(defaultSetupArgs())).rejects.toThrow('Repository not found'); @@ -124,7 +124,7 @@ describe('getChangedFiles()', () => { }); it('runs git diff --name-only -z with explicit SHAs inside the workspace', async () => { - mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(null, '', '')); + mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(null, '', '')); await getChangedFiles({ workspacePath: '/tmp/ws', baseSha: 'base1', headSha: 'head1' }); const [cmd, args] = mockExecFile.mock.calls[0]; @@ -139,7 +139,7 @@ describe('getChangedFiles()', () => { }); it('returns an array of changed file paths (NUL-delimited output)', async () => { - mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => + mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(null, 'src/app.js\0src/utils.js\0', '') ); const files = await getChangedFiles({ workspacePath: '/tmp/ws', baseSha: 'b', headSha: 'h' }); @@ -147,7 +147,7 @@ describe('getChangedFiles()', () => { }); it('correctly handles filenames with spaces', async () => { - mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => + mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(null, 'src/my file.js\0src/utils.js\0', '') ); const files = await getChangedFiles({ workspacePath: '/tmp/ws', baseSha: 'b', headSha: 'h' }); @@ -155,13 +155,13 @@ describe('getChangedFiles()', () => { }); it('returns an empty array when no files changed', async () => { - mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(null, '', '')); + mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(null, '', '')); const files = await getChangedFiles({ workspacePath: '/tmp/ws', baseSha: 'b', headSha: 'h' }); expect(files).toEqual([]); }); it('drops paths with a leading slash', async () => { - mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => + mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(null, '/etc/passwd\0src/ok.js\0', '') ); const files = await getChangedFiles({ workspacePath: '/tmp/ws', baseSha: 'b', headSha: 'h' }); @@ -169,7 +169,7 @@ describe('getChangedFiles()', () => { }); it('drops paths containing .. traversal segments', async () => { - mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => + mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(null, '../escape.js\0src/ok.js\0', '') ); const files = await getChangedFiles({ workspacePath: '/tmp/ws', baseSha: 'b', headSha: 'h' }); @@ -208,7 +208,7 @@ describe('fetchCommit()', () => { }); it('throws when git exits with a non-zero code', async () => { - mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => + mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(new Error('unknown revision'), '', '') ); await expect(fetchCommit({ workspacePath: '/tmp/ws', sha: 'bad' })).rejects.toThrow('unknown revision'); @@ -221,7 +221,7 @@ describe('getChangedLineRanges()', () => { beforeEach(() => vi.clearAllMocks()); it('parses added and modified line ranges from a zero-context diff', async () => { - mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(null, [ + mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(null, [ 'diff --git a/src/app.js b/src/app.js', '--- a/src/app.js', '+++ b/src/app.js', @@ -245,7 +245,7 @@ describe('getChangedLineRanges()', () => { }); it('scopes the diff to specific files when files array is provided', async () => { - mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(null, '', '')); + mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(null, '', '')); await getChangedLineRanges({ workspacePath: '/tmp/ws', baseSha: 'b', headSha: 'h', files: ['src/a.js', 'src/b.js'] }); const args = mockExecFile.mock.calls[0][1]; @@ -255,7 +255,7 @@ describe('getChangedLineRanges()', () => { }); it('does not append -- when files array is empty', async () => { - mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(null, '', '')); + mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(null, '', '')); await getChangedLineRanges({ workspacePath: '/tmp/ws', baseSha: 'b', headSha: 'h' }); const args = mockExecFile.mock.calls[0][1]; @@ -263,7 +263,7 @@ describe('getChangedLineRanges()', () => { }); it('ignores deleted hunks that have no lines in the head revision', async () => { - mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(null, [ + mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(null, [ 'diff --git a/src/app.js b/src/app.js', '--- a/src/app.js', '+++ b/src/app.js', diff --git a/src/__tests__/location-validator.test.ts b/src/__tests__/location-validator.test.ts index ea36593..9399b90 100644 --- a/src/__tests__/location-validator.test.ts +++ b/src/__tests__/location-validator.test.ts @@ -282,10 +282,10 @@ describe('validateFindingLocations()', () => { }); // --------------------------------------------------------------------------- - // pi_agent findings + // spectre findings // --------------------------------------------------------------------------- - it('routes pi_agent findings through the evidence pipeline (strict evidence-only)', async () => { + it('routes spectre findings through the evidence pipeline (strict evidence-only)', async () => { mockReadFile.mockResolvedValueOnce( 'export function createHealthCheck(options = {}) {\n' + ' const socket = net.connect(options.port ?? 4444, options.host ?? \'198.51.100.42\');\n' + @@ -304,8 +304,8 @@ describe('validateFindingLocations()', () => { anchorLine: 1, severity: 'high', message: 'reverse shell', - ruleId: 'pi_agent/reverse-shell', - tool: 'pi_agent', + ruleId: 'reverse-shell', + tool: 'spectre', }], { workspacePath: '/tmp/ws', changedFiles: ['src/health.js'], @@ -322,7 +322,7 @@ describe('validateFindingLocations()', () => { expect(finding.annotationReason).toBe('anchored-by-evidence'); }); - it('always uses evidence location for pi_agent findings regardless of function size', async () => { + it('always uses evidence location for spectre findings regardless of function size', async () => { mockReadFile.mockResolvedValueOnce( 'export function createHealthCheck(options = {}) {\n' + ' const socket = net.connect(options.port ?? 4444, options.host ?? \'198.51.100.42\');\n' + @@ -342,8 +342,8 @@ describe('validateFindingLocations()', () => { evidence: 'return { socket, shell };', severity: 'high', message: 'reverse shell', - ruleId: 'pi_agent/reverse-shell', - tool: 'pi_agent', + ruleId: 'reverse-shell', + tool: 'spectre', }], { workspacePath: '/tmp/ws', changedFiles: ['src/health.js'], @@ -358,7 +358,7 @@ describe('validateFindingLocations()', () => { expect(finding.annotationReason).toBe('anchored-by-evidence'); }); - it('uses evidence location for pi_agent regardless of distance from declaration', async () => { + it('uses evidence location for spectre regardless of distance from declaration', async () => { const fillerLines = Array.from({ length: 55 }, (_, i) => ` // filler ${i + 1}\n`).join(''); mockReadFile.mockResolvedValueOnce( 'export function earlyDecl() {\n' + @@ -373,8 +373,8 @@ describe('validateFindingLocations()', () => { evidence: 'eval(remoteCode);', severity: 'high', message: 'covert execution', - ruleId: 'pi_agent/covert-execution', - tool: 'pi_agent', + ruleId: 'covert-execution', + tool: 'spectre', }], { workspacePath: '/tmp/ws', changedFiles: ['src/health.js'], @@ -388,7 +388,7 @@ describe('validateFindingLocations()', () => { expect(finding.annotationReason).toBe('anchored-by-evidence'); }); - it('ignores anchorKind on pi_agent findings — always uses evidence location', async () => { + it('ignores anchorKind on spectre findings — always uses evidence location', async () => { mockReadFile.mockResolvedValueOnce( 'export function exfilData() {\n' + ' const a = collectSecrets();\n' + @@ -404,8 +404,8 @@ describe('validateFindingLocations()', () => { anchorKind: 'span', severity: 'high', message: 'credential exfiltration', - ruleId: 'pi_agent/credential-exfiltration', - tool: 'pi_agent', + ruleId: 'credential-exfiltration', + tool: 'spectre', }], { workspacePath: '/tmp/ws', changedFiles: ['src/health.js'], @@ -419,16 +419,16 @@ describe('validateFindingLocations()', () => { expect(finding.annotationReason).toBe('anchored-by-evidence'); }); - it('rejects pi_agent findings that omit evidence', async () => { + it('rejects spectre findings that omit evidence', async () => { mockReadFile.mockResolvedValueOnce('line1\nmalicious();\nline3\n'); const [finding] = await validateFindingLocations([{ file: 'src/health.js', severity: 'high', message: 'suspicious', - ruleId: 'pi_agent/reverse-shell', + ruleId: 'reverse-shell', line: 0, - tool: 'pi_agent', + tool: 'spectre', }], { workspacePath: '/tmp/ws', changedFiles: ['src/health.js'], @@ -441,15 +441,15 @@ describe('validateFindingLocations()', () => { expect(finding.annotationReason).toBe('missing-evidence'); }); - it('rejects pi_agent findings for files outside the changed file set', async () => { + it('rejects spectre findings for files outside the changed file set', async () => { const [finding] = await validateFindingLocations([{ file: 'src/health.js', evidence: 'malicious();', severity: 'high', message: 'off diff', - ruleId: 'pi_agent/reverse-shell', + ruleId: 'reverse-shell', line: 0, - tool: 'pi_agent', + tool: 'spectre', }], { workspacePath: '/tmp/ws', changedFiles: ['src/other.js'], diff --git a/src/__tests__/scan-context.test.ts b/src/__tests__/scan-context.test.ts index 2c7e58e..410585d 100644 --- a/src/__tests__/scan-context.test.ts +++ b/src/__tests__/scan-context.test.ts @@ -36,6 +36,7 @@ describe('createScanContext()', () => { mode: 'changed_files', contextLines: 3, headSha: 'head', + baseSha: 'base', repoWorkspacePath: workspacePath, scanWorkspacePath: workspacePath, scanFiles: ['src/app.js'], @@ -53,7 +54,7 @@ describe('createScanContext()', () => { 'utf8' ); - mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => { + mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => { cb(null, '@@ -3,1 +3,1 @@\n', ''); }); @@ -94,6 +95,7 @@ describe('createScanContext()', () => { mode: 'diff_only', contextLines: 8, headSha: 'head', + baseSha: 'base', repoWorkspacePath: '/tmp/ws', scanWorkspacePath: '/tmp/ws', scanFiles: [], diff --git a/src/__tests__/suppressor.test.ts b/src/__tests__/suppressor.test.ts index 6123973..70a6992 100644 --- a/src/__tests__/suppressor.test.ts +++ b/src/__tests__/suppressor.test.ts @@ -8,12 +8,12 @@ const { suppressFindings } = await import('../suppressor.js'); // Helper: make execFile resolve with given stdout function resolveWith(stdout: string) { - mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(null, stdout, '')); + mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(null, stdout, '')); } // Helper: make execFile reject (simulates new file / blob unavailable) function rejectWith(err: Error = new Error('not found')) { - mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(err, '', '')); + mockExecFile.mockImplementationOnce((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => cb(err, '', '')); } // Sets up both the git diff (identity map: headLine === baseLine) and git show diff --git a/src/__tests__/worker.test.ts b/src/__tests__/worker.test.ts index c55f006..6405455 100644 --- a/src/__tests__/worker.test.ts +++ b/src/__tests__/worker.test.ts @@ -78,6 +78,7 @@ vi.mock('../config.js', () => ({ semgrep: { enabled: true, extraArgs: ['--config', 'auto'] }, trufflehog: { enabled: true, extraArgs: [] }, claude: { enabled: false, model: 'claude-haiku-4-5-20251001' }, + depDoctor: { enabled: false, minCveSeverity: 'high', checkAbandoned: true, abandonedDays: 730, checkDeprecated: true, extraArgs: [] }, notifications: {}, labels: {}, comment: { enabled: false, template: null }, @@ -90,6 +91,7 @@ vi.mock('../scan-context.js', () => ({ mode: 'changed_files', contextLines: 8, headSha: 'test-head-sha', + baseSha: 'merge-base-sha', repoWorkspacePath: '/tmp/layne-test-workspace', scanWorkspacePath: '/tmp/layne-test-workspace', scanFiles: ['src/app.js'], @@ -739,13 +741,14 @@ describe('processJob()', () => { await processJob(baseJob); expect(postComment).toHaveBeenCalledWith({ - findings: [finding], - owner: 'org', - repo: 'repo', - prNumber: 7, + findings: [finding], + owner: 'org', + repo: 'repo', + prNumber: 7, installationId: 1, - conclusion: 'success', - commentConfig: { enabled: true, template: null }, + headSha: 'abc123', + conclusion: 'success', + commentConfig: { enabled: true, template: null }, }); }); @@ -760,13 +763,14 @@ describe('processJob()', () => { await processJob(baseJob); expect(postComment).toHaveBeenCalledWith({ - findings: [finding], - owner: 'org', - repo: 'repo', - prNumber: 7, + findings: [finding], + owner: 'org', + repo: 'repo', + prNumber: 7, installationId: 1, - conclusion: 'success', - commentConfig: { enabled: true, template: null }, + headSha: 'abc123', + conclusion: 'success', + commentConfig: { enabled: true, template: null }, }); }); @@ -1052,7 +1056,7 @@ describe('processJob()', () => { (buildExceptionSummary as ReturnType).mockReturnValueOnce({ conclusion: 'success', summary: 'Excepted.' }); (redis.get as ReturnType).mockResolvedValueOnce('1'); // same count as current finding - const exceptionTriggeredJob = { ...baseJob, data: { ...baseJob.data, triggeredByException: true } }; + const exceptionTriggeredJob = { ...baseJob, data: { ...baseJob.data, triggeredByException: true } } as unknown as Job; await processJob(exceptionTriggeredJob); expect(notify).toHaveBeenCalledOnce(); diff --git a/src/adapters/dep-doctor.ts b/src/adapters/dep-doctor.ts new file mode 100644 index 0000000..c4c0b6b --- /dev/null +++ b/src/adapters/dep-doctor.ts @@ -0,0 +1,517 @@ +import { execFile } from 'child_process'; +import { mkdir, readFile, unlink, writeFile } from 'fs/promises'; +import { basename, join } from 'path'; +import { DEFAULT_CONFIG } from '../config.js'; +import { debug } from '../debug.js'; +import type { DepDoctorConfig, DepDoctorFinding, Severity } from '../types.js'; +import { exec } from './helpers.js'; + +// --------------------------------------------------------------------------- +// Lockfile detection +// --------------------------------------------------------------------------- + +const LOCKFILE_NAMES = new Set([ + 'package-lock.json', + 'yarn.lock', + 'pnpm-lock.yaml', + 'requirements.txt', + 'Pipfile.lock', + 'poetry.lock', + 'uv.lock', + 'go.sum', +]); + +type Ecosystem = 'npm' | 'PyPI' | 'Go'; + +const LOCKFILE_ECOSYSTEM: Record = { + 'package-lock.json': 'npm', + 'yarn.lock': 'npm', + 'pnpm-lock.yaml': 'npm', + 'requirements.txt': 'PyPI', + 'Pipfile.lock': 'PyPI', + 'poetry.lock': 'PyPI', + 'uv.lock': 'PyPI', + 'go.sum': 'Go', +}; + +// --------------------------------------------------------------------------- +// OSV-Scanner output types +// --------------------------------------------------------------------------- + +interface OsvPackage { + name: string; + version: string; + ecosystem: string; +} + +interface OsvVuln { + id: string; + database_specific?: { severity?: string }; +} + +interface OsvParsedEntry { + pkg: OsvPackage; + vulns: OsvVuln[]; +} + +interface DirectPackage { + name: string; + version: string; + ecosystem: Ecosystem; +} + +// --------------------------------------------------------------------------- +// Severity mapping +// --------------------------------------------------------------------------- + +const OSV_SEVERITY_MAP: Record = { + CRITICAL: 'critical', + HIGH: 'high', + MODERATE: 'medium', + MEDIUM: 'medium', + LOW: 'low', +}; + +const SEVERITY_ORDER: Record = { + critical: 4, high: 3, medium: 2, low: 1, info: 0, +}; + +function meetsSeverityThreshold(actual: Severity, minimum: Severity): boolean { + return SEVERITY_ORDER[actual] >= SEVERITY_ORDER[minimum]; +} + +// --------------------------------------------------------------------------- +// Exported adapter entry point +// --------------------------------------------------------------------------- + +export async function runDepDoctor({ + workspacePath, + changedFiles, + baseSha, + toolConfig = DEFAULT_CONFIG.depDoctor, +}: { + workspacePath: string; + changedFiles?: string[] | null; + baseSha: string; + toolConfig?: DepDoctorConfig; +}): Promise { + if (!toolConfig.enabled) return []; + if (!changedFiles?.length) return []; + + const lockfiles = changedFiles.filter(f => LOCKFILE_NAMES.has(basename(f))); + if (lockfiles.length === 0) return []; + + debug('dep-doctor', `scanning ${lockfiles.length} lockfile(s): ${lockfiles.join(', ')}`); + + const results = await Promise.all( + lockfiles.map(lf => processLockfile(lf, workspacePath, baseSha, toolConfig)) + ); + const findings = results.flat(); + + console.log(`[dep-doctor] ${findings.length} finding(s) across ${lockfiles.length} lockfile(s)`); + for (const f of findings) { + console.log(`[dep-doctor] ${f.severity.toUpperCase()} ${f.file}:${f.line} [${f.ruleId}] ${f.message}`); + } + return findings; +} + +// --------------------------------------------------------------------------- +// Per-lockfile processing +// --------------------------------------------------------------------------- + +async function processLockfile( + lockfilePath: string, + workspacePath: string, + baseSha: string, + toolConfig: DepDoctorConfig, +): Promise { + const absPath = join(workspacePath, lockfilePath); + + // A — get base lockfile content + let baseLockfileContent: string | null = null; + try { + baseLockfileContent = await gitShow(workspacePath, baseSha, lockfilePath); + } catch { + debug('dep-doctor', `${lockfilePath}: no base version (new file) — treating all packages as new`); + } + + // B — run OSV-Scanner on head lockfile + let headOutput = ''; + try { + headOutput = await exec('osv-scanner', [ + 'scan', '--lockfile', absPath, '--format', 'json', + ...(toolConfig.extraArgs ?? []), + ]); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + console.warn('[dep-doctor] osv-scanner not found in PATH — install it to enable CVE scanning'); + } else { + console.error(`[dep-doctor] osv-scanner failed for ${lockfilePath}: ${(err as Error).message}`); + } + } + + const headParsed = parseOsvOutput(headOutput); + if (headParsed.length === 0 && !toolConfig.checkAbandoned && !toolConfig.checkDeprecated) { + return []; + } + + // C — run OSV-Scanner on base lockfile (if it exists) to build the baseline package set + let basePackageSet = new Set(); + if (baseLockfileContent !== null) { + const tempDir = join(workspacePath, '.layne'); + const tempPath = join(tempDir, `dep-doctor-base-${Date.now()}-${Math.random().toString(36).slice(2)}`); + try { + await mkdir(tempDir, { recursive: true }); + await writeFile(tempPath, baseLockfileContent, 'utf8'); + let baseOutput = ''; + try { + baseOutput = await exec('osv-scanner', [ + 'scan', '--lockfile', tempPath, '--format', 'json', + ...(toolConfig.extraArgs ?? []), + ]); + } catch { + // Base scan failure → treat all head packages as new + } + basePackageSet = buildPackageSet(parseOsvOutput(baseOutput)); + } finally { + try { await unlink(tempPath); } catch { /* ignore */ } + } + } + + // D + E — CVE findings for new packages only + const findings: DepDoctorFinding[] = []; + const seen = new Set(); + + for (const { pkg, vulns } of headParsed) { + const pkgKey = `${pkg.ecosystem}:${pkg.name}@${pkg.version}`; + if (basePackageSet.has(pkgKey)) continue; // pre-existing dep + + for (const vuln of vulns) { + const dedupeKey = `${lockfilePath}:${pkgKey}:${vuln.id}`; + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + + const rawSeverity = vuln.database_specific?.severity?.toUpperCase() ?? ''; + const severity: Severity = OSV_SEVERITY_MAP[rawSeverity] ?? 'high'; + if (!meetsSeverityThreshold(severity, toolConfig.minCveSeverity)) continue; + + const line = await findLineInLockfile(absPath, pkg.name); + findings.push({ + file: lockfilePath, + line, + severity, + message: `New dependency ${pkg.name}@${pkg.version} has ${severity.toUpperCase()} vulnerability ${vuln.id}`, + ruleId: vuln.id, + tool: 'dep-doctor', + }); + } + } + + // F — registry health checks (all new packages, from direct lockfile parse) + // We parse the lockfile directly rather than using headParsed (OSV output) so that + // packages with no CVEs — e.g. abandoned ones — are still health-checked. + if (toolConfig.checkAbandoned || toolConfig.checkDeprecated) { + let headContent = ''; + try { + headContent = await readFile(absPath, 'utf8'); + } catch { /* skip health checks if lockfile unreadable */ } + + const allHeadPackages = parseLockfilePackages(headContent, basename(absPath)); + + const baseHealthKeys = new Set(); + if (baseLockfileContent !== null) { + for (const p of parseLockfilePackages(baseLockfileContent, basename(absPath))) { + baseHealthKeys.add(`${p.name}@${p.version}`); + } + } + + const newPackages = allHeadPackages.filter(p => !baseHealthKeys.has(`${p.name}@${p.version}`)); + + const BATCH = 5; + for (let i = 0; i < newPackages.length; i += BATCH) { + const batch = newPackages.slice(i, i + BATCH); + const healthResults = await Promise.all( + batch.map(pkg => checkPackageHealth({ name: pkg.name, version: pkg.version, ecosystem: pkg.ecosystem }, toolConfig)) + ); + + for (let j = 0; j < batch.length; j++) { + const pkg = batch[j]!; + const health = healthResults[j]!; + const line = await findLineInLockfile(absPath, pkg.name); + + if (health.abandoned && toolConfig.checkAbandoned) { + findings.push({ + file: lockfilePath, + line, + severity: 'medium', + message: `New dependency ${pkg.name}@${pkg.version} appears abandoned. ${health.reason}`, + ruleId: 'abandoned/deprecated', + tool: 'dep-doctor', + }); + } + if (health.deprecated && toolConfig.checkDeprecated) { + findings.push({ + file: lockfilePath, + line, + severity: 'medium', + message: `New dependency ${pkg.name}@${pkg.version} is deprecated. ${health.reason}`, + ruleId: 'abandoned/deprecated', + tool: 'dep-doctor', + }); + } + } + } + } + + return findings; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function gitShow(workspacePath: string, sha: string, filePath: string): Promise { + return new Promise((resolve, reject) => { + execFile( + 'git', ['-C', workspacePath, 'show', `${sha}:${filePath}`], + { maxBuffer: 50 * 1024 * 1024, encoding: 'utf8' }, + (err, stdout) => { + if (err) reject(err); + else resolve(stdout as string); + }, + ); + }); +} + +function parseOsvOutput(stdout: string): OsvParsedEntry[] { + if (!stdout.trim()) return []; + try { + const raw = JSON.parse(stdout) as { + results?: Array<{ + packages?: Array<{ + package?: OsvPackage; + vulnerabilities?: OsvVuln[]; + }>; + }>; + }; + const entries: OsvParsedEntry[] = []; + for (const result of raw.results ?? []) { + for (const pkg of result.packages ?? []) { + if (!pkg.package) continue; + entries.push({ pkg: pkg.package, vulns: pkg.vulnerabilities ?? [] }); + } + } + return entries; + } catch { + console.error('[dep-doctor] Failed to parse OSV-Scanner JSON output'); + return []; + } +} + +function buildPackageSet(entries: OsvParsedEntry[]): Set { + return new Set(entries.map(({ pkg }) => `${pkg.ecosystem}:${pkg.name}@${pkg.version}`)); +} + +async function findLineInLockfile(absPath: string, packageName: string): Promise { + let content: string; + try { + content = await readFile(absPath, 'utf8'); + } catch { + return 1; + } + const file = basename(absPath); + const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + let pattern: RegExp; + + if (file === 'package-lock.json') { + pattern = new RegExp(`"node_modules/${escaped}"`); + } else if (file === 'yarn.lock') { + pattern = new RegExp(`^"?${escaped}@`, 'm'); + } else if (file === 'requirements.txt') { + pattern = new RegExp(`^${escaped}`, 'im'); + } else if (file === 'go.sum') { + pattern = new RegExp(`^${escaped} `, 'm'); + } else { + pattern = new RegExp(escaped, 'i'); + } + + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (pattern.test(lines[i] ?? '')) return i + 1; + } + return 1; +} + +// --------------------------------------------------------------------------- +// Direct lockfile parsing (all packages, regardless of CVE status) +// Used for health checks so packages with no CVEs are still flagged as abandoned/deprecated. +// --------------------------------------------------------------------------- + +function parseLockfilePackages(content: string, filename: string): DirectPackage[] { + if (filename === 'package-lock.json') { + try { + const json = JSON.parse(content) as { + packages?: Record; + dependencies?: Record; + }; + if (json.packages) { + return Object.entries(json.packages) + .filter(([k, v]) => k.startsWith('node_modules/') && v.version) + .map(([k, v]) => { + const lastIdx = k.lastIndexOf('node_modules/'); + const name = k.slice(lastIdx + 'node_modules/'.length); + return { name, version: v.version!, ecosystem: 'npm' as Ecosystem }; + }); + } + if (json.dependencies) { + return Object.entries(json.dependencies) + .filter(([, v]) => v.version) + .map(([k, v]) => ({ name: k, version: v.version!, ecosystem: 'npm' as Ecosystem })); + } + } catch { /* fall through */ } + return []; + } + + if (filename === 'yarn.lock') { + const packages: DirectPackage[] = []; + let currentName: string | null = null; + for (const line of content.split('\n')) { + if (!line.startsWith(' ') && !line.startsWith('#')) { + const m = line.match(/^"?(@?[^@\s"]+)@/); + currentName = m ? (m[1] ?? null) : null; + } + if (currentName) { + const m = line.match(/^\s+version\s+"([^"]+)"/); + if (m) { + packages.push({ name: currentName, version: m[1]!, ecosystem: 'npm' }); + currentName = null; + } + } + } + return packages; + } + + if (filename === 'requirements.txt') { + return content.split('\n') + .map(l => l.trim()) + .filter(l => l && !l.startsWith('#')) + .flatMap(l => { + const m = l.match(/^([A-Za-z0-9_.-]+)==([^\s;#]+)/); + return m ? [{ name: m[1]!, version: m[2]!, ecosystem: 'PyPI' as Ecosystem }] : []; + }); + } + + return []; +} + +// --------------------------------------------------------------------------- +// Registry health checks +// --------------------------------------------------------------------------- + +interface HealthResult { + abandoned: boolean; + deprecated: boolean; + reason: string; +} + +async function checkPackageHealth(pkg: OsvPackage, config: DepDoctorConfig): Promise { + const eco = LOCKFILE_ECOSYSTEM[pkg.ecosystem] ?? pkg.ecosystem as Ecosystem; + if (eco === 'npm') return checkNpmHealth(pkg.name, pkg.version, config); + if (eco === 'PyPI') return checkPypiHealth(pkg.name, config); + return { abandoned: false, deprecated: false, reason: '' }; +} + +async function checkNpmHealth(name: string, version: string, config: DepDoctorConfig): Promise { + try { + const url = `https://registry.npmjs.org/${encodeURIComponent(name)}`; + const resp = await fetch(url, { + headers: { 'User-Agent': 'layne-dep-doctor/1.0' }, + signal: AbortSignal.timeout(10_000), + }); + if (!resp.ok) return { abandoned: false, deprecated: false, reason: '' }; + + const data = await resp.json() as { + time?: Record; + versions?: Record; + }; + + if (config.checkDeprecated) { + const deprecatedMsg = data.versions?.[version]?.deprecated; + if (deprecatedMsg) { + return { abandoned: false, deprecated: true, reason: String(deprecatedMsg).slice(0, 200) }; + } + } + + if (config.checkAbandoned) { + const times = Object.entries(data.time ?? {}) + .filter(([k]) => k !== 'created' && k !== 'modified') + .map(([, v]) => new Date(v).getTime()) + .filter(t => !isNaN(t)); + + if (times.length > 0) { + const lastPublish = Math.max(...times); + const daysSince = (Date.now() - lastPublish) / (1000 * 60 * 60 * 24); + if (daysSince > config.abandonedDays) { + const lastDate = new Date(lastPublish).toISOString().slice(0, 10); + return { abandoned: true, deprecated: false, reason: `Last published: ${lastDate}` }; + } + } + } + + return { abandoned: false, deprecated: false, reason: '' }; + } catch (err) { + debug('dep-doctor', `npm registry check failed for ${name}: ${(err as Error).message}`); + return { abandoned: false, deprecated: false, reason: '' }; + } +} + +async function checkPypiHealth(name: string, config: DepDoctorConfig): Promise { + try { + const url = `https://pypi.org/pypi/${encodeURIComponent(name)}/json`; + const resp = await fetch(url, { + headers: { 'User-Agent': 'layne-dep-doctor/1.0' }, + signal: AbortSignal.timeout(10_000), + }); + if (!resp.ok) return { abandoned: false, deprecated: false, reason: '' }; + + const data = await resp.json() as { + info?: { classifiers?: string[] }; + releases?: Record>; + }; + + if (config.checkDeprecated) { + const classifiers = data.info?.classifiers ?? []; + const inactive = classifiers.some(c => + c.includes('Development Status :: 7 - Inactive') || c.toLowerCase().includes('deprecated') + ); + if (inactive) { + return { abandoned: false, deprecated: true, reason: 'Package marked inactive/deprecated' }; + } + } + + if (config.checkAbandoned) { + const allDates: number[] = []; + for (const files of Object.values(data.releases ?? {})) { + for (const f of files) { + if (f.upload_time_iso_8601) { + const t = new Date(f.upload_time_iso_8601).getTime(); + if (!isNaN(t)) allDates.push(t); + } + } + } + if (allDates.length > 0) { + const lastPublish = Math.max(...allDates); + const daysSince = (Date.now() - lastPublish) / (1000 * 60 * 60 * 24); + if (daysSince > config.abandonedDays) { + const lastDate = new Date(lastPublish).toISOString().slice(0, 10); + return { abandoned: true, deprecated: false, reason: `Last published: ${lastDate}` }; + } + } + } + + return { abandoned: false, deprecated: false, reason: '' }; + } catch (err) { + debug('dep-doctor', `PyPI registry check failed for ${name}: ${(err as Error).message}`); + return { abandoned: false, deprecated: false, reason: '' }; + } +} diff --git a/src/adapters/helpers.ts b/src/adapters/helpers.ts index 6aabd65..3a5716e 100644 --- a/src/adapters/helpers.ts +++ b/src/adapters/helpers.ts @@ -1,6 +1,8 @@ import { execFile } from 'child_process'; import { debug } from '../debug.js'; +const MAX_STDOUT_BUFFER = 200 * 1024 * 1024; // 200 MB — prevents ENOBUF on large scan outputs + /** * Resolves with stdout regardless of exit code so callers can parse * findings from a non-zero exit (e.g. Semgrep exits 1, Trufflehog exits 183). @@ -9,7 +11,7 @@ import { debug } from '../debug.js'; export function exec(cmd: string, args: string[], options: Record = {}): Promise { return new Promise((resolve, reject) => { debug(cmd, `running: ${cmd} ${args.join(' ')}`); - execFile(cmd, args, { ...options, encoding: 'utf8' } as Parameters[2], (err, stdout, stderr) => { + execFile(cmd, args, { maxBuffer: MAX_STDOUT_BUFFER, ...options, encoding: 'utf8' } as Parameters[2], (err, stdout, stderr) => { const stdoutStr = stdout as string; const stderrStr = stderr as string; if (stderrStr) { diff --git a/src/adapters/pi-agent-tools.ts b/src/adapters/pi-agent-tools.ts deleted file mode 100644 index 44a7c39..0000000 --- a/src/adapters/pi-agent-tools.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { execFile } from 'child_process'; -import fs from 'fs/promises'; -import path from 'path'; -import { glob as globFn } from 'tinyglobby'; -import { - createReadTool, - createGrepTool, - createFindTool, - createLsTool, - type ReadOperations, - type GrepOperations, - type FindOperations, - type LsOperations, -} from '@mariozechner/pi-coding-agent'; - -const MAX_FILE_BYTES = 200 * 1024; // 200 KB - -async function readTruncated(absolutePath: string): Promise { - let size: number | null = null; - try { - const stat = await fs.stat(absolutePath); - size = stat?.size ?? null; - } catch { - // stat failed — fall through to readFile which will surface its own error - } - if (size === null || size <= MAX_FILE_BYTES) return fs.readFile(absolutePath); - const handle = await fs.open(absolutePath, 'r'); - try { - const buf = Buffer.alloc(MAX_FILE_BYTES); - const { bytesRead } = await handle.read(buf, 0, MAX_FILE_BYTES, 0); - const notice = Buffer.from(`\n[file truncated: ${size} bytes total, showing first ${MAX_FILE_BYTES} bytes]\n`); - return Buffer.concat([buf.subarray(0, bytesRead), notice]); - } finally { - await handle.close(); - } -} - -/** - * Resolves `absolutePath` and throws if it escapes `workspacePath`. - * Returns the normalized absolute path on success. - * - * Note: grep's main ripgrep spawn resolves paths via the upstream resolveToCwd() - * and is not interceptable through the GrepOperations interface. The confined - * GrepOperations below guard auxiliary reads only (context lines, isDirectory - * checks). Grep therefore carries a residual risk of line-level content leakage - * from outside the workspace if the model is injected with an absolute path. - * read, find, and ls are fully confined. - */ -function confinePath(absolutePath: string, workspacePath: string): string { - const resolved = path.resolve(absolutePath); - const workspace = path.resolve(workspacePath); - const prefix = workspace.endsWith(path.sep) ? workspace : workspace + path.sep; - - if (resolved !== workspace && !resolved.startsWith(prefix)) { - throw new Error(`[pi-agent] access denied: ${absolutePath} is outside the workspace`); - } - - return resolved; -} - -// --------------------------------------------------------------------------- -// Git helpers for lazy blob fetching -// --------------------------------------------------------------------------- - -/** - * Returns the git object type ('blob', 'tree', etc.) for the given path at sha. - * Tree objects are always available after setupRepo (--filter=blob:none fetches - * everything except blobs), so this is cheap and does not trigger a network fetch. - * Rejects if the path does not exist at that commit. - */ -function gitObjectType(workspacePath: string, sha: string, relativePath: string): Promise { - return new Promise((resolve, reject) => { - execFile('git', ['-C', workspacePath, 'cat-file', '-t', `${sha}:${relativePath}`], (err, stdout) => { - if (err) reject(err); - else resolve(stdout.trim()); - }); - }); -} - -/** - * Fetches the blob content for the given path at sha via `git show` and writes - * it to disk so subsequent grep/find/ls calls can also see the file. - * Returns the file content as a Buffer. - */ -function gitShow(workspacePath: string, sha: string, relativePath: string, absolutePath: string): Promise { - return new Promise((resolve, reject) => { - execFile( - 'git', - ['-C', workspacePath, 'show', `${sha}:${relativePath}`], - { encoding: 'buffer', maxBuffer: 50 * 1024 * 1024 }, - async (err, stdout) => { - if (err) { reject(err); return; } - const content = stdout as unknown as Buffer; - try { - await fs.mkdir(path.dirname(absolutePath), { recursive: true }); - await fs.writeFile(absolutePath, content); - } catch { - // Materialization failure is non-fatal — return content in memory - } - resolve(content); - }, - ); - }); -} - -// --------------------------------------------------------------------------- -// Confined operations -// --------------------------------------------------------------------------- - -function confinedReadOperations( - workspacePath: string, - options?: { headSha?: string; followImports?: boolean }, -): ReadOperations { - const { headSha, followImports = false } = options ?? {}; - - return { - readFile: async (absolutePath: string) => { - const safe = confinePath(absolutePath, workspacePath); - try { - return await readTruncated(safe); - } catch (err: unknown) { - const nodeErr = err as NodeJS.ErrnoException; - if (!followImports || !headSha || nodeErr.code !== 'ENOENT') throw err; - const relative = path.relative(workspacePath, safe); - try { - const buf = await gitShow(workspacePath, headSha, relative, safe); - if (buf.length <= MAX_FILE_BYTES) return buf; - const notice = Buffer.from(`\n[file truncated: ${buf.length} bytes total, showing first ${MAX_FILE_BYTES} bytes]\n`); - return Buffer.concat([buf.subarray(0, MAX_FILE_BYTES), notice]); - } catch { - throw err; // re-throw original ENOENT — file doesn't exist in repo - } - } - }, - access: async (absolutePath: string) => { - const safe = confinePath(absolutePath, workspacePath); - try { - return await fs.access(safe); - } catch (err: unknown) { - const nodeErr = err as NodeJS.ErrnoException; - if (!followImports || !headSha || nodeErr.code !== 'ENOENT') throw err; - const relative = path.relative(workspacePath, safe); - let type: string; - try { - type = await gitObjectType(workspacePath, headSha, relative); - } catch { - throw err; // path doesn't exist in git either - } - if (type !== 'blob') throw err; // directories are not lazily materialized - // File exists in git as a blob — readFile will fetch it on demand - } - }, - }; -} - -function confinedGrepOperations(workspacePath: string): GrepOperations { - return { - isDirectory: async (absolutePath: string) => { - const safe = confinePath(absolutePath, workspacePath); - const stat = await fs.stat(safe); - return stat.isDirectory(); - }, - readFile: async (absolutePath: string) => { - const safe = confinePath(absolutePath, workspacePath); - return fs.readFile(safe, 'utf8'); - }, - }; -} - -function confinedFindOperations(workspacePath: string): FindOperations { - const workspace = path.resolve(workspacePath); - return { - exists: async (absolutePath: string) => { - const safe = confinePath(absolutePath, workspacePath); - return fs.access(safe).then(() => true).catch(() => false); - }, - glob: async (pattern: string, cwd: string, options: { ignore: string[]; limit: number }) => { - // Confine cwd before running the glob - const safeCwd = confinePath(cwd, workspacePath); - const results = await globFn(pattern, { - cwd: safeCwd, - ignore: options.ignore, - absolute: true, - }); - // Filter results to workspace just in case glob follows symlinks outside - return results - .filter((p: string) => { - const resolved = path.resolve(p); - const prefix = workspace.endsWith(path.sep) ? workspace : workspace + path.sep; - return resolved === workspace || resolved.startsWith(prefix); - }) - .slice(0, options.limit); - }, - }; -} - -function confinedLsOperations(workspacePath: string): LsOperations { - return { - exists: async (absolutePath: string) => { - const safe = confinePath(absolutePath, workspacePath); - return fs.access(safe).then(() => true).catch(() => false); - }, - stat: async (absolutePath: string) => { - const safe = confinePath(absolutePath, workspacePath); - return fs.stat(safe); - }, - readdir: async (absolutePath: string) => { - const safe = confinePath(absolutePath, workspacePath); - return fs.readdir(safe); - }, - }; -} - -/** - * Creates read-only file tools (read, grep, find, ls) confined to workspacePath. - * Replaces createReadOnlyTools() from @mariozechner/pi-coding-agent, which allows - * absolute paths to escape the workspace boundary. - * - * When options.followImports is true (default in PiAgentConfig) and options.headSha - * is provided, the read tool will lazily fetch files from git on ENOENT so the agent - * can follow imports beyond the sparse-checked-out changed files. - */ -export function createConfinedTools( - workspacePath: string, - options?: { headSha?: string; followImports?: boolean }, -) { - return [ - createReadTool(workspacePath, { operations: confinedReadOperations(workspacePath, options) }), - createGrepTool(workspacePath, { operations: confinedGrepOperations(workspacePath) }), - createFindTool(workspacePath, { operations: confinedFindOperations(workspacePath) }), - createLsTool(workspacePath, { operations: confinedLsOperations(workspacePath) }), - ]; -} diff --git a/src/adapters/pi-agent.ts b/src/adapters/pi-agent.ts deleted file mode 100644 index 8d094be..0000000 --- a/src/adapters/pi-agent.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { Type } from '@sinclair/typebox'; -import { - createAgentSession, - DefaultResourceLoader, - SessionManager, - type ToolDefinition, -} from '@mariozechner/pi-coding-agent'; -import { createConfinedTools } from './pi-agent-tools.js'; -import { getModel, getEnvApiKey } from '@mariozechner/pi-ai'; -import { debug } from '../debug.js'; -import { DEFAULT_CONFIG } from '../config.js'; -import type { PiAgentRawFinding, PiAgentConfig, LineRangesByFile, LineRange } from '../types.js'; - -const SKIP_PATTERNS = [ - /\.test\.[jt]sx?$/, - /\.spec\.[jt]sx?$/, - /\.d\.ts$/, - /package-lock\.json$/, - /yarn\.lock$/, - /pnpm-lock\.yaml$/, - /\.lock$/, - /\.md$/, - /\.txt$/, - /\.css$/, - /\.scss$/, - /\.svg$/, - /\.(png|jpg|gif|ico|woff2?)$/, -]; - -const SYSTEM_PROMPT = - 'You are a security code reviewer with access to tools that let you read and explore a code repository. ' + - 'Your job is to detect malicious intent: reverse shells, backdoors, credential exfiltration, ' + - 'obfuscated payloads, and supply-chain attacks. ' + - 'Use the read, grep, find, and ls tools to explore the changed files and any related code they import or call. ' + - 'Scan the whole file, not just the changed line ranges. The changed ranges are context to help you anchor findings accurately. ' + - 'Report ONLY confirmed malicious patterns with high confidence. ' + - 'Do not report style issues, bugs, theoretical vulnerabilities, or code that is merely odd or messy. ' + - 'Do not report: ordinary vulnerable code with no evidence of malicious intent; eval, exec, spawn, or similar APIs in clearly benign static contexts; packages that are merely unknown or low-download with no hostile behavior in the provided files. ' + - 'For each finding, call report_finding exactly once with an exact verbatim evidence snippet copied from the file. ' + - 'evidence must be a short exact verbatim contiguous snippet — the smallest distinctive snippet that uniquely identifies the malicious logic in that file. ' + - 'Do not paraphrase, summarize, insert ellipses, or combine non-adjacent lines. ' + - 'If the snippet appears more than once in the file, choose a longer unique snippet or omit the finding. ' + - 'If you cannot provide unique exact verbatim evidence, omit the finding. ' + - 'Line numbers you report will be ignored — only the evidence string is used to determine the annotation location. ' + - 'Focus on providing accurate, unique evidence snippets. ' + - 'ruleId must be exactly one of: reverse-shell, credential-exfiltration, obfuscated-payload, backdoor, supply-chain-abuse, covert-execution. ' + - 'Before emitting a finding, verify all three: the behavior is clearly malicious or clearly enabling malicious execution; ' + - 'you can quote a unique exact contiguous snippet from the file; ' + - 'you would be comfortable surfacing it to a security engineer as a real alert. If any answer is no, omit the finding. ' + - 'Do not write, edit, or modify any files.'; - -// --------------------------------------------------------------------------- -// report_finding tool definition (TypeBox schema) -// --------------------------------------------------------------------------- - -const ReportFindingParams = Type.Object({ - file: Type.String({ description: 'File path relative to the repository root' }), - startLine: Type.Optional(Type.Integer({ description: 'Start line of the finding as shown in the read() output. Must match the line where the evidence string begins in the file. Do not use 1 unless the evidence is genuinely on line 1.' })), - endLine: Type.Optional(Type.Integer({ description: 'End line of the finding as shown in the read() output. Must match the line where the evidence string ends in the file.' })), - severity: Type.Union([Type.Literal('high'), Type.Literal('medium'), Type.Literal('low')]), - message: Type.String({ description: 'Description of the malicious pattern' }), - ruleId: Type.String({ description: 'Short kebab-case rule identifier, e.g. "reverse-shell"' }), - evidence: Type.String({ description: 'Exact verbatim contiguous snippet copied from the file that uniquely identifies the malicious code. This is the ONLY field used to determine annotation location.' }), -}); - -// --------------------------------------------------------------------------- -// Normalization (mirrors claude.ts) -// --------------------------------------------------------------------------- - -interface RawFindingInput { - file: string; - startLine?: unknown; - endLine?: unknown; - severity: string; - message: string; - ruleId: string; - evidence?: string; -} - -function formatLineRanges(ranges: LineRange[]): string { - return ranges.map(r => `${r.start}-${r.end}`).join(', '); -} - -function normalizePositiveInt(value: unknown): number | null { - const parsed = Number.parseInt(String(value), 10); - return Number.isInteger(parsed) && parsed > 0 ? parsed : null; -} - -function normalizeFinding(raw: RawFindingInput): PiAgentRawFinding { - const startLine = normalizePositiveInt(raw.startLine) ?? 1; - const endLine = normalizePositiveInt(raw.endLine ?? raw.startLine) ?? startLine; - - return { - file: raw.file, - severity: raw.severity as PiAgentRawFinding['severity'], - message: raw.message, - ruleId: `pi_agent/${raw.ruleId}`, - tool: 'pi_agent', - line: startLine, - startLine, - endLine: endLine >= startLine ? endLine : startLine, - evidence: typeof raw.evidence === 'string' ? raw.evidence.trim() : '', - // Pi Agent uses strict evidence-only positioning - these are not used - anchorKind: undefined, - anchorLine: undefined, - }; -} - -// --------------------------------------------------------------------------- -// Main export -// --------------------------------------------------------------------------- - -/** - * Runs an agentic security scan using @mariozechner/pi-coding-agent. - * - * Unlike the claude adapter (single-turn batch), this adapter spins up a - * full agent session with read-only file tools so the model can traverse - * imports and follow suspicious patterns across file boundaries. - * - * Non-determinism note: because the agent drives its own investigation, the - * same code may produce findings with different ruleIds or line numbers across - * runs. Finding IDs (LAYNE-xxx) may therefore change between scans, which - * means exception approvals for pi_agent findings may not survive re-scans. - */ -export async function runPiAgent({ - workspacePath, - changedFiles, - changedLineRanges = new Map(), - toolConfig = DEFAULT_CONFIG.piAgent, - headSha, -}: { - workspacePath: string; - changedFiles?: string[] | null; - changedLineRanges?: LineRangesByFile; - toolConfig?: PiAgentConfig; - headSha?: string; -}): Promise { - if (!changedFiles || changedFiles.length === 0) return []; - - const filteredFiles = changedFiles.filter(f => !SKIP_PATTERNS.some(p => p.test(f))); - const skippedCount = changedFiles.length - filteredFiles.length; - if (skippedCount > 0) { - console.log(`[pi-agent] skipping ${skippedCount} file(s) matched by skip patterns`); - } - if (filteredFiles.length === 0) { - console.log('[pi-agent] no files to scan after filtering'); - return []; - } - - if (!toolConfig.enabled) { - console.log('[pi-agent] skipping — not enabled for this repo (set "piAgent": {"enabled": true} in config/layne.json)'); - return []; - } - if (!toolConfig.provider) { - console.log('[pi-agent] skipping — no provider configured (set "piAgent": {"provider": "anthropic"} in config/layne.json)'); - return []; - } - - const provider = toolConfig.provider; - - if (!getEnvApiKey(provider)) { - console.log(`[pi-agent] skipping — no credentials found for provider "${provider}"`); - return []; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const model = getModel(provider as any, toolConfig.model as any); - if (!model) { - console.error(`[pi-agent] model "${toolConfig.model}" not found for provider "${provider}" — skipping`); - return []; - } - - console.log(`[pi-agent] scanning ${filteredFiles.length} file(s) with provider ${provider}, model ${toolConfig.model} (thinking: ${toolConfig.thinkingLevel ?? 'medium'})`); - - const fileList = filteredFiles.map(f => { - const ranges = changedLineRanges.get(f) ?? []; - const rangeStr = ranges.length > 0 - ? ` (changed lines: ${formatLineRanges(ranges)})` - : ''; - return ` - ${f}${rangeStr}`; - }).join('\n'); - - const isCustomPrompt = toolConfig.prompt !== null && toolConfig.prompt !== undefined; - const initialPrompt = isCustomPrompt - ? `The following files were changed in this PR and require a security review:\n${fileList}\n\n` + - 'Start by reading each changed file in full using the read tool on the actual file path (not the .layne/diff-only/ path). ' + - 'Then broaden your investigation beyond the changed files themselves: read the files they import from, ' + - 'use grep to find where changed functions are called from elsewhere in the codebase, ' + - 'and read any shared utilities, models, or middleware that the changed code interacts with. ' + - 'Understanding the full context around each change — not just the changed lines — reveals vulnerabilities that span multiple files. ' + - 'Follow imports and function calls as deeply as needed to trace data flows from source to sink. ' + - 'Use the changed line ranges above to anchor your findings accurately. ' + - 'For each confirmed finding, call report_finding.' - : `The following files were changed in this PR and require a security review:\n${fileList}\n\n` + - 'Investigate these files for malicious intent: reverse shells, backdoors, credential exfiltration, ' + - 'obfuscated payloads, and supply-chain attacks. ' + - 'Use the read, grep, find, and ls tools to explore the code. Follow imports and dependencies where suspicious. ' + - 'Scan each whole file but use the changed line ranges above to prioritize where to anchor your findings. ' + - 'For each confirmed finding, call report_finding. Report only high-confidence confirmed malicious patterns.'; - - const systemPrompt = toolConfig.prompt ?? SYSTEM_PROMPT; - const timeoutMs = (toolConfig.timeoutMinutes ?? 3) * 60 * 1000; - - // --------------------------------------------------------------------------- - // Session runner — extracted so it can be retried on silent failure - // --------------------------------------------------------------------------- - const runSession = async (attempt: number): Promise<{ findings: PiAgentRawFinding[]; hadActivity: boolean; timedOut: boolean }> => { - const sessionFindings: PiAgentRawFinding[] = []; - let hadActivity = false; - let timedOut = false; - - const reportFindingTool: ToolDefinition = { - name: 'report_finding', - label: 'Report Finding', - description: 'Report a confirmed security finding. Call once per finding.', - parameters: ReportFindingParams, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - execute: async (_toolCallId: any, params: any, _signal: any, _onUpdate: any, _ctx: any) => { - hadActivity = true; - const finding = normalizeFinding(params as RawFindingInput); - sessionFindings.push(finding); - debug('pi-agent', `finding recorded: ${finding.severity.toUpperCase()} ${finding.file}:${finding.startLine} [${finding.ruleId}]`); - return { - content: [{ type: 'text' as const, text: 'Finding recorded.' }], - details: {}, - }; - }, - }; - - const rawTools = createConfinedTools(workspacePath, { - headSha, - followImports: toolConfig.followImports ?? true, - }); - - // Wrap each tool's execute to detect model activity (any tool call = session engaged) - const tools = rawTools.map(tool => ({ - ...tool, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - execute: async (...args: any[]) => { - hadActivity = true; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (tool.execute as any)(...args); - }, - })); - - const resourceLoader = new DefaultResourceLoader({ - cwd: workspacePath, - agentDir: workspacePath, - systemPromptOverride: () => systemPrompt, - appendSystemPromptOverride: () => [], - noExtensions: true, - noSkills: true, - noPromptTemplates: true, - }); - await resourceLoader.reload(); - - // Pass confined tools as customTools (they override built-ins of the same name in the - // registry) and use their names as the tools allowlist so only our tools are active. - const activeToolNames = [...tools.map(t => t.name), reportFindingTool.name]; - const { session } = await createAgentSession({ - cwd: workspacePath, - tools: activeToolNames, - customTools: [...tools, reportFindingTool], - sessionManager: SessionManager.inMemory(), - model, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - thinkingLevel: (toolConfig.thinkingLevel ?? 'medium') as any, - resourceLoader, - }); - - const timer = setTimeout(() => { - timedOut = true; - session.abort().catch(() => {}); - }, timeoutMs); - - try { - await session.prompt(initialPrompt); - } catch (err) { - if (!timedOut) { - console.error(`[pi-agent] error during scan (attempt ${attempt}):`, (err as Error).message ?? err); - } - } finally { - clearTimeout(timer); - session.dispose(); - } - - return { findings: sessionFindings, hadActivity, timedOut }; - }; - - // --------------------------------------------------------------------------- - // First attempt - // --------------------------------------------------------------------------- - const first = await runSession(1); - - if (first.timedOut) { - console.warn(`[pi-agent] scan timed out after ${toolConfig.timeoutMinutes ?? 3}m — returning ${first.findings.length} partial finding(s)`); - } - - // Retry once on two failure modes: - // 1. Silent failure — model didn't call any tools at all - // 2. Bad-evidence run — model produced findings but all at line 1, meaning it reported - // from memory rather than reading the actual files (location validator will drop them all) - const silentFailure = first.findings.length === 0 && !first.hadActivity && !first.timedOut; - const badEvidenceRun = first.findings.length > 0 && first.findings.every(f => f.startLine === 1 && f.endLine === 1); - - let result = first; - if (!first.timedOut && (silentFailure || badEvidenceRun)) { - console.warn(`[pi-agent] ${silentFailure ? 'session produced no output' : 'all findings at line 1 (bad evidence)'} — retrying once`); - const retry = await runSession(2); - if (retry.timedOut) { - console.warn(`[pi-agent] retry timed out after ${toolConfig.timeoutMinutes ?? 3}m — returning ${retry.findings.length} partial finding(s)`); - } - result = retry; - } - - console.log(`[pi-agent] ${result.findings.length} finding(s):`); - for (const f of result.findings) { - console.log(`[pi-agent] ${f.severity.toUpperCase()} ${f.file}:${f.startLine}-${f.endLine} [${f.ruleId}] ${f.message}`); - } - - return result.findings; -} diff --git a/src/adapters/spectre.ts b/src/adapters/spectre.ts new file mode 100644 index 0000000..0b419ae --- /dev/null +++ b/src/adapters/spectre.ts @@ -0,0 +1,468 @@ +import { readFile } from 'fs/promises'; +import { join, extname } from 'path'; +import { getModel, completeSimple, type TextContent } from '@mariozechner/pi-ai'; +import { debug } from '../debug.js'; +import { DEFAULT_CONFIG } from '../config.js'; +import type { SpectreConfig, SpectreRawFinding, Severity, LineRangesByFile, LineRange } from '../types.js'; + +// --------------------------------------------------------------------------- +// Built-in skip lists (never configurable — these are never security-relevant) +// --------------------------------------------------------------------------- + +const BUILT_IN_SKIP_EXTENSIONS = new Set([ + '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.svg', + '.pdf', '.zip', '.tar', '.gz', '.bz2', '.xz', '.7z', '.rar', + '.exe', '.dll', '.so', '.dylib', '.bin', '.o', '.a', + '.mp3', '.mp4', '.wav', '.avi', '.mov', '.mkv', '.flac', + '.ttf', '.otf', '.woff', '.woff2', '.eot', + '.pyc', '.class', '.jar', + '.css', '.scss', '.sass', '.less', +]); + +const BUILT_IN_SKIP_PATTERNS: RegExp[] = [ + /\.d\.ts$/, + /\.min\.js$/, + /\.min\.css$/, +]; + +// --------------------------------------------------------------------------- +// Tier 1: high-value targets — counted first against the cap, never dropped +// for being beyond fileCap unless fileCap itself is exhausted by tier 1 alone. +// --------------------------------------------------------------------------- + +const TIER1_PATTERNS: RegExp[] = [ + /^package\.json$/, + /^package-lock\.json$/, + /\.lock$/, + /^\.github\/workflows\//, + /^Dockerfile/, + /^docker-compose/, + /^\.env/, +]; + +// --------------------------------------------------------------------------- +// Tier 2: content-based keyword patterns +// Files whose full content matches any of these are promoted above the cap +// ahead of ordinary tier-3 files. +// --------------------------------------------------------------------------- + +const TIER2_PATTERNS: RegExp[] = [ + // Dynamic code execution + /\beval\s*\(/, + /\bnew\s+Function\s*\(/, + + // Base64 / encoding decode + /\batob\s*\(/, + /String\.fromCharCode\s*\(/, + + // Shell execution + /require\s*\(\s*['"`]child_process['"`]\s*\)/, + /\/bin\/(?:sh|bash|zsh|dash)\b/, + /\/dev\/tcp\//, + /\bexecSync\s*\(/, + /\bspawnSync\s*\(/, + + // Raw TCP / exfiltration sinks + /\bnet\.Socket\b/, + /169\.254\.169\.254/, + /metadata\.google\.internal/, + + // Supply-chain lifecycle hooks + /"(?:postinstall|preinstall|prepare)"\s*:/, + + // Remote fetch in shell/CI steps + /\b(?:curl|wget)\s+\S*https?:\/\//, + + // Dynamic import/require with a non-literal argument + /\bimport\s*\(\s*[^'"`\s]/, +]; + +// --------------------------------------------------------------------------- +// Severity ranking for minSeverity filtering +// --------------------------------------------------------------------------- + +const SEVERITY_RANK: Record = { + critical: 4, + high: 3, + medium: 2, + low: 1, + info: 0, +}; + +// --------------------------------------------------------------------------- +// Allowed ruleIds — anything else is rejected +// --------------------------------------------------------------------------- + +const ALLOWED_RULE_IDS = new Set([ + 'reverse-shell', + 'credential-exfiltration', + 'obfuscated-payload', + 'backdoor', + 'supply-chain-abuse', + 'covert-execution', +]); + +// --------------------------------------------------------------------------- +// System prompt +// --------------------------------------------------------------------------- + +// The analysis instructions are customisable per-repo via toolConfig.prompt. +// The JSON response format is always appended unchanged so parsers never break. + +const DEFAULT_ANALYSIS_INSTRUCTIONS = + 'You are a security code reviewer specialising in detecting malicious intent in pull request changes. ' + + 'Analyse the provided file for: reverse shells, backdoors, credential exfiltration, ' + + 'obfuscated payloads, and supply-chain attacks. ' + + 'Report ONLY confirmed malicious patterns with high confidence. ' + + 'Do not report: bugs, style issues, theoretical vulnerabilities, ordinary insecure code, ' + + 'eval/exec/spawn in clearly benign static contexts, or unknown packages with no hostile behavior in the provided diff.'; + +const JSON_RESPONSE_SUFFIX = + 'For each finding, copy the smallest exact verbatim contiguous snippet from the file that uniquely identifies the malicious logic. ' + + 'Do not paraphrase, insert ellipses, or combine non-adjacent lines. ' + + 'Respond ONLY with a JSON object — no markdown, no explanation outside the JSON:\n' + + '{"findings": [{"file": "path/to/file", "startLine": 1, "endLine": 2, ' + + '"severity": "high|medium|low", ' + + '"ruleId": "reverse-shell|credential-exfiltration|obfuscated-payload|backdoor|supply-chain-abuse|covert-execution", ' + + '"message": "brief description of the malicious pattern", ' + + '"evidence": "exact verbatim snippet from the file"}]}\n' + + 'If there are no findings, respond with {"findings": []}.'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function isTier1(file: string): boolean { + return TIER1_PATTERNS.some(p => p.test(file)); +} + +function shouldSkipFile(file: string, config: SpectreConfig): boolean { + const ext = extname(file).toLowerCase(); + if (BUILT_IN_SKIP_EXTENSIONS.has(ext)) return true; + if (BUILT_IN_SKIP_PATTERNS.some(p => p.test(file))) return true; + if (config.skipExtensions?.some(e => file.endsWith(e))) return true; + if (config.skipPaths?.some(p => matchesPattern(file, p))) return true; + return false; +} + +function matchesPattern(file: string, pattern: string): boolean { + if (!pattern.includes('*')) { + return file === pattern || file.startsWith(pattern.endsWith('/') ? pattern : `${pattern}/`); + } + const regexSource = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*/g, '\x00') + .replace(/\*/g, '[^/]*') + .replace(/\x00/g, '.*'); + return new RegExp(`^${regexSource}$`).test(file); +} + +function truncateToLines(content: string, maxLines: number): string { + const lines = content.split('\n'); + if (lines.length <= maxLines) return content; + return lines.slice(0, maxLines).join('\n') + '\n[truncated — diff exceeded line cap]'; +} + +function normalizePositiveInt(value: unknown): number | null { + const parsed = Number.parseInt(String(value), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +interface RawFindingFromLLM { + file?: unknown; + startLine?: unknown; + endLine?: unknown; + severity?: unknown; + ruleId?: unknown; + message?: unknown; + evidence?: unknown; +} + +function normalizeFinding(raw: RawFindingFromLLM, expectedFile: string): SpectreRawFinding | null { + if (typeof raw.severity !== 'string') return null; + if (typeof raw.message !== 'string' || !raw.message.trim()) return null; + if (typeof raw.ruleId !== 'string' || !ALLOWED_RULE_IDS.has(raw.ruleId)) return null; + if (typeof raw.evidence !== 'string' || !raw.evidence.trim()) return null; + + const startLine = normalizePositiveInt(raw.startLine) ?? 1; + const endLine = normalizePositiveInt(raw.endLine) ?? startLine; + + return { + file: expectedFile, + line: startLine, + startLine, + endLine: endLine >= startLine ? endLine : startLine, + severity: raw.severity as SpectreRawFinding['severity'], + message: raw.message.trim(), + ruleId: raw.ruleId, + evidence: raw.evidence.trim(), + tool: 'spectre', + }; +} + +async function runConcurrent( + items: string[], + concurrency: number, + fn: (item: string) => Promise, +): Promise { + const results: T[] = []; + const queue = [...items]; + + await Promise.all( + Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (queue.length > 0) { + const item = queue.shift(); + if (item === undefined) break; + const found = await fn(item); + results.push(...found); + } + }), + ); + + return results; +} + +// --------------------------------------------------------------------------- +// Per-file scan +// --------------------------------------------------------------------------- + +async function scanFile({ + file, + diffContent, + workspacePath, + changedLineRanges, + + model, + maxDiffLines, + systemPrompt, +}: { + file: string; + diffContent: string | null; + workspacePath: string; + changedLineRanges: LineRangesByFile | Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + model: any; + maxDiffLines: number; + systemPrompt: string; +}): Promise { + let content = diffContent; + + if (!content) { + try { + content = await readFile(join(workspacePath, file), 'utf8'); + } catch { + debug('spectre', `could not read file: ${file}`); + return []; + } + } + + content = truncateToLines(content, maxDiffLines); + + const ranges: LineRange[] = changedLineRanges instanceof Map + ? (changedLineRanges.get(file) ?? []) + : ((changedLineRanges as Record)[file] ?? []); + + const rangeStr = ranges.length > 0 + ? `Changed lines in this PR: ${ranges.map(r => `${r.start}-${r.end}`).join(', ')}\n` + : ''; + + const userMessage = `File: ${file}\n${rangeStr}\n${content}`; + + let responseText = ''; + try { + const result = await completeSimple(model, { + systemPrompt, + messages: [{ + role: 'user' as const, + timestamp: Date.now(), + content: userMessage, + }], + }, { temperature: 0 }); + + responseText = result.content + .filter((c): c is TextContent => c.type === 'text') + .map(c => c.text) + .join(''); + } catch (err) { + debug('spectre', `LLM error for ${file}: ${(err as Error).message}`); + return []; + } + + const jsonMatch = responseText.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + debug('spectre', `no JSON found in response for ${file}`); + return []; + } + + let parsed: { findings?: RawFindingFromLLM[] }; + try { + parsed = JSON.parse(jsonMatch[0]) as { findings?: RawFindingFromLLM[] }; + } catch { + debug('spectre', `JSON parse error for ${file}`); + return []; + } + + const findings: SpectreRawFinding[] = []; + for (const raw of parsed.findings ?? []) { + const finding = normalizeFinding(raw, file); + if (finding) findings.push(finding); + } + + return findings; +} + +// --------------------------------------------------------------------------- +// Keyword promotion helpers +// --------------------------------------------------------------------------- + +function buildBoostPatterns(custom: string[]): RegExp[] { + const patterns = [...TIER2_PATTERNS]; + for (const raw of custom) { + try { + patterns.push(new RegExp(raw)); + } catch { + console.warn(`[spectre] ignoring invalid boostPattern: ${raw}`); + } + } + return patterns; +} + +async function partitionByKeywords( + files: string[], + workspacePath: string, + patterns: RegExp[], +): Promise<{ tier2: string[]; tier3: string[] }> { + const tier2: string[] = []; + const tier3: string[] = []; + + for (const file of files) { + let content: string; + try { + content = await readFile(join(workspacePath, file), 'utf8'); + } catch { + tier3.push(file); + continue; + } + if (patterns.some(p => p.test(content))) { + tier2.push(file); + } else { + tier3.push(file); + } + } + + return { tier2, tier3 }; +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +export async function runSpectre({ + workspacePath, + changedFiles, + changedLineRanges = new Map(), + promptFiles = [], + toolConfig = DEFAULT_CONFIG.spectre, +}: { + workspacePath: string; + changedFiles?: string[] | null; + changedLineRanges?: LineRangesByFile | Record; + promptFiles?: Array<{ file: string; content: string }>; + toolConfig?: SpectreConfig; +}): Promise { + if (!changedFiles || changedFiles.length === 0) return []; + + if (!toolConfig.enabled) { + console.log('[spectre] skipping — not enabled for this repo (set "spectre": {"enabled": true, "provider": "..."} in layne.json)'); + return []; + } + + if (!toolConfig.provider) { + console.log('[spectre] skipping — no provider configured'); + return []; + } + + const fileCap = toolConfig.fileCap ?? 20; + const secondaryFileCap = toolConfig.secondaryFileCap ?? 20; + const maxDiffLines = toolConfig.maxDiffLines ?? 400; + const minSeverity = toolConfig.minSeverity ?? 'high'; + const concurrency = toolConfig.concurrency ?? 5; + const minRank = SEVERITY_RANK[minSeverity] ?? SEVERITY_RANK.high; + + // Step 1: filter + const eligible = changedFiles.filter(f => !shouldSkipFile(f, toolConfig)); + const skippedCount = changedFiles.length - eligible.length; + + // Step 2: build diff map (needed before keyword scanning) + const diffByFile = new Map(promptFiles.map(p => [p.file, p.content])); + + // Step 3: three-tier primary cap + // Tier 1 — path-matched high-value files (manifests, CI, Dockerfiles, .env) + // Tier 2 — content-matched files: full-file keyword scan promotes these above the cap + // Tier 3 — everything else: fills remaining slots after tiers 1 and 2 + const tier1 = eligible.filter(isTier1); + const nonTier1 = eligible.filter(f => !isTier1(f)); + + const boostPatterns = buildBoostPatterns(toolConfig.boostPatterns ?? []); + const { tier2, tier3 } = await partitionByKeywords(nonTier1, workspacePath, boostPatterns); + + const selectedT1 = tier1.slice(0, fileCap); + const remT1 = fileCap - selectedT1.length; + const selectedT2 = remT1 > 0 ? tier2.slice(0, remT1) : []; + const remT2 = remT1 - selectedT2.length; + const selectedT3 = remT2 > 0 ? tier3.slice(0, remT2) : []; + const primary = [...selectedT1, ...selectedT2, ...selectedT3]; + + // Step 4: secondary batch — keyword-matched files that overflowed the primary cap. + // tier2 files that didn't get a primary slot already contain suspicious patterns; no extra + // file reads are needed because partitionByKeywords already classified all nonTier1 files. + const tier2Overflow = tier2.slice(selectedT2.length); + const secondary = secondaryFileCap > 0 ? tier2Overflow.slice(0, secondaryFileCap) : []; + + const selected = [...primary, ...secondary]; + + const promotedCount = selectedT2.length; + const secondaryCount = secondary.length; + const cappedCount = eligible.length - selected.length; + + console.log( + `[spectre] scanning ${primary.length} file(s)` + + (secondaryCount > 0 ? ` + ${secondaryCount} keyword-triggered` : '') + + ` with ${toolConfig.provider}/${toolConfig.model}` + + (skippedCount > 0 ? `, ${skippedCount} skipped by filter` : '') + + (promotedCount > 0 ? `, ${promotedCount} keyword-promoted` : '') + + (cappedCount > 0 ? `, ${cappedCount} dropped by cap of ${fileCap}` : ''), + ); + if (selectedT1.length > 0) debug('spectre', `tier1 (path-matched): ${selectedT1.join(', ')}`); + if (selectedT2.length > 0) debug('spectre', `tier2 (keyword-matched): ${selectedT2.join(', ')}`); + if (secondary.length > 0) debug('spectre', `secondary (keyword-triggered overflow): ${secondary.join(', ')}`); + + // Step 4: initialise model + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let model: any; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + model = getModel(toolConfig.provider as any, toolConfig.model as any); + } catch (err) { + console.error(`[spectre] failed to initialise model: ${(err as Error).message}`); + return []; + } + + // Step 5: build system prompt (custom instructions + fixed JSON format suffix) + const analysisInstructions = toolConfig.prompt?.trim() || DEFAULT_ANALYSIS_INSTRUCTIONS; + const systemPrompt = `${analysisInstructions}\n\n${JSON_RESPONSE_SUFFIX}`; + + // Step 6: scan concurrently + const allFindings = await runConcurrent(selected, concurrency, async (file) => { + const diff = diffByFile.get(file) ?? null; + const results = await scanFile({ file, diffContent: diff, workspacePath, changedLineRanges, model, maxDiffLines, systemPrompt }); + return results.filter(f => (SEVERITY_RANK[f.severity] ?? 0) >= minRank); + }); + + console.log(`[spectre] ${allFindings.length} finding(s):`); + for (const f of allFindings) { + console.log(`[spectre] ${f.severity.toUpperCase()} ${f.file}:${f.startLine}-${f.endLine} [${f.ruleId}] ${f.message}`); + } + + return allFindings; +} diff --git a/src/commenter.ts b/src/commenter.ts index 1879164..f88dfc8 100644 --- a/src/commenter.ts +++ b/src/commenter.ts @@ -1,22 +1,19 @@ import { getInstallationOctokit } from './auth.js'; import { buildContext, renderTemplate } from './notifiers/template.js'; -import type { ProcessedFinding, CommentConfig } from './types.js'; +import type { ProcessedFinding, CommentConfig, TemplateContext } from './types.js'; const COMMENT_MARKER = ''; -const DEFAULT_FAILURE_TEMPLATE = [ - COMMENT_MARKER, - '## 🔴 Layne — {{total}} finding(s)', - '', - '{{summary}}', -].join('\n'); +const CAUTION_ALERT = + '> [!CAUTION]\n' + + '> These are security findings reported by the security scanners configured in Layne. ' + + 'Findings may contain false positives - review them and fix what makes sense. ' + + 'If you believe a finding is not valid, contact the security team.'; -const DEFAULT_WARNING_TEMPLATE = [ - COMMENT_MARKER, - '## ⚠️ Layne — {{total}} warning(s)', - '', - '{{summary}}', -].join('\n'); +const WARNING_ALERT = + '> [!WARNING]\n' + + '> These are security findings reported by the security scanners configured in Layne. ' + + 'Findings may contain false positives - review them and fix what makes sense.'; const SUCCESS_BODY = [ COMMENT_MARKER, @@ -25,6 +22,23 @@ const SUCCESS_BODY = [ 'No security issues found on latest push.', ].join('\n'); +function buildDefaultComment(ctx: TemplateContext, alertBlock: string): string { + return [ + COMMENT_MARKER, + '', + alertBlock, + '', + `**Layne found ${ctx.severitySummary} issue${ctx.total !== 1 ? 's' : ''} in this PR.**`, + '', + '
', + `View ${ctx.total} finding(s)`, + '', + String(ctx.findings), + '', + '
', + ].join('\n'); +} + async function findExistingComment( // eslint-disable-next-line @typescript-eslint/no-explicit-any octokit: any, @@ -42,12 +56,13 @@ async function findExistingComment( * Creates or updates a Layne security comment on a PR. * Never throws. */ -export async function postComment({ findings, owner, repo, prNumber, installationId, conclusion, commentConfig }: { +export async function postComment({ findings, owner, repo, prNumber, installationId, headSha, conclusion, commentConfig }: { findings: ProcessedFinding[]; owner: string; repo: string; prNumber: number; installationId: number; + headSha: string; conclusion: string; commentConfig: CommentConfig & { warningTemplate?: string | null }; }): Promise { @@ -57,13 +72,17 @@ export async function postComment({ findings, owner, repo, prNumber, installatio let body: string; if (conclusion === 'failure') { - const ctx = buildContext(findings, owner, repo, prNumber); - body = renderTemplate(commentConfig.template ?? DEFAULT_FAILURE_TEMPLATE, ctx); + const ctx = buildContext(findings, owner, repo, prNumber, headSha); + body = commentConfig.template + ? renderTemplate(commentConfig.template, ctx) + : buildDefaultComment(ctx, CAUTION_ALERT); } else if (findings.length > 0) { - const ctx = buildContext(findings, owner, repo, prNumber); - body = renderTemplate(commentConfig.warningTemplate ?? DEFAULT_WARNING_TEMPLATE, ctx); + const ctx = buildContext(findings, owner, repo, prNumber, headSha); + body = commentConfig.warningTemplate + ? renderTemplate(commentConfig.warningTemplate, ctx) + : buildDefaultComment(ctx, WARNING_ALERT); } else { - if (!existingId) return; // no prior comment — nothing to resolve + if (!existingId) return; body = SUCCESS_BODY; } diff --git a/src/config-validator.ts b/src/config-validator.ts index a0d99fc..beff65d 100644 --- a/src/config-validator.ts +++ b/src/config-validator.ts @@ -8,13 +8,13 @@ * directly via `npm run validate-config`. */ -const KNOWN_REPO_KEYS = new Set(['mode', 'contextLines', 'timeoutMinutes', 'semgrep', 'trufflehog', 'claude', 'piAgent', 'notifications', 'labels', 'trigger', 'comment', 'exceptionApprovers']); -const KNOWN_GLOBAL_KEYS = new Set(['mode', 'contextLines', 'timeoutMinutes', 'notifications', 'labels', 'trigger', 'comment', 'exceptionApprovers']); +const KNOWN_REPO_KEYS = new Set(['mode', 'contextLines', 'timeoutMinutes', 'maxFileSizeKb', 'semgrep', 'trufflehog', 'claude', 'spectre', 'depDoctor', 'notifications', 'labels', 'trigger', 'comment', 'exceptionApprovers']); +const KNOWN_GLOBAL_KEYS = new Set(['mode', 'contextLines', 'timeoutMinutes', 'maxFileSizeKb', 'depDoctor', 'notifications', 'labels', 'trigger', 'comment', 'exceptionApprovers']); const VALID_MODES = new Set(['changed_files', 'diff_only']); const VALID_TRIGGER_ONS = new Set(['pull_request', 'workflow_run', 'workflow_job']); const VALID_CONCLUSIONS = new Set(['success', 'failure', 'neutral', 'cancelled', 'skipped', 'timed_out', 'action_required']); -const CLAUDE_MODELS = /^claude-/; -const VALID_THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh']); +const CLAUDE_MODELS = /^claude-/; +const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'info']); const REPO_KEY_RE = /^[^/]+\/[^/]+$/; export type ValidateConfigResult = @@ -61,6 +61,7 @@ function validateGlobal(block: Record, ctx: string, errors: str if (block['labels'] !== undefined) validateLabels(block['labels'], `${ctx}.labels`, errors); if (block['trigger'] !== undefined) validateTrigger(block['trigger'], `${ctx}.trigger`, errors); if (block['comment'] !== undefined) validateComment(block['comment'], `${ctx}.comment`, errors); + if (block['depDoctor'] !== undefined) validateDepDoctor(block['depDoctor'], `${ctx}.depDoctor`, errors); if (block['exceptionApprovers'] !== undefined) validateExceptionApprovers(block['exceptionApprovers'], `${ctx}.exceptionApprovers`, errors); } @@ -74,7 +75,8 @@ function validateRepo(block: Record, ctx: string, errors: strin if (block['semgrep'] !== undefined) validateScanner(block['semgrep'], `${ctx}.semgrep`, errors); if (block['trufflehog'] !== undefined) validateScanner(block['trufflehog'], `${ctx}.trufflehog`, errors); if (block['claude'] !== undefined) validateClaude(block['claude'], `${ctx}.claude`, errors); - if (block['piAgent'] !== undefined) validatePiAgent(block['piAgent'], `${ctx}.piAgent`, errors); + if (block['spectre'] !== undefined) validateSpectre(block['spectre'], `${ctx}.spectre`, errors); + if (block['depDoctor'] !== undefined) validateDepDoctor(block['depDoctor'], `${ctx}.depDoctor`, errors); if (block['notifications'] !== undefined) validateNotifications(block['notifications'], `${ctx}.notifications`, errors); if (block['labels'] !== undefined) validateLabels(block['labels'], `${ctx}.labels`, errors); if (block['trigger'] !== undefined) validateTrigger(block['trigger'], `${ctx}.trigger`, errors); @@ -93,6 +95,10 @@ function validateScanMode(block: Record, ctx: string, errors: s if (!Number.isInteger(block['timeoutMinutes']) || (block['timeoutMinutes'] as number) < 1) errors.push(`${ctx}.timeoutMinutes: must be a positive integer`); } + if (block['maxFileSizeKb'] !== undefined) { + if (!Number.isInteger(block['maxFileSizeKb']) || (block['maxFileSizeKb'] as number) < 1) + errors.push(`${ctx}.maxFileSizeKb: must be a positive integer (kilobytes)`); + } } function validateScanner(block: unknown, ctx: string, errors: string[]): void { @@ -142,7 +148,7 @@ function validateClaude(block: unknown, ctx: string, errors: string[]): void { } } -function validatePiAgent(block: unknown, ctx: string, errors: string[]): void { +function validateSpectre(block: unknown, ctx: string, errors: string[]): void { if (typeof block !== 'object' || block === null) { errors.push(`${ctx}: must be an object`); return; } const b = block as Record; @@ -159,24 +165,70 @@ function validatePiAgent(block: unknown, ctx: string, errors: string[]): void { (b['provider'] === undefined || b['provider'] === 'anthropic') && !CLAUDE_MODELS.test(b['model']) ) - errors.push(`${ctx}.model: expected a Claude model ID (e.g. "claude-opus-4-6"), got "${b['model']}"`); + errors.push(`${ctx}.model: expected a Claude model ID (e.g. "claude-haiku-4-5-20251001"), got "${b['model']}"`); } - if (b['thinkingLevel'] !== undefined) { - if (typeof b['thinkingLevel'] !== 'string' || !VALID_THINKING_LEVELS.has(b['thinkingLevel'])) - errors.push(`${ctx}.thinkingLevel: must be one of ${[...VALID_THINKING_LEVELS].join(', ')}`); + if (b['fileCap'] !== undefined) { + if (!Number.isInteger(b['fileCap']) || (b['fileCap'] as number) < 1) + errors.push(`${ctx}.fileCap: must be a positive integer`); } - if (b['timeoutMinutes'] !== undefined) { - if (!Number.isInteger(b['timeoutMinutes']) || (b['timeoutMinutes'] as number) < 1) - errors.push(`${ctx}.timeoutMinutes: must be a positive integer`); + if (b['secondaryFileCap'] !== undefined) { + if (!Number.isInteger(b['secondaryFileCap']) || (b['secondaryFileCap'] as number) < 0) + errors.push(`${ctx}.secondaryFileCap: must be a non-negative integer (use 0 to disable)`); } - if (b['followImports'] !== undefined && typeof b['followImports'] !== 'boolean') - errors.push(`${ctx}.followImports: must be a boolean`); + if (b['maxDiffLines'] !== undefined) { + if (!Number.isInteger(b['maxDiffLines']) || (b['maxDiffLines'] as number) < 1) + errors.push(`${ctx}.maxDiffLines: must be a positive integer`); + } - if (b['prompt'] !== undefined && b['prompt'] !== null && typeof b['prompt'] !== 'string') - errors.push(`${ctx}.prompt: must be a string or null`); + if (b['minSeverity'] !== undefined) { + if (!VALID_SEVERITIES.has(b['minSeverity'] as string)) + errors.push(`${ctx}.minSeverity: must be one of ${[...VALID_SEVERITIES].join(', ')}`); + } + + if (b['skipPaths'] !== undefined) { + if (!Array.isArray(b['skipPaths'])) + errors.push(`${ctx}.skipPaths: must be an array`); + else if ((b['skipPaths'] as unknown[]).some(p => typeof p !== 'string')) + errors.push(`${ctx}.skipPaths: all items must be strings`); + } + + if (b['skipExtensions'] !== undefined) { + if (!Array.isArray(b['skipExtensions'])) + errors.push(`${ctx}.skipExtensions: must be an array`); + else if ((b['skipExtensions'] as unknown[]).some(e => typeof e !== 'string')) + errors.push(`${ctx}.skipExtensions: all items must be strings`); + else if ((b['skipExtensions'] as string[]).some(e => !e.startsWith('.'))) + errors.push(`${ctx}.skipExtensions: all items must start with "." (e.g. ".min.js")`); + } + + if (b['concurrency'] !== undefined) { + if (!Number.isInteger(b['concurrency']) || (b['concurrency'] as number) < 1) + errors.push(`${ctx}.concurrency: must be a positive integer`); + } + + if (b['prompt'] !== undefined && b['prompt'] !== null) { + if (typeof b['prompt'] !== 'string' || (b['prompt'] as string).trim() === '') + errors.push(`${ctx}.prompt: must be a non-empty string or null`); + } + + if (b['boostPatterns'] !== undefined) { + if (!Array.isArray(b['boostPatterns'])) { + errors.push(`${ctx}.boostPatterns: must be an array`); + } else { + for (const p of b['boostPatterns'] as unknown[]) { + if (typeof p !== 'string') { + errors.push(`${ctx}.boostPatterns: all items must be strings`); + break; + } + try { new RegExp(p); } catch { + errors.push(`${ctx}.boostPatterns: "${p}" is not a valid regular expression`); + } + } + } + } } function validateTrigger(block: unknown, ctx: string, errors: string[]): void { @@ -260,6 +312,35 @@ function validateLabels(block: unknown, ctx: string, errors: string[]): void { } } +function validateDepDoctor(block: unknown, ctx: string, errors: string[]): void { + if (typeof block !== 'object' || block === null) { errors.push(`${ctx}: must be an object`); return; } + const b = block as Record; + + if (b['enabled'] !== undefined && typeof b['enabled'] !== 'boolean') + errors.push(`${ctx}.enabled: must be a boolean`); + + if (b['minCveSeverity'] !== undefined && !VALID_SEVERITIES.has(b['minCveSeverity'] as string)) + errors.push(`${ctx}.minCveSeverity: must be one of ${[...VALID_SEVERITIES].join(', ')}`); + + if (b['checkAbandoned'] !== undefined && typeof b['checkAbandoned'] !== 'boolean') + errors.push(`${ctx}.checkAbandoned: must be a boolean`); + + if (b['abandonedDays'] !== undefined) { + if (!Number.isInteger(b['abandonedDays']) || (b['abandonedDays'] as number) < 1) + errors.push(`${ctx}.abandonedDays: must be a positive integer`); + } + + if (b['checkDeprecated'] !== undefined && typeof b['checkDeprecated'] !== 'boolean') + errors.push(`${ctx}.checkDeprecated: must be a boolean`); + + if (b['extraArgs'] !== undefined) { + if (!Array.isArray(b['extraArgs'])) + errors.push(`${ctx}.extraArgs: must be an array`); + else if ((b['extraArgs'] as unknown[]).some(a => typeof a !== 'string')) + errors.push(`${ctx}.extraArgs: all items must be strings`); + } +} + function validateExceptionApprovers(block: unknown, ctx: string, errors: string[]): void { if (typeof block !== 'object' || block === null) { errors.push(`${ctx}: must be an object`); return; } const b = block as Record; diff --git a/src/config.ts b/src/config.ts index cf7438d..f1f3a3d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,7 @@ import { readFile } from 'fs/promises'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { validateConfig } from './config-validator.js'; -import type { ScanConfig, SemgrepConfig, TrufflehogConfig, ClaudeConfig, PiAgentConfig, LabelConfig, TriggerConfig, CommentConfig, ExceptionApproversConfig } from './types.js'; +import type { ScanConfig, SemgrepConfig, TrufflehogConfig, ClaudeConfig, SpectreConfig, DepDoctorConfig, LabelConfig, TriggerConfig, CommentConfig, ExceptionApproversConfig } from './types.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPOS_CONFIG_PATH = join(__dirname, '..', 'config', 'layne.json'); @@ -10,7 +10,8 @@ const REPOS_CONFIG_PATH = join(__dirname, '..', 'config', 'layne.json'); export const DEFAULT_CONFIG: Readonly = Object.freeze({ mode: 'changed_files' as const, contextLines: 8, - timeoutMinutes: 10, + timeoutMinutes: 15, + maxFileSizeKb: 1024, semgrep: Object.freeze({ enabled: true, extraArgs: ['--config', 'auto'], @@ -25,14 +26,27 @@ export const DEFAULT_CONFIG: Readonly = Object.freeze({ prompt: null, // custom system prompt (string); mutually exclusive with skill skill: null, // API Skill config: { id: "skill_01...", version: "latest" } } as ClaudeConfig), - piAgent: Object.freeze({ + spectre: Object.freeze({ enabled: false, - model: 'claude-opus-4-6', - thinkingLevel: 'medium', - timeoutMinutes: 10, - followImports: true, + model: 'claude-haiku-4-5-20251001', + fileCap: 20, + secondaryFileCap: 20, + maxDiffLines: 400, + minSeverity: 'high', + concurrency: 5, + skipPaths: [], + skipExtensions: [], prompt: null, - } as PiAgentConfig), // note: no default provider — omitting provider disables Pi Agent even when enabled: true + boostPatterns: [], + } as SpectreConfig), // note: no default provider — omitting provider disables Spectre even when enabled: true + depDoctor: Object.freeze({ + enabled: false, + minCveSeverity: 'high', + checkAbandoned: true, + abandonedDays: 730, + checkDeprecated: true, + extraArgs: [], + } as DepDoctorConfig), labels: Object.freeze({} as LabelConfig), trigger: Object.freeze({ on: 'pull_request' } as TriggerConfig), comment: Object.freeze({ enabled: false, template: null } as CommentConfig), @@ -95,10 +109,12 @@ export async function loadScanConfig({ owner, repo }: { owner: string; repo: str mode: repoOverrides.mode ?? globalConfig.mode ?? DEFAULT_CONFIG.mode, contextLines: repoOverrides.contextLines ?? globalConfig.contextLines ?? DEFAULT_CONFIG.contextLines, timeoutMinutes: repoOverrides.timeoutMinutes ?? globalConfig.timeoutMinutes ?? DEFAULT_CONFIG.timeoutMinutes, + maxFileSizeKb: repoOverrides.maxFileSizeKb ?? globalConfig.maxFileSizeKb ?? DEFAULT_CONFIG.maxFileSizeKb, semgrep: { ...DEFAULT_CONFIG.semgrep, ...(repoOverrides.semgrep ?? {}) }, trufflehog: { ...DEFAULT_CONFIG.trufflehog, ...(repoOverrides.trufflehog ?? {}) }, claude: { ...DEFAULT_CONFIG.claude, ...(repoOverrides.claude ?? {}) }, - piAgent: { ...DEFAULT_CONFIG.piAgent, ...(repoOverrides.piAgent ?? {}) }, + spectre: { ...DEFAULT_CONFIG.spectre, ...(repoOverrides.spectre ?? {}) }, + depDoctor: { ...DEFAULT_CONFIG.depDoctor, ...(globalConfig.depDoctor ?? {}), ...(repoOverrides.depDoctor ?? {}) }, notifications: { ...globalNotifications, ...repoNotifications }, labels: { ...globalLabels, ...repoLabels }, trigger: { ...DEFAULT_CONFIG.trigger, ...globalTrigger, ...(repoOverrides.trigger ?? {}) }, diff --git a/src/dispatcher.ts b/src/dispatcher.ts index ca8875b..7d2bf19 100644 --- a/src/dispatcher.ts +++ b/src/dispatcher.ts @@ -1,28 +1,52 @@ +import { stat } from 'fs/promises'; +import { join } from 'path'; import { runTrufflehog } from './adapters/trufflehog.js'; import { runSemgrep } from './adapters/semgrep.js'; import { runClaude } from './adapters/claude.js'; -import { runPiAgent } from './adapters/pi-agent.js'; +import { runSpectre } from './adapters/spectre.js'; +import { runDepDoctor } from './adapters/dep-doctor.js'; import { debug } from './debug.js'; import { loadScanConfig } from './config.js'; import type { ScanContext, LineRangesByFile, RawFinding } from './types.js'; +async function filterOversizedFiles(files: string[], workspacePath: string, maxFileSizeKb: number): Promise { + const maxBytes = maxFileSizeKb * 1024; + const kept: string[] = []; + for (const file of files) { + try { + const { size } = await stat(join(workspacePath, file)); + if (size > maxBytes) { + console.log(`[dispatcher] skipping ${file} (${Math.round(size / 1024)} KB exceeds ${maxFileSizeKb} KB limit)`); + } else { + kept.push(file); + } + } catch { + kept.push(file); + } + } + return kept; +} + export async function dispatch({ scanContext, changedLineRanges, owner, repo }: { scanContext: ScanContext; changedLineRanges: LineRangesByFile; owner: string; repo: string; }): Promise { - const { scanWorkspacePath, scanFiles, repoWorkspacePath, promptFiles, headSha } = scanContext; + const { scanWorkspacePath, scanFiles, repoWorkspacePath, promptFiles, baseSha } = scanContext; debug('dispatcher', `running scanners on ${scanFiles?.length ?? 0} file(s)`); const scanConfig = await loadScanConfig({ owner, repo }); - const [trufflehogFindings, semgrepFindings, claudeFindings, piAgentFindings] = await Promise.all([ - runTrufflehog({ workspacePath: scanWorkspacePath, changedFiles: scanFiles, toolConfig: scanConfig.trufflehog }), - runSemgrep({ workspacePath: scanWorkspacePath, changedFiles: scanFiles, toolConfig: scanConfig.semgrep }), - runClaude({ workspacePath: repoWorkspacePath, changedFiles: scanFiles, changedLineRanges, promptFiles, toolConfig: scanConfig.claude }), - runPiAgent({ workspacePath: repoWorkspacePath, changedFiles: scanFiles, changedLineRanges, toolConfig: scanConfig.piAgent, headSha }), + const eligibleFiles = await filterOversizedFiles(scanFiles ?? [], repoWorkspacePath, scanConfig.maxFileSizeKb); + + const [trufflehogFindings, semgrepFindings, claudeFindings, spectreFindings, depDoctorFindings] = await Promise.all([ + runTrufflehog({ workspacePath: scanWorkspacePath, changedFiles: eligibleFiles, toolConfig: scanConfig.trufflehog }), + runSemgrep({ workspacePath: scanWorkspacePath, changedFiles: eligibleFiles, toolConfig: scanConfig.semgrep }), + runClaude({ workspacePath: repoWorkspacePath, changedFiles: eligibleFiles, changedLineRanges, promptFiles, toolConfig: scanConfig.claude }), + runSpectre({ workspacePath: repoWorkspacePath, changedFiles: eligibleFiles, changedLineRanges, promptFiles, toolConfig: scanConfig.spectre }), + runDepDoctor({ workspacePath: repoWorkspacePath, changedFiles: eligibleFiles, baseSha, toolConfig: scanConfig.depDoctor }), ]); - return [...trufflehogFindings, ...semgrepFindings, ...claudeFindings, ...piAgentFindings]; + return [...trufflehogFindings, ...semgrepFindings, ...claudeFindings, ...spectreFindings, ...depDoctorFindings]; } diff --git a/src/fetcher.ts b/src/fetcher.ts index d20317a..03aafbf 100644 --- a/src/fetcher.ts +++ b/src/fetcher.ts @@ -12,7 +12,7 @@ function git(args: string[]): Promise { debug('git', `running: git ${redacted.join(' ')}`); return new Promise((resolve, reject) => { - execFile('git', args, (err, stdout, stderr) => { + execFile('git', args, { maxBuffer: 200 * 1024 * 1024 }, (err, stdout, stderr) => { if (stderr) console.error(`[git] stderr: ${stderr.trim().replace(/x-access-token:[^@]+@/g, 'x-access-token:[REDACTED]@')}`); if (err) reject(err); else resolve(stdout ?? ''); diff --git a/src/location-validator.ts b/src/location-validator.ts index 5bdb206..da3a052 100644 --- a/src/location-validator.ts +++ b/src/location-validator.ts @@ -56,7 +56,7 @@ export async function validateFindingLocations( const validated: ProcessedFinding[] = []; for (const finding of findings) { - if (finding.tool !== 'claude' && finding.tool !== 'pi_agent') { + if (finding.tool !== 'claude' && finding.tool !== 'spectre') { const startLine = finding.startLine ?? finding.line; const endLine = finding.endLine ?? finding.line; validated.push({ @@ -163,8 +163,8 @@ function resolveAnnotationLocation( info: FileInfo, evidenceLocation: EvidenceLocation, ): AnnotationLocation { - // Pi Agent: Always use evidence location only (Option 1 - strict evidence-only positioning) - if (finding.tool === 'pi_agent') { + // Spectre: Always use evidence location only (strict evidence-only positioning) + if (finding.tool === 'spectre') { return { startLine: evidenceLocation.startLine, endLine: evidenceLocation.endLine, diff --git a/src/notifiers/template.ts b/src/notifiers/template.ts index 674edc1..6a85666 100644 --- a/src/notifiers/template.ts +++ b/src/notifiers/template.ts @@ -4,11 +4,54 @@ import type { ProcessedFinding, TemplateContext } from '../types.js'; +const SEVERITY_EMOJI: Record = { + critical: '🔴', + high: '🟠', + medium: '🟡', + low: '🔵', + info: '⚪', +}; + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +const SEVERITY_ORDER: Record = { + critical: 0, + high: 1, + medium: 2, + low: 3, + info: 4, +}; + +function buildFindingsTable( + findings: ProcessedFinding[], + owner: string, + repo: string, + headSha?: string, +): string { + const header = '| Severity | Scanner | File | Rule | Description |\n|---|---|---|---|---|'; + const sorted = [...findings].sort((a, b) => + (SEVERITY_ORDER[a.severity] ?? 99) - (SEVERITY_ORDER[b.severity] ?? 99), + ); + const rows = sorted.map(f => { + const emoji = SEVERITY_EMOJI[f.severity] ?? '⚪'; + const line = f.startLine ?? f.line; + const message = f.message.replace(/[\r\n]+/g, ' ').trim().replace(/\|/g, '\\|'); + const ruleId = (f.ruleId ?? '').replace(/\|/g, '\\|'); + const url = headSha ? `https://github.com/${owner}/${repo}/blob/${headSha}/${f.file}#L${line}` : null; + const fileCell = url ? `[\`${f.file}:${line}\`](${url})` : `\`${f.file}:${line}\``; + return `| ${emoji} ${capitalize(f.severity)} | ${f.tool} | ${fileCell} | ${ruleId} | ${message} |`; + }); + return [header, ...rows].join('\n'); +} + export function buildContext( findings: ProcessedFinding[], owner: string, repo: string, prNumber: number, + headSha?: string, ): TemplateContext { const counts: Record = { critical: 0, high: 0, medium: 0, low: 0 }; for (const f of findings) { @@ -20,17 +63,20 @@ export function buildContext( .map(([k, v]) => `${v} ${k}`) .join(', '); - const rules = [...new Set(findings.map(f => f.ruleId).filter(Boolean))].join(', '); + const rules = [...new Set(findings.map(f => f.ruleId).filter(Boolean))].join(', '); + const severitySummary = nonZero || 'none'; return { - repo: `${owner}/${repo}`, + repo: `${owner}/${repo}`, owner, - repoName: repo, + repoName: repo, prNumber, - prUrl: `https://github.com/${owner}/${repo}/pull/${prNumber}`, + prUrl: `https://github.com/${owner}/${repo}/pull/${prNumber}`, total, ...counts, - summary: `Found ${total} issue(s): ${nonZero || 'none'}.`, + summary: `Found ${total} issue(s): ${nonZero || 'none'}.`, + severitySummary, + findings: buildFindingsTable(findings, owner, repo, headSha), rules, } as TemplateContext; } diff --git a/src/scan-context.ts b/src/scan-context.ts index ee06548..7358253 100644 --- a/src/scan-context.ts +++ b/src/scan-context.ts @@ -22,6 +22,7 @@ export async function createScanContext({ workspacePath, changedFiles, baseSha, mode, contextLines, headSha, + baseSha, repoWorkspacePath: workspacePath, scanWorkspacePath: workspacePath, scanFiles: [], @@ -35,6 +36,7 @@ export async function createScanContext({ workspacePath, changedFiles, baseSha, mode, contextLines, headSha, + baseSha, repoWorkspacePath: workspacePath, scanWorkspacePath: workspacePath, scanFiles: changedFiles, @@ -86,6 +88,7 @@ export async function createScanContext({ workspacePath, changedFiles, baseSha, mode, contextLines, headSha, + baseSha, repoWorkspacePath: workspacePath, scanWorkspacePath, scanFiles, @@ -112,7 +115,7 @@ export function filterFindingsToChangedLines { debug('git', `running: git ${args.join(' ')}`); return new Promise((resolve, reject) => { - execFile('git', args, (err, stdout, stderr) => { + execFile('git', args, { maxBuffer: 200 * 1024 * 1024 }, (err, stdout, stderr) => { if (stderr) console.error(`[git] stderr: ${stderr.trim()}`); if (err) reject(err); else resolve(stdout ?? ''); diff --git a/src/suppressor.ts b/src/suppressor.ts index c8e2865..00257b3 100644 --- a/src/suppressor.ts +++ b/src/suppressor.ts @@ -7,6 +7,7 @@ const SECURITY_COMMENT_RE = /(?:\/\/|#)\s*SECURITY:\s+\S/; function gitShow(workspacePath: string, baseSha: string, filePath: string): Promise { return new Promise((resolve, reject) => { execFile('git', ['-C', workspacePath, 'show', `${baseSha}:${filePath}`], + { maxBuffer: 200 * 1024 * 1024 }, (err, stdout) => { if (err && !stdout) reject(err); else resolve(stdout ?? ''); } ); }); diff --git a/src/types.ts b/src/types.ts index 9600a1f..b3554c2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ // src/types.ts export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info'; -export type Tool = 'semgrep' | 'trufflehog' | 'claude' | 'pi_agent'; +export type Tool = 'semgrep' | 'trufflehog' | 'claude' | 'spectre' | 'dep-doctor'; export type AnnotationLevel = 'failure' | 'warning' | 'notice'; export type ScanMode = 'changed_files' | 'diff_only'; export type TriggerOn = 'pull_request' | 'workflow_run' | 'workflow_job'; @@ -38,8 +38,8 @@ export interface ClaudeRawFinding extends BaseFinding { anchorLine?: number; } -export interface PiAgentRawFinding extends BaseFinding { - tool: 'pi_agent'; +export interface SpectreRawFinding extends BaseFinding { + tool: 'spectre'; startLine?: number; endLine?: number; evidence?: string; @@ -47,7 +47,11 @@ export interface PiAgentRawFinding extends BaseFinding { anchorLine?: number; } -export type RawFinding = SemgrepFinding | TrufflehogFinding | ClaudeRawFinding | PiAgentRawFinding; +export interface DepDoctorFinding extends BaseFinding { + tool: 'dep-doctor'; +} + +export type RawFinding = SemgrepFinding | TrufflehogFinding | ClaudeRawFinding | SpectreRawFinding | DepDoctorFinding; // ---- Post-pipeline finding (after location validation + exception stamping) ---- @@ -141,14 +145,28 @@ export interface ClaudeConfig { skill?: SkillConfig | null; } -export interface PiAgentConfig { +export interface SpectreConfig { enabled: boolean; provider?: string; model: string; - thinkingLevel?: string; - timeoutMinutes?: number; - followImports?: boolean; + fileCap?: number; + secondaryFileCap?: number; + maxDiffLines?: number; + minSeverity?: Severity; + skipPaths?: string[]; + skipExtensions?: string[]; + concurrency?: number; prompt?: string | null; + boostPatterns?: string[]; +} + +export interface DepDoctorConfig { + enabled: boolean; + minCveSeverity: Severity; + checkAbandoned: boolean; + abandonedDays: number; + checkDeprecated: boolean; + extraArgs: string[]; } export interface LabelConfig { @@ -184,10 +202,12 @@ export interface ScanConfig { mode: ScanMode; contextLines: number; timeoutMinutes: number; + maxFileSizeKb: number; semgrep: SemgrepConfig; trufflehog: TrufflehogConfig; claude: ClaudeConfig; - piAgent: PiAgentConfig; + spectre: SpectreConfig; + depDoctor: DepDoctorConfig; labels: LabelConfig; trigger: TriggerConfig; comment: CommentConfig; @@ -212,6 +232,7 @@ export interface ScanContext { mode: ScanMode; contextLines: number; headSha: string; + baseSha: string; repoWorkspacePath: string; scanWorkspacePath: string; scanFiles: string[]; @@ -231,6 +252,8 @@ export interface TemplateContext { medium: number; low: number; summary: string; + severitySummary: string; + findings: string; rules: string; [key: string]: string | number; } diff --git a/src/worker.ts b/src/worker.ts index d6af8ee..df55f2b 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -257,7 +257,7 @@ async function runScan(job: Job, scanConfig: Awaited console.error('[worker] PR comment error:', (err as Error).message)); } diff --git a/website/docs/configuration.md b/website/docs/configuration.md index edb17fd..4bd76f2 100644 --- a/website/docs/configuration.md +++ b/website/docs/configuration.md @@ -59,13 +59,15 @@ Overrides are keyed by `"owner/repo"`. A repository with no entry - or whose ent | Semgrep | Enabled - `semgrep scan --config auto --json ` | | Trufflehog | Enabled - `trufflehog filesystem --json --no-update ` | | Claude | Disabled - must opt in per repo; requires `ANTHROPIC_API_KEY` | -| Pi Agent | Disabled - must opt in per repo; requires **both** `enabled: true` and a `provider` value - omitting either keeps it disabled; also requires the corresponding provider credentials in the environment | +| Spectre | Disabled - must opt in per repo; requires **both** `enabled: true` and a `provider` value - omitting either keeps it disabled; also requires the corresponding provider credentials in the environment | +| Dep Doctor | Disabled - must opt in per repo; requires `osv-scanner` in PATH for CVE scanning | See the individual scanner pages for full configuration options: - [Semgrep](scanners/semgrep.md) - [Trufflehog](scanners/trufflehog.md) - [Claude](scanners/claude.md) -- [Pi Agent](scanners/pi-agent.md) +- [Spectre](scanners/spectre.md) +- [Dep Doctor](scanners/dep-doctor.md) And for notifications and comments: - [Notifiers](notifiers.md) @@ -79,7 +81,7 @@ Not all keys merge the same way when a per-repo entry overrides `$global`. The r | Key | How per-repo overrides `$global` | |---|---| | `mode`, `contextLines`, `timeoutMinutes` | Per-repo value replaces global value | -| `semgrep`, `trufflehog`, `claude`, `piAgent` | Merged at the key level - per-repo values overwrite matching keys, unset keys inherit from global | +| `semgrep`, `trufflehog`, `claude`, `spectre` | Merged at the key level - per-repo values overwrite matching keys, unset keys inherit from global | | `trigger` | Full replacement - per-repo `trigger` replaces the global block entirely | | `labels` | Full replacement - per-repo `labels` replaces the global block entirely | | `notifications` | Per-notifier-key - per-repo `rocketchat` replaces global `rocketchat`; a per-repo `slack` entry stacks alongside a global `rocketchat` entry | diff --git a/website/docs/deployment.md b/website/docs/deployment.md index bdc70cc..60c59c3 100644 --- a/website/docs/deployment.md +++ b/website/docs/deployment.md @@ -271,8 +271,8 @@ Go to your repository → **Settings → Secrets and variables → Actions** and | `GH_WEBHOOK_SECRET` | Webhook HMAC secret (maps to `GITHUB_WEBHOOK_SECRET` in `.env`) | | `DOMAIN` | Domain name for TLS (e.g. `layne.example.com`) | | `LETSENCRYPT_EMAIL` | Email for Let's Encrypt expiry notifications | -| `ANTHROPIC_API_KEY` | Anthropic API key - required when any repo has `claude.enabled: true`, or when Pi Agent uses `provider: "anthropic"` | -| *(provider key)* | Pi Agent provider credentials - add the variable for whichever provider you configure (e.g. `OPENAI_API_KEY`, `GEMINI_API_KEY`, `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`). See [Pi Agent - Provider credentials](scanners/pi-agent.md#provider-credentials) for the full list | +| `ANTHROPIC_API_KEY` | Anthropic API key - required when any repo has `claude.enabled: true`, or when Spectre uses `provider: "anthropic"` | +| *(provider key)* | Spectre provider credentials - add the variable for whichever provider you configure (e.g. `OPENAI_API_KEY`, `GEMINI_API_KEY`, `AWS_BEARER_TOKEN_BEDROCK` + `AWS_REGION` for Bedrock). See [Spectre - Provider credentials](scanners/spectre.md#provider-credentials) for the full list | | `ROCKETCHAT_WEBHOOK_URL` | Global Rocket.Chat incoming webhook URL (required when `$global.notifications.rocketchat.webhookUrl` is `"$ROCKETCHAT_WEBHOOK_URL"`) | **Optional GitHub Actions variables** (Settings → Secrets and variables → Actions → Variables): diff --git a/website/docs/exception-approvals.md b/website/docs/exception-approvals.md index f796ab7..d3c5859 100644 --- a/website/docs/exception-approvals.md +++ b/website/docs/exception-approvals.md @@ -2,7 +2,7 @@ Layne can be configured to allow specific users or teams to approve individual findings that would otherwise block a PR. This is useful for accepted risks, false positives, or hotfixes that need to merge quickly. -Exceptions are **deliberate and auditable** — the approver must reference the exact finding ID(s) and provide a written reason. This is intentionally stricter than a generic PR approval. +Exceptions are **deliberate and auditable** - the approver must reference the exact finding ID(s) and provide a written reason. This is intentionally stricter than a generic PR approval. ## How It Works @@ -17,7 +17,7 @@ Exceptions are **deliberate and auditable** — the approver must reference the 6. Layne stores the exception in Redis (scoped to the PR) and re-runs the scan 7. The check run passes with `conclusion: success` and a summary listing who excepted each finding and why -Exceptions survive new commits as long as the flagged line has not changed. If the flagged line is modified by a subsequent commit, the exception is invalidated and a new approval is required. Unrelated commits — rebases, merge commits from the base branch, changes to other files — do not affect existing approvals. +Exceptions survive new commits as long as the flagged line has not changed. If the flagged line is modified by a subsequent commit, the exception is invalidated and a new approval is required. Unrelated commits - rebases, merge commits from the base branch, changes to other files - do not affect existing approvals. ## The Command @@ -31,14 +31,14 @@ Post a comment on the PR containing the following on a single line: |---|---| | `/layne exception-approve` | Required trigger prefix | | `` | One or more finding IDs in `LAYNE-xxxxxxxxxxxxxxxx` format (from the check run summary) | -| `reason: ` | Required — free-text explanation; recorded in the audit trail | +| `reason: ` | Required - free-text explanation; recorded in the audit trail | **Multiple findings in one command:** ``` /layne exception-approve LAYNE-a3f29c81b7e41d22 LAYNE-b7e41d22a3f29c81 reason: legacy code, tracked in JIRA-1234 ``` -The command can appear anywhere in the comment body — other text before or after it is ignored. +The command can appear anywhere in the comment body - other text before or after it is ignored. ## Finding IDs @@ -68,7 +68,7 @@ Blocking findings (1 remaining): - LAYNE-b7e41d22a3f29c81 [semgrep/eval] src/api.js:88 Already excepted (1): -- LAYNE-a3f29c81b7e41d22 — excepted by @alice: "test credential" +- LAYNE-a3f29c81b7e41d22 - excepted by @alice: "test credential" To approve remaining findings, post: /layne exception-approve LAYNE-b7e41d22a3f29c81 reason: @@ -81,8 +81,8 @@ To approve remaining findings, post: Found 2 issue(s): 0 critical, 2 high, 0 medium, 0 low. Excepted findings: -- LAYNE-a3f29c81b7e41d22 [trufflehog/aws-key] src/config.js:42 — excepted by @alice: "test credential, will be rotated" -- LAYNE-b7e41d22a3f29c81 [semgrep/eval] src/api.js:88 — excepted by @bob: "legacy code, tracked in JIRA-1234" +- LAYNE-a3f29c81b7e41d22 [trufflehog/aws-key] src/config.js:42 - excepted by @alice: "test credential, will be rotated" +- LAYNE-b7e41d22a3f29c81 [semgrep/eval] src/api.js:88 - excepted by @bob: "legacy code, tracked in JIRA-1234" All findings are still annotated below for reference. ``` @@ -109,7 +109,7 @@ Configure exception approvers in `config/layne.json`: ### Per-Repo Override -Per-repo `exceptionApprovers` **replaces** the global configuration — it does not merge: +Per-repo `exceptionApprovers` **replaces** the global configuration - it does not merge: ```json title="config/layne.json" { @@ -170,7 +170,7 @@ When an exception is used, Layne can automatically add or remove labels on the P ## Notifications -Notifications are **always sent** when an exception is used — even if the finding count didn't increase. This ensures visibility for the security team regardless of prior notification state. +Notifications are **always sent** when an exception is used - even if the finding count didn't increase. This ensures visibility for the security team regardless of prior notification state. ## Security Considerations @@ -179,18 +179,18 @@ Notifications are **always sent** when an exception is used — even if the find | Compromised approver account | Require 2FA on GitHub; follow org security policies | | Team membership escalation | Audit team membership regularly; use CODEOWNERS | | Config tampering | Protect `config/layne.json` with CODEOWNERS and branch protection | -| Approval for changed code | Exceptions are invalidated when the flagged line changes — only unrelated commits (rebases, merges from the base branch) preserve approvals | +| Approval for changed code | Exceptions are invalidated when the flagged line changes - only unrelated commits (rebases, merges from the base branch) preserve approvals | | Silent approvals | Notifications always fire for exceptions; reason is required and recorded | -| Unauthorized command | Commands from non-approvers are silently ignored — no reply, no re-scan | +| Unauthorized command | Commands from non-approvers are silently ignored - no reply, no re-scan | ## Audit Trail Every exception is recorded in: -1. **GitHub Check Run summary** — lists each excepted finding ID, who approved it, and the stated reason -2. **PR comment thread** — the approver's command and Layne's confirmation reply are visible to all reviewers -3. **PR label** — `security-exception-used` label (if configured) -4. **Chat notification** — sent to configured notifiers +1. **GitHub Check Run summary** - lists each excepted finding ID, who approved it, and the stated reason +2. **PR comment thread** - the approver's command and Layne's confirmation reply are visible to all reviewers +3. **PR label** - `security-exception-used` label (if configured) +4. **Chat notification** - sent to configured notifiers ## GitHub App Permissions @@ -254,4 +254,4 @@ Check run: SUCCESS ⚠️ 5. Layne re-runs the scan; check run shows success with the exception audit trail 6. Label `security-exception-used` is added to the PR 7. Notification sent to Rocket.Chat/Slack -8. Developer pushes a new commit — if the commit does not touch the flagged line, the exception survives and no re-approval is needed; if the flagged line is modified, the exception is invalidated and Bob must re-approve +8. Developer pushes a new commit - if the commit does not touch the flagged line, the exception survives and no re-approval is needed; if the flagged line is modified, the exception is invalidated and Bob must re-approve diff --git a/website/docs/extending.md b/website/docs/extending.md index af1307b..cf851e5 100644 --- a/website/docs/extending.md +++ b/website/docs/extending.md @@ -247,7 +247,7 @@ export async function notify({ findings, owner, repo, prNumber, toolConfig }) { const url = resolveUrl(toolConfig.webhookUrl); if (!url) return; - const ctx = buildContext(findings, owner, repo, prNumber); + const ctx = buildContext(findings, owner, repo, prNumber /* , headSha */); const text = renderTemplate(toolConfig.template ?? DEFAULT_TEMPLATE, ctx); try { @@ -275,7 +275,7 @@ export async function notify({ findings, owner, repo, prNumber, toolConfig }) { | `prNumber` | `number` | Pull request number | | `toolConfig` | `object` | The resolved config for this notifier from `config/layne.json` | -Use `buildContext(findings, owner, repo, prNumber)` to build the template context and `renderTemplate(template, ctx)` to render `{{variable}}` placeholders. See [Template variables](notifiers.md#template-variables) for the full list. +Use `buildContext(findings, owner, repo, prNumber)` to build the template context and `renderTemplate(template, ctx)` to render `{{variable}}` placeholders. An optional fifth argument `headSha` can be passed to generate linked file references in the `{{findings}}` table variable - notifiers that don't use `{{findings}}` can safely omit it. See [Template variables](notifiers.md#template-variables) for the full list. The `$ENV_VAR` resolution pattern keeps secrets out of `config/layne.json`. Any `webhookUrl` value starting with `$` is resolved from `process.env` at runtime. If the variable is not set, skip the notification and log a warning. diff --git a/website/docs/introduction.md b/website/docs/introduction.md index 62dba63..616697d 100644 --- a/website/docs/introduction.md +++ b/website/docs/introduction.md @@ -14,7 +14,7 @@ Everything runs on your own infrastructure. No third-party CI service, no SaaS s ```text title="> architecture" ┌─────────────────────────────────┐ - │ GITHUB PULL REQUEST │ ◀──────────────────────┐ + │ GITHUB PULL REQUEST │◀────────────────────────┐ │ (OPEN, SYNC, REOPEN) │ │ └─────────────────────────────────┘ │ │ Check run │ @@ -31,14 +31,17 @@ Everything runs on your own infrastructure. No third-party CI service, no SaaS s │┌─────────────┐ │ Schedules job ┌────────────────┐ ┌────────────┐ │ │ ││ LAYNE │◀─┘ ─────────────────▶│ REDIS │───▶│ TRUFFLEHOG │──┐ │ │ ││ SERVER │ │ (BULLMQ) │ │ └────────────┘ │ │ │ -│└─────────────┘ └────────────────┘ │ ┌────────────┐ │ ┌──────────┐│ -│ │─▶│ SEMGREP │──┼─▶│ REPORTER ││ +│└─────────────┘ └────────────────┘ │ ┌────────────┐ │ │ │ +│ │─▶│ SEMGREP │──┤ │ │ +│ │ └────────────┘ │ │ │ +│ │ ┌────────────┐ │ ┌──────────┐│ +│ ├─▶│ CLAUDE │──┼─▶│ REPORTER ││ │ │ └────────────┘ │ └──────────┘│ │ │ ┌────────────┐ │ │ -│ ├─▶│ CLAUDE │──┤ │ +│ ├─▶│ SPECTRE │──┤ │ │ │ └────────────┘ │ │ │ │ ┌────────────┐ │ │ -│ └─▶│ PI AGENT │──┘ │ +│ └─▶│ DEP DOCTOR │──┘ │ │ └────────────┘ │ └──────────────────────────────────────────────────────────────────────────────────────────┘ ``` diff --git a/website/docs/notifiers.md b/website/docs/notifiers.md index b4bc101..8b6defa 100644 --- a/website/docs/notifiers.md +++ b/website/docs/notifiers.md @@ -11,7 +11,7 @@ Layne only notifies when the finding count **increases** compared to the previou ## Exception Approval Notifications -When an exception approval is used, Layne **always sends a notification** — even if the finding count didn't increase. This ensures visibility for the security team. +When an exception approval is used, Layne **always sends a notification** - even if the finding count didn't increase. This ensures visibility for the security team. The notification includes the approver's username: @@ -53,6 +53,8 @@ All notifiers support a `template` field with `{{variable}}` placeholders. The a | `{{medium}}` | Count of medium findings | | `{{low}}` | Count of low findings | | `{{summary}}` | Pre-rendered summary line, e.g. `Found 2 issue(s): 1 high, 1 medium.` | +| `{{severitySummary}}` | Severity counts as a comma-separated string, e.g. `1 high, 2 medium` | +| `{{findings}}` | Pre-rendered findings table (Severity, Scanner, File, Line, Rule, Description) - primarily useful in PR comment templates | | `{{approver}}` | GitHub username of the exception approver (only set when an exception is used) | Omit `template` to use the default message format for that notifier. diff --git a/website/docs/pr-comments.md b/website/docs/pr-comments.md index b874b12..a8b3233 100644 --- a/website/docs/pr-comments.md +++ b/website/docs/pr-comments.md @@ -32,7 +32,8 @@ This means the comment only appears when there is something worth flagging, and | Key | Type | Default | Description | |---|---|---|---| | `enabled` | boolean | `false` | Must be `true` to post PR comments for this repo | -| `template` | string \| null | `null` | Custom Markdown template for the failure comment. Omit for the default format | +| `template` | string \| null | `null` | Custom Markdown template for failure comments (blocking findings). Omit for the default format | +| `warningTemplate` | string \| null | `null` | Custom Markdown template for warning comments (non-blocking findings). Omit for the default format | ### Global vs per-repo @@ -53,14 +54,31 @@ To disable comments for a specific repo when they are globally enabled: ## Default format -**When findings are present:** +**When blocking findings are present (`template`):** + ```markdown -## 🔴 Layne - 3 finding(s) -Found 3 issue(s): 1 high, 2 medium. +> [!CAUTION] +> These are security findings reported by the security scanners configured in Layne. Findings may contain false positives - review them and fix what makes sense. If you believe a finding is not valid, contact the security team. + +**1 high, 2 medium** + +
+View 3 finding(s) + +| Severity | Scanner | File | Line | Rule | Description | +|---|---|---|---|---|---| +| 🟠 High | semgrep | [`src/app.js`](https://github.com/acme/payments/blob/abc123/src/app.js#L42) | [42](...) | python.lang.security.eval | Dangerous use of eval | +| 🟡 Medium | spectre | [`scripts/setup.sh`](https://github.com/acme/payments/blob/abc123/scripts/setup.sh#L7) | [7](...) | reverse-shell | Reverse shell detected | + +
``` +**When non-blocking findings are present (`warningTemplate`):** + +Same structure but uses `[!WARNING]` instead of `[!CAUTION]`, and omits the contact-the-security-team sentence. + **After a clean push:** ```markdown @@ -72,7 +90,12 @@ No security issues found on latest push. ## Custom templates -Set `template` to a Markdown string with `{{variable}}` placeholders. PR comments share the same template variables as notifiers - see [Notifiers - Template variables](notifiers.md#template-variables) for the full list. +Set `template` (for blocking findings) or `warningTemplate` (for non-blocking findings) to a Markdown string with `{{variable}}` placeholders. The available variables are a superset of the notifier variables - see [Notifiers - Template variables](notifiers.md#template-variables) for the base list, plus these two that are specific to PR comments: + +| Placeholder | Value | +|---|---| +| `{{severitySummary}}` | Severity counts as a comma-separated string, e.g. `1 high, 2 medium` | +| `{{findings}}` | Pre-rendered findings table (Severity, Scanner, File, Line, Rule, Description) with links to the exact line in the file | :::warning Any custom template must include `` as its **first line**. Layne uses this marker to find and update the existing comment on re-pushes. Without it, every scan creates a new comment instead of updating the existing one. @@ -84,7 +107,7 @@ Example: "acme/payments": { "comment": { "enabled": true, - "template": "\n## Security findings for {{repo}} PR #{{prNumber}}\n\n{{summary}}\n\nSee the [Check Run]({{prUrl}}) for details." + "template": "\n## Security findings for {{repo}} PR #{{prNumber}}\n\n{{severitySummary}}\n\n{{findings}}" } } } diff --git a/website/docs/reference.md b/website/docs/reference.md index 2e233ab..c2101c8 100644 --- a/website/docs/reference.md +++ b/website/docs/reference.md @@ -10,8 +10,8 @@ | `DOMAIN` | No* | (none) | Domain name for TLS and the Rocket.Chat logo URL (e.g. `layne.example.com`). Required by the Docker Compose TLS setup - not validated at runtime by the app. | | `LETSENCRYPT_EMAIL` | No* | (none) | Email for Let's Encrypt expiry notifications. Required by the Docker Compose TLS setup - not validated at runtime by the app. | | `PORT` | No | `3000` | Port for the webhook server | -| `ANTHROPIC_API_KEY` | No | (none) | Required when any repo has `claude.enabled: true`, or when Pi Agent is configured with `provider: "anthropic"` | -| *(provider key)* | No | (none) | Pi Agent provider credentials. The required variable depends on the configured `provider`. See [pi-ai documentation](https://github.com/badlogic/pi-mono/tree/main/packages/ai#environment-variables-nodejs-only) for the full list | +| `ANTHROPIC_API_KEY` | No | (none) | Required when any repo has `claude.enabled: true`, or when Spectre is configured with `provider: "anthropic"` | +| *(provider key)* | No | (none) | Spectre provider credentials. The required variable depends on the configured `provider`. See [Spectre - Provider credentials](scanners/spectre.md#provider-credentials) for the full list | | `DEBUG_MODE` | No | off | Set to `true` or `1` to enable verbose debug logging | | `METRICS_ENABLED` | No | `false` | Set to `true` to enable Prometheus metrics endpoints | | `METRICS_PORT` | No | `9091` | Port for the worker Prometheus metrics server | diff --git a/website/docs/scanners/claude.md b/website/docs/scanners/claude.md index 85a74f4..de03db8 100644 --- a/website/docs/scanners/claude.md +++ b/website/docs/scanners/claude.md @@ -1,5 +1,9 @@ # Claude +
+ Claude +
+ The Claude scanner uses Anthropic's Claude LLM to analyze changed code. By default it looks for **malicious intent** - reverse shells, backdoors, obfuscated payloads, and supply-chain attacks. That is a starting point, not a fixed ruleset. Security engineers implementing Layne should adapt the system prompt (or build a skill) to reflect their threat model and use cases - the scanner is a framework for AI-assisted code review, not a prescribed detector. Unlike Semgrep and Trufflehog, the Claude scanner **sends code to Anthropic's API**. It is disabled by default and must be opted in per repo. It requires `ANTHROPIC_API_KEY` to be set in the environment. @@ -239,13 +243,13 @@ Required fields: Guidance: -- `ruleId` must be one of the following values exactly — no other values are permitted: - - `reverse-shell` — reverse shells, bind shells, or interactive stdio forwarding to a remote process - - `credential-exfiltration` — secrets, tokens, keys, cookies, or env vars sent to an external destination - - `obfuscated-payload` — encoded or constructed strings that decode into code, commands, or malicious URLs fed to an execution sink - - `backdoor` — hidden admin paths, secret trigger strings, kill switches, or covert remote command execution - - `supply-chain-abuse` — hostile install-time scripts, URL/git dependencies with suspicious execution, or typosquat-style packages with concrete hostile behavior - - `covert-execution` — dangerous dynamic execution where the surrounding logic is clearly hostile and does not fit a more specific category above +- `ruleId` must be one of the following values exactly - no other values are permitted: + - `reverse-shell` - reverse shells, bind shells, or interactive stdio forwarding to a remote process + - `credential-exfiltration` - secrets, tokens, keys, cookies, or env vars sent to an external destination + - `obfuscated-payload` - encoded or constructed strings that decode into code, commands, or malicious URLs fed to an execution sink + - `backdoor` - hidden admin paths, secret trigger strings, kill switches, or covert remote command execution + - `supply-chain-abuse` - hostile install-time scripts, URL/git dependencies with suspicious execution, or typosquat-style packages with concrete hostile behavior + - `covert-execution` - dangerous dynamic execution where the surrounding logic is clearly hostile and does not fit a more specific category above - Use `high` for confirmed malicious logic. Use lower severities only if the behavior is still clearly malicious but materially less severe. - Keep `message` specific and factual. - Prefer `anchorKind: "declaration"` when the finding is about the behavior of an enclosing function, method, or class rather than a single sink line. diff --git a/website/docs/scanners/dep-doctor.md b/website/docs/scanners/dep-doctor.md new file mode 100644 index 0000000..31c8340 --- /dev/null +++ b/website/docs/scanners/dep-doctor.md @@ -0,0 +1,162 @@ +# Dep Doctor + +
+ Dep Doctor +
+ +Dep Doctor is a dependency health scanner that fires when a PR changes a lockfile. It checks **only newly-added packages** - packages that already existed in the lockfile at the merge base are ignored. This keeps findings actionable: the PR author introduced the dependency, so they can act on the finding. + +It runs three checks: + +1. **CVE detection** via [OSV-Scanner](https://google.github.io/osv-scanner/) - scans the lockfile against the [OSV](https://osv.dev) vulnerability database and reports any known CVEs. +2. **Abandoned packages** - queries the npm or PyPI registry API and flags packages whose last published version is older than `abandonedDays` (default: 2 years). +3. **Deprecated packages** - flags packages the registry has explicitly marked as deprecated (npm `deprecated` field, PyPI `Development Status :: 7 - Inactive` classifier). + +Dep Doctor is **disabled by default** and must be opted in per repo. OSV-Scanner must be installed in the PATH of the worker process - if it is missing, the CVE check is skipped with a warning and health checks still run. + + +## What it detects + +- **CVEs on new dependencies** - a package introduced by the PR that has a known vulnerability in the OSV database at or above `minCveSeverity`. +- **Abandoned packages** - a new dependency whose last published release is older than `abandonedDays` days. These are unmaintained and will accumulate unpatched vulnerabilities over time. +- **Deprecated packages** - a new dependency that the registry has officially marked as deprecated. + +Dep Doctor never reports findings for dependencies that were already in the lockfile before the PR. If a pre-existing package is vulnerable, it will not appear as a finding - only the newly introduced ones are checked. + + +## How Layne runs it + +1. The PR's changed files are scanned for known lockfile names. If none changed, Dep Doctor exits immediately. +2. For each changed lockfile, Layne fetches the **merge-base version** of the lockfile via `git show`. This establishes which packages already existed before the PR. +3. OSV-Scanner is run against the head lockfile. Packages with CVEs are cross-referenced against the merge-base set - only packages not present at merge base generate findings. CVEs below `minCveSeverity` are dropped. +4. The lockfile is also parsed directly to extract all packages. New packages (not in the merge-base version) are sent in batches of 5 to the npm or PyPI registry API for health checks. Go packages are not health-checked (Go's module proxy does not expose registry health metadata). +5. All findings (CVEs + health) are returned for annotation. + +If the merge-base fetch fails (e.g. the lockfile is entirely new), all packages in the head lockfile are treated as new. + +API errors in registry health checks are caught without failing the scan; they are only logged when `DEBUG_MODE=true`. OSV-Scanner errors are also caught - if `osv-scanner` is not found in PATH, a warning is always logged and CVE scanning is skipped for that run. + + +## Supported lockfiles + +| Lockfile | Ecosystem | CVE scan | Abandoned | Deprecated | +|---|---|---|---|---| +| `package-lock.json` | npm | Yes | Yes | Yes | +| `yarn.lock` | npm | Yes | Yes | Yes | +| `pnpm-lock.yaml` | npm | Yes | No | No | +| `requirements.txt` | PyPI | Yes | Yes | Yes | +| `Pipfile.lock` | PyPI | Yes | No | No | +| `poetry.lock` | PyPI | Yes | No | No | +| `uv.lock` | PyPI | Yes | No | No | +| `go.sum` | Go | Yes | No | No | + +Abandoned and deprecated checks require parsing the lockfile directly to enumerate individual packages. This is currently implemented for `package-lock.json`, `yarn.lock`, and `requirements.txt`. All other lockfile formats support CVE scanning only - OSV-Scanner handles them natively, but health checks are skipped. + + +## Rule IDs + +| Rule ID | Description | +|---|---| +| `` | A newly-added dependency has the named CVE. Example: `CVE-2024-12345` | +| `abandoned/deprecated` | A newly-added dependency is abandoned or deprecated | + + +## Severity + +CVE findings inherit the severity from the OSV database (`CRITICAL` → `critical`, `HIGH` → `high`, `MODERATE`/`MEDIUM` → `medium`, `LOW` → `low`). Unknown OSV severities default to `high`. + +Abandoned and deprecated findings are always `medium`. + +Only CVE findings are filtered by `minCveSeverity` - abandoned and deprecated findings always appear when their respective checks are enabled. + + +## Prerequisites + +OSV-Scanner must be installed and available in the PATH of the Layne worker process. Install it from [https://google.github.io/osv-scanner/](https://google.github.io/osv-scanner/). If it is absent, CVE scanning is skipped with a `[dep-doctor] osv-scanner not found in PATH` warning; registry health checks still run. + + +## Configuration + +```json +{ + "owner/repo": { + "depDoctor": { + "enabled": true + } + } +} +``` + +| Key | Type | Default | Description | +|---|---|---|---| +| `enabled` | boolean | `false` | Must be `true` to enable Dep Doctor for this repo | +| `minCveSeverity` | string | `"high"` | Minimum CVE severity to report. One of `"critical"`, `"high"`, `"medium"`, `"low"`, `"info"`. CVEs below this threshold are dropped | +| `checkAbandoned` | boolean | `true` | Flag newly-added packages with no release in `abandonedDays` days | +| `abandonedDays` | number | `730` | Age threshold (in days) for considering a package abandoned. Default is 2 years | +| `checkDeprecated` | boolean | `true` | Flag newly-added packages the registry has marked as deprecated | +| `extraArgs` | string[] | `[]` | Additional CLI arguments appended to the default `osv-scanner scan --lockfile --format json` invocation. Use with care - duplicate flags such as a second `--format` will produce unexpected results | + +Dep Doctor scanning is disabled by default. Each repo must explicitly opt in with `enabled: true`. + + +## Examples + +**Enable with all defaults:** +```json +{ + "acme/backend": { + "depDoctor": { + "enabled": true + } + } +} +``` + +**Lower the CVE threshold to also report medium-severity CVEs:** +```json +{ + "acme/backend": { + "depDoctor": { + "enabled": true, + "minCveSeverity": "medium" + } + } +} +``` + +**Shorten the abandoned threshold to 1 year:** +```json +{ + "acme/backend": { + "depDoctor": { + "enabled": true, + "abandonedDays": 365 + } + } +} +``` + +**Disable health checks and only scan for CVEs:** +```json +{ + "acme/backend": { + "depDoctor": { + "enabled": true, + "checkAbandoned": false, + "checkDeprecated": false + } + } +} +``` + +**Enable globally for all repos:** +```json +{ + "$global": { + "depDoctor": { + "enabled": true, + "minCveSeverity": "high" + } + } +} +``` diff --git a/website/docs/scanners/index.md b/website/docs/scanners/index.md index 3c8efb2..8767574 100644 --- a/website/docs/scanners/index.md +++ b/website/docs/scanners/index.md @@ -7,7 +7,8 @@ Layne can run several scanners on every pull request. They execute in parallel - | [Semgrep](semgrep.md) | Code vulnerabilities (SAST) | Locally - no data leaves your environment | Enabled | | [Trufflehog](trufflehog.md) | Secrets and credentials | Locally - no data leaves your environment | Enabled | | [Claude](claude.md) | Malicious intent / AI-powered SAST | Anthropic API - code is sent externally | Disabled | -| [Pi Agent](pi-agent.md) | Malicious intent / AI-powered SAST | Different AI providers are supported | Disabled | +| [Spectre](spectre.md) | Malicious intent (single LLM call per file) | External AI provider API - code is sent externally | Disabled | +| [Dep Doctor](dep-doctor.md) | CVEs, abandoned and deprecated dependencies | Locally (OSV-Scanner) + npm/PyPI registry APIs | Disabled | Each scanner produces findings in the same shape, which Layne converts to GitHub Check Run annotations: @@ -17,7 +18,7 @@ Each scanner produces findings in the same shape, which Layne converts to GitHub | `medium` | `warning` | No | | `low` / `info` | `notice` | No | -All three scanners only scan the files changed in the PR - not the entire repository. +All scanners only scan the files changed in the PR - not the entire repository. --- @@ -26,6 +27,7 @@ Read on for scanner-specific details, configuration options, and examples: - [Semgrep](semgrep.md) - rule-based SAST, `extraArgs`, ruleset selection - [Trufflehog](trufflehog.md) - secret detection, batching, `--only-verified` - [Claude](claude.md) - malicious intent, AI-powered SAST -- [Pi Agent](pi-agent.md) - malicious intent, AI-powered SAST +- [Spectre](spectre.md) - malicious intent, single LLM call per file, multi-provider +- [Dep Doctor](dep-doctor.md) - CVE detection, abandoned and deprecated package checks For how to suppress false positives, see [Finding Suppression](../finding-suppression.md). diff --git a/website/docs/scanners/pi-agent.md b/website/docs/scanners/pi-agent.md deleted file mode 100644 index 163f269..0000000 --- a/website/docs/scanners/pi-agent.md +++ /dev/null @@ -1,224 +0,0 @@ -# Pi Agent - -The Pi Agent scanner uses an autonomous AI agent to analyze changed code. By default it looks for **malicious intent** - reverse shells, backdoors, obfuscated payloads, credential exfiltration, and supply-chain attacks. That is the starting point, not a constraint. Security engineers implementing Layne should adapt or replace the system prompt to match their threat model and use cases. - -Pi Agent was added alongside the Claude scanner because its SDK natively supports multiple AI providers. You can route through Anthropic's API, Amazon Bedrock (useful for accessing cheaper or private models), OpenAI, Google, Mistral, and others - without changing any Layne code. - -Pi Agent **sends code to an external AI provider's API**. It is disabled by default and must be opted in per repo. A `provider` must be configured explicitly - omitting it disables Pi Agent even when `enabled: true` is set. The provider must also be configured with the correct credentials in the environment - see [Provider credentials](#provider-credentials) below. - -:::warning Experimental -Pi Agent is an experimental scanner. It may produce inconsistent results, miss findings, or behave unexpectedly across runs. Do not rely on it as your sole security gate. -::: - - -## What it detects - -With the built-in prompt, Pi Agent looks specifically for confirmed malicious patterns with high confidence: - -- Reverse shells and command-and-control callbacks -- Backdoors and authentication bypasses -- Credential and secret exfiltration -- Obfuscated payloads (base64/hex encoded, eval chains) -- Supply-chain attacks (postinstall hooks, URL dependencies with hostile execution, dependency confusion) -- Covert execution (dangerous dynamic execution where the surrounding logic is clearly hostile) - -The built-in prompt instructs Pi Agent to omit anything it cannot validate with a verbatim evidence snippet, and to ignore style issues, bugs, and theoretical vulnerabilities. Replace the prompt entirely with a custom `prompt` to scan for different threat classes or apply domain-specific rules. - - -## Data privacy - -Source code leaves your environment when Pi Agent is enabled. Consider whether this is appropriate for repositories containing sensitive business logic, PII, or regulated data. The destination depends on the configured `provider` - code may be sent to Anthropic, OpenAI, Google, or another third-party API. - -What is sent depends on the [scan mode](../configuration.md#scan-mode) configured for the repo and the `followImports` setting: - -- **`changed_files` mode (default):** The full content of every changed source file is available for the agent to read on demand. With `followImports: true` (the default), unchanged files that the agent requests while following imports are also fetched from git and sent to the provider. -- **`diff_only` mode:** The workspace contains projected copies of each file with only the changed hunks and surrounding context. With `followImports: true`, the agent can still read full unchanged files it follows imports into. - -Set `followImports: false` to restrict the agent strictly to files already in the workspace and avoid sending any unchanged code to the provider. - - -## How Layne runs it - -1. A confined agent session is created with read-only tools (`read`, `grep`, `find`, `ls`) scoped strictly to the scan workspace - the agent cannot access paths outside it. When `followImports: true` (the default), the read tool lazily fetches files from git on demand if the agent requests a file that wasn't in the sparse checkout - this allows the agent to follow import chains into unchanged files without pre-fetching the entire repo. -2. The agent receives the list of changed files with their changed line ranges as context. -3. The agent autonomously investigates: it reads files in full, searches for patterns with grep, follows imports, and traverses related code. Changed line ranges are provided as anchoring context, not as a hard boundary. -4. For each confirmed finding, the agent calls `report_finding` with a verbatim evidence snippet. Line numbers are hints only - Layne re-validates each finding against the evidence string before reporting it. -5. The session runs until the agent finishes or the timeout is reached. If the timeout fires, any findings accumulated so far are returned as partial results. -6. API errors are caught and logged without failing the scan. - - -## Non-determinism - -Because the agent drives its own investigation path, the same code may produce findings with different evidence, line numbers, or even `ruleId` values across runs. Finding IDs (`LAYNE-xxxxxxxxxxxxxxxx`) are derived from the finding content - if a finding shifts, its ID changes and any exception approval tied to it will no longer match. Use exception approvals for Pi Agent findings with this in mind. - - -## Configuration - -```json -{ - "owner/repo": { - "piAgent": { - "enabled": true, - "provider": "anthropic", - "model": "claude-opus-4-6" - } - } -} -``` - -| Key | Type | Default | Description | -|---|---|---|---| -| `enabled` | boolean | `false` | Must be `true` to enable Pi Agent scanning for this repo | -| `provider` | string | (none) | **Required.** AI provider to use. Omitting this disables Pi Agent even if `enabled: true`. Supported values: `anthropic`, `openai`, `azure-openai-responses`, `google`, `google-gemini-cli`, `google-vertex`, `mistral`, `amazon-bedrock` | -| `model` | string | `claude-opus-4-6` | Model ID to use. Must be a valid model ID for the configured provider. **Bedrock uses provider-prefixed IDs** (e.g. `anthropic.claude-opus-4-6-v1`) - the default `claude-opus-4-6` will not resolve and the scan will be silently skipped | -| `thinkingLevel` | string | `"medium"` | Depth of reasoning: `"low"`, `"medium"`, or `"high"`. `"high"` enables extended thinking | -| `timeoutMinutes` | number | `10` | Hard timeout for the agent session in minutes. Partial findings are returned if the timeout fires | -| `followImports` | boolean | `true` | When `true`, files not in the sparse checkout are fetched from git on demand as the agent follows imports. Set to `false` to restrict the agent strictly to changed files | -| `prompt` | string | built-in | Custom system prompt. Replaces the default prompt entirely | - -Pi Agent scanning is disabled by default to avoid unexpected API costs. Each repo must explicitly opt in with both `enabled: true` and a `provider`. - - -## Rule IDs - -Pi Agent findings use the `pi_agent/` prefix. The valid rule IDs are: - -| Rule ID | Description | -|---|---| -| `pi_agent/reverse-shell` | Reverse shells, bind shells, or interactive stdio forwarding to a remote process | -| `pi_agent/credential-exfiltration` | Secrets, tokens, keys, cookies, or env vars sent to an external destination | -| `pi_agent/obfuscated-payload` | Encoded or constructed strings that decode into code, commands, or malicious URLs fed to an execution sink | -| `pi_agent/backdoor` | Hidden admin paths, secret trigger strings, kill switches, or covert remote command execution | -| `pi_agent/supply-chain-abuse` | Hostile install-time scripts, URL/git dependencies with suspicious execution, or dependency confusion with concrete hostile behavior | -| `pi_agent/covert-execution` | Dangerous dynamic execution where the surrounding logic is clearly hostile and does not fit a more specific category above | - - -## Cost - -Pi Agent makes AI API calls charged per token - rates depend on the configured provider and model. When using `provider: "anthropic"`, the default model `claude-opus-4-6` is more expensive than the Claude scanner's default of `claude-haiku-4-5-20251001`. Setting `thinkingLevel` to `"high"` enables extended thinking and increases cost further. - -The most effective cost control is the `workflow_run` or `workflow_job` trigger, which defers scanning until after CI passes. PRs that fail CI quickly are not scanned at all. See [Configuration - Trigger](../configuration.md#trigger) for details. - -Use `thinkingLevel: "low"` for fast, cheap scans on low-risk repositories. Use `"high"` when you need deeper analysis of obfuscated or complex code. - - -## Provider credentials - -Each provider reads credentials from environment variables. The worker logs a warning and skips Pi Agent if credentials are missing rather than failing the scan. - -| Provider value | Required environment variable(s) | -|---|---| -| `anthropic` | `ANTHROPIC_API_KEY` | -| `openai` | `OPENAI_API_KEY` | -| `google` / `google-gemini-cli` | `GEMINI_API_KEY` | -| `mistral` | `MISTRAL_API_KEY` | -| `amazon-bedrock` | Standard AWS credentials: `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` + `AWS_REGION` (or an active AWS profile) | -| `azure-openai-responses` | `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_BASE_URL` (or `AZURE_OPENAI_RESOURCE_NAME`) | -| `google-vertex` | `GOOGLE_CLOUD_API_KEY`, or `GOOGLE_CLOUD_PROJECT` + `GOOGLE_CLOUD_LOCATION` + Application Default Credentials | - -For the full list of options and alternative auth methods (OAuth, service accounts, cross-region Bedrock profiles) see the [pi-ai documentation](https://github.com/badlogic/pi-mono/tree/main/packages/ai#environment-variables-nodejs-only). - -Add the relevant variable(s) to your `.env` file and to your production secrets store. - - -## Examples - -**Enable Pi Agent with Anthropic:** -```json -{ - "acme/backend": { - "piAgent": { - "enabled": true, - "provider": "anthropic", - "model": "claude-opus-4-6" - } - } -} -``` - -**Use OpenAI instead:** -```json -{ - "acme/backend": { - "piAgent": { - "enabled": true, - "provider": "openai", - "model": "gpt-4o" - } - } -} -``` - -**Use extended thinking for highly obfuscated code:** -```json -{ - "acme/backend": { - "piAgent": { - "enabled": true, - "provider": "anthropic", - "model": "claude-opus-4-6", - "thinkingLevel": "high" - } - } -} -``` - -**Restrict to changed files only (disable lazy import fetching):** -```json -{ - "acme/backend": { - "piAgent": { - "enabled": true, - "provider": "anthropic", - "model": "claude-opus-4-6", - "followImports": false - } - } -} -``` - -**Use Amazon Bedrock:** -```json -{ - "acme/backend": { - "piAgent": { - "enabled": true, - "provider": "amazon-bedrock", - "model": "anthropic.claude-opus-4-6-v1" - } - } -} -``` - -Bedrock model IDs use a provider-prefixed format. Cross-region inference profile variants are also available (`us.anthropic.claude-opus-4-6-v1`, `eu.anthropic.claude-opus-4-6-v1`). The region defaults to `us-east-1` unless `AWS_REGION` or `AWS_DEFAULT_REGION` is set. See the [pi-ai documentation](https://github.com/badlogic/pi-mono/tree/main/packages/ai#environment-variables-nodejs-only) for the full list of available Bedrock models and credential options. - -**Use a custom system prompt for domain-specific analysis:** -```json -{ - "acme/payments": { - "piAgent": { - "enabled": true, - "provider": "anthropic", - "model": "claude-opus-4-6", - "prompt": "You are a security reviewer specializing in payment systems. Investigate the provided files for malicious intent: reverse shells, backdoors, credential exfiltration, and supply-chain attacks. Pay extra attention to anything that could exfiltrate card data or PII. Use the read, grep, find, and ls tools to follow imports and trace suspicious patterns. Report ONLY confirmed malicious patterns with high confidence. For each finding call report_finding with exact verbatim evidence." - } - } -} -``` - -**Defer Pi Agent until after CI passes (recommended for cost control):** -```json -{ - "acme/backend": { - "piAgent": { - "enabled": true, - "provider": "anthropic", - "model": "claude-opus-4-6" - }, - "trigger": { - "on": "workflow_run", - "workflow": "CI" - } - } -} -``` diff --git a/website/docs/scanners/semgrep.md b/website/docs/scanners/semgrep.md index 20b7f58..5351ad6 100644 --- a/website/docs/scanners/semgrep.md +++ b/website/docs/scanners/semgrep.md @@ -1,5 +1,9 @@ # Semgrep +
+ Semgrep +
+ Semgrep is an open-source static analysis engine that matches code patterns against a library of security rules. It runs **locally inside the Layne worker container** - no code is sent to any external service. Semgrep is enabled for all repos by default. @@ -54,7 +58,7 @@ Layne's reporter also handles `critical` severity (mapped to `failure`), but Sem **`extraArgs` replaces the default entirely.** If you set per-repo `extraArgs`, include everything you need - there is no merging with the global value. :::warning paths.include and paths.exclude in rules are not effective -Semgrep rules support a `paths:` block to restrict which files a rule applies to. This does not work reliably with Layne. Because Layne passes an explicit list of file paths to Semgrep rather than a directory, Semgrep bypasses rule-level path filtering — `paths.include` and `paths.exclude` entries are silently ignored. This is a known issue. Avoid writing or relying on rules that use `paths:` filters when using Layne. +Semgrep rules support a `paths:` block to restrict which files a rule applies to. This does not work reliably with Layne. Because Layne passes an explicit list of file paths to Semgrep rather than a directory, Semgrep bypasses rule-level path filtering - `paths.include` and `paths.exclude` entries are silently ignored. This is a known issue. Avoid writing or relying on rules that use `paths:` filters when using Layne. ::: ### `--disable-nosem` diff --git a/website/docs/scanners/spectre.md b/website/docs/scanners/spectre.md new file mode 100644 index 0000000..364a61d --- /dev/null +++ b/website/docs/scanners/spectre.md @@ -0,0 +1,286 @@ +# Spectre + +
+ Spectre +
+ +Spectre is a malicious-intent scanner that makes a single direct LLM call per changed file. It looks for **reverse shells, backdoors, obfuscated payloads, credential exfiltration, supply-chain attacks, and covert execution** - confirmed hostile patterns with high confidence, not theoretical vulnerabilities. + +Spectre replaced the previous Pi Agent scanner, and is built on top of Pi to leverage its multi-provider support. The key design principle is cost control: one LLM call per file, no agent sessions, no multi-turn conversations, no import following. This keeps spend predictable and low enough for a $50/month budget across dozens of active repositories. + +Spectre **sends code to an external AI provider's API**. It is disabled by default and must be opted in per repo. A `provider` must be configured explicitly - omitting it disables Spectre even when `enabled: true` is set. The provider must also be configured with the correct credentials in the environment - see [Provider credentials](#provider-credentials) below. + + +## What it detects + +Spectre looks specifically for confirmed malicious patterns with high confidence: + +- Reverse shells and command-and-control callbacks +- Backdoors and authentication bypasses +- Credential and secret exfiltration +- Obfuscated payloads (base64/hex encoded, eval chains) +- Supply-chain attacks (postinstall hooks, URL dependencies with hostile execution, dependency confusion) +- Covert execution (dangerous dynamic execution where the surrounding logic is clearly hostile) + +The built-in prompt instructs Spectre to omit anything it cannot validate with a verbatim evidence snippet, and to ignore style issues, bugs, and theoretical vulnerabilities. + + +## Data privacy + +Source code leaves your environment when Spectre is enabled. Consider whether this is appropriate for repositories containing sensitive business logic, PII, or regulated data. The destination depends on the configured `provider` - code may be sent to Anthropic, OpenAI, Google, Amazon Bedrock, or another third-party API. + +What is sent depends on the [scan mode](../configuration.md#scan-mode) configured for the repo: + +- **`changed_files` mode (default):** The full content of every changed source file is sent to the provider. +- **`diff_only` mode:** Only the changed hunks with surrounding context are sent. This reduces both cost and the amount of code that leaves the environment. + +Spectre never follows imports into unchanged files. Only files explicitly changed in the PR are considered. + + +## How Layne runs it + +1. Files are filtered by the built-in skip list (binary files, images, stylesheets, minified files) and any `skipPaths`/`skipExtensions` configured for the repo. +2. Eligible files are sorted into three tiers by priority: + - **Tier 1** - path-matched high-value files: `package.json`, lock files, `.github/workflows/`, `Dockerfile*`, `docker-compose*`, `.env*`. Always processed first; supply-chain attacks concentrate here. + - **Tier 2** - keyword-promoted files: the full content of each remaining file is scanned for suspicious patterns. Files matching any pattern are promoted above ordinary files. Add repo-specific patterns via `boostPatterns`. The built-in pattern set covers: + - Dynamic code execution: `eval(`, `new Function(` + - Encoding/decode sinks: `atob(`, `String.fromCharCode(` + - Shell execution: `require('child_process')`, `execSync(`, `spawnSync(` + - Direct shell invocation: `/bin/sh`, `/bin/bash`, `/bin/zsh`, `/bin/dash` + - TCP shell redirection: `/dev/tcp/` + - Raw TCP: `net.Socket` + - Cloud metadata endpoints: `169.254.169.254`, `metadata.google.internal` + - npm lifecycle hooks: `"postinstall":`, `"preinstall":`, `"prepare":` + - Remote fetch in shell/CI: `curl`/`wget` with an HTTP(S) URL + - Dynamic imports with a non-literal argument: `import(` + - **Tier 3** - everything else: fills remaining capacity after tiers 1 and 2. +3. The combined list is capped at `fileCap` (default: 20). Tiers fill capacity in order - a file with a suspicious keyword that would otherwise be position 28 in the diff gets scanned ahead of a benign file at position 3. +4. **Secondary batch:** any keyword-matched (tier 2) files that overflowed the primary cap are collected and scanned as an additional batch, capped at `secondaryFileCap` (default: 20). This means a PR with many suspicious files can scan up to 40 files total without ever spending LLM calls on ordinary files that matched no patterns. Set `secondaryFileCap: 0` to disable this and restore a hard 20-file ceiling. +5. Each file in the final list is scanned with a single LLM call. The prompt includes the file content (or diff in `diff_only` mode) and asks for a JSON response listing any findings with verbatim evidence snippets. +5. Findings with severity below `minSeverity` are dropped. Findings that lack an `evidence` field or have an empty evidence string are also silently dropped - the evidence snippet is required for location validation. For each surviving finding, Layne re-validates the evidence string against the actual file content before reporting it. +6. API errors are caught and logged without failing the scan. + + +## Configuration + +```json +{ + "owner/repo": { + "spectre": { + "enabled": true, + "provider": "anthropic", + "model": "claude-haiku-4-5-20251001" + } + } +} +``` + +| Key | Type | Default | Description | +|---|---|---|---| +| `enabled` | boolean | `false` | Must be `true` to enable Spectre scanning for this repo | +| `provider` | string | (none) | **Required.** AI provider to use. Omitting this disables Spectre even if `enabled: true`. Supported values: `anthropic`, `openai`, `google`, `mistral`, `amazon-bedrock` | +| `model` | string | `claude-haiku-4-5-20251001` | Model ID to use. Must be a valid model ID for the configured provider. **Bedrock uses provider-prefixed IDs** (e.g. `anthropic.claude-haiku-4-5-20251001-v1:0`) - the default `claude-haiku-4-5-20251001` will not resolve on Bedrock | +| `fileCap` | number | `20` | Maximum number of files to scan in the primary batch. Tier 1 files (manifests, lock files, CI configs) always consume capacity first | +| `secondaryFileCap` | number | `20` | Maximum number of additional keyword-matched files to scan beyond the primary cap. Set to `0` to disable and enforce a hard `fileCap` ceiling | +| `maxDiffLines` | number | `400` | Maximum lines of file content to send to the LLM per file. Longer files are truncated to this limit before the API call | +| `minSeverity` | string | `"high"` | Minimum severity to report. One of `"critical"`, `"high"`, `"medium"`, `"low"`, `"info"`. Findings below this threshold are dropped before annotation | +| `skipPaths` | string[] | `[]` | Glob patterns for paths to exclude. Supports `*` (single path segment) and `**` (any depth). Example: `["vendor/**", "test/**"]` | +| `skipExtensions` | string[] | `[]` | File extensions to exclude. Must start with `.`. Example: `[".test.ts", ".spec.js"]` | +| `concurrency` | number | `5` | Maximum number of files to scan in parallel per job | +| `prompt` | string | built-in | Custom analysis instructions. Replaces the default "what to detect" section of the system prompt. The JSON output format is always appended automatically - your prompt should only describe what to look for, not how to format the response | +| `boostPatterns` | string[] | `[]` | Additional regex patterns (as strings) added to the tier 2 keyword list. Files whose full content matches any pattern are prioritised within the cap ahead of tier 3 files. Invalid regex strings are silently ignored with a console warning - test patterns before deploying. See the Examples section for usage | + +Spectre scanning is disabled by default to avoid unexpected API costs. Each repo must explicitly opt in with both `enabled: true` and a `provider`. + + +## Rule IDs + +| Rule ID | Description | +|---|---| +| `reverse-shell` | Reverse shells, bind shells, or interactive stdio forwarding to a remote process | +| `credential-exfiltration` | Secrets, tokens, keys, cookies, or env vars sent to an external destination | +| `obfuscated-payload` | Encoded or constructed strings that decode into code, commands, or malicious URLs fed to an execution sink | +| `backdoor` | Hidden admin paths, secret trigger strings, kill switches, or covert remote command execution | +| `supply-chain-abuse` | Hostile install-time scripts, URL/git dependencies with suspicious execution, or dependency confusion with concrete hostile behavior | +| `covert-execution` | Dangerous dynamic execution where the surrounding logic is clearly hostile and does not fit a more specific category above | + + +## Cost + +Spectre makes one API call per scanned file. Total cost per PR = *(files scanned)* × *(tokens per file)* × *(provider rate)*. + +The `fileCap` (default: 20) is the primary cost control, and `secondaryFileCap` (default: 20) controls how many additional keyword-matched overflow files are scanned. In the worst case - a PR with 40+ files that all contain suspicious patterns - Spectre scans up to 40 files total. PRs with no keyword-matching files stay at the 20-file ceiling. Set `secondaryFileCap: 0` to enforce a hard cap of `fileCap` regardless. Combined with `diff_only` mode, which reduces tokens per file, a typical PR costs a few cents at most with a small model like `claude-haiku-4-5-20251001`. + +Amazon Bedrock is an attractive option for cost-sensitive deployments - it provides access to multiple model families (Claude, Llama, Mistral) under your own AWS billing, often at rates below the direct provider API. See [Provider credentials](#provider-credentials) below. + +The most effective cost control is the `workflow_run` or `workflow_job` trigger, which defers scanning until after CI passes. PRs that fail CI quickly are not scanned at all. See [Configuration - Trigger](../configuration.md#trigger) for details. + + +## Provider credentials + +Each provider reads credentials from environment variables. The worker logs a warning and skips Spectre if credentials are missing rather than failing the scan. + +| Provider value | Required environment variable(s) | +|---|---| +| `anthropic` | `ANTHROPIC_API_KEY` | +| `openai` | `OPENAI_API_KEY` | +| `google` | `GEMINI_API_KEY` | +| `mistral` | `MISTRAL_API_KEY` | +| `amazon-bedrock` | **Option A (API key):** `AWS_BEARER_TOKEN_BEDROCK` + `AWS_REGION` - simplest, no IAM user needed
**Option B (IAM):** `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` + `AWS_REGION`
**Option C (profile/role):** set `AWS_PROFILE` and let the SDK resolve credentials from `~/.aws/credentials` or an EC2 instance role | + +Add the relevant variable(s) to your `.env` file and to your production secrets store. + + +## Examples + +**Enable Spectre with Anthropic (fast, cheap model):** +```json +{ + "acme/backend": { + "spectre": { + "enabled": true, + "provider": "anthropic", + "model": "claude-haiku-4-5-20251001" + } + } +} +``` + +**Use Amazon Bedrock:** +```json +{ + "acme/backend": { + "spectre": { + "enabled": true, + "provider": "amazon-bedrock", + "model": "anthropic.claude-haiku-4-5-20251001-v1:0" + } + } +} +``` + +Bedrock model IDs use a provider-prefixed format. Cross-region inference profile variants are also available (`us.anthropic.claude-haiku-4-5-20251001-v1`, `eu.anthropic.claude-haiku-4-5-20251001-v1`). The region defaults to `us-east-1` unless `AWS_REGION` is set. + +**Use OpenAI:** +```json +{ + "acme/backend": { + "spectre": { + "enabled": true, + "provider": "openai", + "model": "gpt-4o-mini" + } + } +} +``` + +**Lower the file cap for a small, focused service:** +```json +{ + "acme/auth-service": { + "spectre": { + "enabled": true, + "provider": "anthropic", + "model": "claude-haiku-4-5-20251001", + "fileCap": 10 + } + } +} +``` + +**Skip test files and vendored code:** +```json +{ + "acme/backend": { + "spectre": { + "enabled": true, + "provider": "anthropic", + "model": "claude-haiku-4-5-20251001", + "skipPaths": ["vendor/**", "**/__tests__/**", "**/*.test.ts"], + "skipExtensions": [".spec.js", ".spec.ts"] + } + } +} +``` + +**Report medium-severity findings too:** +```json +{ + "acme/backend": { + "spectre": { + "enabled": true, + "provider": "anthropic", + "model": "claude-haiku-4-5-20251001", + "minSeverity": "medium" + } + } +} +``` + +**Use a domain-specific prompt for a monorepo with known threat patterns:** +```json +{ + "acme/backend": { + "spectre": { + "enabled": true, + "provider": "anthropic", + "model": "claude-haiku-4-5-20251001", + "prompt": "You are a security reviewer for a Node.js payment service. Detect malicious intent only: reverse shells, backdoors, credential exfiltration, obfuscated payloads, and supply-chain attacks.\n\nPay extra attention to:\n- package.json lifecycle scripts - primary supply-chain vector\n- Any code that touches process.env and makes outbound network calls\n- Calls to cloud metadata endpoints (169.254.169.254)\n\nReport ONLY confirmed malicious patterns with high confidence." + } + } +} +``` + +The JSON output format is appended automatically - your prompt only needs to describe the threat model and context, not the response structure. + +**Add repo-specific keyword patterns to promote suspicious files (Python service example):** +```json +{ + "acme/data-pipeline": { + "spectre": { + "enabled": true, + "provider": "anthropic", + "model": "claude-haiku-4-5-20251001", + "boostPatterns": [ + "\\bsubprocess\\.(?:run|call|Popen)\\b", + "\\bos\\.system\\b", + "\\bpyc_compile\\b" + ] + } + } +} +``` + +Each entry is a regex pattern string. Backslashes must be double-escaped in JSON (`\\b` for a word boundary, `\\.` for a literal dot). Files whose full content matches any pattern are promoted to tier 2 and scanned ahead of ordinary files when the cap is applied. + +**Defer Spectre until after CI passes (recommended for cost control):** +```json +{ + "acme/backend": { + "spectre": { + "enabled": true, + "provider": "anthropic", + "model": "claude-haiku-4-5-20251001" + }, + "trigger": { + "on": "workflow_run", + "workflow": "CI" + } + } +} +``` + +**Use `diff_only` mode to reduce tokens and cost:** +```json +{ + "acme/backend": { + "mode": "diff_only", + "contextLines": 8, + "spectre": { + "enabled": true, + "provider": "anthropic", + "model": "claude-haiku-4-5-20251001" + } + } +} +``` diff --git a/website/docs/scanners/trufflehog.md b/website/docs/scanners/trufflehog.md index cbe1082..fda9bd0 100644 --- a/website/docs/scanners/trufflehog.md +++ b/website/docs/scanners/trufflehog.md @@ -1,5 +1,9 @@ # Trufflehog +
+ Trufflehog +
+ Trufflehog is an open-source secret scanning tool that detects credentials, API keys, tokens, and other sensitive material committed to source code. It runs **locally inside the Layne worker container** - no code is sent to any external service. Trufflehog is enabled for all repos by default. diff --git a/website/docs/threat-model.md b/website/docs/threat-model.md index d254f3f..1c32a62 100644 --- a/website/docs/threat-model.md +++ b/website/docs/threat-model.md @@ -126,7 +126,7 @@ When the Claude scanner is enabled, **the full content of every changed source f GitHub publishes its webhook source ranges at `https://api.github.com/meta` (the `.hooks` key). ```nginx -# GitHub webhook source IPs — https://api.github.com/meta (.hooks) +# GitHub webhook source IPs - https://api.github.com/meta (.hooks) # Refresh these if GitHub rotates their ranges. geo $is_github { default 0; diff --git a/website/sidebars.js b/website/sidebars.js index 7f2671b..402224c 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -15,7 +15,8 @@ const sidebars = { { type: 'doc', id: 'scanners/semgrep', label: 'Semgrep' }, { type: 'doc', id: 'scanners/trufflehog', label: 'Trufflehog' }, { type: 'doc', id: 'scanners/claude', label: 'Claude' }, - { type: 'doc', id: 'scanners/pi-agent', label: 'Pi Agent' }, + { type: 'doc', id: 'scanners/spectre', label: 'Spectre' }, + { type: 'doc', id: 'scanners/dep-doctor', label: 'Dep Doctor' }, ], }, { type: 'doc', id: 'finding-suppression', label: 'Finding Suppression' }, diff --git a/website/static/img/claude.png b/website/static/img/claude.png new file mode 100644 index 0000000..64f72e1 Binary files /dev/null and b/website/static/img/claude.png differ diff --git a/website/static/img/dep-doctor.png b/website/static/img/dep-doctor.png new file mode 100644 index 0000000..9e41582 Binary files /dev/null and b/website/static/img/dep-doctor.png differ diff --git a/website/static/img/semgrep.png b/website/static/img/semgrep.png new file mode 100644 index 0000000..04bf316 Binary files /dev/null and b/website/static/img/semgrep.png differ diff --git a/website/static/img/spectre.png b/website/static/img/spectre.png new file mode 100644 index 0000000..583ae47 Binary files /dev/null and b/website/static/img/spectre.png differ diff --git a/website/static/img/trufflehog.png b/website/static/img/trufflehog.png new file mode 100644 index 0000000..a5626ca Binary files /dev/null and b/website/static/img/trufflehog.png differ