pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
218 lines (217 loc) • 9.11 kB
JavaScript
/**
* opengrep CLI client for pi-lens — bulk/full-workspace project-diagnostics
* extractor (#584).
*
* opengrep already runs as an always-on LSP auxiliary (`clients/lsp/server.ts`
* `OpengrepServer`) for real-time per-edit feedback, and this client does NOT
* touch that path. It exists solely so `lens_diagnostics mode=full` /
* `lsp_diagnostics` full-workspace scans can read opengrep's findings from a
* single project-wide CLI scan instead of one LSP touch per file.
*
* Why: opengrep has no `workspace/diagnostic` pull support (push-only, per
* `docs/servercapabilities.md`), and `reopenOnResync: true`
* (`clients/lsp/server-strategies.ts`) means every LSP touch already forces a
* full re-scan of that one file — there's no incremental efficiency lost by
* moving bulk scans off the per-file touch loop. On a full sweep the old path
* instead paid opengrep's full per-file wait-tier budget serially, one file at
* a time within its server group (#387's deliberate single-flight-per-server
* serialization) — on a real 50-file sweep this produced 49/50 files reporting
* "unconfirmed (timed out)".
*
* Lifecycle mirrors gitleaks/trivy/knip:
* - session_start scan (via `runTask`/`runHeavyweightTask` in
* runtime-session.ts), cached via `cacheManager`
* - `lens_diagnostics mode=full` reads the cache through the extractor
* registry (`project-diagnostics/extractors.ts`) — never launches a scan
* - per-edit LSP path (real-time feedback) is untouched
*
* Enablement mirrors the LSP server (`opengrepInitialization` in server.ts):
* opengrep is structurally always-on — `resolveOpengrepConfig` only chooses
* WHICH rules run (a local `.opengrep.yml`/`.semgrep.yml` rule file if
* present, else the `auto` registry ruleset), not whether it runs at all.
*
* `// nosemgrep` / `# nosemgrep` suppression: unlike opengrep's LSP mode
* (which does NOT honor it natively — that gap is exactly why
* `isNosemgrepSuppressed`/`applyAuxiliarySuppressions` exist in
* `clients/dispatch/auxiliary-lsp.ts`, #441/#586/#587), the CLI `scan --json`
* path DOES suppress `nosemgrep`-annotated findings itself, before they ever
* reach `--json` output — verified empirically against the real installed
* opengrep 1.25.0 binary (see the captured raw JSON in
* `tests/clients/opengrep-client.test.ts`: an annotated line's finding is
* absent from `results` while an identical unannotated twin still appears).
* So `opengrepResultToProjectDiagnostics` deliberately applies NO suppression
* filtering of its own — doing so would be redundant at best.
*
* Refs: #584, #111 (opengrep adoption), #387 (workspace-sweep serialization)
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { mkdtempSync } from "node:fs";
import { resolveOpengrepConfig } from "./opengrep-config.js";
import { safeSpawnAsync } from "./safe-spawn.js";
import { SecurityScanClient } from "./security-scan-client.js";
const EMPTY_RESULT = {
success: false,
findings: [],
};
// opengrep loads/compiles a full rule pack (1000+ rules for `auto`) before
// scanning; generous budget for a large tree, matching trivy's CVE-DB-fetch
// allowance rather than the lighter jscpd/gitleaks scans.
const SCAN_TIMEOUT_MS = 180_000;
// --- Client ---
export 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 = path.resolve(cwd);
const scannedAt = new Date().toISOString();
if (!(await this.ensureAvailable())) {
return {
...EMPTY_RESULT,
scannedAt,
summary: "opengrep not installed",
};
}
return this.dedupeScan(targetDir, () => this.runScan(targetDir));
}
async runScan(cwd) {
const scannedAt = 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",
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,
scannedAt,
summary: (result.stderr ?? "").trim().split("\n")[0] || "no report produced",
};
}
const findings = parseOpengrepReport(fs.readFileSync(reportPath, "utf-8"));
return {
success: true,
findings,
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 ---
/**
* Map opengrep's `--json` report (semgrep-compatible schema: top-level
* `results: [{ check_id, path, start:{line,col}, end:{line,col}, extra:{
* message, severity, metadata:{cwe} } }]`) to our structured
* `OpengrepFinding[]` shape. Exported for unit tests.
*
* Verified against the real installed opengrep 1.25.0 binary's own `--json`
* output (not assumed from upstream semgrep docs — opengrep is a fork and its
* CLI surface has drifted in places, e.g. `--files-with-matches` requires
* `--experimental` where semgrep's doesn't).
*/
export function parseOpengrepReport(raw) {
if (!raw.trim())
return [];
let parsed;
try {
parsed = JSON.parse(raw);
}
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 : undefined;
const filePath = typeof e.path === "string" ? e.path : undefined;
const start = e.start;
const end = e.end;
const startLine = typeof start?.line === "number" ? start.line : undefined;
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")
: undefined;
findings.push({
checkId,
path: filePath,
startLine: 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;
}