pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
344 lines (343 loc) • 12.9 kB
JavaScript
/**
* govulncheck client for pi-lens
*
* Surfaces Go module CVEs that are actually reachable from the build graph.
* Complements trivy-style \"all CVEs in any dep\" scanning by filtering to
* vulnerabilities whose vulnerable function is called from the analyzed code.
*
* Lifecycle:
* - session_start scan + cache (keyed by go.sum mtime via cacheManager)
* - turn_end delta vs cached findings (mirrors KnipClient)
* - skipped silently if `govulncheck` is not on PATH (no auto-install in this
* slice — see issue #132 for the deferred `go-install` installer strategy)
*
* Invocation: `govulncheck -mode=source -format=json ./...` from the module root.
*
* Docs: https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck
* Refs: #132
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { safeSpawnAsync } from "./safe-spawn.js";
import { SecurityScanClient } from "./security-scan-client.js";
const EMPTY_RESULT = {
success: false,
findings: [],
};
const SCAN_TIMEOUT_MS = 120_000;
// --- Client ---
export class GovulncheckClient extends SecurityScanClient {
constructor(verbose = false) {
super("govulncheck", verbose);
}
/**
* Detect whether the project root is a Go module. Cheap filesystem check.
*/
static hasGoModule(cwd) {
try {
return fs.existsSync(path.join(cwd, "go.mod"));
}
catch {
return false;
}
}
/**
* Resolve `govulncheck`: PATH probe, then auto-install via `go install`
* (not the GitHub-release installer the other security clients use — the
* toolchain is present by definition when there's a go.mod).
*/
async doEnsureAvailable() {
// PATH probe first.
if (await this.probeVersion(["-version"])) {
this.available = true;
return true;
}
// Not on PATH — auto-install via `go install`. This is safe to assume
// here because the only path reaching `ensureAvailable()` is the
// session_start task gated on `hasGoModule(analysisRoot)`; if the
// project has a go.mod the user has the Go toolchain by definition.
// Same shape as rust-clippy / cargo: lean on the language's own
// install mechanism rather than adding a new installer strategy.
const goOnPath = await safeSpawnAsync("go", ["version"], {
timeout: 5000,
});
if (goOnPath.error || goOnPath.status !== 0) {
this.log("go binary not on PATH — cannot auto-install govulncheck");
this.available = false;
return false;
}
this.log("govulncheck not found, attempting auto-install via go install");
const install = await safeSpawnAsync("go", ["install", "golang.org/x/vuln/cmd/govulncheck@latest"], { timeout: 60_000 });
if (install.error || install.status !== 0) {
this.log(`govulncheck auto-install failed: ${(install.stderr ?? "").slice(0, 200)}`);
this.available = false;
return false;
}
// `go install` writes to `$GOBIN` or `$GOPATH/bin`. The user may not
// have that on `$PATH`. Re-probe by name (works when it is on PATH)
// then fall back to the canonical bin dirs.
const reprobe = await safeSpawnAsync("govulncheck", ["-version"], {
timeout: 5000,
});
if (!reprobe.error && reprobe.status === 0) {
this.log("govulncheck auto-installed and found on PATH");
this.available = true;
return true;
}
// Look in the canonical install locations and remember the absolute
// path so subsequent invocations spawn against it directly.
const homeDir = os.homedir();
const isWin = process.platform === "win32";
const ext = isWin ? ".exe" : "";
const candidates = [
process.env.GOBIN,
process.env.GOPATH ? path.join(process.env.GOPATH, "bin") : undefined,
path.join(homeDir, "go", "bin"),
]
.filter((d) => Boolean(d))
.map((d) => path.join(d, `govulncheck${ext}`));
for (const candidate of candidates) {
try {
if (fs.existsSync(candidate)) {
this.binaryPath = candidate;
this.available = true;
this.log(`govulncheck auto-installed at ${candidate}`);
return true;
}
}
catch {
// fall through to next candidate
}
}
this.log("govulncheck auto-install succeeded but binary not locatable — check $GOBIN / $GOPATH");
this.available = false;
return false;
}
/**
* Scan a Go module for reachable CVEs.
*
* Re-entrancy safe: concurrent calls against the same root share a single
* govulncheck process. Mirrors the in-flight dedupe pattern used by
* KnipClient / JscpdClient.
*/
async analyze(cwd) {
const targetDir = path.resolve(cwd);
if (!GovulncheckClient.hasGoModule(targetDir)) {
return {
...EMPTY_RESULT,
success: true,
scannedAt: new Date().toISOString(),
summary: "No go.mod found at analysis root; govulncheck skipped",
};
}
if (!(await this.ensureAvailable())) {
return {
...EMPTY_RESULT,
scannedAt: new Date().toISOString(),
summary: "govulncheck not installed",
};
}
return this.dedupeScan(targetDir, () => this.runScan(targetDir));
}
async runScan(cwd) {
const scannedAt = new Date().toISOString();
const bin = this.binaryPath ?? "govulncheck";
try {
const result = await safeSpawnAsync(bin, ["-mode=source", "-format=json", "./..."], { cwd, timeout: SCAN_TIMEOUT_MS });
// govulncheck exits non-zero (status 3) when vulnerabilities are
// found — that's success from our perspective. Genuine failures
// produce empty stdout + a stderr message.
const rawStdout = result.stdout ?? "";
if (!rawStdout.trim() && result.status !== 0 && result.status !== 3) {
this.log(`Scan failed: ${(result.stderr ?? "").slice(0, 200)}`);
return {
...EMPTY_RESULT,
scannedAt,
summary: (result.stderr ?? "").trim().split("\n")[0] || "scan failed",
};
}
const findings = parseGovulncheckJson(rawStdout);
return {
success: true,
findings,
scannedAt,
};
}
catch (err) {
this.log(`Scan error: ${err instanceof Error ? err.message : err}`);
return {
...EMPTY_RESULT,
scannedAt,
summary: err instanceof Error ? err.message.slice(0, 200) : String(err),
};
}
}
}
// --- Parser ---
/**
* Parse govulncheck's `-format=json` stream into a clean finding list.
*
* The stream is a series of newline-or-brace-separated JSON objects of
* mixed type:
* - `{"config": {...}}` — DB / Go version
* - `{"progress": {...}}` — scan progress
* - `{"osv": {...}}` — vulnerability metadata (we extract module +
* fixed version + summary + URL here)
* - `{"finding": {...}}` — actual reachable finding (we extract the
* osv id + trace)
*
* We extract OSV metadata first and then enrich each Finding with the
* matching osv's `summary` / `fixed_version` / `url`. A single Finding
* record from govulncheck has only the OSV id + trace, so this two-pass
* approach is required to produce the structured shape the runner / UI
* consumes.
*
* Exported for unit tests.
*/
export function parseGovulncheckJson(stream) {
if (!stream.trim())
return [];
const records = splitJsonStream(stream);
const osvMeta = new Map();
const findings = [];
for (const record of records) {
const asOsv = record;
if (asOsv.osv && typeof asOsv.osv.id === "string") {
const affected = asOsv.osv.affected?.[0];
const packageName = affected?.package?.name;
const fixedVersion = affected?.ranges
?.flatMap((r) => r.events ?? [])
.find((e) => typeof e.fixed === "string")?.fixed;
osvMeta.set(asOsv.osv.id, {
module: packageName,
fixedVersion,
summary: asOsv.osv.summary ?? asOsv.osv.details,
url: asOsv.osv.database_specific?.url ?? affected?.database_specific?.url,
});
}
}
for (const record of records) {
const asFinding = record;
const f = asFinding.finding;
if (!f || typeof f.osv !== "string")
continue;
const trace = (f.trace ?? []).map((t) => ({
module: t.module,
packageName: t.package,
functionName: t.function,
filename: t.position?.filename,
line: t.position?.line,
}));
const meta = osvMeta.get(f.osv);
findings.push({
osv: f.osv,
module: meta?.module,
packageName: trace.find((t) => t.packageName)?.packageName,
fixedVersion: f.fixed_version ?? meta?.fixedVersion,
summary: meta?.summary,
url: meta?.url,
trace,
});
}
// Dedupe: govulncheck may emit multiple finding records per OSV when the
// vulnerable function is called from several call sites. Collapse to one
// finding per OSV ID, preserving the *first* trace (deepest call-site
// attribution is what the agent needs).
const seen = new Set();
const deduped = [];
for (const f of findings) {
if (seen.has(f.osv))
continue;
seen.add(f.osv);
deduped.push(f);
}
return deduped;
}
/**
* Parse govulncheck's JSON-stream output into structured records.
*
* govulncheck's `-format=json` emits one top-level JSON object per logical
* record, but the framing is informal: most records arrive on their own
* line, some are concatenated on one line, and corrupt / truncated lines
* can appear when the scan is interrupted.
*
* Strategy:
* 1. Try a fast line-by-line `JSON.parse` first — handles the dominant
* newline-delimited case and rejects malformed lines without letting
* them poison downstream records.
* 2. If any line fails to parse cleanly, fall back to a brace-depth
* scanner over that line — handles records that have been
* concatenated together without newlines.
* 3. Malformed slices are dropped rather than failing the whole scan.
*/
function splitJsonStream(stream) {
const records = [];
for (const rawLine of stream.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line)
continue;
try {
records.push(JSON.parse(line));
continue;
}
catch {
// Fall through to the multi-object brace scanner.
}
for (const obj of extractBalancedObjects(line)) {
records.push(obj);
}
}
return records;
}
function extractBalancedObjects(input) {
const found = [];
let depth = 0;
let start = -1;
let inString = false;
let escape = false;
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (escape) {
escape = false;
continue;
}
if (inString) {
if (ch === "\\")
escape = true;
else if (ch === '"')
inString = false;
continue;
}
if (ch === '"') {
inString = true;
continue;
}
if (ch === "{") {
if (depth === 0)
start = i;
depth++;
}
else if (ch === "}") {
depth--;
if (depth === 0 && start !== -1) {
const slice = input.slice(start, i + 1);
try {
found.push(JSON.parse(slice));
}
catch {
// Drop the malformed slice; keep scanning for the next
// balanced object so a corrupt prefix doesn't poison the
// remainder of the line.
}
start = -1;
}
else if (depth < 0) {
// Stray `}` — reset and resume.
depth = 0;
start = -1;
}
}
}
return found;
}