UNPKG

pi-lens

Version:

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

283 lines (281 loc) • 10.3 kB
import { createRequire as __pilensCreateRequire } from "node:module"; const require = __pilensCreateRequire(import.meta.url); import { getScratchTreeDirNames } from "./chunk-TO6WL3GM.js"; import { SecurityScanClient } from "./chunk-7F72PXY2.js"; import { resolveOpengrepConfig } from "./chunk-VDBPVIKD.js"; import "./chunk-EOKRQ3SA.js"; import "./chunk-WONERJTP.js"; import { safeSpawnAsync } from "./chunk-VN2AXLEX.js"; import "./chunk-BW5KFIQC.js"; import "./chunk-DQ2NZ7C4.js"; import "./chunk-VNMT7C64.js"; import "./chunk-2EECBNC5.js"; import { recordDegradationOnce } from "./chunk-N3YQJI6O.js"; import "./chunk-O6TQT6RI.js"; import "./chunk-5THXD6X5.js"; import "./chunk-UK3CMEAL.js"; import "./chunk-LJDODBBZ.js"; import "./chunk-ABBOA7UD.js"; import { realpathOrResolve } from "./chunk-Q5U6FMDI.js"; import "./chunk-NXL4FFQQ.js"; // dist/clients/opengrep-client.js import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { mkdtempSync } from "node:fs"; var EMPTY_RESULT = { success: false, findings: [] }; var SCAN_TIMEOUT_MS = 18e4; var OpengrepClient = class _OpengrepClient extends SecurityScanClient { constructor(verbose = false) { super("opengrep", verbose); } /** * Structurally always-on (mirrors `opengrepInitialization` in * `clients/lsp/server.ts`) — `resolveOpengrepConfig(cwd, { enabled: true })` * only resolves WHICH rules to run, not whether opengrep runs at all. * Exported as a static so callers can gate/log without constructing. */ static resolveConfig(cwd) { return resolveOpengrepConfig(cwd, { enabled: true }); } /** * opengrep's top-level `--version` (no `scan` subcommand) — matches the * installer's `checkArgs: ["--version"]` entry (`installer/index.ts`). */ doEnsureAvailable() { return this.ensureViaInstaller(["--version"]); } /** * Scan a directory tree with opengrep's rule set (local config or `auto`). * Re-entrancy safe: concurrent calls against the same root share a single * opengrep process (mirrors `GitleaksClient`/`JscpdClient`). */ async scan(cwd) { const targetDir = realpathOrResolve(cwd); const scannedAt = (/* @__PURE__ */ new Date()).toISOString(); if (!await this.ensureAvailable()) { return { ...EMPTY_RESULT, scannedAt, reason: "not-installed", summary: "opengrep not installed" }; } return this.dedupeScan(targetDir, () => this.runScan(targetDir)); } async runScan(cwd) { const scannedAt = (/* @__PURE__ */ new Date()).toISOString(); const bin = this.binaryPath ?? "opengrep"; const resolved = _OpengrepClient.resolveConfig(cwd); const outDir = mkdtempSync(path.join(os.tmpdir(), "pi-lens-opengrep-")); const reportPath = path.join(outDir, "opengrep-report.json"); try { const result = await safeSpawnAsync(bin, [ "scan", "--config", resolved.configArg ?? "auto", "--json", "--json-output", reportPath, // Never fail the scan on findings — this is a read, not a gate // (matches gitleaks's `--exit-code 0` intent). "--no-error", "--quiet", "--disable-version-check", // #1562 class fix: opengrep's own `.gitignore` respect covers the // common case (scratch trees are usually gitignored), but not a // scratch/cache tree that ISN'T (e.g. an un-gitignored worktree // cache) — `--exclude` is semgrep-compatible, so a slash-free // pattern matches that directory name anywhere in the tree, // independent of gitignore. Same `EXCLUDED_DIRS`-derived list // gitleaks/trivy use, so the three scanners can't drift apart. ...getScratchTreeDirNames().flatMap((name) => ["--exclude", name]), cwd ], { cwd, timeout: SCAN_TIMEOUT_MS }); if (result.error) { this.log(`Scan error: ${result.error.message}`); const reason = `${result.failure ?? "spawn-failed"}: ${result.error.message}`; this.recordRefusal(cwd, reason, result.status); return { ...EMPTY_RESULT, scannedAt, reason: "spawn-failed", summary: reason.slice(0, 200) }; } if (!fs.existsSync(reportPath)) { const reason = (result.stderr ?? "").trim().split("\n")[0] || "no report produced"; this.recordRefusal(cwd, reason, result.status); return { ...EMPTY_RESULT, scannedAt, reason: "no-report", summary: reason }; } const raw = fs.readFileSync(reportPath, "utf-8"); const report = readOpengrepReport(raw); if (report.verdict === "refused" || result.status !== 0) { const reason = report.verdict === "refused" ? report.reason : `opengrep exited with status ${result.status}`; this.recordRefusal(cwd, reason, result.status); return { ...EMPTY_RESULT, scannedAt, reason: "refused", summary: reason.slice(0, 200) }; } if (report.partial) { recordDegradationOnce({ kind: "opengrep-partial-scan", subject: cwd, reason: report.partial.reason, metadata: { status: result.status } }); } return { success: true, // A warning-level partial report with no scanned paths proves that // the producer went cold. A complete empty report still proves a // genuine scan, so it is still "analysed" for the render layer's // analysed-and-found-nothing state (#2970) — but it carries no // FILE-level authority, which `analyzedFiles: []` below is what // says. analyzed: !report.partial || report.scanned.length > 0, // #2962: the set is ALWAYS carried, empty included. "This producer // declared its coverage and it was zero files" and "this producer // declares no coverage at all" are different facts; dropping the // empty array collapses them into the second, and the second is // what hands a zero-file scan whole-root retirement authority // downstream (`runnerRetirementDecision`, tools/lens-diagnostics.ts). analyzedFiles: report.scanned.map((file) => realpathOrResolve(path.resolve(cwd, file))), ...report.partial ? { partial: true } : {}, ...report.partial ? { summary: report.partial.reason } : {}, ...report.partial && report.scanned.length === 0 ? { reason: "partial-no-paths" } : {}, findings: report.findings, scannedAt }; } catch (err) { const reason = err instanceof Error ? err.message : String(err); this.recordRefusal(cwd, reason, void 0); return { ...EMPTY_RESULT, scannedAt, reason: "crashed", summary: reason.slice(0, 200) }; } finally { try { fs.rmSync(outDir, { recursive: true, force: true }); } catch { } } } recordRefusal(cwd, reason, status) { recordDegradationOnce({ kind: "opengrep-scan-refused", subject: cwd, reason, metadata: { status } }); } }; function readOpengrepReport(raw) { let parsed; try { parsed = JSON.parse(raw); } catch { return { verdict: "refused", reason: "unparseable opengrep report" }; } if (!parsed || typeof parsed !== "object") return { verdict: "refused", reason: "unparseable opengrep report" }; const report = parsed; if (!Array.isArray(report.results) && !Array.isArray(report.errors)) return { verdict: "refused", reason: "unparseable opengrep report" }; const errors = (Array.isArray(report.errors) ? report.errors : []).map((error) => { if (typeof error === "string") return { message: error }; if (!error || typeof error !== "object") return { message: "unrecognised opengrep error entry" }; const entry = error; const level = typeof entry.level === "string" ? entry.level : void 0; const message = typeof entry.message === "string" ? entry.message : void 0; return { ...level ? { level } : {}, message: message ?? level ?? "unrecognised opengrep error entry" }; }); const refusal = errors.find((error) => error.level !== "warn"); if (refusal) return { verdict: "refused", reason: refusal.message }; const scanned = Array.isArray(report.paths?.scanned) ? report.paths.scanned.filter((file) => typeof file === "string") : []; return { verdict: "usable", ...errors[0] ? { partial: { reason: errors[0].message } } : {}, findings: parseOpengrepReport(parsed), scanned }; } function parseOpengrepReport(rawOrParsed) { if (typeof rawOrParsed === "string" && !rawOrParsed.trim()) return []; let parsed = rawOrParsed; if (typeof rawOrParsed === "string") { try { parsed = JSON.parse(rawOrParsed); } catch { return []; } } if (!parsed || typeof parsed !== "object") return []; const results = parsed.results; if (!Array.isArray(results)) return []; const findings = []; for (const entry of results) { if (!entry || typeof entry !== "object") continue; const e = entry; const checkId = typeof e.check_id === "string" ? e.check_id : void 0; const filePath = typeof e.path === "string" ? e.path : void 0; const start = e.start; const end = e.end; const startLine = typeof start?.line === "number" ? start.line : void 0; if (!checkId || !filePath || !Number.isFinite(startLine)) continue; const extra = e.extra ?? {}; const metadata = extra.metadata ?? {}; const cwe = Array.isArray(metadata.cwe) ? metadata.cwe.filter((c) => typeof c === "string") : void 0; findings.push({ checkId, path: filePath, startLine, startCol: typeof start?.col === "number" ? start.col : 1, endLine: typeof end?.line === "number" ? end.line : startLine, endCol: typeof end?.col === "number" ? end.col : 1, message: typeof extra.message === "string" ? extra.message : "opengrep finding", severity: typeof extra.severity === "string" ? extra.severity : "WARNING", cwe }); } return findings; } export { OpengrepClient, parseOpengrepReport };