pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
301 lines (300 loc) • 11.2 kB
JavaScript
/**
* gitleaks client for pi-lens
*
* Surfaces committed secrets (API keys, tokens, passwords, certificates)
* detected by Aaron Vargas's `gitleaks` scanner. Cross-language by design
* — gitleaks operates on bytes via regex + entropy, not AST.
*
* Lifecycle:
* - session_start scan (via the existing `runTask(setImmediate)` wrapper)
* - turn_end advisory reads the cached result and surfaces top N findings
* - per-edit scope: skipped — secrets either are or aren't in a file;
* re-scanning every keystroke is wasteful when the cache is hot
*
* Detection gate (config-first per #130 default):
* - `.gitleaks.toml` / `.gitleaks.yaml` / `.gitleaksignore` at the
* project root, OR
* - `gitleaks` reference in `package.json` deps, OR
* - a git pre-commit hook (.husky/, .git/hooks/) referencing gitleaks
*
* `lens_diagnostics mode=full`'s fresh-fetch path (`clients/project-diagnostics/
* fresh-fetch.ts`) uses a looser "smart-default" gate instead — any tracked
* git repo, via `hasGitRepo` — since that's an explicitly-requested
* comprehensive review where gitleaks's low cost and advisory-only findings
* make the stricter default needlessly conservative. session_start and
* per-edit dispatch keep the strict gate above unchanged.
*
* If the gate trips, the runner auto-installs gitleaks from GitHub releases
* (installer entry registered in clients/installer/index.ts) and runs
* `gitleaks detect --no-git --report-format json` against the analysis root.
*
* Refs: #130
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { mkdtempSync } from "node:fs";
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;
// --- Detection ---
/**
* Detect whether the project root has opted in to gitleaks via any of the
* standard signals. Config-first gating per the #130 default — gitleaks
* runs when the user has given us any indication they want it.
*
* Exported for tests and for callers that want the gate without instantiating
* the client.
*/
export function hasGitleaksSignal(cwd) {
const candidates = [
".gitleaks.toml",
".gitleaks.yaml",
".gitleaks.yml",
".gitleaksignore",
];
for (const candidate of candidates) {
try {
if (fs.existsSync(path.join(cwd, candidate)))
return true;
}
catch {
// non-fatal
}
}
// Check package.json devDependencies / dependencies for any `gitleaks*`
// reference. Catches `gitleaks`, `lint-staged-gitleaks`, etc.
const pkgJsonPath = path.join(cwd, "package.json");
try {
if (fs.existsSync(pkgJsonPath)) {
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
for (const name of Object.keys(deps)) {
if (name.toLowerCase().includes("gitleaks"))
return true;
}
}
}
catch {
// malformed package.json — don't treat as signal
}
// husky / git hooks referencing gitleaks
const hookCandidates = [
path.join(cwd, ".husky", "pre-commit"),
path.join(cwd, ".husky", "_", "pre-commit"),
path.join(cwd, ".git", "hooks", "pre-commit"),
];
for (const hook of hookCandidates) {
try {
if (!fs.existsSync(hook))
continue;
const content = fs.readFileSync(hook, "utf-8");
if (content.includes("gitleaks"))
return true;
}
catch {
// non-fatal
}
}
return false;
}
/**
* "Smart-default" tier from #130's own considered-but-unshipped options:
* fire whenever the project is a tracked git repo, not only when an explicit
* gitleaks signal is present. gitleaks's own scan target is "a git repo's
* history/tree" — nearly every project qualifies, so this is meaningfully
* looser than {@link hasGitleaksSignal}. Used ONLY by `mode=full`'s
* fresh-fetch path (an explicitly-requested comprehensive review, where
* gitleaks's low cost — ~10MB binary, no external DB pull — and advisory-only
* findings make the stricter opt-in gate needlessly conservative); session_start
* and per-edit dispatch keep the strict {@link hasGitleaksSignal} gate
* unchanged, so day-to-day noise/cost stays exactly as conservative as before.
*/
export function hasGitRepo(cwd) {
try {
return fs.existsSync(path.join(cwd, ".git"));
}
catch {
return false;
}
}
// --- Client ---
export class GitleaksClient extends SecurityScanClient {
constructor(verbose = false) {
super("gitleaks", verbose);
}
/**
* Static detection helper so callers can gate before constructing
* (matches `GovulncheckClient.hasGoModule` shape).
*/
static hasGitleaksSignal(cwd) {
return hasGitleaksSignal(cwd);
}
/** Smart-default tier (#130) — see {@link hasGitRepo}'s doc comment. */
static hasGitRepo(cwd) {
return hasGitRepo(cwd);
}
/**
* Auto-install via the GitHub-release path (registered in
* `clients/installer/index.ts`) when gitleaks isn't already on PATH.
* gitleaks uses `version` (no leading dashes) as its CLI verb.
*/
doEnsureAvailable() {
return this.ensureViaInstaller(["version"]);
}
/**
* Scan a directory tree for secrets.
*
* Skips early when the directory shows no gitleaks opt-in signal — unless
* `requireSignal: false` (the `mode=full` fresh-fetch path uses this to
* apply the looser #130 "smart-default" gate, {@link hasGitRepo}, instead;
* session_start and per-edit dispatch never pass this, so their behavior
* is unchanged). When gitleaks is unavailable, returns an empty result
* with an explanatory summary rather than failing the session_start task.
*
* Re-entrancy safe: concurrent calls against the same root share a
* single gitleaks process (mirrors `KnipClient` / `JscpdClient` /
* `GovulncheckClient`).
*/
async scan(cwd, options) {
const targetDir = path.resolve(cwd);
const scannedAt = new Date().toISOString();
const requireSignal = options?.requireSignal ?? true;
if (requireSignal && !GitleaksClient.hasGitleaksSignal(targetDir)) {
return {
...EMPTY_RESULT,
success: true,
scannedAt,
summary: "no gitleaks opt-in signal at project root",
};
}
if (!(await this.ensureAvailable())) {
return {
...EMPTY_RESULT,
scannedAt,
summary: "gitleaks not installed",
};
}
return this.dedupeScan(targetDir, () => this.runScan(targetDir));
}
async runScan(cwd) {
const scannedAt = new Date().toISOString();
const bin = this.binaryPath ?? "gitleaks";
const outDir = mkdtempSync(path.join(os.tmpdir(), "pi-lens-gitleaks-"));
const reportPath = path.join(outDir, "gitleaks-report.json");
try {
const result = await safeSpawnAsync(bin, [
"detect",
"--no-git",
"--source",
cwd,
"--report-format",
"json",
"--report-path",
reportPath,
"--exit-code",
"0",
"--no-banner",
], { 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)) {
// gitleaks writes the report file even when nothing is found.
// If the file is missing the scan likely errored before
// writing it — surface a summary line from stderr.
return {
...EMPTY_RESULT,
success: true,
scannedAt,
summary: (result.stderr ?? "").trim().split("\n")[0] || "no report produced",
};
}
const findings = parseGitleaksReport(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 gitleaks's JSON report (a flat array of finding objects) to our
* structured `GitleaksFinding[]` shape. Exported for unit tests.
*
* Gitleaks emits `null` (or `[]`) when no findings are present. Malformed
* input returns `[]` rather than throwing — gitleaks itself is occasionally
* truncated by upstream pipe failures.
*/
export function parseGitleaksReport(raw) {
if (!raw.trim())
return [];
let parsed;
try {
parsed = JSON.parse(raw);
}
catch {
return [];
}
if (!Array.isArray(parsed))
return [];
const findings = [];
for (const entry of parsed) {
if (!entry || typeof entry !== "object")
continue;
const e = entry;
const ruleId = typeof e.RuleID === "string" ? e.RuleID : undefined;
const file = typeof e.File === "string" ? e.File : undefined;
const startLine = typeof e.StartLine === "number"
? e.StartLine
: Number.parseInt(String(e.StartLine ?? ""), 10);
if (!ruleId || !file || !Number.isFinite(startLine))
continue;
findings.push({
ruleId,
description: typeof e.Description === "string" ? e.Description : undefined,
file,
startLine,
endLine: typeof e.EndLine === "number"
? e.EndLine
: Number.isFinite(Number(e.EndLine))
? Number(e.EndLine)
: undefined,
match: typeof e.Match === "string" ? e.Match : undefined,
secret: typeof e.Secret === "string" ? e.Secret : undefined,
tags: Array.isArray(e.Tags)
? e.Tags.filter((t) => typeof t === "string")
: undefined,
commit: typeof e.Commit === "string" ? e.Commit : undefined,
author: typeof e.Author === "string" ? e.Author : undefined,
date: typeof e.Date === "string" ? e.Date : undefined,
});
}
return findings;
}