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.
- package/.gitleaks.toml +91 -0
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/dist/config.js +44 -0
- package/dist/detectors/detectStack.js +28 -0
- package/dist/fixLibrary/templates.js +311 -0
- package/dist/index.js +354 -0
- package/dist/promptConfirm.js +12 -0
- package/dist/report.js +155 -0
- package/dist/resolveTarget.js +119 -0
- package/dist/scanners/bearerScanner.js +108 -0
- package/dist/scanners/ensureBinary.js +101 -0
- package/dist/scanners/gitleaksScanner.js +109 -0
- package/dist/scanners/hygieneScanner.js +156 -0
- package/dist/scanners/npmAuditScanner.js +118 -0
- package/dist/scanners/npmFixActions.js +50 -0
- package/dist/scanners/pipAuditScanner.js +73 -0
- package/dist/scanners/pythonAuditScanner.js +66 -0
- package/package.json +57 -0
- package/scripts/generateTemplates.mjs +48 -0
- package/scripts/postPrComment.mjs +113 -0
- package/scripts/postinstall.mjs +20 -0
- package/scripts/runTests.mjs +39 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { resolve, join, dirname } from "node:path";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { detectStack } from "./detectors/detectStack.js";
|
|
7
|
+
import { runGitleaksScan, GitleaksBinaryMissingError } from "./scanners/gitleaksScanner.js";
|
|
8
|
+
import { runNpmAuditScan, NpmAuditScanFailedError, WrongPackageManagerError } from "./scanners/npmAuditScanner.js";
|
|
9
|
+
import { runHygieneScan } from "./scanners/hygieneScanner.js";
|
|
10
|
+
import { runBearerScan, BearerBinaryMissingError, BearerScanFailedError } from "./scanners/bearerScanner.js";
|
|
11
|
+
import { runPipAuditScan, PipAuditNotInstalledError, PipAuditScanFailedError } from "./scanners/pipAuditScanner.js";
|
|
12
|
+
import { resolveTarget, cleanupTarget, persistTarget, readProvenanceMarker, removeFolder } from "./resolveTarget.js";
|
|
13
|
+
import { loadConfig, saveConfig } from "./config.js";
|
|
14
|
+
import { promptConfirm } from "./promptConfirm.js";
|
|
15
|
+
import { printReport, hasFindings, hasHighRiskFindings } from "./report.js";
|
|
16
|
+
import { runFix, removeDependency } from "./scanners/npmFixActions.js";
|
|
17
|
+
const program = new Command();
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
// Read the real version from package.json rather than hardcoding it —
|
|
22
|
+
// confirmed as a real bug: a hardcoded "0.0.1" had silently drifted out
|
|
23
|
+
// of sync with the actual published version.
|
|
24
|
+
const packageJson = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
25
|
+
program
|
|
26
|
+
.name("codevet")
|
|
27
|
+
.description("Vet your code before it ships — free, open-source security scanning")
|
|
28
|
+
.version(packageJson.version);
|
|
29
|
+
program
|
|
30
|
+
.command("scan")
|
|
31
|
+
.description("Scan a project for security issues. Defaults to the current directory. Accepts a local path or a git/GitHub URL (which gets shallow-cloned to a temp folder first, so you can review it before deciding to keep it).")
|
|
32
|
+
.argument("[path...]", "project path or git URL to scan (defaults to the current directory)")
|
|
33
|
+
.option("--no-secrets", "skip the secret-scanning check for this run only")
|
|
34
|
+
.option("--no-dependencies", "skip the dependency-vulnerability check for this run only")
|
|
35
|
+
.option("--no-hygiene", "skip the missing-middleware check for this run only")
|
|
36
|
+
.option("--no-data-flow", "skip the personal-data-flow check for this run only")
|
|
37
|
+
.option("--json <path>", "also write a machine-readable JSON report to this path (for CI/tooling)")
|
|
38
|
+
.option("--fail-on-high-risk", "exit with a non-zero code if any secret or high/critical dependency finding is present")
|
|
39
|
+
.action(async (pathArgs, options) => {
|
|
40
|
+
const target = await resolveTarget(pathArgs, process.cwd());
|
|
41
|
+
if (target.rejoinedFromSplitArgs) {
|
|
42
|
+
console.log(chalk.yellow(`Note: your path contains spaces and wasn't quoted, so I rejoined it as: "${target.path}". If that's wrong, wrap the path in quotes.`));
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
// A path that doesn't exist must be a hard error, never a silent
|
|
46
|
+
// "clean" result — confirmed as a real risk: gitleaks, npm audit,
|
|
47
|
+
// and the hygiene scanner all previously proceeded on a nonexistent
|
|
48
|
+
// path and reported "no findings," which is indistinguishable from
|
|
49
|
+
// a genuine clean scan. Bearer already validates this internally
|
|
50
|
+
// (confirmed it correctly errors on its own); this check makes the
|
|
51
|
+
// other three scanners fail the same safe way.
|
|
52
|
+
if (!existsSync(target.path)) {
|
|
53
|
+
console.log(chalk.red(`✖ ${target.path} does not exist — nothing was scanned.`));
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
console.log(chalk.bold("\nCodeVet — scanning " + target.path + "\n"));
|
|
58
|
+
const stacks = detectStack(target.path);
|
|
59
|
+
if (stacks.length === 0) {
|
|
60
|
+
console.log(chalk.yellow("No recognized stack found (looked for package.json, requirements.txt, build.gradle, Podfile)."));
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
console.log(chalk.dim("Detected: " + stacks.map((s) => `${s.stack} (${s.matchedOn})`).join(", ")));
|
|
64
|
+
}
|
|
65
|
+
// Untrusted clones ALWAYS run the full scan, regardless of any
|
|
66
|
+
// .codevet/config.json the repo itself might contain — otherwise a
|
|
67
|
+
// malicious repo could just ship a config that disables every check
|
|
68
|
+
// and sail through review undetected.
|
|
69
|
+
const config = target.isUntrustedClone
|
|
70
|
+
? { secrets: true, dependencies: true, hygiene: true, dataFlow: true }
|
|
71
|
+
: await loadConfig(target.path);
|
|
72
|
+
const runSecrets = options.secrets !== false && config.secrets;
|
|
73
|
+
const runDependencies = options.dependencies !== false && config.dependencies;
|
|
74
|
+
const runHygiene = options.hygiene !== false && config.hygiene;
|
|
75
|
+
const runDataFlow = options.dataFlow !== false && config.dataFlow;
|
|
76
|
+
console.log(chalk.dim("\nChecking for exposed secrets...\n"));
|
|
77
|
+
const secrets = runSecrets ? await runGitleaksScan(target.path) : [];
|
|
78
|
+
// Wrapped exactly like the bearer scanner below — confirmed as a
|
|
79
|
+
// real crash risk in testing: npm audit's own lockfile-generation
|
|
80
|
+
// step can fail (e.g. an npm internal error on a specific
|
|
81
|
+
// package.json), and left uncaught this took down the ENTIRE scan
|
|
82
|
+
// command, hiding secrets/hygiene/data-flow results that had
|
|
83
|
+
// nothing to do with the failure. Never let one scanner's failure
|
|
84
|
+
// silence the others.
|
|
85
|
+
let dependencies = [];
|
|
86
|
+
let dependenciesUnavailableReason;
|
|
87
|
+
let dependenciesFailed = false;
|
|
88
|
+
if (runDependencies) {
|
|
89
|
+
try {
|
|
90
|
+
dependencies = await runNpmAuditScan(target.path);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
if (err instanceof NpmAuditScanFailedError) {
|
|
94
|
+
dependenciesUnavailableReason = err.message;
|
|
95
|
+
dependenciesFailed = true;
|
|
96
|
+
}
|
|
97
|
+
else if (err instanceof WrongPackageManagerError) {
|
|
98
|
+
// A deliberate, understood decision not to run — not an
|
|
99
|
+
// unexpected failure — so this stays a calm skip, not a
|
|
100
|
+
// red warning. Confirmed the underlying npm crash is real
|
|
101
|
+
// by directly reproducing it against a pnpm-managed project.
|
|
102
|
+
dependenciesUnavailableReason = err.message;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
let dataFlow = [];
|
|
110
|
+
let dataFlowUnavailableReason;
|
|
111
|
+
let dataFlowFailed = false;
|
|
112
|
+
if (runDataFlow) {
|
|
113
|
+
try {
|
|
114
|
+
dataFlow = await runBearerScan(target.path);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
if (err instanceof BearerBinaryMissingError) {
|
|
118
|
+
dataFlowUnavailableReason = err.message;
|
|
119
|
+
}
|
|
120
|
+
else if (err instanceof BearerScanFailedError) {
|
|
121
|
+
dataFlowUnavailableReason = err.message;
|
|
122
|
+
dataFlowFailed = true;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Gated by the same toggle as npm audit — conceptually both are
|
|
130
|
+
// "dependency scanning," just for a different ecosystem, so one
|
|
131
|
+
// config field covers both rather than adding a Python-specific one.
|
|
132
|
+
// Applicability now also covers pyproject.toml, not just
|
|
133
|
+
// requirements.txt — closing a gap explicitly flagged as missing
|
|
134
|
+
// in an earlier, less capable version of this scanner.
|
|
135
|
+
let pythonDependencies = [];
|
|
136
|
+
let pythonDependenciesUnavailableReason;
|
|
137
|
+
let pythonDependenciesFailed = false;
|
|
138
|
+
const pythonDependenciesApplicable = runDependencies &&
|
|
139
|
+
(existsSync(join(target.path, "requirements.txt")) ||
|
|
140
|
+
existsSync(join(target.path, "pyproject.toml")));
|
|
141
|
+
if (pythonDependenciesApplicable) {
|
|
142
|
+
try {
|
|
143
|
+
pythonDependencies = await runPipAuditScan(target.path);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
if (err instanceof PipAuditNotInstalledError) {
|
|
147
|
+
pythonDependenciesUnavailableReason = err.message;
|
|
148
|
+
}
|
|
149
|
+
else if (err instanceof PipAuditScanFailedError) {
|
|
150
|
+
pythonDependenciesUnavailableReason = err.message;
|
|
151
|
+
pythonDependenciesFailed = true;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
let hygiene = [];
|
|
159
|
+
if (runHygiene) {
|
|
160
|
+
hygiene = await runHygieneScan(target.path);
|
|
161
|
+
}
|
|
162
|
+
const report = {
|
|
163
|
+
secrets,
|
|
164
|
+
dependencies,
|
|
165
|
+
hygiene,
|
|
166
|
+
dataFlow,
|
|
167
|
+
pythonDependencies,
|
|
168
|
+
pythonDependenciesApplicable,
|
|
169
|
+
pythonDependenciesFailed,
|
|
170
|
+
pythonDependenciesUnavailableReason,
|
|
171
|
+
secretsScanSkipped: !runSecrets,
|
|
172
|
+
dependenciesScanSkipped: !runDependencies || (Boolean(dependenciesUnavailableReason) && !dependenciesFailed),
|
|
173
|
+
dependenciesScanFailed: dependenciesFailed,
|
|
174
|
+
dependenciesUnavailableReason,
|
|
175
|
+
dataFlowScanSkipped: !runDataFlow || (Boolean(dataFlowUnavailableReason) && !dataFlowFailed),
|
|
176
|
+
dataFlowScanFailed: dataFlowFailed,
|
|
177
|
+
dataFlowUnavailableReason,
|
|
178
|
+
};
|
|
179
|
+
printReport(report);
|
|
180
|
+
if (options.json) {
|
|
181
|
+
const { writeFile } = await import("node:fs/promises");
|
|
182
|
+
await writeFile(options.json, JSON.stringify(report, null, 2));
|
|
183
|
+
console.log(chalk.dim(`\nJSON report written to ${options.json}`));
|
|
184
|
+
}
|
|
185
|
+
if (target.isUntrustedClone) {
|
|
186
|
+
await handleUntrustedCloneDecision(target, report);
|
|
187
|
+
}
|
|
188
|
+
if (options.failOnHighRisk && hasHighRiskFindings(report)) {
|
|
189
|
+
process.exitCode = 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
if (err instanceof GitleaksBinaryMissingError) {
|
|
194
|
+
console.log(chalk.red("✖ " + err.message));
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
if (!target.isUntrustedClone) {
|
|
202
|
+
await cleanupTarget(target);
|
|
203
|
+
}
|
|
204
|
+
// Untrusted-clone cleanup/persistence is handled inside
|
|
205
|
+
// handleUntrustedCloneDecision instead — it decides whether to keep
|
|
206
|
+
// or delete based on the user's answer.
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
async function handleUntrustedCloneDecision(target, report) {
|
|
210
|
+
if (!hasFindings(report)) {
|
|
211
|
+
const destination = await persistTarget(target, process.cwd());
|
|
212
|
+
console.log(chalk.green(`\n✔ No issues found — kept at ${destination}`));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const serious = hasHighRiskFindings(report);
|
|
216
|
+
const proceed = await promptConfirm(chalk.yellow(`\n${serious ? "⚠ Serious issues" : "Some issues"} were found above. Do you still want to keep this repository?`));
|
|
217
|
+
if (proceed) {
|
|
218
|
+
const destination = await persistTarget(target, process.cwd());
|
|
219
|
+
console.log(chalk.yellow(`Kept at ${destination} despite the issues above — review them before running this code.`));
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
await cleanupTarget(target);
|
|
223
|
+
console.log(chalk.dim("Discarded — nothing was kept."));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const configCmd = program
|
|
227
|
+
.command("config")
|
|
228
|
+
.description("Enable or disable specific scanners for this project (stored in .codevet/config.json)");
|
|
229
|
+
configCmd
|
|
230
|
+
.command("status")
|
|
231
|
+
.argument("[path]", "project path", ".")
|
|
232
|
+
.action(async (path) => {
|
|
233
|
+
const config = await loadConfig(path === "." ? process.cwd() : path);
|
|
234
|
+
console.log(chalk.bold("\nCodeVet scanner status:\n"));
|
|
235
|
+
console.log(` secrets: ${config.secrets ? chalk.green("enabled") : chalk.red("disabled")}`);
|
|
236
|
+
console.log(` dependencies: ${config.dependencies ? chalk.green("enabled") : chalk.red("disabled")}`);
|
|
237
|
+
console.log(` hygiene: ${config.hygiene ? chalk.green("enabled") : chalk.red("disabled")}`);
|
|
238
|
+
console.log(` data-flow: ${config.dataFlow ? chalk.green("enabled") : chalk.red("disabled")}`);
|
|
239
|
+
});
|
|
240
|
+
for (const action of ["enable", "disable"]) {
|
|
241
|
+
configCmd
|
|
242
|
+
.command(action)
|
|
243
|
+
.argument("<scanner>", "'secrets', 'dependencies', 'hygiene', 'data-flow', or 'all'")
|
|
244
|
+
.argument("[path]", "project path", ".")
|
|
245
|
+
.description(`${action === "enable" ? "Enable" : "Disable"} a scanner for this project`)
|
|
246
|
+
.action(async (scanner, path) => {
|
|
247
|
+
const projectRoot = path === "." ? process.cwd() : path;
|
|
248
|
+
const config = await loadConfig(projectRoot);
|
|
249
|
+
const value = action === "enable";
|
|
250
|
+
if (scanner === "all") {
|
|
251
|
+
config.secrets = value;
|
|
252
|
+
config.dependencies = value;
|
|
253
|
+
config.hygiene = value;
|
|
254
|
+
config.dataFlow = value;
|
|
255
|
+
}
|
|
256
|
+
else if (scanner === "data-flow") {
|
|
257
|
+
config.dataFlow = value;
|
|
258
|
+
}
|
|
259
|
+
else if (scanner === "secrets" || scanner === "dependencies" || scanner === "hygiene") {
|
|
260
|
+
config[scanner] = value;
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
console.log(chalk.red(`Unknown scanner "${scanner}" — expected 'secrets', 'dependencies', 'hygiene', 'data-flow', or 'all'.`));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
await saveConfig(projectRoot, config);
|
|
267
|
+
console.log(chalk.green(`✔ ${scanner} scanner${scanner === "all" ? "s" : ""} ${action}d for this project.`));
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
program
|
|
271
|
+
.command("clean")
|
|
272
|
+
.description("Remove a repository that CodeVet previously cloned and kept")
|
|
273
|
+
.argument("<path>", "path to the folder to remove")
|
|
274
|
+
.option("--yes", "skip the confirmation prompt")
|
|
275
|
+
.action(async (path, options) => {
|
|
276
|
+
const target = resolve(path);
|
|
277
|
+
if (!existsSync(target)) {
|
|
278
|
+
console.log(chalk.red(`✖ ${target} doesn't exist.`));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const marker = await readProvenanceMarker(target);
|
|
282
|
+
if (marker) {
|
|
283
|
+
console.log(chalk.dim(`This was cloned from ${marker.sourceUrl} on ${new Date(marker.clonedAt).toLocaleString()}.`));
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
console.log(chalk.yellow("This folder doesn't have a CodeVet clone marker — it may not have been created by 'codevet scan <url>'. Double-check the path before continuing."));
|
|
287
|
+
}
|
|
288
|
+
const proceed = options.yes || (await promptConfirm(`Remove ${target}?`));
|
|
289
|
+
if (!proceed) {
|
|
290
|
+
console.log(chalk.dim("Cancelled — nothing was removed."));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
await removeFolder(target);
|
|
295
|
+
console.log(chalk.green(`✔ Removed ${target}`));
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
console.log(chalk.red("✖ " + (err instanceof Error ? err.message : String(err))));
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
program
|
|
302
|
+
.command("fix")
|
|
303
|
+
.description("Upgrade flagged dependencies to a safe version (runs 'npm audit fix' — does not change major-version constraints unless --force is passed).")
|
|
304
|
+
.argument("[path]", "project path", ".")
|
|
305
|
+
.option("--force", "allow major-version upgrades that may include breaking changes")
|
|
306
|
+
.action(async (path, options) => {
|
|
307
|
+
const projectRoot = path === "." ? process.cwd() : path;
|
|
308
|
+
if (options.force) {
|
|
309
|
+
console.log(chalk.yellow("⚠ Running with --force: this can upgrade packages across major versions, which may break your code. Review the diff before committing."));
|
|
310
|
+
}
|
|
311
|
+
console.log(chalk.dim("\nRunning npm audit fix...\n"));
|
|
312
|
+
const result = await runFix(projectRoot, options.force);
|
|
313
|
+
const beforeCount = result.before.length;
|
|
314
|
+
const afterCount = result.after.length;
|
|
315
|
+
const fixedCount = beforeCount - afterCount;
|
|
316
|
+
if (fixedCount > 0) {
|
|
317
|
+
console.log(chalk.green(`✔ Fixed ${fixedCount} of ${beforeCount} flagged package(s).`));
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
console.log(chalk.yellow("No packages were auto-fixable."));
|
|
321
|
+
}
|
|
322
|
+
if (afterCount > 0) {
|
|
323
|
+
console.log(chalk.yellow(`${afterCount} package(s) still flagged — these likely need a major-version bump. Run 'codevet fix --force' to attempt those, or 'codevet remove-dependency <name>' if a flagged package isn't actually needed.`));
|
|
324
|
+
for (const d of result.after) {
|
|
325
|
+
console.log(chalk.dim(` - ${d.packageName} (${d.severity})`));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
program
|
|
330
|
+
.command("remove-dependency")
|
|
331
|
+
.description("Explicitly uninstall a flagged package. WARNING: this is different from 'fix' — if your code actually imports this package, removing it will break your app. Only use this for packages you're sure are unused.")
|
|
332
|
+
.argument("<package>", "the package name to remove")
|
|
333
|
+
.argument("[path]", "project path", ".")
|
|
334
|
+
.option("--yes", "skip the confirmation prompt")
|
|
335
|
+
.action(async (packageName, path, options) => {
|
|
336
|
+
const projectRoot = path === "." ? process.cwd() : path;
|
|
337
|
+
console.log(chalk.yellow(`⚠ This will run 'npm uninstall ${packageName}'. If your code imports this package, your app will break until you either restore it or remove those imports.`));
|
|
338
|
+
const proceed = options.yes || (await promptConfirm(`Remove ${packageName}?`));
|
|
339
|
+
if (!proceed) {
|
|
340
|
+
console.log(chalk.dim("Cancelled — nothing was removed."));
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const result = await removeDependency(projectRoot, packageName);
|
|
344
|
+
if (!result.wasPresent) {
|
|
345
|
+
console.log(chalk.yellow(`${packageName} wasn't found in package.json — nothing to remove.`));
|
|
346
|
+
}
|
|
347
|
+
else if (result.removed) {
|
|
348
|
+
console.log(chalk.green(`✔ Removed ${packageName}.`));
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
console.log(chalk.red(`✖ Failed to remove ${packageName}: ${result.error}`));
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
program.parse();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
export async function promptConfirm(question) {
|
|
3
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
4
|
+
try {
|
|
5
|
+
const answer = await rl.question(`${question} (y/N): `);
|
|
6
|
+
const normalized = answer.trim().toLowerCase();
|
|
7
|
+
return normalized === "y" || normalized === "yes";
|
|
8
|
+
}
|
|
9
|
+
finally {
|
|
10
|
+
rl.close();
|
|
11
|
+
}
|
|
12
|
+
}
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
/**
|
|
3
|
+
* One consistent High/Moderate/Low(/Critical) label used everywhere in the
|
|
4
|
+
* report — this is the direct signal for "what do I actually need to fix
|
|
5
|
+
* before shipping" vs "what can wait." Critical/High mean real,
|
|
6
|
+
* meaningfully exploitable risk; Moderate is real but rarely the sole
|
|
7
|
+
* cause of an incident on its own; Low is worth doing but not urgent.
|
|
8
|
+
*/
|
|
9
|
+
export function hygieneSeverityLabel(severity) {
|
|
10
|
+
switch (severity) {
|
|
11
|
+
case "critical":
|
|
12
|
+
return chalk.bgRed.white.bold(" CRITICAL ");
|
|
13
|
+
case "high":
|
|
14
|
+
return chalk.red.bold("✖ HIGH");
|
|
15
|
+
case "moderate":
|
|
16
|
+
return chalk.yellow.bold("○ MODERATE");
|
|
17
|
+
case "low":
|
|
18
|
+
return chalk.dim("○ LOW");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function hasFindings(report) {
|
|
22
|
+
return (report.secrets.length > 0 ||
|
|
23
|
+
report.dependencies.length > 0 ||
|
|
24
|
+
report.hygiene.length > 0 ||
|
|
25
|
+
report.dataFlow.length > 0 ||
|
|
26
|
+
report.pythonDependencies.length > 0 ||
|
|
27
|
+
Boolean(report.dataFlowScanFailed) ||
|
|
28
|
+
Boolean(report.dependenciesScanFailed) ||
|
|
29
|
+
Boolean(report.pythonDependenciesFailed));
|
|
30
|
+
}
|
|
31
|
+
export function hasHighRiskFindings(report) {
|
|
32
|
+
const hasCriticalOrHighDep = report.dependencies.some((d) => d.severity === "critical" || d.severity === "high");
|
|
33
|
+
const hasCriticalOrHighDataFlow = report.dataFlow.some((d) => d.severity === "critical" || d.severity === "high");
|
|
34
|
+
const hasCriticalOrHighHygiene = report.hygiene.some((h) => h.severity === "critical" || h.severity === "high");
|
|
35
|
+
// A scan that was attempted but genuinely failed (e.g. bearer's rule
|
|
36
|
+
// download hit a rate limit) must never be treated as equivalent to a
|
|
37
|
+
// clean result — confirmed as a real risk in testing, where forcing a
|
|
38
|
+
// success exit code masked exactly this failure mode.
|
|
39
|
+
//
|
|
40
|
+
// pythonDependencies is deliberately NOT included here — pip-audit's
|
|
41
|
+
// JSON output has no severity field at all (confirmed in testing,
|
|
42
|
+
// unlike npm audit which does), so treating every Python CVE as
|
|
43
|
+
// high-risk would fabricate a confidence level we don't actually have.
|
|
44
|
+
// These findings are still always shown in the report, just not used
|
|
45
|
+
// to gate CI.
|
|
46
|
+
return (report.secrets.length > 0 ||
|
|
47
|
+
hasCriticalOrHighDep ||
|
|
48
|
+
hasCriticalOrHighDataFlow ||
|
|
49
|
+
hasCriticalOrHighHygiene ||
|
|
50
|
+
Boolean(report.dataFlowScanFailed) ||
|
|
51
|
+
Boolean(report.dependenciesScanFailed) ||
|
|
52
|
+
Boolean(report.pythonDependenciesFailed));
|
|
53
|
+
}
|
|
54
|
+
export function printReport(report) {
|
|
55
|
+
if (report.secretsScanSkipped) {
|
|
56
|
+
console.log(chalk.dim("○ Secret scan skipped."));
|
|
57
|
+
}
|
|
58
|
+
else if (report.secrets.length === 0) {
|
|
59
|
+
console.log(chalk.green("✔ No exposed secrets found."));
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
console.log(chalk.red(`✖ ${report.secrets.length} exposed secret(s) found:\n`));
|
|
63
|
+
for (const s of report.secrets) {
|
|
64
|
+
console.log(chalk.bold(` ${s.file}:${s.line}`));
|
|
65
|
+
console.log(` ${s.description}`);
|
|
66
|
+
console.log(chalk.dim(" Why it matters: this file is likely tracked by git — anyone with repo access (or anyone who forks a public repo) can read this credential directly.\n"));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
console.log(chalk.dim("\nChecking dependencies for known vulnerabilities...\n"));
|
|
70
|
+
if (report.dependenciesScanFailed) {
|
|
71
|
+
console.log(chalk.red(`⚠ Dependency scan FAILED — this is not a clean result: ${report.dependenciesUnavailableReason}`));
|
|
72
|
+
}
|
|
73
|
+
else if (report.dependenciesScanSkipped) {
|
|
74
|
+
console.log(chalk.dim(report.dependenciesUnavailableReason
|
|
75
|
+
? `○ Dependency scan skipped — ${report.dependenciesUnavailableReason}`
|
|
76
|
+
: "○ Dependency scan skipped."));
|
|
77
|
+
}
|
|
78
|
+
else if (report.dependencies.length === 0) {
|
|
79
|
+
console.log(chalk.green("✔ No known-vulnerable dependencies found."));
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
console.log(chalk.red(`✖ ${report.dependencies.length} package(s) with known vulnerabilities:\n`));
|
|
83
|
+
for (const d of report.dependencies) {
|
|
84
|
+
const severityColor = d.severity === "critical" || d.severity === "high" ? chalk.red : chalk.yellow;
|
|
85
|
+
console.log(severityColor.bold(` ${d.packageName} — ${d.severity.toUpperCase()}`) +
|
|
86
|
+
chalk.dim(d.isDirect ? "" : " (transitive dependency)"));
|
|
87
|
+
console.log(` ${d.advisories[0].title}`);
|
|
88
|
+
if (d.advisories.length > 1) {
|
|
89
|
+
console.log(chalk.dim(` + ${d.advisories.length - 1} more advisor${d.advisories.length - 1 === 1 ? "y" : "ies"} for this package`));
|
|
90
|
+
}
|
|
91
|
+
console.log(chalk.dim(` Fix: ${d.fixAvailable ? d.fixAvailable : "no automatic fix published yet — check the advisory"}\n`));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (report.pythonDependenciesApplicable) {
|
|
95
|
+
console.log(chalk.dim("\nChecking Python dependencies for known vulnerabilities (via pip-audit)...\n"));
|
|
96
|
+
if (report.pythonDependenciesFailed) {
|
|
97
|
+
console.log(chalk.red(`⚠ Python dependency scan FAILED — this is not a clean result: ${report.pythonDependenciesUnavailableReason}`));
|
|
98
|
+
}
|
|
99
|
+
else if (report.pythonDependenciesUnavailableReason) {
|
|
100
|
+
console.log(chalk.dim(`○ ${report.pythonDependenciesUnavailableReason}`));
|
|
101
|
+
}
|
|
102
|
+
else if (report.pythonDependencies.length === 0) {
|
|
103
|
+
console.log(chalk.green("✔ No known-vulnerable Python dependencies found."));
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
console.log(chalk.yellow(`○ ${report.pythonDependencies.length} package(s) with known CVEs — pip-audit does not provide severity ratings, so these are listed unranked. Prioritize by whether a fix version exists:\n`));
|
|
107
|
+
for (const p of report.pythonDependencies) {
|
|
108
|
+
console.log(chalk.bold(` ${p.packageName}@${p.installedVersion}`));
|
|
109
|
+
for (const advisory of p.advisories) {
|
|
110
|
+
console.log(` ${advisory.id}: ${advisory.description}`);
|
|
111
|
+
console.log(chalk.dim(` Fix: ${advisory.fixVersions.length > 0 ? `upgrade to ${advisory.fixVersions[advisory.fixVersions.length - 1]}` : "no fix published yet — check the advisory"}`));
|
|
112
|
+
}
|
|
113
|
+
console.log("");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
console.log(chalk.dim("\nChecking for missing security middleware...\n"));
|
|
118
|
+
if (report.hygiene.length === 0) {
|
|
119
|
+
console.log(chalk.green("✔ No missing middleware patterns detected."));
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
for (const h of report.hygiene) {
|
|
123
|
+
const label = hygieneSeverityLabel(h.severity);
|
|
124
|
+
console.log(`${label} ${chalk.bold(h.title)}`);
|
|
125
|
+
console.log(` ${h.description}`);
|
|
126
|
+
if (h.verifyNote) {
|
|
127
|
+
console.log(chalk.dim(` Verify: ${h.verifyNote}`));
|
|
128
|
+
}
|
|
129
|
+
console.log(chalk.dim(` Suggested fix — ${h.fix.title}:`));
|
|
130
|
+
console.log(chalk.dim(h.fix.code.split("\n").map((l) => " " + l).join("\n")));
|
|
131
|
+
console.log("");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
console.log(chalk.dim("\nChecking personal data flow (via bearer)...\n"));
|
|
135
|
+
if (report.dataFlowScanFailed) {
|
|
136
|
+
console.log(chalk.red(`⚠ Data flow scan FAILED — this is not a clean result: ${report.dataFlowUnavailableReason}`));
|
|
137
|
+
}
|
|
138
|
+
else if (report.dataFlowScanSkipped) {
|
|
139
|
+
console.log(chalk.dim(report.dataFlowUnavailableReason
|
|
140
|
+
? `○ Data flow scan skipped — ${report.dataFlowUnavailableReason}`
|
|
141
|
+
: "○ Data flow scan skipped."));
|
|
142
|
+
}
|
|
143
|
+
else if (report.dataFlow.length === 0) {
|
|
144
|
+
console.log(chalk.green("✔ No personal-data-flow risks found."));
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
console.log(chalk.red(`✖ ${report.dataFlow.length} data-flow finding(s):\n`));
|
|
148
|
+
for (const d of report.dataFlow) {
|
|
149
|
+
const severityColor = d.severity === "critical" || d.severity === "high" ? chalk.red : chalk.yellow;
|
|
150
|
+
console.log(severityColor.bold(` ${d.title} — ${d.severity.toUpperCase()}`));
|
|
151
|
+
console.log(` ${d.file}:${d.line}`);
|
|
152
|
+
console.log(chalk.dim(` ${d.description}\n`));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
import { mkdtemp, rm, rename, cp, mkdir, writeFile, readFile } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, basename, resolve } from "node:path";
|
|
6
|
+
const URL_PATTERN = /^(https?:\/\/|git@|git:\/\/|file:\/\/)/i;
|
|
7
|
+
/**
|
|
8
|
+
* Commander hands us `args` as whatever positional tokens the shell passed
|
|
9
|
+
* through. If someone runs `codevet scan D:\Some Folder\project` without
|
|
10
|
+
* quotes, PowerShell/cmd/bash all split that into 3 separate tokens before
|
|
11
|
+
* our program ever sees it — confirmed in real testing on Windows. We can't
|
|
12
|
+
* recover the original spacing with certainty, but rejoining with a single
|
|
13
|
+
* space is correct for the overwhelmingly common case (a real folder name
|
|
14
|
+
* with spaces) and far more useful than just erroring.
|
|
15
|
+
*/
|
|
16
|
+
export async function resolveTarget(args, cwd) {
|
|
17
|
+
const rejoinedFromSplitArgs = args.length > 1;
|
|
18
|
+
const raw = args.length === 0 ? "." : args.join(" ");
|
|
19
|
+
if (URL_PATTERN.test(raw)) {
|
|
20
|
+
const dir = await mkdtemp(join(tmpdir(), "codevet-clone-"));
|
|
21
|
+
await execa("git", ["clone", "--depth", "1", raw, dir]);
|
|
22
|
+
return {
|
|
23
|
+
path: dir,
|
|
24
|
+
cleanupDir: dir,
|
|
25
|
+
isUntrustedClone: true,
|
|
26
|
+
rejoinedFromSplitArgs: false,
|
|
27
|
+
sourceUrl: raw,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const path = raw === "." ? cwd : raw;
|
|
31
|
+
return { path, isUntrustedClone: false, rejoinedFromSplitArgs };
|
|
32
|
+
}
|
|
33
|
+
export async function cleanupTarget(target) {
|
|
34
|
+
if (target.cleanupDir) {
|
|
35
|
+
await rm(target.cleanupDir, { recursive: true, force: true }).catch(() => { });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function deriveRepoName(url) {
|
|
39
|
+
const cleaned = url.replace(/\.git$/, "").replace(/\/+$/, "");
|
|
40
|
+
const name = basename(cleaned) || "codevet-clone";
|
|
41
|
+
return name.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Moves a cloned target from its temp directory to a permanent destination
|
|
45
|
+
* next to where the user ran the command. Only call this after the user
|
|
46
|
+
* has actually agreed to keep it (or the scan came back clean).
|
|
47
|
+
*/
|
|
48
|
+
export async function persistTarget(target, destinationParent) {
|
|
49
|
+
if (!target.cleanupDir || !target.sourceUrl) {
|
|
50
|
+
throw new Error("persistTarget called on a target that wasn't cloned from a URL");
|
|
51
|
+
}
|
|
52
|
+
let destination = join(destinationParent, deriveRepoName(target.sourceUrl));
|
|
53
|
+
let suffix = 2;
|
|
54
|
+
while (existsSync(destination)) {
|
|
55
|
+
destination = join(destinationParent, `${deriveRepoName(target.sourceUrl)}-${suffix}`);
|
|
56
|
+
suffix += 1;
|
|
57
|
+
}
|
|
58
|
+
await moveAcrossDevices(target.cleanupDir, destination);
|
|
59
|
+
await writeProvenanceMarker(destination, target.sourceUrl);
|
|
60
|
+
return destination;
|
|
61
|
+
}
|
|
62
|
+
function markerPath(dir) {
|
|
63
|
+
return join(dir, ".codevet", "clone-origin.json");
|
|
64
|
+
}
|
|
65
|
+
async function writeProvenanceMarker(dir, sourceUrl) {
|
|
66
|
+
const marker = { sourceUrl, clonedAt: new Date().toISOString() };
|
|
67
|
+
await mkdir(join(dir, ".codevet"), { recursive: true });
|
|
68
|
+
await writeFile(markerPath(dir), JSON.stringify(marker, null, 2));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Checks whether a folder was actually persisted by `codevet scan <url>`,
|
|
72
|
+
* so the `clean` command can refuse (or clearly warn) before deleting
|
|
73
|
+
* something it didn't create — instead of just trusting any path a user
|
|
74
|
+
* happens to type.
|
|
75
|
+
*/
|
|
76
|
+
export async function readProvenanceMarker(dir) {
|
|
77
|
+
try {
|
|
78
|
+
const raw = await readFile(markerPath(dir), "utf-8");
|
|
79
|
+
return JSON.parse(raw);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Deletes a folder, but only ever a folder actually inside/under the
|
|
87
|
+
* current working directory tree or an absolute path the user explicitly
|
|
88
|
+
* gave — this is a thin, explicit wrapper so `clean` never silently
|
|
89
|
+
* resolves to something unexpected like the filesystem root.
|
|
90
|
+
*/
|
|
91
|
+
export async function removeFolder(targetPath) {
|
|
92
|
+
const resolved = resolve(targetPath);
|
|
93
|
+
if (resolved === resolve("/") || resolved === resolve(process.env.HOME ?? "/")) {
|
|
94
|
+
throw new Error(`Refusing to remove ${resolved} — this looks like a root or home directory, not a scanned clone.`);
|
|
95
|
+
}
|
|
96
|
+
await rm(resolved, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* fs.rename is fast (just a filesystem pointer update) but fails with
|
|
100
|
+
* EXDEV when source and destination are on different drives/filesystems —
|
|
101
|
+
* e.g. a Windows temp folder on C:\ being moved to a project on E:\.
|
|
102
|
+
* Confirmed with a real cross-filesystem mount in testing, matching a
|
|
103
|
+
* real user's exact error. Falls back to copy-then-delete, which works
|
|
104
|
+
* across any boundary at the cost of being slower for large repos.
|
|
105
|
+
*/
|
|
106
|
+
async function moveAcrossDevices(source, destination) {
|
|
107
|
+
try {
|
|
108
|
+
await rename(source, destination);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
if (err instanceof Error && "code" in err && err.code === "EXDEV") {
|
|
112
|
+
await cp(source, destination, { recursive: true });
|
|
113
|
+
await rm(source, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|