codevet-cli 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ import { execa } from "execa";
2
+ import { readFile, unlink } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { ensureBinary } from "./ensureBinary.js";
5
+ export class BearerBinaryMissingError extends Error {
6
+ constructor() {
7
+ const isWindows = process.platform === "win32";
8
+ super(isWindows
9
+ ? "bearer has no native Windows build, so the personal-data-flow check isn't available on this machine — secrets, dependencies, and hygiene checks are unaffected. WSL is a workaround if you need this specific check."
10
+ : "bearer binary not found and could not be downloaded automatically. Check your network connection and try again.");
11
+ this.name = "BearerBinaryMissingError";
12
+ }
13
+ }
14
+ export class BearerScanFailedError extends Error {
15
+ constructor(reason) {
16
+ super(`bearer's data-flow scan did not actually run: ${reason}. This is being reported as a failure rather than "no findings" — a scan that never ran is not the same as a clean result.`);
17
+ this.name = "BearerScanFailedError";
18
+ }
19
+ }
20
+ // Bearer's default security-report ruleset includes general Express
21
+ // hygiene rules that CodeVet's own hygiene scanner already covers
22
+ // (helmet, server fingerprinting) — excluded here to avoid showing the
23
+ // same finding twice from two different scanners. Everything else stays,
24
+ // since Bearer's actual differentiator is data-flow rules like
25
+ // "javascript_lang_logger" (PII logged) that no pattern-based scanner
26
+ // (including CodeVet's own hygiene checks) can detect — those require
27
+ // understanding where a value came from and where it goes, not just
28
+ // whether a line matches a regex.
29
+ const OVERLAPS_WITH_HYGIENE_SCANNER = new Set([
30
+ "javascript_express_helmet_missing",
31
+ "javascript_express_reduce_fingerprint",
32
+ ]);
33
+ export async function runBearerScan(projectRoot) {
34
+ // ensureBinary returns null on Windows (no native build exists) or if
35
+ // the download genuinely fails — both correctly surface as
36
+ // BearerBinaryMissingError below, same as before.
37
+ const binaryPath = await ensureBinary("bearer");
38
+ if (!binaryPath) {
39
+ throw new BearerBinaryMissingError();
40
+ }
41
+ const reportPath = join(projectRoot, ".codevet-bearer-report.json");
42
+ try {
43
+ // IMPORTANT: do NOT pass --exit-code 0 here. That flag forces bearer
44
+ // to always report success, which was confirmed in testing to mask a
45
+ // genuine internal failure (rule definitions failing to download,
46
+ // producing "0 rules found") as a silent, false "no findings" result
47
+ // — exactly the kind of fake-clean result this project exists to
48
+ // prevent. We handle bearer's real non-zero exit code ourselves below
49
+ // instead of suppressing it.
50
+ const result = await execa(binaryPath, [
51
+ "scan",
52
+ projectRoot,
53
+ "--format",
54
+ "json",
55
+ "--output",
56
+ reportPath,
57
+ "--quiet",
58
+ "--hide-progress-bar",
59
+ "--no-rule-meta",
60
+ // Directories holding example/template code (e.g. CodeVet's own
61
+ // fix-library, or any project keeping similar reference snippets)
62
+ // are structurally full of intentional secret-handling examples —
63
+ // Bearer's static analysis can't distinguish a variable named
64
+ // `SECRET` read correctly from process.env inside a documentation
65
+ // string from a real hardcoded secret in executable code.
66
+ // Confirmed as a real false-positive source by testing against
67
+ // CodeVet's own repo before shipping this default.
68
+ "--skip-path",
69
+ "**/fix-library/**,**/fixLibrary/**,**/security-templates/**",
70
+ ], { reject: false });
71
+ // Bearer's own internal errors (e.g. rule definitions failing to
72
+ // download) print "Error: ..." to stderr and produce an empty/invalid
73
+ // report, distinct from its normal "exit non-zero because findings
74
+ // exist" behavior. Confirmed this distinction by directly reproducing
75
+ // a rule-download failure (GitHub API rate limit) in testing.
76
+ const stderrText = result.stderr ?? "";
77
+ const errorLine = stderrText.split("\n").find((line) => line.includes("Error:"));
78
+ if (errorLine) {
79
+ throw new BearerScanFailedError(errorLine.trim());
80
+ }
81
+ const raw = await readFile(reportPath, "utf-8").catch(() => null);
82
+ if (raw === null) {
83
+ throw new BearerScanFailedError("no report file was produced");
84
+ }
85
+ const parsed = JSON.parse(raw);
86
+ const findings = [];
87
+ for (const [severity, entries] of Object.entries(parsed)) {
88
+ if (!["critical", "high", "medium", "low"].includes(severity))
89
+ continue;
90
+ for (const entry of entries) {
91
+ if (OVERLAPS_WITH_HYGIENE_SCANNER.has(entry.id))
92
+ continue;
93
+ findings.push({
94
+ ruleId: entry.id,
95
+ title: entry.title,
96
+ description: entry.description.split("\n")[0].replace(/^##\s*Description\s*/, "").trim() || entry.title,
97
+ file: entry.filename,
98
+ line: entry.line_number,
99
+ severity: severity,
100
+ });
101
+ }
102
+ }
103
+ return findings;
104
+ }
105
+ finally {
106
+ await unlink(reportPath).catch(() => { });
107
+ }
108
+ }
@@ -0,0 +1,101 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, chmod, rm, writeFile } from "node:fs/promises";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ const execFileAsync = promisify(execFile);
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ // This file lives at dist/scanners/ensureBinary.js at runtime — two levels
10
+ // up is the package root, matching every other scanner's path resolution.
11
+ const PACKAGE_ROOT = join(__dirname, "..", "..");
12
+ const VENDOR_DIR = join(PACKAGE_ROOT, "bin", "vendor");
13
+ const GITLEAKS_VERSION = "8.30.1";
14
+ const BEARER_VERSION = "2.1.0";
15
+ function resolveGitleaksSpec() {
16
+ const platformMap = { linux: "linux", darwin: "darwin", win32: "windows" };
17
+ const archMap = { x64: "x64", arm64: "arm64" };
18
+ const mappedPlatform = platformMap[process.platform];
19
+ const mappedArch = archMap[process.arch];
20
+ if (!mappedPlatform || !mappedArch)
21
+ return null;
22
+ const ext = mappedPlatform === "windows" ? "zip" : "tar.gz";
23
+ const binName = mappedPlatform === "windows" ? "gitleaks.exe" : "gitleaks";
24
+ const assetName = `gitleaks_${GITLEAKS_VERSION}_${mappedPlatform}_${mappedArch}.${ext}`;
25
+ return {
26
+ toolName: "gitleaks",
27
+ targetBin: join(VENDOR_DIR, binName),
28
+ url: `https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${assetName}`,
29
+ ext,
30
+ binName,
31
+ };
32
+ }
33
+ function resolveBearerSpec() {
34
+ if (process.platform === "win32")
35
+ return null; // confirmed: no native Windows build exists
36
+ const platformMap = { linux: "linux", darwin: "darwin" };
37
+ const archMap = { x64: "amd64", arm64: "arm64" };
38
+ const mappedPlatform = platformMap[process.platform];
39
+ const mappedArch = archMap[process.arch];
40
+ if (!mappedPlatform || !mappedArch)
41
+ return null;
42
+ const assetName = `bearer_${BEARER_VERSION}_${mappedPlatform}_${mappedArch}.tar.gz`;
43
+ return {
44
+ toolName: "bearer",
45
+ targetBin: join(VENDOR_DIR, "bearer"),
46
+ url: `https://github.com/Bearer/bearer/releases/download/v${BEARER_VERSION}/${assetName}`,
47
+ ext: "tar.gz",
48
+ binName: "bearer",
49
+ };
50
+ }
51
+ async function downloadAndExtract(spec) {
52
+ await mkdir(VENDOR_DIR, { recursive: true });
53
+ const archivePath = join(VENDOR_DIR, `${spec.toolName}-download.${spec.ext}`);
54
+ const res = await fetch(spec.url);
55
+ if (!res.ok) {
56
+ throw new Error(`Failed to download ${spec.toolName} from ${spec.url} (status ${res.status})`);
57
+ }
58
+ const buf = Buffer.from(await res.arrayBuffer());
59
+ await writeFile(archivePath, buf);
60
+ if (spec.ext === "tar.gz") {
61
+ await execFileAsync("tar", ["-xzf", archivePath, "-C", VENDOR_DIR, spec.binName]);
62
+ }
63
+ else {
64
+ // Windows has no default 'unzip' — PowerShell's Expand-Archive ships
65
+ // with every Windows 10+ machine, same fix as the original postinstall bug.
66
+ await execFileAsync("powershell", [
67
+ "-NoProfile",
68
+ "-NonInteractive",
69
+ "-Command",
70
+ `Expand-Archive -LiteralPath '${archivePath}' -DestinationPath '${VENDOR_DIR}' -Force`,
71
+ ]);
72
+ }
73
+ await rm(archivePath);
74
+ await chmod(spec.targetBin, 0o755);
75
+ }
76
+ /**
77
+ * Ensures a scanner binary is present, downloading it if missing. This is
78
+ * the RUNTIME fallback that makes CodeVet resilient to a real, confirmed
79
+ * issue: npm now blocks postinstall scripts by default (its own
80
+ * "allowScripts" feature) unless a package is explicitly approved.
81
+ * postinstall.mjs's download logic was completely correct — it just
82
+ * never got permission to run on a real user's machine, silently leaving
83
+ * gitleaks missing with no indication why.
84
+ *
85
+ * Rather than depending only on install-time postinstall (a single point
86
+ * of failure), every scanner now self-heals: if its binary isn't there
87
+ * when actually needed, it downloads right then — the user sees exactly
88
+ * what's happening (one-time message) instead of a permanent, confusing
89
+ * failure that "try npm install again" can never actually fix.
90
+ */
91
+ export async function ensureBinary(tool) {
92
+ const spec = tool === "gitleaks" ? resolveGitleaksSpec() : resolveBearerSpec();
93
+ if (!spec)
94
+ return null; // unsupported platform (e.g. bearer on Windows) — not an error
95
+ if (existsSync(spec.targetBin)) {
96
+ return spec.targetBin;
97
+ }
98
+ console.error(`[codevet] ${spec.toolName} binary not found — this usually means npm blocked the install script (npm's "allowScripts" security feature). Downloading it directly now instead — this only happens once.`);
99
+ await downloadAndExtract(spec);
100
+ return spec.targetBin;
101
+ }
@@ -0,0 +1,109 @@
1
+ import { execa } from "execa";
2
+ import { readFile, unlink } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { ensureBinary } from "./ensureBinary.js";
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ // This file lives at dist/scanners/gitleaksScanner.js at runtime, whether
9
+ // CodeVet is run standalone (cloned repo) or installed as a dependency
10
+ // inside another project's node_modules/codevet/ — the relative path up
11
+ // to the package root is identical either way, so this resolves correctly
12
+ // in both installation modes.
13
+ const PACKAGE_ROOT = join(__dirname, "..", "..");
14
+ const RULES_CONFIG = join(PACKAGE_ROOT, ".gitleaks.toml");
15
+ export class GitleaksBinaryMissingError extends Error {
16
+ constructor() {
17
+ super("gitleaks binary not found and could not be downloaded automatically. Check your network connection and try again, or install gitleaks manually and place it at bin/vendor/gitleaks.");
18
+ this.name = "GitleaksBinaryMissingError";
19
+ }
20
+ }
21
+ export async function runGitleaksScan(projectRoot) {
22
+ // ensureBinary self-heals: downloads the binary right now if it's
23
+ // missing (e.g. npm blocked postinstall from ever running it), instead
24
+ // of just failing with a message that "try npm install again" can
25
+ // never actually fix. Confirmed as a real, live issue via a bug report.
26
+ const binaryPath = await ensureBinary("gitleaks");
27
+ if (!binaryPath) {
28
+ throw new GitleaksBinaryMissingError();
29
+ }
30
+ const reportPath = join(projectRoot, ".codevet-gitleaks-report.json");
31
+ try {
32
+ await execa(binaryPath, [
33
+ "detect",
34
+ "--source",
35
+ projectRoot,
36
+ "--config",
37
+ RULES_CONFIG,
38
+ "--report-format",
39
+ "json",
40
+ "--report-path",
41
+ reportPath,
42
+ "--no-banner",
43
+ "--no-git",
44
+ ], { reject: false });
45
+ const raw = await readFile(reportPath, "utf-8").catch(() => "[]");
46
+ const parsed = JSON.parse(raw);
47
+ const findings = parsed.map((entry) => ({
48
+ ruleId: entry.RuleID,
49
+ description: entry.Description,
50
+ file: entry.File,
51
+ line: entry.StartLine,
52
+ }));
53
+ return await postProcess(findings, projectRoot);
54
+ }
55
+ finally {
56
+ await unlink(reportPath).catch(() => { });
57
+ }
58
+ }
59
+ /**
60
+ * Two real issues found in live Windows testing, fixed here rather than in
61
+ * the raw gitleaks output:
62
+ *
63
+ * 1. Duplicate findings — the same (file, line, rule) can be reported more
64
+ * than once by gitleaks when overlapping rules both match; we only want
65
+ * to show it once.
66
+ *
67
+ * 2. .env files legitimately contain real secret values locally — that's
68
+ * the whole point of .env. The actual risk isn't the values, it's
69
+ * whether the file is protected by .gitignore. If it IS gitignored, its
70
+ * contents can never reach a commit, so flagging them as "leaked" is a
71
+ * false positive. If it's NOT gitignored, that's a real and more
72
+ * important problem worth its own clear finding.
73
+ */
74
+ async function postProcess(findings, projectRoot) {
75
+ const seen = new Set();
76
+ const deduped = findings.filter((f) => {
77
+ const key = `${f.file}:${f.line}:${f.ruleId}`;
78
+ if (seen.has(key))
79
+ return false;
80
+ seen.add(key);
81
+ return true;
82
+ });
83
+ const isGitRepo = existsSync(join(projectRoot, ".git"));
84
+ if (!isGitRepo)
85
+ return deduped;
86
+ const envIsIgnored = await execa("git", ["check-ignore", ".env"], {
87
+ cwd: projectRoot,
88
+ reject: false,
89
+ }).then((r) => r.exitCode === 0);
90
+ if (envIsIgnored) {
91
+ // .env is protected — drop findings whose file is exactly .env, since
92
+ // its contents can't leak through git.
93
+ return deduped.filter((f) => !/(^|[/\\])\.env$/.test(f.file));
94
+ }
95
+ // .env exists and is NOT gitignored — replace any per-line findings
96
+ // inside it with one clear, higher-signal warning instead of dumping
97
+ // every value found inside it.
98
+ const hasEnvFindings = deduped.some((f) => /(^|[/\\])\.env$/.test(f.file));
99
+ const withoutEnvDetails = deduped.filter((f) => !/(^|[/\\])\.env$/.test(f.file));
100
+ if (hasEnvFindings) {
101
+ withoutEnvDetails.push({
102
+ ruleId: "env-file-not-gitignored",
103
+ description: ".env exists and contains real values, but is NOT excluded by .gitignore — every credential in it can end up committed to git history.",
104
+ file: ".env",
105
+ line: 1,
106
+ });
107
+ }
108
+ return withoutEnvDetails;
109
+ }
@@ -0,0 +1,156 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import fg from "fast-glob";
5
+ import { FIX_TEMPLATES } from "../fixLibrary/templates.js";
6
+ /**
7
+ * These are heuristic checks — package.json presence and regex scans, not
8
+ * a real AST parse. That means false negatives are possible (a helmet-like
9
+ * setup written by hand instead of the package would be missed) and
10
+ * "advisory" findings can be wrong for unusual architectures. Marked
11
+ * explicitly per-finding rather than presented with false confidence.
12
+ */
13
+ export async function runHygieneScan(projectRoot) {
14
+ const packageJsonPath = join(projectRoot, "package.json");
15
+ if (!existsSync(packageJsonPath))
16
+ return [];
17
+ const packageJson = JSON.parse(await readFile(packageJsonPath, "utf-8"));
18
+ const allDeps = { ...packageJson.dependencies, ...packageJson.devDependencies };
19
+ const hasDep = (name) => Boolean(allDeps[name]);
20
+ const findings = [];
21
+ // RLS check is independent of Express — Supabase is at least as common
22
+ // with Next.js/other frontends as with an Express backend, and skipping
23
+ // it here would silently miss exactly the audience most likely to use
24
+ // Supabase directly from the client.
25
+ findings.push(...(await runSupabaseRlsScan(projectRoot)));
26
+ // Only run Express-specific checks on projects that actually use Express
27
+ // — otherwise every finding below would be a guaranteed false positive
28
+ // on a non-Express project.
29
+ if (!hasDep("express"))
30
+ return findings;
31
+ if (!hasDep("helmet")) {
32
+ findings.push({
33
+ id: "missing-helmet",
34
+ title: "No security headers configured",
35
+ description: "helmet isn't installed — your responses have no protection against clickjacking, MIME-sniffing, or several XSS vectors.",
36
+ severity: "moderate",
37
+ verifyNote: "Defense-in-depth, not usually directly exploitable on its own — real risk, but rarely the single point of failure in an incident.",
38
+ fix: FIX_TEMPLATES.helmet,
39
+ });
40
+ }
41
+ if (!hasDep("express-rate-limit")) {
42
+ findings.push({
43
+ id: "missing-rate-limit",
44
+ title: "No rate limiting found",
45
+ description: "express-rate-limit isn't installed — login and other sensitive endpoints have no protection against brute-force or spam.",
46
+ severity: "high",
47
+ verifyNote: "Directly exploitable if auth routes exist with no other rate-limiting layer (e.g. a reverse proxy).",
48
+ fix: FIX_TEMPLATES.rateLimit,
49
+ });
50
+ }
51
+ const hasValidationLib = hasDep("zod") || hasDep("joi") || hasDep("yup");
52
+ if (!hasValidationLib) {
53
+ findings.push({
54
+ id: "missing-validation",
55
+ title: "No schema validation library found",
56
+ description: "Neither zod, joi, nor yup is installed. If requests are validated by hand, this is likely a false positive — worth a manual check either way.",
57
+ severity: "moderate",
58
+ verifyNote: "This check only looks for known validation libraries — hand-written validation is common and invisible to it. Confirm manually before treating this as real.",
59
+ fix: FIX_TEMPLATES.validation,
60
+ });
61
+ }
62
+ const sourceFindings = await scanSourceForPatterns(projectRoot);
63
+ findings.push(...sourceFindings);
64
+ return findings;
65
+ }
66
+ /**
67
+ * Supabase-specific check: scans .sql migration files for CREATE TABLE
68
+ * statements with no matching ENABLE ROW LEVEL SECURITY in the same file.
69
+ * This is a best-effort STATIC check only — it cannot see tables created
70
+ * via the Supabase dashboard UI or RLS enabled outside migration files.
71
+ * Rated CRITICAL severity (a real missing-RLS table is the single most
72
+ * common cause of a drained/defaced Supabase app), with verifyNote always
73
+ * set to keep that static-check limitation visible alongside the severity.
74
+ */
75
+ export async function runSupabaseRlsScan(projectRoot) {
76
+ const sqlFiles = await fg(["**/*.sql"], {
77
+ cwd: projectRoot,
78
+ absolute: true,
79
+ ignore: ["**/node_modules/**"],
80
+ });
81
+ if (sqlFiles.length === 0)
82
+ return [];
83
+ const findings = [];
84
+ for (const sqlFile of sqlFiles) {
85
+ const content = await readFile(sqlFile, "utf-8").catch(() => "");
86
+ const tableMatches = [...content.matchAll(/CREATE TABLE\s+(?:IF NOT EXISTS\s+)?["`]?(\w+)["`]?/gi)];
87
+ for (const match of tableMatches) {
88
+ const tableName = match[1];
89
+ const rlsPattern = new RegExp(`ALTER TABLE\\s+["\`]?${tableName}["\`]?\\s+ENABLE ROW LEVEL SECURITY`, "i");
90
+ if (!rlsPattern.test(content)) {
91
+ findings.push({
92
+ id: `supabase-rls-missing-${tableName}`,
93
+ title: `Table '${tableName}' may be missing Row Level Security`,
94
+ description: `Found in ${sqlFile.replace(projectRoot, "").replace(/^[/\\]/, "")} — no matching 'ENABLE ROW LEVEL SECURITY' for this table in the same file. Without RLS, the public anon key can read and write this table directly — this is the single most common cause of drained or defaced Supabase-backed apps.`,
95
+ severity: "critical",
96
+ verifyNote: "Static check only — it cannot see RLS enabled via the Supabase dashboard UI or outside migration files. Verify directly in the dashboard before treating a clean result as certain.",
97
+ fix: FIX_TEMPLATES.rlsEnable,
98
+ });
99
+ }
100
+ }
101
+ }
102
+ return findings;
103
+ }
104
+ async function scanSourceForPatterns(projectRoot) {
105
+ const findings = [];
106
+ const files = await fg(["**/*.js", "**/*.ts"], {
107
+ cwd: projectRoot,
108
+ absolute: true,
109
+ ignore: [
110
+ "**/node_modules/**",
111
+ "**/.next/**",
112
+ "**/dist/**",
113
+ "**/build/**",
114
+ "**/*.test.*",
115
+ "**/*.spec.*",
116
+ ],
117
+ });
118
+ let corsWildcardFound = false;
119
+ let errorLeakFound = false;
120
+ for (const file of files) {
121
+ const content = await readFile(file, "utf-8").catch(() => "");
122
+ if (!corsWildcardFound && /cors\s*\(\s*\)|origin\s*:\s*['"]\*['"]/.test(content)) {
123
+ corsWildcardFound = true;
124
+ }
125
+ // Heuristic: sending err.stack or err.message directly in a response
126
+ // body is a real, specific leak pattern — not "any error handling."
127
+ // Matches res.json(...) and the more common res.status(500).json(...).
128
+ if (!errorLeakFound &&
129
+ /res(\.status\([^)]*\))?\.(json|send)\([^)]*(err(or)?\.(stack|message))/.test(content)) {
130
+ errorLeakFound = true;
131
+ }
132
+ if (corsWildcardFound && errorLeakFound)
133
+ break;
134
+ }
135
+ if (corsWildcardFound) {
136
+ findings.push({
137
+ id: "cors-wildcard",
138
+ title: "CORS may be wide open",
139
+ description: "Found cors() with no origin restriction, or an explicit '*' origin — any website can currently read responses from this API.",
140
+ severity: "high",
141
+ verifyNote: "Pattern-based match — confirm the actual runtime config, since environment-specific overrides won't show here.",
142
+ fix: FIX_TEMPLATES.cors,
143
+ });
144
+ }
145
+ if (errorLeakFound) {
146
+ findings.push({
147
+ id: "error-leak",
148
+ title: "Error response may leak internals",
149
+ description: "Found a response that sends err.stack or err.message directly to the client — this can expose file paths, internal logic, or raw database errors.",
150
+ severity: "high",
151
+ verifyNote: "Confirmed by matching an actual response-sending pattern, not just the presence/absence of a library — lower false-positive risk than the other checks here.",
152
+ fix: FIX_TEMPLATES.errorHandler,
153
+ });
154
+ }
155
+ return findings;
156
+ }
@@ -0,0 +1,118 @@
1
+ import { execa } from "execa";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ export class NpmAuditScanFailedError extends Error {
5
+ constructor(reason) {
6
+ super(`npm audit did not actually run: ${reason}. This is being reported as a failure rather than "no vulnerabilities" — a scan that never ran is not the same as a clean result.`);
7
+ this.name = "NpmAuditScanFailedError";
8
+ }
9
+ }
10
+ export class WrongPackageManagerError extends Error {
11
+ constructor(manager) {
12
+ super(`this project uses ${manager}, not npm — npm audit cannot reliably scan a ${manager}-managed dependency tree. Confirmed in testing: npm's own dependency resolver (arborist) crashes with an internal error ("Cannot read properties of null") when node_modules contains ${manager}'s symlink structure, which is a real bug in npm itself, not something CodeVet can work around. Run '${manager} audit' directly for this project.`);
13
+ this.name = "WrongPackageManagerError";
14
+ }
15
+ }
16
+ /**
17
+ * Detects a package manager npm's own dependency resolver cannot reliably
18
+ * handle. Confirmed by directly reproducing the failure: a pnpm-managed
19
+ * node_modules/.pnpm virtual store crashes npm's arborist mid-resolution
20
+ * with an unhandled internal TypeError, not a normal, catchable npm error.
21
+ * Rather than let that crash surface as a scary internal stack trace, we
22
+ * detect the situation up front and skip cleanly with a clear reason.
23
+ */
24
+ function detectIncompatiblePackageManager(projectRoot) {
25
+ if (existsSync(join(projectRoot, "pnpm-lock.yaml")) || existsSync(join(projectRoot, "node_modules", ".pnpm"))) {
26
+ return "pnpm";
27
+ }
28
+ // Same underlying npm/arborist bug class (npm/cli#9459 — Arborist
29
+ // crashes with "Cannot read properties of null (reading 'matches')" on
30
+ // a Link with a null target), triggered by Yarn's node_modules
31
+ // structure (classic Yarn's flat symlink layout, or Yarn Berry/PnP's
32
+ // .pnp.cjs) instead of pnpm's virtual store. Detected via yarn.lock or
33
+ // the .yarnrc.yml Yarn Berry uses, rather than trying to reproduce the
34
+ // crash signature itself.
35
+ if (existsSync(join(projectRoot, "yarn.lock")) ||
36
+ existsSync(join(projectRoot, ".yarnrc.yml")) ||
37
+ existsSync(join(projectRoot, ".pnp.cjs"))) {
38
+ return "yarn";
39
+ }
40
+ return null;
41
+ }
42
+ /**
43
+ * Runs a real npm audit against package.json/package-lock.json. This checks
44
+ * against npm's published advisory database (known, disclosed CVEs) — it
45
+ * does NOT detect novel malware or unpublished zero-days. That distinction
46
+ * matters and should stay visible in the report, not be implied away.
47
+ *
48
+ * If no lockfile exists, we generate one with `--package-lock-only
49
+ * --ignore-scripts` — this resolves the dependency tree WITHOUT installing
50
+ * any package or running any install/postinstall script. That matters for
51
+ * scanning an untrusted repo (e.g. before a URL-clone confirmation): a
52
+ * normal `npm install` would execute arbitrary scripts from every
53
+ * dependency before we ever get to look at the results.
54
+ */
55
+ export async function runNpmAuditScan(projectRoot) {
56
+ const packageJsonPath = join(projectRoot, "package.json");
57
+ if (!existsSync(packageJsonPath)) {
58
+ return [];
59
+ }
60
+ const incompatibleManager = detectIncompatiblePackageManager(projectRoot);
61
+ if (incompatibleManager) {
62
+ throw new WrongPackageManagerError(incompatibleManager);
63
+ }
64
+ const lockfilePath = join(projectRoot, "package-lock.json");
65
+ if (!existsSync(lockfilePath)) {
66
+ const lockResult = await execa("npm", ["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"], { cwd: projectRoot, reject: false });
67
+ // If lockfile generation itself failed, npm audit below will fail too
68
+ // (ENOLOCK) — no point running it, and the error is clearer here.
69
+ // Show the FULL stderr, not just the first line — a truncated error
70
+ // message made a real failure (a Turborepo monorepo project) much
71
+ // harder to diagnose than necessary, since npm's actual diagnostic
72
+ // detail (often including a debug-log path) was being cut off.
73
+ if (lockResult.exitCode !== 0 && !existsSync(lockfilePath)) {
74
+ const fullError = lockResult.stderr.trim() || lockResult.stdout.trim() || "unknown error";
75
+ throw new NpmAuditScanFailedError(`could not generate a package-lock.json.\n${fullError}`);
76
+ }
77
+ }
78
+ const result = await execa("npm", ["audit", "--json"], {
79
+ cwd: projectRoot,
80
+ reject: false, // npm audit exits non-zero when it finds vulnerabilities — expected
81
+ });
82
+ let report;
83
+ try {
84
+ report = JSON.parse(result.stdout);
85
+ }
86
+ catch {
87
+ // Genuinely unparseable output (not even npm's own error JSON shape)
88
+ // — a real failure, not a clean result.
89
+ throw new NpmAuditScanFailedError(result.stderr.split("\n")[0] || "npm audit produced no readable output");
90
+ }
91
+ if (report.error) {
92
+ throw new NpmAuditScanFailedError(`${report.error.code}: ${report.error.summary}`);
93
+ }
94
+ if (!report.vulnerabilities)
95
+ return [];
96
+ return Object.values(report.vulnerabilities).map((v) => {
97
+ const advisories = v.via
98
+ .filter((entry) => typeof entry !== "string")
99
+ .map((entry) => ({
100
+ title: entry.title,
101
+ url: entry.url,
102
+ severity: entry.severity ?? v.severity,
103
+ }));
104
+ return {
105
+ packageName: v.name,
106
+ severity: v.severity,
107
+ advisories: advisories.length > 0
108
+ ? advisories
109
+ : [{ title: `Known vulnerability in ${v.name}`, severity: v.severity }],
110
+ fixAvailable: typeof v.fixAvailable === "object"
111
+ ? `${v.fixAvailable.name}@${v.fixAvailable.version}`
112
+ : v.fixAvailable
113
+ ? "Run `npm audit fix`"
114
+ : false,
115
+ isDirect: v.isDirect,
116
+ };
117
+ });
118
+ }
@@ -0,0 +1,50 @@
1
+ import { execa } from "execa";
2
+ import { existsSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { runNpmAuditScan } from "./npmAuditScanner.js";
6
+ /**
7
+ * Runs `npm audit fix` — upgrades packages to the newest version that
8
+ * satisfies the existing semver range in package.json. This is the "safe"
9
+ * remediation: it does not change your major version constraints unless
10
+ * `force` is explicitly requested, matching npm's own safety model rather
11
+ * than inventing our own.
12
+ */
13
+ export async function runFix(projectRoot, force) {
14
+ const before = await runNpmAuditScan(projectRoot);
15
+ const args = ["audit", "fix"];
16
+ if (force)
17
+ args.push("--force");
18
+ await execa("npm", args, { cwd: projectRoot, reject: false });
19
+ const after = await runNpmAuditScan(projectRoot);
20
+ return { before, after, ranForceFix: force };
21
+ }
22
+ /**
23
+ * Explicitly uninstalls a single flagged package. This is NOT the same as
24
+ * `fix` — removing a dependency the application actually imports and uses
25
+ * will break the app. This exists for the case where a flagged package is
26
+ * unused/leftover and the right move is deleting it outright, not
27
+ * upgrading it. The CLI layer is responsible for warning the user before
28
+ * calling this.
29
+ */
30
+ export async function removeDependency(projectRoot, packageName) {
31
+ const packageJsonPath = join(projectRoot, "package.json");
32
+ if (!existsSync(packageJsonPath)) {
33
+ return { packageName, wasPresent: false, removed: false, error: "No package.json found" };
34
+ }
35
+ const packageJson = JSON.parse(await readFile(packageJsonPath, "utf-8"));
36
+ const wasPresent = Boolean(packageJson.dependencies?.[packageName] || packageJson.devDependencies?.[packageName]);
37
+ if (!wasPresent) {
38
+ return { packageName, wasPresent: false, removed: false };
39
+ }
40
+ const result = await execa("npm", ["uninstall", packageName], {
41
+ cwd: projectRoot,
42
+ reject: false,
43
+ });
44
+ return {
45
+ packageName,
46
+ wasPresent: true,
47
+ removed: result.exitCode === 0,
48
+ error: result.exitCode !== 0 ? result.stderr : undefined,
49
+ };
50
+ }