pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
332 lines (328 loc) • 10.5 kB
JavaScript
import { createRequire as __pilensCreateRequire } from "node:module"; const require = __pilensCreateRequire(import.meta.url);
import {
getSecretsLaneAllowlistPaths,
isUnderSecretsLaneScratchTree
} from "./chunk-TO6WL3GM.js";
import {
SecurityScanClient
} from "./chunk-7F72PXY2.js";
import {
collectTrackedFiles,
collectUntrackedIgnoredIds,
safeSpawnAsync
} from "./chunk-VN2AXLEX.js";
import {
normalizeEphemeralMapKey,
normalizeMapKey
} from "./chunk-Q5U6FMDI.js";
// dist/clients/gitleaks-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 = 12e4;
var LOCAL_GITLEAKS_CONFIG_NAMES = [
".gitleaks.toml",
".gitleaks.yaml",
".gitleaks.yml"
];
function findLocalGitleaksConfig(cwd) {
for (const name of LOCAL_GITLEAKS_CONFIG_NAMES) {
const candidate = path.join(cwd, name);
try {
if (fs.existsSync(candidate))
return candidate;
} catch {
}
}
return void 0;
}
var PLACEHOLDER_SECRET_REGEXES = [
String.raw`(?i)^(your|my|insert|replace|enter|add)[-_ ]?(the[-_ ]?)?(api[-_ ]?)?(key|token|secret|password|credential)s?$`,
String.raw`(?i)^<[^<>]*(api[-_ ]?key|token|secret|password|credential|key)[^<>]*>$`,
String.raw`(?i)^x{3,}$`,
String.raw`(?i)^(changeme|change[-_]me|example|placeholder|dummy|fake|redacted|xxxxxxxx)$`
];
function writeScopedGitleaksConfig(outDir, cwd) {
const localConfig = findLocalGitleaksConfig(cwd);
const extendLine = localConfig ? `path = ${JSON.stringify(localConfig)}` : "useDefault = true";
const pathPatterns = getSecretsLaneAllowlistPaths().map((p) => ` ${JSON.stringify(p)},`).join("\n");
const regexPatterns = PLACEHOLDER_SECRET_REGEXES.map((r) => ` ${JSON.stringify(r)},`).join("\n");
const toml = `[extend]
${extendLine}
[allowlist]
paths = [
${pathPatterns}
]
regexes = [
${regexPatterns}
]
`;
const configPath = path.join(outDir, "gitleaks-scoped-config.toml");
fs.writeFileSync(configPath, toml, "utf-8");
return configPath;
}
function hasGitleaksSignal(cwd) {
const candidates = [...LOCAL_GITLEAKS_CONFIG_NAMES, ".gitleaksignore"];
for (const candidate of candidates) {
try {
if (fs.existsSync(path.join(cwd, candidate)))
return true;
} catch {
}
}
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 {
}
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 {
}
}
return false;
}
function hasGitRepo(cwd) {
try {
return fs.existsSync(path.join(cwd, ".git"));
} catch {
return false;
}
}
var GitleaksClient = 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 = (/* @__PURE__ */ 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 = (/* @__PURE__ */ 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 configPath = writeScopedGitleaksConfig(outDir, cwd);
const result = await safeSpawnAsync(bin, [
"detect",
"--no-git",
"--source",
cwd,
"--config",
configPath,
"--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)) {
return {
...EMPTY_RESULT,
success: true,
scannedAt,
summary: (result.stderr ?? "").trim().split("\n")[0] || "no report produced"
};
}
const rawFindings = parseGitleaksReport(fs.readFileSync(reportPath, "utf-8"));
const findings = await classifyAndFilterFindings(rawFindings, cwd);
return {
success: true,
analyzed: 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 {
}
}
}
};
function isInsideNestedGitRepository(file, cwd) {
const root = path.resolve(cwd);
const absoluteFile = path.isAbsolute(file) ? path.resolve(file) : path.resolve(root, file);
const relative2 = path.relative(root, absoluteFile);
if (relative2 === ".." || relative2.startsWith(`..${path.sep}`) || path.isAbsolute(relative2))
return false;
let directory = path.dirname(absoluteFile);
while (directory !== root) {
try {
if (fs.existsSync(path.join(directory, ".git")))
return true;
} catch {
return false;
}
const parent = path.dirname(directory);
if (parent === directory)
return false;
directory = parent;
}
return false;
}
async function classifyAndFilterFindings(findings, cwd) {
if (findings.length === 0)
return findings;
const [trackedIds, untrackedIgnoredIds] = await Promise.all([
collectTrackedFiles(cwd),
collectUntrackedIgnoredIds(cwd)
]);
const classified = [];
for (const finding of findings) {
if (isUnderSecretsLaneScratchTree(finding.file)) {
classified.push({ ...finding, pathStatus: "scratch" });
continue;
}
const absPath = path.isAbsolute(finding.file) ? finding.file : path.resolve(cwd, finding.file);
if (isInsideNestedGitRepository(absPath, cwd)) {
classified.push({ ...finding, pathStatus: "nested-repository" });
continue;
}
let pathStatus;
if (trackedIds?.has(normalizeEphemeralMapKey(absPath))) {
pathStatus = "tracked";
} else if (untrackedIgnoredIds?.has(normalizeMapKey(absPath))) {
pathStatus = "ignored";
} else if (trackedIds !== void 0 || untrackedIgnoredIds !== void 0) {
pathStatus = "untracked";
}
classified.push(pathStatus ? { ...finding, pathStatus } : finding);
}
return classified;
}
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 : void 0;
const file = typeof e.File === "string" ? e.File : void 0;
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 : void 0,
file,
startLine,
endLine: typeof e.EndLine === "number" ? e.EndLine : Number.isFinite(Number(e.EndLine)) ? Number(e.EndLine) : void 0,
match: typeof e.Match === "string" ? e.Match : void 0,
secret: typeof e.Secret === "string" ? e.Secret : void 0,
tags: Array.isArray(e.Tags) ? e.Tags.filter((t) => typeof t === "string") : void 0,
commit: typeof e.Commit === "string" ? e.Commit : void 0,
author: typeof e.Author === "string" ? e.Author : void 0,
date: typeof e.Date === "string" ? e.Date : void 0
});
}
return findings;
}
export {
PLACEHOLDER_SECRET_REGEXES,
writeScopedGitleaksConfig,
hasGitleaksSignal,
hasGitRepo,
GitleaksClient,
classifyAndFilterFindings,
parseGitleaksReport
};