osv-depguard 1.0.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.
Files changed (3) hide show
  1. package/README.md +75 -0
  2. package/depguard.js +448 -0
  3. package/package.json +26 -0
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # OSV-DepGuard πŸ›‘οΈ
2
+
3
+ **Deterministic Hybrid Vulnerability Scanner for Node.js projects.**
4
+
5
+ | Layer | Tool | Role |
6
+ |---|---|---|
7
+ | **Scanner** | OSV.dev (Google) | 100% deterministic CVE lookup β€” no hallucination |
8
+ | **Source** | `package-lock.json` | Exact installed versions, not semver ranges |
9
+ | **AI** | Claude (Anthropic) | Interprets OSV data into plain English + fix commands |
10
+ | **UI** | chalk + cli-table3 | Colour-coded terminal table |
11
+
12
+ ## Setup
13
+
14
+ ### 1. Install dependencies
15
+ ```bash
16
+ npm install
17
+ ```
18
+
19
+ ### 2. API key β€” add to .env
20
+ ```
21
+ ANTHROPIC_API_KEY=sk-ant-...
22
+ ```
23
+
24
+ ### IMPORTANT β€” do this immediately:
25
+ ```bash
26
+ echo ".env" >> .gitignore
27
+ ```
28
+
29
+ DepGuard will warn you at startup if .env is missing from .gitignore.
30
+
31
+ ## Usage
32
+
33
+ ```bash
34
+ node depguard.js # scan ./package-lock.json
35
+ node depguard.js ~/projects/my-app # scan a specific directory
36
+ node depguard.js --no-dev # skip devDependencies
37
+ node depguard.js --min-severity high # only HIGH + CRITICAL
38
+ node depguard.js --json # raw JSON output for CI
39
+ ```
40
+
41
+ ### Install globally
42
+ ```bash
43
+ npm install -g .
44
+ depguard
45
+ ```
46
+
47
+ ## How it works
48
+
49
+ ```
50
+ package-lock.json
51
+ β”‚
52
+ β–Ό exact installed versions
53
+ OSV.dev /v1/querybatch ──► real CVE data, zero hallucination
54
+ β”‚
55
+ β–Ό (if vulns found)
56
+ Anthropic API ──────────► plain English summary + remediation
57
+ (no web search β€” interprets OSV data only, cannot fabricate vulns)
58
+ β”‚
59
+ β–Ό
60
+ cli-table3 + chalk ─────► colour-coded terminal table
61
+ ```
62
+
63
+ ## Security notes
64
+
65
+ - Never hardcode your API key. Use `.env` via dotenv.
66
+ - Always add `.env` to `.gitignore` before your first commit.
67
+ - OSV.dev is a public API β€” no key required, only package names + versions are sent.
68
+
69
+ ## CI integration
70
+
71
+ ```bash
72
+ node depguard.js --json --min-severity high | jq '.[].package'
73
+ ```
74
+
75
+ Exit code `1` = scan failed (missing lockfile, API error). Exit code `0` = completed (check JSON for vulns).
package/depguard.js ADDED
@@ -0,0 +1,448 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * OSV - DepGuard β€” Deterministic Hybrid Vulnerability Scanner
5
+ *
6
+ * Pipeline:
7
+ * 1. Parse package-lock.json β†’ exact installed versions
8
+ * 2. Batch query OSV.dev API β†’ deterministic, real CVE data
9
+ * 3. Send OSV results to AI β†’ human-readable summaries & remediation steps only
10
+ * 4. Render colour-coded table via chalk + cli-table3
11
+ */
12
+
13
+ import "dotenv/config";
14
+ import chalk from "chalk";
15
+ import ora from "ora";
16
+ import { Command } from "commander";
17
+ import Table from "cli-table3";
18
+ import fs from "fs";
19
+ import path from "path";
20
+
21
+ // ─── API key guard ────────────────────────────────────────────────────────────
22
+ const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
23
+ if (!ANTHROPIC_API_KEY) {
24
+ console.error(
25
+ chalk.red("\n βœ– ANTHROPIC_API_KEY is not set.\n") +
26
+ chalk.gray(" Add it to a .env file or export it in your shell:\n\n") +
27
+ chalk.white(" echo 'ANTHROPIC_API_KEY=sk-ant-...' >> .env\n") +
28
+ chalk.yellow("\n ⚠ Make sure .env is listed in your .gitignore!\n")
29
+ );
30
+ process.exit(1);
31
+ }
32
+
33
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
34
+ const program = new Command();
35
+ program
36
+ .name("depguard")
37
+ .description("Deterministic hybrid dependency vulnerability scanner")
38
+ .version("1.0.0")
39
+ .argument("[path]", "Directory containing package-lock.json", ".")
40
+ .option("--no-dev", "Skip devDependencies")
41
+ .option(
42
+ "--min-severity <level>",
43
+ "Minimum severity to show: low | medium | high | critical",
44
+ "low"
45
+ )
46
+ .option("--json", "Output raw JSON instead of table")
47
+ .parse(process.argv);
48
+
49
+ const opts = program.opts();
50
+ const [scanDir] = program.args.length ? program.args : ["."];
51
+ const lockPath = path.resolve(scanDir, "package-lock.json");
52
+
53
+ // ─── Load package-lock.json ───────────────────────────────────────────────────
54
+ if (!fs.existsSync(lockPath)) {
55
+ console.error(
56
+ chalk.red(`\n βœ– Cannot find package-lock.json at: ${lockPath}`) +
57
+ chalk.gray("\n Run `npm install` first to generate a lockfile.\n")
58
+ );
59
+ process.exit(1);
60
+ }
61
+
62
+ const lock = JSON.parse(fs.readFileSync(lockPath, "utf-8"));
63
+ const lockVersion = lock.lockfileVersion || 1;
64
+
65
+ /**
66
+ * Extract exact installed package versions from the lockfile.
67
+ * Supports lockfileVersion 1, 2, and 3.
68
+ */
69
+ function extractPackages(lock, includeDev) {
70
+ const packages = {};
71
+
72
+ if (lockVersion >= 2 && lock.packages) {
73
+ // v2 / v3: "packages" map β€” keys like "node_modules/chalk"
74
+ for (const [key, meta] of Object.entries(lock.packages)) {
75
+ if (!key || key === "") continue; // skip the root project entry
76
+ if (!includeDev && meta.dev) continue;
77
+ const name = key.replace(/^.*node_modules\//, "");
78
+ if (name && meta.version) packages[name] = meta.version;
79
+ }
80
+ } else if (lock.dependencies) {
81
+ for (const [name, meta] of Object.entries(lock.dependencies)) {
82
+ if (!includeDev && meta.dev) continue;
83
+ if (meta.version) packages[name] = meta.version;
84
+ }
85
+ }
86
+
87
+ return packages;
88
+ }
89
+
90
+ const packageMap = extractPackages(lock, opts.dev !== false);
91
+ const packageEntries = Object.entries(packageMap);
92
+
93
+ if (packageEntries.length === 0) {
94
+ console.log(chalk.yellow("\n No packages found in lockfile.\n"));
95
+ process.exit(0);
96
+ }
97
+
98
+ // ─── OSV.dev batch query ──────────────────────────────────────────────────────
99
+ /**
100
+ * OSV batch endpoint β€” up to 1000 queries per call.
101
+ * https://google.github.io/osv.dev/post-v1-querybatch
102
+ */
103
+ async function queryOSV(packages) {
104
+ const queries = packages.map(([name, version]) => ({
105
+ version,
106
+ package: { name, ecosystem: "npm" },
107
+ }));
108
+
109
+ const BATCH_SIZE = 1000;
110
+ const allResults = [];
111
+
112
+ for (let i = 0; i < queries.length; i += BATCH_SIZE) {
113
+ const batch = queries.slice(i, i + BATCH_SIZE);
114
+ const res = await fetch("https://api.osv.dev/v1/querybatch", {
115
+ method: "POST",
116
+ headers: { "Content-Type": "application/json" },
117
+ body: JSON.stringify({ queries: batch }),
118
+ });
119
+
120
+ if (!res.ok) throw new Error(`OSV API ${res.status}: ${res.statusText}`);
121
+
122
+ const data = await res.json();
123
+ allResults.push(...(data.results || []));
124
+ }
125
+
126
+ return allResults;
127
+ }
128
+
129
+ // ─── Severity helpers ─────────────────────────────────────────────────────────
130
+ const SEVERITY_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, UNKNOWN: 0 };
131
+ const MIN_RANK =
132
+ SEVERITY_RANK[(opts.minSeverity || "low").toUpperCase()] ?? 1;
133
+
134
+ function extractSeverity(vuln) {
135
+ const candidates = [
136
+ ...(vuln.severity || []),
137
+ ...(vuln.database_specific?.severity
138
+ ? [{ score: vuln.database_specific.severity }]
139
+ : []),
140
+ ];
141
+
142
+ for (const s of candidates) {
143
+ const score = (s.score || "").toUpperCase();
144
+ if (["CRITICAL", "HIGH", "MEDIUM", "LOW"].includes(score)) return score;
145
+ const num = parseFloat(score);
146
+ if (!isNaN(num)) {
147
+ if (num >= 9.0) return "CRITICAL";
148
+ if (num >= 7.0) return "HIGH";
149
+ if (num >= 4.0) return "MEDIUM";
150
+ return "LOW";
151
+ }
152
+ }
153
+ return "UNKNOWN";
154
+ }
155
+
156
+ function colourSeverity(sev) {
157
+ switch (sev) {
158
+ case "CRITICAL": return chalk.bgRed.white.bold(` ${sev} `);
159
+ case "HIGH": return chalk.red.bold(sev);
160
+ case "MEDIUM": return chalk.yellow.bold(sev);
161
+ case "LOW": return chalk.blue(sev);
162
+ default: return chalk.gray(sev);
163
+ }
164
+ }
165
+
166
+ // ─── Build vulnerability list from OSV data ───────────────────────────────────
167
+ function buildVulnList(packages, osvResults) {
168
+ const vulns = [];
169
+
170
+ packages.forEach(([name, version], idx) => {
171
+ const result = osvResults[idx];
172
+ if (!result?.vulns?.length) return;
173
+
174
+ for (const vuln of result.vulns) {
175
+ const severity = extractSeverity(vuln);
176
+ if ((SEVERITY_RANK[severity] ?? 0) < MIN_RANK) continue;
177
+
178
+ // Extract fixed version from affected ranges
179
+ const fixedVersions = (vuln.affected || [])
180
+ .flatMap((a) => a.ranges || [])
181
+ .flatMap((r) => r.events || [])
182
+ .map((e) => e.fixed)
183
+ .filter(Boolean);
184
+
185
+ vulns.push({
186
+ package: name,
187
+ version,
188
+ id: vuln.id,
189
+ aliases: (vuln.aliases || []).filter((a) => a.startsWith("CVE-")),
190
+ severity,
191
+ summary: vuln.summary || "No summary available",
192
+ details: vuln.details || "",
193
+ fixedIn: fixedVersions[0] || null,
194
+ references: (vuln.references || []).map((r) => r.url).slice(0, 2),
195
+ });
196
+ }
197
+ });
198
+
199
+ // Sort: highest severity first, then alphabetically by package name
200
+ vulns.sort(
201
+ (a, b) =>
202
+ (SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0) ||
203
+ a.package.localeCompare(b.package)
204
+ );
205
+
206
+ return vulns;
207
+ }
208
+
209
+ // ─── AI enrichment (interpretation only β€” no web search) ─────────────────────
210
+ /**
211
+ * Claude (or any other AI model) receives only the verified OSV data and produces:
212
+ * - humanSummary: plain English explanation of the real risk
213
+ * - remediationStep: concrete actionable fix command
214
+ *
215
+ * No tools, no web search β€” Claude cannot invent vulnerabilities.
216
+ */
217
+ async function enrichWithAI(vulns) {
218
+ if (vulns.length === 0) return [];
219
+
220
+ const payload = vulns.map((v) => ({
221
+ id: v.id,
222
+ package: v.package,
223
+ installedVersion: v.version,
224
+ severity: v.severity,
225
+ summary: v.summary,
226
+ details: v.details.slice(0, 600), // keep prompt size reasonable
227
+ fixedIn: v.fixedIn,
228
+ aliases: v.aliases,
229
+ }));
230
+
231
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
232
+ method: "POST",
233
+ headers: {
234
+ "Content-Type": "application/json",
235
+ "x-api-key": ANTHROPIC_API_KEY,
236
+ "anthropic-version": "2023-06-01",
237
+ },
238
+ body: JSON.stringify({
239
+ model: "claude-sonnet-4-20250514",
240
+ max_tokens: 1000,
241
+ system: `You are a security advisor. You will receive structured vulnerability data sourced directly from the OSV.dev database.
242
+ Your only job is to interpret this data and produce output that is easier for developers to act on.
243
+ Do NOT invent, assume, or add any information not present in the input.
244
+ Respond ONLY with a valid JSON array (no markdown, no backticks, no preamble):
245
+ [
246
+ {
247
+ "id": "<OSV id from input>",
248
+ "humanSummary": "2-3 sentences in plain English describing the risk, attack vector, and potential impact",
249
+ "remediationStep": "A single specific command or action the developer should take (e.g. 'Run: npm install packageName@X.Y.Z')"
250
+ }
251
+ ]`,
252
+ messages: [
253
+ {
254
+ role: "user",
255
+ content: `Generate human-readable summaries and remediation steps for these verified vulnerabilities:\n\n${JSON.stringify(payload, null, 2)}`,
256
+ },
257
+ ],
258
+ }),
259
+ });
260
+
261
+ if (!res.ok) throw new Error(`Anthropic API ${res.status}: ${res.statusText}`);
262
+
263
+ const data = await res.json();
264
+ const text =
265
+ data.content
266
+ ?.filter((b) => b.type === "text")
267
+ .map((b) => b.text)
268
+ .join("") || "[]";
269
+
270
+ const clean = text.replace(/```json|```/g, "").trim();
271
+ return JSON.parse(clean);
272
+ }
273
+
274
+ // ─── Render table ─────────────────────────────────────────────────────────────
275
+ function renderTable(enriched) {
276
+ const table = new Table({
277
+ head: [
278
+ chalk.bold.white("Package"),
279
+ chalk.bold.white("Installed"),
280
+ chalk.bold.white("ID / CVE"),
281
+ chalk.bold.white("Severity"),
282
+ chalk.bold.white("Human Summary"),
283
+ chalk.bold.white("Remediation"),
284
+ ],
285
+ colWidths: [20, 11, 22, 12, 44, 34],
286
+ wordWrap: true,
287
+ style: { head: [], border: ["gray"] },
288
+ });
289
+
290
+ for (const v of enriched) {
291
+ const cveLabel = v.aliases?.length
292
+ ? chalk.gray("\n" + v.aliases.join(", "))
293
+ : "";
294
+
295
+ table.push([
296
+ chalk.bold.white(v.package),
297
+ chalk.gray(v.version),
298
+ chalk.cyan(v.id) + cveLabel,
299
+ colourSeverity(v.severity),
300
+ v.humanSummary || v.summary,
301
+ v.remediationStep ||
302
+ (v.fixedIn
303
+ ? chalk.green(`npm i ${v.package}@${v.fixedIn}`)
304
+ : chalk.gray("No fix available")),
305
+ ]);
306
+ }
307
+
308
+ console.log(table.toString());
309
+ }
310
+
311
+ // ─── .gitignore check ────────────────────────────────────────────────────────
312
+ function checkGitignore(dir) {
313
+ const gitignorePath = path.resolve(dir, ".gitignore");
314
+ if (!fs.existsSync(gitignorePath)) {
315
+ console.log(
316
+ chalk.yellow(" ⚠ No .gitignore found.\n") +
317
+ chalk.gray(' Create one and add .env:\n') +
318
+ chalk.white(' echo ".env" >> .gitignore\n')
319
+ );
320
+ return;
321
+ }
322
+ const content = fs.readFileSync(gitignorePath, "utf-8");
323
+ if (!content.split("\n").some((l) => l.trim() === ".env")) {
324
+ console.log(
325
+ chalk.yellow(" ⚠ .env is not in your .gitignore β€” your API key could be exposed!\n") +
326
+ chalk.white(' Fix it now: echo ".env" >> .gitignore\n')
327
+ );
328
+ }
329
+ }
330
+
331
+ // ─── Main ─────────────────────────────────────────────────────────────────────
332
+ async function main() {
333
+ console.log(
334
+ "\n" +
335
+ chalk.bold.cyan(" DepGuard") +
336
+ chalk.bold.gray(" v2") +
337
+ chalk.gray(" Β· Deterministic Hybrid Scanner\n") +
338
+ chalk.gray(` Lockfile : `) + chalk.white(lockPath) + "\n" +
339
+ chalk.gray(` Packages : `) + chalk.white(packageEntries.length) +
340
+ chalk.gray(` (lockfileVersion ${lockVersion})\n`)
341
+ );
342
+
343
+ checkGitignore(scanDir);
344
+
345
+ // ── Step 1: Query OSV.dev ─────────────────────────────────────────────────
346
+ const osvSpinner = ora({
347
+ text: chalk.gray(`Querying OSV.dev for ${packageEntries.length} packages…`),
348
+ color: "cyan",
349
+ }).start();
350
+
351
+ let osvResults;
352
+ try {
353
+ osvResults = await queryOSV(packageEntries);
354
+ osvSpinner.succeed(chalk.green("OSV.dev scan complete β€” deterministic results"));
355
+ } catch (err) {
356
+ osvSpinner.fail(chalk.red("OSV.dev query failed: " + err.message));
357
+ process.exit(1);
358
+ }
359
+
360
+ // ── Step 2: Build vuln list ───────────────────────────────────────────────
361
+ const vulns = buildVulnList(packageEntries, osvResults);
362
+
363
+ if (vulns.length === 0) {
364
+ console.log(
365
+ chalk.green(
366
+ "\n βœ” No vulnerabilities found" +
367
+ (opts.minSeverity !== "low" ? ` at or above ${opts.minSeverity} severity` : "") +
368
+ ".\n"
369
+ )
370
+ );
371
+ process.exit(0);
372
+ }
373
+
374
+ console.log(
375
+ chalk.gray(`\n Found `) +
376
+ chalk.bold.red(vulns.length) +
377
+ chalk.gray(` vulnerabilit${vulns.length === 1 ? "y" : "ies"}`) +
378
+ chalk.gray(" β€” sending to AI for interpretation…\n")
379
+ );
380
+
381
+ // ── Step 3: AI enrichment ─────────────────────────────────────────────────
382
+ const aiSpinner = ora({
383
+ text: chalk.gray("Generating human-readable summaries & remediation steps…"),
384
+ color: "cyan",
385
+ }).start();
386
+
387
+ let aiData = [];
388
+ try {
389
+ aiData = await enrichWithAI(vulns);
390
+ aiSpinner.succeed(chalk.green("AI interpretation complete"));
391
+ } catch (err) {
392
+ aiSpinner.warn(
393
+ chalk.yellow("AI enrichment failed β€” falling back to raw OSV summaries\n ") +
394
+ chalk.gray(err.message)
395
+ );
396
+ }
397
+
398
+ // Merge AI data into vuln objects
399
+ const aiMap = Object.fromEntries((aiData || []).map((a) => [a.id, a]));
400
+ const enriched = vulns.map((v) => ({
401
+ ...v,
402
+ humanSummary: aiMap[v.id]?.humanSummary || v.summary,
403
+ remediationStep:
404
+ aiMap[v.id]?.remediationStep ||
405
+ (v.fixedIn ? `Run: npm install ${v.package}@${v.fixedIn}` : "No fix available"),
406
+ }));
407
+
408
+ // ── Step 4: Output ────────────────────────────────────────────────────────
409
+ if (opts.json) {
410
+ console.log(JSON.stringify(enriched, null, 2));
411
+ return;
412
+ }
413
+
414
+ console.log();
415
+ renderTable(enriched);
416
+
417
+ // Summary bar
418
+ const counts = enriched.reduce((acc, v) => {
419
+ acc[v.severity] = (acc[v.severity] || 0) + 1;
420
+ return acc;
421
+ }, {});
422
+
423
+ const summaryParts = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"]
424
+ .filter((s) => counts[s])
425
+ .map((s) => colourSeverity(s) + chalk.gray(` Γ—${counts[s]}`));
426
+
427
+ console.log(
428
+ "\n " + chalk.bold("Summary ") + summaryParts.join(chalk.gray(" ")) + "\n"
429
+ );
430
+
431
+ // Advisory references
432
+ const withRefs = enriched.filter((v) => v.references?.length);
433
+ if (withRefs.length > 0) {
434
+ console.log(chalk.bold.gray(" Advisory Links"));
435
+ for (const v of withRefs) {
436
+ console.log(chalk.gray(` ${chalk.cyan(v.id)}`));
437
+ v.references.forEach((url) =>
438
+ console.log(chalk.gray(" β†’ ") + chalk.underline(url))
439
+ );
440
+ }
441
+ console.log();
442
+ }
443
+ }
444
+
445
+ main().catch((err) => {
446
+ console.error(chalk.red("\n Unexpected error: " + err.message));
447
+ process.exit(1);
448
+ });
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "osv-depguard",
3
+ "version": "1.0.0",
4
+ "description": "Scan npm dependencies for vulnerabilities via OSV.dev + AI summaries",
5
+ "type": "module",
6
+ "bin": {
7
+ "osv-depguard": "./depguard.js"
8
+ },
9
+ "engines": { "node": ">=18.0.0" },
10
+ "keywords": ["security", "vulnerability", "osv", "npm audit", "cli"],
11
+ "author": "Abbas Uddin",
12
+ "license": "MIT",
13
+ "homepage": "https://github.com/CodeAbbas/osv-depguard",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/CodeAbbas/osv-depguard.git"
17
+ },
18
+ "bugs": { "url": "https://github.com/CodeAbbas/osv-depguard/issues" },
19
+ "dependencies": {
20
+ "chalk": "^5.3.0",
21
+ "cli-table3": "^0.6.5",
22
+ "commander": "^12.0.0",
23
+ "dotenv": "^16.4.5",
24
+ "ora": "^8.0.1"
25
+ }
26
+ }