@hungpg/skill-audit 0.1.1 → 0.3.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/dist/hooks.js ADDED
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Hook Configuration for Claude Code
3
+ *
4
+ * Provides PreToolUse hook that audits skills before installation.
5
+ * Hook is triggered when user runs `npx skills add <package>`.
6
+ */
7
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from "fs";
8
+ import { join, dirname } from "path";
9
+ import { homedir } from "os";
10
+ // Default settings path for Claude Code
11
+ const CLAUDE_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
12
+ const CLAUDE_SETTINGS_BACKUP = join(homedir(), ".claude", "settings.json.backup");
13
+ const SKIP_HOOK_FILE = join(homedir(), ".skill-audit-skip-hook");
14
+ // Hook identifier for skill-audit
15
+ const HOOK_ID = "skill-audit-pre-install";
16
+ /**
17
+ * Get the default hook configuration
18
+ */
19
+ export function getDefaultHookConfig() {
20
+ return {
21
+ threshold: 3.0,
22
+ blockOnFailure: true
23
+ };
24
+ }
25
+ /**
26
+ * Generate the PreToolUse hook configuration
27
+ */
28
+ export function generateHookConfig(config = getDefaultHookConfig()) {
29
+ return {
30
+ hooks: {
31
+ PreToolUse: [
32
+ {
33
+ id: HOOK_ID,
34
+ matcher: {
35
+ toolName: "run_shell_command",
36
+ input: "npx skills add"
37
+ },
38
+ hooks: [
39
+ {
40
+ type: "command",
41
+ command: `skill-audit --mode audit --threshold ${config.threshold}${config.blockOnFailure ? " --block" : ""}`
42
+ }
43
+ ]
44
+ }
45
+ ]
46
+ }
47
+ };
48
+ }
49
+ /**
50
+ * Check if the skip hook file exists
51
+ */
52
+ export function shouldSkipHookPrompt() {
53
+ return existsSync(SKIP_HOOK_FILE);
54
+ }
55
+ /**
56
+ * Create the skip hook file
57
+ */
58
+ export function createSkipHookFile() {
59
+ writeFileSync(SKIP_HOOK_FILE, JSON.stringify({
60
+ createdAt: new Date().toISOString(),
61
+ reason: "User chose to skip hook installation prompt"
62
+ }, null, 2));
63
+ }
64
+ /**
65
+ * Remove the skip hook file
66
+ */
67
+ export function removeSkipHookFile() {
68
+ if (existsSync(SKIP_HOOK_FILE)) {
69
+ const fs = require("fs");
70
+ fs.unlinkSync(SKIP_HOOK_FILE);
71
+ }
72
+ }
73
+ /**
74
+ * Load existing settings.json
75
+ */
76
+ function loadSettings() {
77
+ if (!existsSync(CLAUDE_SETTINGS_PATH)) {
78
+ return {};
79
+ }
80
+ try {
81
+ const content = readFileSync(CLAUDE_SETTINGS_PATH, "utf-8");
82
+ return JSON.parse(content);
83
+ }
84
+ catch (e) {
85
+ console.error("Failed to parse existing settings.json:", e);
86
+ return {};
87
+ }
88
+ }
89
+ /**
90
+ * Backup existing settings.json
91
+ */
92
+ function backupSettings() {
93
+ if (!existsSync(CLAUDE_SETTINGS_PATH)) {
94
+ return true; // Nothing to backup
95
+ }
96
+ try {
97
+ // Ensure directory exists
98
+ const settingsDir = dirname(CLAUDE_SETTINGS_BACKUP);
99
+ if (!existsSync(settingsDir)) {
100
+ mkdirSync(settingsDir, { recursive: true });
101
+ }
102
+ copyFileSync(CLAUDE_SETTINGS_PATH, CLAUDE_SETTINGS_BACKUP);
103
+ return true;
104
+ }
105
+ catch (e) {
106
+ console.error("Failed to backup settings.json:", e);
107
+ return false;
108
+ }
109
+ }
110
+ /**
111
+ * Check if hook is already installed
112
+ */
113
+ export function isHookInstalled() {
114
+ const settings = loadSettings();
115
+ if (!settings.hooks || !Array.isArray(settings.hooks.PreToolUse)) {
116
+ return false;
117
+ }
118
+ // PreToolUse can be an array of arrays or array of objects
119
+ const preToolUseHooks = settings.hooks.PreToolUse;
120
+ for (const item of preToolUseHooks) {
121
+ // Handle nested array structure
122
+ if (Array.isArray(item)) {
123
+ if (item.some((h) => h.id === HOOK_ID)) {
124
+ return true;
125
+ }
126
+ }
127
+ else if (typeof item === "object" && item !== null) {
128
+ if (item.id === HOOK_ID) {
129
+ return true;
130
+ }
131
+ }
132
+ }
133
+ return false;
134
+ }
135
+ /**
136
+ * Install the PreToolUse hook
137
+ */
138
+ export function installHook(config = getDefaultHookConfig()) {
139
+ // Check if already installed
140
+ if (isHookInstalled()) {
141
+ return { success: true, message: "Hook is already installed" };
142
+ }
143
+ // Backup existing settings
144
+ if (!backupSettings()) {
145
+ return { success: false, message: "Failed to backup settings.json" };
146
+ }
147
+ // Load existing settings
148
+ const settings = loadSettings();
149
+ // Initialize hooks structure if not present
150
+ if (!settings.hooks) {
151
+ settings.hooks = {};
152
+ }
153
+ if (!settings.hooks.PreToolUse) {
154
+ settings.hooks.PreToolUse = [];
155
+ }
156
+ // Create the new hook object
157
+ const newHook = {
158
+ id: HOOK_ID,
159
+ matcher: {
160
+ toolName: "run_shell_command",
161
+ input: "npx skills add"
162
+ },
163
+ hooks: [
164
+ {
165
+ type: "command",
166
+ command: `skill-audit --mode audit --threshold ${config.threshold}${config.blockOnFailure ? " --block" : ""}`
167
+ }
168
+ ]
169
+ };
170
+ // Add the hook - wrap in array to match existing structure
171
+ const preToolUseHooks = settings.hooks.PreToolUse;
172
+ preToolUseHooks.push([newHook]);
173
+ // Ensure directory exists
174
+ const settingsDir = dirname(CLAUDE_SETTINGS_PATH);
175
+ if (!existsSync(settingsDir)) {
176
+ mkdirSync(settingsDir, { recursive: true });
177
+ }
178
+ // Write updated settings
179
+ try {
180
+ writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2));
181
+ return { success: true, message: `Hook installed successfully (threshold: ${config.threshold})` };
182
+ }
183
+ catch (e) {
184
+ // Restore backup on failure
185
+ if (existsSync(CLAUDE_SETTINGS_BACKUP)) {
186
+ copyFileSync(CLAUDE_SETTINGS_BACKUP, CLAUDE_SETTINGS_PATH);
187
+ }
188
+ return { success: false, message: `Failed to write settings: ${e}` };
189
+ }
190
+ }
191
+ /**
192
+ * Uninstall the PreToolUse hook
193
+ */
194
+ export function uninstallHook() {
195
+ const settings = loadSettings();
196
+ if (!settings.hooks || !Array.isArray(settings.hooks.PreToolUse)) {
197
+ return { success: true, message: "No hooks to remove" };
198
+ }
199
+ const preToolUseHooks = settings.hooks.PreToolUse;
200
+ const initialLength = preToolUseHooks.length;
201
+ // Filter out our hook (handles nested array structure)
202
+ const filteredHooks = preToolUseHooks.filter((item) => {
203
+ if (Array.isArray(item)) {
204
+ return !item.some((h) => h.id === HOOK_ID);
205
+ }
206
+ else if (typeof item === "object" && item !== null) {
207
+ return item.id !== HOOK_ID;
208
+ }
209
+ return true;
210
+ });
211
+ if (filteredHooks.length === initialLength) {
212
+ return { success: true, message: "Hook was not installed" };
213
+ }
214
+ // Backup before modification
215
+ if (!backupSettings()) {
216
+ return { success: false, message: "Failed to backup settings.json" };
217
+ }
218
+ // Update settings
219
+ settings.hooks.PreToolUse = filteredHooks;
220
+ // Remove hooks object if empty
221
+ if (filteredHooks.length === 0) {
222
+ delete settings.hooks.PreToolUse;
223
+ }
224
+ if (Object.keys(settings.hooks).length === 0) {
225
+ delete settings.hooks;
226
+ }
227
+ // Write updated settings
228
+ try {
229
+ writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2));
230
+ return { success: true, message: "Hook uninstalled successfully" };
231
+ }
232
+ catch (e) {
233
+ // Restore backup on failure
234
+ if (existsSync(CLAUDE_SETTINGS_BACKUP)) {
235
+ copyFileSync(CLAUDE_SETTINGS_BACKUP, CLAUDE_SETTINGS_PATH);
236
+ }
237
+ return { success: false, message: `Failed to write settings: ${e}` };
238
+ }
239
+ }
240
+ /**
241
+ * Get hook status
242
+ */
243
+ export function getHookStatus() {
244
+ const settings = loadSettings();
245
+ if (!settings.hooks || !Array.isArray(settings.hooks.PreToolUse)) {
246
+ return { installed: false, settingsPath: CLAUDE_SETTINGS_PATH };
247
+ }
248
+ const preToolUseHooks = settings.hooks.PreToolUse;
249
+ // Find the hook in nested array structure
250
+ let hook;
251
+ for (const item of preToolUseHooks) {
252
+ if (Array.isArray(item)) {
253
+ hook = item.find((h) => h.id === HOOK_ID);
254
+ if (hook)
255
+ break;
256
+ }
257
+ else if (typeof item === "object" && item !== null) {
258
+ if (item.id === HOOK_ID) {
259
+ hook = item;
260
+ break;
261
+ }
262
+ }
263
+ }
264
+ if (!hook) {
265
+ return { installed: false, settingsPath: CLAUDE_SETTINGS_PATH };
266
+ }
267
+ // Extract config from hook command
268
+ const hookHooks = hook.hooks;
269
+ const command = hookHooks[0].command;
270
+ const thresholdMatch = command.match(/--threshold\s+([\d.]+)/);
271
+ const threshold = thresholdMatch ? parseFloat(thresholdMatch[1]) : 3.0;
272
+ const blockOnFailure = command.includes("--block");
273
+ return {
274
+ installed: true,
275
+ config: { threshold, blockOnFailure },
276
+ settingsPath: CLAUDE_SETTINGS_PATH
277
+ };
278
+ }
package/dist/index.js CHANGED
@@ -5,14 +5,15 @@ import { auditSecurity } from "./security.js";
5
5
  import { validateSkillSpec } from "./spec.js";
6
6
  import { createGroupedAuditResult } from "./scoring.js";
7
7
  import { scanDependencies } from "./deps.js";
8
- import { getKEV, getEPSS, isCacheStale } from "./intel.js";
8
+ import { getKEV, getEPSS, getNVD, isCacheStale, downloadOfflineDB } from "./intel.js";
9
+ import { installHook, uninstallHook, getHookStatus, getDefaultHookConfig } from "./hooks.js";
9
10
  import { writeFileSync } from "fs";
10
11
  // Build CLI - no subcommands, just options + action
11
12
  const program = new Command();
12
13
  program
13
- .name("skills-audit")
14
+ .name("skill-audit")
14
15
  .description("Security auditing CLI for AI agent skills")
15
- .version("0.1.0")
16
+ .version("0.3.0")
16
17
  .option("-g, --global", "Audit global skills only (default: true)")
17
18
  .option("-p, --project", "Audit project-level skills only")
18
19
  .option("-a, --agent <agents...>", "Filter by specific agents")
@@ -23,16 +24,73 @@ program
23
24
  .option("--no-deps", "Skip dependency scanning (faster)")
24
25
  .option("--mode <mode>", "Audit mode: 'lint' (spec only) or 'audit' (full)", "audit")
25
26
  .option("--update-db", "Update advisory intelligence feeds")
26
- .option("--source <sources...>", "Sources for update-db: kev, epss, all", ["all"])
27
+ .option("--source <sources...>", "Sources for update-db: kev, epss, nvd, all", ["all"])
27
28
  .option("--strict", "Fail if feeds are stale")
28
- .option("--quiet", "Suppress non-error output");
29
+ .option("--quiet", "Suppress non-error output")
30
+ .option("--download-offline-db <dir>", "Download offline vulnerability databases to directory")
31
+ .option("--install-hook", "Install PreToolUse hook for automatic skill auditing")
32
+ .option("--uninstall-hook", "Remove the PreToolUse hook")
33
+ .option("--hook-threshold <score>", "Risk threshold for hook (default: 3.0)", parseFloat)
34
+ .option("--hook-status", "Show current hook status")
35
+ .option("--block", "Exit with code 1 if threshold exceeded (for hooks)");
29
36
  program.parse(process.argv);
30
37
  const options = program.opts();
38
+ // Handle download-offline-db action
39
+ if (options.downloadOfflineDb) {
40
+ await downloadOfflineDB(options.downloadOfflineDb);
41
+ process.exit(0);
42
+ }
31
43
  // Handle update-db action
32
44
  if (options.updateDb) {
33
45
  await updateAdvisoryDB({ source: options.source, strict: options.strict });
34
46
  process.exit(0);
35
47
  }
48
+ // Handle hook-status action
49
+ if (options.hookStatus) {
50
+ const status = getHookStatus();
51
+ console.log("\nšŸŖ skill-audit Hook Status\n");
52
+ console.log(` Installed: ${status.installed ? "āœ… Yes" : "āŒ No"}`);
53
+ if (status.installed && status.config) {
54
+ console.log(` Threshold: ${status.config.threshold}`);
55
+ console.log(` Block on failure: ${status.config.blockOnFailure ? "Yes" : "No"}`);
56
+ }
57
+ console.log(` Settings file: ${status.settingsPath}\n`);
58
+ process.exit(0);
59
+ }
60
+ // Handle install-hook action
61
+ if (options.installHook) {
62
+ const config = getDefaultHookConfig();
63
+ if (options.hookThreshold) {
64
+ config.threshold = options.hookThreshold;
65
+ }
66
+ config.blockOnFailure = true;
67
+ console.log("\nšŸŖ Installing skill-audit hook...\n");
68
+ const result = installHook(config);
69
+ if (result.success) {
70
+ console.log(`āœ… ${result.message}`);
71
+ console.log(` Settings file: ${getHookStatus().settingsPath}`);
72
+ console.log("\n Skills will now be audited before installation.");
73
+ console.log(" Run 'skill-audit --uninstall-hook' to remove.\n");
74
+ }
75
+ else {
76
+ console.error(`āŒ ${result.message}`);
77
+ process.exit(1);
78
+ }
79
+ process.exit(0);
80
+ }
81
+ // Handle uninstall-hook action
82
+ if (options.uninstallHook) {
83
+ console.log("\nšŸŖ Removing skill-audit hook...\n");
84
+ const result = uninstallHook();
85
+ if (result.success) {
86
+ console.log(`āœ… ${result.message}\n`);
87
+ }
88
+ else {
89
+ console.error(`āŒ ${result.message}`);
90
+ process.exit(1);
91
+ }
92
+ process.exit(0);
93
+ }
36
94
  // Default to global skills
37
95
  const scope = options.project ? "project" : "global";
38
96
  const mode = options.mode || "audit";
@@ -72,10 +130,11 @@ reportGroupedResults(results, {
72
130
  output: options.output,
73
131
  verbose: options.verbose,
74
132
  threshold: options.threshold,
75
- mode
133
+ mode,
134
+ block: options.block
76
135
  });
77
136
  async function updateAdvisoryDB(opts) {
78
- const sources = opts.source.includes("all") ? ["kev", "epss"] : opts.source;
137
+ const sources = opts.source.includes("all") ? ["kev", "epss", "nvd"] : opts.source;
79
138
  const quiet = program.opts().quiet;
80
139
  if (!quiet) {
81
140
  console.log("šŸ“„ Updating advisory intelligence feeds...\n");
@@ -98,6 +157,12 @@ async function updateAdvisoryDB(opts) {
98
157
  console.log(` āœ“ EPSS: ${result.findings.length} scores cached (stale: ${result.stale})`);
99
158
  }
100
159
  }
160
+ else if (source === "nvd") {
161
+ const result = await getNVD();
162
+ if (!quiet) {
163
+ console.log(` āœ“ NVD: ${result.findings.length} CVEs cached (stale: ${result.stale})`);
164
+ }
165
+ }
101
166
  }
102
167
  catch (e) {
103
168
  console.error(` āœ— Failed to fetch ${source}:`, e);
@@ -112,7 +177,7 @@ async function updateAdvisoryDB(opts) {
112
177
  }
113
178
  }
114
179
  function reportGroupedResults(results, options) {
115
- const { json, output, verbose, threshold, mode } = options;
180
+ const { json, output, verbose, threshold, mode, block } = options;
116
181
  // Export to file if specified
117
182
  if (output) {
118
183
  const report = {
@@ -159,8 +224,16 @@ function reportGroupedResults(results, options) {
159
224
  // Check cache freshness and warn if stale
160
225
  const kevStale = isCacheStale("kev");
161
226
  const epssStale = isCacheStale("epss");
162
- if (!options.json && (kevStale.warn || epssStale.warn)) {
163
- console.log(`\nāš ļø Vulnerability DB is stale (${kevStale.age?.toFixed(1)} days for KEV, ${epssStale.age?.toFixed(1)} days for EPSS)`);
227
+ const nvdStale = isCacheStale("nvd");
228
+ if (!options.json && (kevStale.warn || epssStale.warn || nvdStale.warn)) {
229
+ const ages = [];
230
+ if (kevStale.age)
231
+ ages.push(`${kevStale.age.toFixed(1)} days for KEV`);
232
+ if (epssStale.age)
233
+ ages.push(`${epssStale.age.toFixed(1)} days for EPSS`);
234
+ if (nvdStale.age)
235
+ ages.push(`${nvdStale.age.toFixed(1)} days for NVD`);
236
+ console.log(`\nāš ļø Vulnerability DB is stale (${ages.join(", ")})`);
164
237
  console.log(` Run: npx skill-audit --update-db`);
165
238
  }
166
239
  if (threshold !== undefined) {
@@ -170,6 +243,10 @@ function reportGroupedResults(results, options) {
170
243
  for (const f of failing) {
171
244
  console.log(` - ${f.skill.name}: ${f.riskScore}`);
172
245
  }
246
+ // Exit with error code if block flag is set
247
+ if (block) {
248
+ process.exit(1);
249
+ }
173
250
  }
174
251
  else {
175
252
  console.log(`\nāœ… All skills pass threshold ${threshold}`);
package/dist/intel.js CHANGED
@@ -8,6 +8,8 @@ const METRICS_FILE = join(PACKAGE_ROOT, ".cache/skill-audit/metrics.json");
8
8
  // Cache configuration - differentiated by source update frequency
9
9
  const MAX_CACHE_AGE_DAYS = {
10
10
  kev: 1, // Daily updates - critical for actively exploited vulns
11
+ nvd: 1, // Daily - official NVD database updates frequently
12
+ ghsa: 3, // 3 days - GitHub Security Advisories
11
13
  epss: 3, // Matches FIRST.org update cycle
12
14
  osv: 7 // Stable database - weekly acceptable
13
15
  };
@@ -15,6 +17,21 @@ const WARN_CACHE_AGE_DAYS = 3;
15
17
  const FETCH_TIMEOUT_MS = 30000; // 30 seconds
16
18
  const MAX_RETRIES = 3;
17
19
  const RETRY_DELAY_MS = 1000; // Base delay for exponential backoff
20
+ // Map internal ecosystem names to GitHub GraphQL enum values
21
+ const GHSA_ECOSYSTEM_MAP = {
22
+ 'npm': 'NPM',
23
+ 'PyPI': 'PIP',
24
+ 'pypi': 'PIP',
25
+ 'crates.io': 'RUST',
26
+ 'RubyGems': 'RUBYGEMS',
27
+ 'Maven': 'MAVEN',
28
+ 'Packagist': 'COMPOSER',
29
+ 'Go': 'GO',
30
+ 'NuGet': 'NUGET',
31
+ 'Pub': 'PUB',
32
+ 'Hex': 'ERLANG',
33
+ 'SwiftURL': 'SWIFT',
34
+ };
18
35
  /**
19
36
  * Ensure cache directory exists
20
37
  */
@@ -101,6 +118,12 @@ function recordFetchResult(source, count, durationMs, error) {
101
118
  else if (source === 'epss') {
102
119
  metrics.epssCount = count;
103
120
  }
121
+ else if (source === 'nvd') {
122
+ metrics.nvdCount = count;
123
+ }
124
+ else if (source === 'ghsa') {
125
+ metrics.ghsaCount = count;
126
+ }
104
127
  if (error) {
105
128
  metrics.errors.push(`${source}: ${error}`);
106
129
  // Keep only last 10 errors
@@ -293,7 +316,7 @@ export async function queryGHSA(ecosystem, packageName) {
293
316
  }
294
317
  `,
295
318
  variables: {
296
- ecosystem: ecosystem.toUpperCase(),
319
+ ecosystem: GHSA_ECOSYSTEM_MAP[ecosystem] || ecosystem.toUpperCase(),
297
320
  package: packageName
298
321
  }
299
322
  })
@@ -386,6 +409,80 @@ export async function fetchEPSS() {
386
409
  return [];
387
410
  }
388
411
  }
412
+ /**
413
+ * Fetch NIST NVD (National Vulnerability Database)
414
+ * Uses NVD API v2.0 with CVSS scoring
415
+ * API: https://nvd.nist.gov/developers/vulnerabilities
416
+ */
417
+ export async function fetchNVD() {
418
+ const startTime = Date.now();
419
+ const apiKey = process.env.NVD_API_KEY;
420
+ // Calculate date range for last 24 hours
421
+ const now = new Date();
422
+ const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
423
+ // NVD API requires ISO8601 format without milliseconds
424
+ const formatDate = (date) => date.toISOString().replace(/\.\d{3}Z$/, 'Z');
425
+ const lastModStartDate = formatDate(yesterday);
426
+ const lastModEndDate = formatDate(now);
427
+ const url = `https://services.nvd.nist.gov/rest/json/cves/2.0?lastModStartDate=${lastModStartDate}&lastModEndDate=${lastModEndDate}`;
428
+ try {
429
+ const headers = {
430
+ 'User-Agent': 'skill-audit/0.1.0 (Vulnerability Intelligence Scanner)'
431
+ };
432
+ if (apiKey) {
433
+ headers['apiKey'] = apiKey;
434
+ }
435
+ const response = await fetchWithRetry(url, FETCH_TIMEOUT_MS, { headers });
436
+ const data = await response.json();
437
+ if (!data.vulnerabilities) {
438
+ recordFetchResult('nvd', 0, Date.now() - startTime, 'No vulnerabilities in response');
439
+ return [];
440
+ }
441
+ const records = data.vulnerabilities.map(v => {
442
+ // Extract CVSS score (prefer v3.1, fallback to v3.0)
443
+ let cvss;
444
+ let cvssVector;
445
+ let severity;
446
+ if (v.cve.metrics?.cvssMetricV31?.[0]?.cvssData) {
447
+ const cvss31 = v.cve.metrics.cvssMetricV31[0].cvssData;
448
+ cvss = cvss31.baseScore;
449
+ cvssVector = cvss31.vectorString;
450
+ severity = cvss31.baseSeverity;
451
+ }
452
+ else if (v.cve.metrics?.cvssMetricV30?.[0]?.cvssData) {
453
+ const cvss30 = v.cve.metrics.cvssMetricV30[0].cvssData;
454
+ cvss = cvss30.baseScore;
455
+ cvssVector = cvss30.vectorString;
456
+ severity = cvss30.baseSeverity;
457
+ }
458
+ // Extract CWE
459
+ const cwe = v.cve.weaknesses?.[0]?.description?.map(d => d.value) || [];
460
+ // Extract description as summary
461
+ const summary = v.cve.descriptions?.find(d => d.lang === 'en')?.value;
462
+ return {
463
+ id: v.cve.id,
464
+ aliases: [v.cve.id],
465
+ source: "NVD",
466
+ severity,
467
+ cvss,
468
+ cvssVector,
469
+ cwe,
470
+ published: v.cve.published,
471
+ modified: v.cve.lastModified,
472
+ summary,
473
+ references: v.cve.references?.map(r => r.url) || []
474
+ };
475
+ });
476
+ recordFetchResult('nvd', records.length, Date.now() - startTime);
477
+ return records;
478
+ }
479
+ catch (error) {
480
+ const errorMsg = error instanceof Error ? error.message : 'Unknown error';
481
+ recordFetchResult('nvd', 0, Date.now() - startTime, errorMsg);
482
+ console.error(`NVD fetch failed:`, error);
483
+ return [];
484
+ }
485
+ }
389
486
  /**
390
487
  * Query vulnerability intelligence for a package
391
488
  */
@@ -443,6 +540,48 @@ export async function getEPSS() {
443
540
  warn
444
541
  };
445
542
  }
543
+ /**
544
+ * Get NVD vulnerabilities (enriched)
545
+ */
546
+ export async function getNVD() {
547
+ const { stale, age, warn } = isCacheStale("nvd");
548
+ let records = loadFromCache("nvd");
549
+ if (records.length === 0 || stale) {
550
+ records = await fetchNVD();
551
+ if (records.length > 0) {
552
+ saveToCache("nvd", records);
553
+ }
554
+ }
555
+ return {
556
+ findings: records,
557
+ cacheAge: age,
558
+ stale,
559
+ warn
560
+ };
561
+ }
562
+ /**
563
+ * Get GHSA advisories (enriched)
564
+ */
565
+ export async function getGHSA() {
566
+ const { stale, age, warn } = isCacheStale("ghsa");
567
+ let records = loadFromCache("ghsa");
568
+ if (records.length === 0 || stale) {
569
+ // GHSA doesn't have a bulk feed - would need to query per-package
570
+ // For now, return empty - GHSA integration is via queryGHSA() per-package
571
+ return {
572
+ findings: [],
573
+ cacheAge: age,
574
+ stale,
575
+ warn
576
+ };
577
+ }
578
+ return {
579
+ findings: records,
580
+ cacheAge: age,
581
+ stale,
582
+ warn
583
+ };
584
+ }
446
585
  /**
447
586
  * Merge advisory records by alias
448
587
  */
@@ -484,3 +623,71 @@ export function prioritizeRecords(records) {
484
623
  return 0;
485
624
  });
486
625
  }
626
+ /**
627
+ * Download offline vulnerability databases
628
+ * @param outputDir - Directory to save offline databases
629
+ * @returns Object with download statistics
630
+ */
631
+ export async function downloadOfflineDB(outputDir) {
632
+ const results = {
633
+ kev: { success: false, count: 0 },
634
+ epss: { success: false, count: 0 },
635
+ nvd: { success: false, count: 0 },
636
+ osv: { success: false, message: '' }
637
+ };
638
+ try {
639
+ // Ensure output directory exists
640
+ if (!existsSync(outputDir)) {
641
+ mkdirSync(outputDir, { recursive: true });
642
+ }
643
+ // Download KEV
644
+ console.log('šŸ“„ Downloading CISA KEV...');
645
+ const kevRecords = await fetchKEV();
646
+ if (kevRecords.length > 0) {
647
+ writeFileSync(join(outputDir, 'kev.json'), JSON.stringify({ fetchedAt: new Date().toISOString(), records: kevRecords }, null, 2));
648
+ results.kev = { success: true, count: kevRecords.length };
649
+ console.log(` āœ“ KEV: ${kevRecords.length} vulnerabilities`);
650
+ }
651
+ // Download EPSS
652
+ console.log('šŸ“„ Downloading EPSS scores...');
653
+ const epssRecords = await fetchEPSS();
654
+ if (epssRecords.length > 0) {
655
+ writeFileSync(join(outputDir, 'epss.json'), JSON.stringify({ fetchedAt: new Date().toISOString(), records: epssRecords }, null, 2));
656
+ results.epss = { success: true, count: epssRecords.length };
657
+ console.log(` āœ“ EPSS: ${epssRecords.length} scores`);
658
+ }
659
+ // Download NVD
660
+ console.log('šŸ“„ Downloading NIST NVD...');
661
+ const nvdRecords = await fetchNVD();
662
+ if (nvdRecords.length > 0) {
663
+ writeFileSync(join(outputDir, 'nvd.json'), JSON.stringify({ fetchedAt: new Date().toISOString(), records: nvdRecords }, null, 2));
664
+ results.nvd = { success: true, count: nvdRecords.length };
665
+ console.log(` āœ“ NVD: ${nvdRecords.length} CVEs`);
666
+ }
667
+ // Note: OSV is query-based, not a bulk download
668
+ // Users would need to query OSV API per-package
669
+ results.osv = {
670
+ success: true,
671
+ message: 'OSV uses on-demand API queries (not bulk download). Use OSV CLI for offline scanning.'
672
+ };
673
+ console.log(' ā„¹ļø OSV: Query-based API (use --update-db for caching)');
674
+ // Save metadata
675
+ const metadata = {
676
+ downloadedAt: new Date().toISOString(),
677
+ sources: results,
678
+ cacheAges: {
679
+ kev: MAX_CACHE_AGE_DAYS.kev,
680
+ epss: MAX_CACHE_AGE_DAYS.epss,
681
+ nvd: MAX_CACHE_AGE_DAYS.nvd,
682
+ osv: MAX_CACHE_AGE_DAYS.osv
683
+ }
684
+ };
685
+ writeFileSync(join(outputDir, 'metadata.json'), JSON.stringify(metadata, null, 2));
686
+ console.log('\nāœ… Offline databases downloaded to:', outputDir);
687
+ }
688
+ catch (error) {
689
+ console.error('āŒ Download failed:', error);
690
+ results.osv.message = error instanceof Error ? error.message : 'Download error';
691
+ }
692
+ return results;
693
+ }