@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/README.md +122 -2
- package/SKILL.md +63 -7
- package/dist/deps.js +238 -40
- package/dist/hooks.js +278 -0
- package/dist/index.js +87 -10
- package/dist/intel.js +208 -1
- package/dist/patterns.js +67 -0
- package/dist/security.js +96 -15
- package/package.json +4 -2
- package/rules/default-patterns.json +99 -0
- package/scripts/postinstall.cjs +190 -0
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("
|
|
14
|
+
.name("skill-audit")
|
|
14
15
|
.description("Security auditing CLI for AI agent skills")
|
|
15
|
-
.version("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
|
-
|
|
163
|
-
|
|
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
|
+
}
|