UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

442 lines (441 loc) • 16.1 kB
/** * Trivy client for pi-lens — dependency CVE detection (Phase 1 of #131). * * Surfaces known vulnerabilities (CVE / GHSA) in a project's declared * dependencies, across every ecosystem Trivy understands (npm, PyPI, Maven, * Gradle, Go modules, Cargo, Composer, RubyGems, NuGet, …) from a single * filesystem walk. pi-lens has zero dependency-CVE coverage otherwise. * * Lifecycle (mirrors the gitleaks / govulncheck session-scan clients): * - session_start scan (via the `runTask(setImmediate)` background wrapper) * - turn_end advisory reads the cached result and surfaces top-N findings; * CRITICAL is treated as a blocker, the rest as advisory * - per-edit scope: skipped — CVE data is daily-ish and the scan is whole-tree; * re-running per keystroke is wasteful. Re-scan is driven by the cache layer * (keyed on lockfile mtimes by the caller). * * Detection gate (explicit opt-in per #131): the project must opt in via * `trivy.enabled: true` in `.pi-lens.json` AND declare a scannable dependency * surface (any manifest at the analysis root). The opt-in is required because a * first scan auto-installs the binary and pulls a 30-200 MB vuln DB — too heavy * to enable for every project that merely has a `package.json`. Set * `trivy.minSeverity` to widen what surfaces (default HIGH; never hides * HIGH/CRITICAL). * * When the gate trips, the client auto-installs trivy from GitHub releases * (installer entry registered in clients/installer/index.ts) and runs * `trivy fs --scanners vuln`. The first run downloads Trivy's vuln DB * (~30-200 MB); because the scan runs in the background session_start task it * never blocks an edit. * * Scope note: this slice is dependency CVEs only. IaC misconfig (per-edit), * secrets (pending dedup vs gitleaks), and license compliance are follow-ups * tracked on #131. * * Refs: #131 */ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { mkdtempSync } from "node:fs"; import { loadPiLensProjectConfig } from "./project-lens-config.js"; import { safeSpawnAsync } from "./safe-spawn.js"; import { SecurityScanClient } from "./security-scan-client.js"; const EMPTY_RESULT = { success: false, findings: [], secrets: [], licenses: [], }; // Generous: the FIRST run downloads the vuln DB (~30-200 MB). This runs in the // background session_start task, so a slow cold start never blocks an edit. const SCAN_TIMEOUT_MS = 180_000; // --- Detection --- /** * Dependency manifests / lockfiles whose presence at the analysis root opts the * project in to CVE scanning. Mirrors the ecosystem coverage Trivy's SCA walk * understands (#131). */ const DEPENDENCY_MANIFESTS = [ // JS / TS "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", // Python "pyproject.toml", "requirements.txt", "poetry.lock", "Pipfile.lock", // Java / JVM "pom.xml", "build.gradle", "build.gradle.kts", "gradle.lockfile", // Go "go.mod", "go.sum", // Rust "Cargo.toml", "Cargo.lock", // .NET "packages.lock.json", // PHP "composer.json", "composer.lock", // Ruby "Gemfile", "Gemfile.lock", ]; /** * Detect whether the analysis root declares any dependencies Trivy can scan. * Root-level check (fast, synchronous) — the same shape as `hasGitleaksSignal`. * Nested-only manifests in deep monorepo packages are a known follow-up; the * gate intentionally errs toward not auto-pulling a 100 MB DB for a project that * shows no dependency surface at its root. * * Exported for tests and gate-before-construct callers. */ export function hasAnyDependencyManifest(cwd) { for (const manifest of DEPENDENCY_MANIFESTS) { try { if (fs.existsSync(path.join(cwd, manifest))) return true; } catch { // non-fatal — keep probing the remaining manifests } } return false; } /** * Explicit opt-in: trivy runs only when the project sets `trivy.enabled: true` * in `.pi-lens.json` (the loader walks up, so a `~/.pi-lens.json` enables it * globally). Required because the first scan auto-installs the binary and pulls * a 30-200 MB vuln DB — too costly to enable implicitly. Default OFF. * * Exported for tests and gate-before-construct callers. */ export function isTrivyEnabled(cwd) { try { const config = loadPiLensProjectConfig(cwd); const trivy = config.raw ?.trivy; return trivy?.enabled === true; } catch { return false; } } /** * Full session-scan gate: explicit opt-in AND a scannable dependency surface. * Both must hold before we auto-install trivy / pull its DB. */ export function shouldScanTrivy(cwd) { return isTrivyEnabled(cwd) && hasAnyDependencyManifest(cwd); } // --- Severity floor --- const SEVERITY_RANK = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3, }; /** * Resolve the `--severity` list from the project's `pi-lens.trivy.minSeverity` * (default HIGH). The floor can be LOWERED (MEDIUM / LOW = see more) but is * clamped so it can never be raised above HIGH — a CRITICAL or HIGH CVE must * always surface regardless of config (#131 non-goal: no bypassing criticals). */ export function resolveSeverityFloor(cwd) { let raw; try { const config = loadPiLensProjectConfig(cwd); raw = config.raw ?.trivy?.minSeverity; } catch { raw = undefined; } const requested = typeof raw === "string" ? SEVERITY_RANK[raw.toUpperCase()] : undefined; // Default HIGH; never stricter than HIGH so HIGH/CRITICAL are never hidden. const floor = Math.min(requested ?? SEVERITY_RANK.HIGH, SEVERITY_RANK.HIGH); return ["LOW", "MEDIUM", "HIGH", "CRITICAL"].filter((s) => SEVERITY_RANK[s] >= floor); } // --- Client --- export class TrivyClient extends SecurityScanClient { constructor(verbose = false) { super("trivy", verbose); } /** Static gates so callers can skip before constructing. */ static hasAnyDependencyManifest(cwd) { return hasAnyDependencyManifest(cwd); } /** Full opt-in gate (config opt-in AND a dependency manifest). */ static shouldScan(cwd) { return shouldScanTrivy(cwd); } /** * Auto-install via the GitHub-release path (registered in * `clients/installer/index.ts`) when trivy isn't already on PATH. */ doEnsureAvailable() { return this.ensureViaInstaller(["--version"]); } /** * Scan a directory tree for dependency CVEs. * * Skips early when the opt-in gate isn't satisfied. When trivy is * unavailable, returns an empty (but successful) result rather than failing * the session_start task. Re-entrancy safe: concurrent calls against the * same root share a single process. */ async scan(cwd) { const targetDir = path.resolve(cwd); const scannedAt = new Date().toISOString(); if (!shouldScanTrivy(targetDir)) { return { ...EMPTY_RESULT, success: true, scannedAt, summary: hasAnyDependencyManifest(targetDir) ? "trivy not enabled (set trivy.enabled in .pi-lens.json)" : "no dependency manifest at analysis root", }; } if (!(await this.ensureAvailable())) { return { ...EMPTY_RESULT, scannedAt, summary: "trivy not installed" }; } return this.dedupeScan(targetDir, () => this.runScan(targetDir)); } async runScan(cwd) { const scannedAt = new Date().toISOString(); const bin = this.binaryPath ?? "trivy"; const severities = resolveSeverityFloor(cwd); const outDir = mkdtempSync(path.join(os.tmpdir(), "pi-lens-trivy-")); const reportPath = path.join(outDir, "trivy-report.json"); try { // One filesystem walk covers all three scanners. `--severity` filters // both the vuln and the license results; secret findings are // severity-independent (trivy always emits them) and collapsed // downstream against gitleaks / ast-grep. const result = await safeSpawnAsync(bin, [ "fs", "--scanners", "vuln,secret,license", "--severity", severities.join(","), "--format", "json", "--output", reportPath, "--quiet", "--no-progress", cwd, ], { cwd, timeout: SCAN_TIMEOUT_MS }); if (result.error) { this.log(`Scan error: ${result.error.message}`); return { ...EMPTY_RESULT, scannedAt, summary: result.error.message.slice(0, 200), }; } if (!fs.existsSync(reportPath)) { return { ...EMPTY_RESULT, success: true, scannedAt, summary: (result.stderr ?? "").trim().split("\n")[0] || "no report produced", }; } const raw = fs.readFileSync(reportPath, "utf-8"); const findings = parseTrivyReport(raw); const secrets = parseTrivySecrets(raw); const licenses = parseTrivyLicenses(raw); return { success: true, findings, secrets, licenses, scannedAt }; } catch (err) { return { ...EMPTY_RESULT, scannedAt, summary: err instanceof Error ? err.message.slice(0, 200) : String(err), }; } finally { try { fs.rmSync(outDir, { recursive: true, force: true }); } catch { // non-fatal } } } } // --- Parser --- function normalizeSeverity(raw) { const s = typeof raw === "string" ? raw.toUpperCase() : ""; if (s === "CRITICAL" || s === "HIGH" || s === "MEDIUM" || s === "LOW") { return s; } return "UNKNOWN"; } /** * Map Trivy's `fs --format json` report to our `TrivyFinding[]`. The report is * `{ Results: [{ Target, Vulnerabilities: [{ VulnerabilityID, PkgName, … }] }] }`; * `Results` / `Vulnerabilities` are often `null` when nothing is found. * * Defensive: malformed / truncated input returns `[]` rather than throwing — * the session_start task must never crash the session. Exported for unit tests. */ export function parseTrivyReport(raw) { if (!raw.trim()) return []; let parsed; try { parsed = JSON.parse(raw); } catch { return []; } const results = parsed?.Results; if (!Array.isArray(results)) return []; const findings = []; for (const resultEntry of results) { if (!resultEntry || typeof resultEntry !== "object") continue; const r = resultEntry; const target = typeof r.Target === "string" ? r.Target : undefined; const vulns = r.Vulnerabilities; if (!Array.isArray(vulns)) continue; for (const vulnEntry of vulns) { if (!vulnEntry || typeof vulnEntry !== "object") continue; const v = vulnEntry; const vulnerabilityId = typeof v.VulnerabilityID === "string" ? v.VulnerabilityID : undefined; const pkgName = typeof v.PkgName === "string" ? v.PkgName : undefined; if (!vulnerabilityId || !pkgName) continue; findings.push({ vulnerabilityId, pkgName, installedVersion: typeof v.InstalledVersion === "string" ? v.InstalledVersion : undefined, fixedVersion: typeof v.FixedVersion === "string" && v.FixedVersion.trim() ? v.FixedVersion : undefined, severity: normalizeSeverity(v.Severity), title: typeof v.Title === "string" ? v.Title : undefined, primaryUrl: typeof v.PrimaryURL === "string" ? v.PrimaryURL : undefined, target, }); } } return findings; } /** * Map Trivy's `Results[].Secrets[]` rows to normalized secret findings. The * file is the result's `Target`; each secret carries a `RuleID`, `StartLine`, * and `Title`. Same defensive contract as `parseTrivyReport` — malformed input * returns `[]`. These are collapsed against gitleaks / ast-grep downstream * (`clients/secret-findings.ts`) so the same secret surfaces once. Exported for * unit tests. */ export function parseTrivySecrets(raw) { if (!raw.trim()) return []; let parsed; try { parsed = JSON.parse(raw); } catch { return []; } const results = parsed?.Results; if (!Array.isArray(results)) return []; const secrets = []; for (const resultEntry of results) { if (!resultEntry || typeof resultEntry !== "object") continue; const r = resultEntry; const target = typeof r.Target === "string" ? r.Target : undefined; const rows = r.Secrets; if (!target || !Array.isArray(rows)) continue; for (const row of rows) { if (!row || typeof row !== "object") continue; const s = row; const ruleId = typeof s.RuleID === "string" ? s.RuleID : undefined; const line = typeof s.StartLine === "number" ? s.StartLine : undefined; if (!ruleId || line == null) continue; secrets.push({ ruleId, file: target, line, title: typeof s.Title === "string" ? s.Title : undefined, }); } } return secrets; } /** * Map Trivy's `Results[].Licenses[]` rows to normalized license-risk findings * (#131 Mode 4). Each row carries `Name` (the license id, e.g. "GPL-3.0"), * `PkgName`, `Severity`, `Category` (trivy's classification: restricted / * reciprocal / forbidden / …), and a `FilePath` for license-file findings. * `Severity` is already filtered by the scan's `--severity` floor. Same * defensive contract as the sibling parsers. Exported for unit tests. */ export function parseTrivyLicenses(raw) { if (!raw.trim()) return []; let parsed; try { parsed = JSON.parse(raw); } catch { return []; } const results = parsed?.Results; if (!Array.isArray(results)) return []; const licenses = []; for (const resultEntry of results) { if (!resultEntry || typeof resultEntry !== "object") continue; const r = resultEntry; const target = typeof r.Target === "string" ? r.Target : undefined; const rows = r.Licenses; if (!Array.isArray(rows)) continue; for (const row of rows) { if (!row || typeof row !== "object") continue; const l = row; const license = typeof l.Name === "string" ? l.Name : undefined; if (!license) continue; const filePath = typeof l.FilePath === "string" && l.FilePath ? l.FilePath : target; // Package licenses carry PkgName; license-file findings fall back to // the file they were found in. const pkgName = typeof l.PkgName === "string" && l.PkgName ? l.PkgName : (filePath ?? "unknown"); licenses.push({ license, pkgName, severity: normalizeSeverity(l.Severity), category: typeof l.Category === "string" ? l.Category : undefined, filePath, }); } } return licenses; }