@hungpg/skill-audit 0.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/dist/deps.js ADDED
@@ -0,0 +1,408 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { readdirSync, existsSync, realpathSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { resolveSkillPath } from './discover.js';
5
+ // Map OSV ecosystem names to our package managers
6
+ const OSV_ECOSYSTEMS = {
7
+ 'npm': 'npm',
8
+ 'PyPI': 'python',
9
+ 'pypi': 'python',
10
+ 'Go': 'go',
11
+ 'crates.io': 'rust',
12
+ 'Maven': 'java',
13
+ 'maven': 'java',
14
+ 'RubyGems': 'ruby',
15
+ 'Packagist': 'php',
16
+ 'Pub': 'dart',
17
+ };
18
+ // Check if a scanner is available
19
+ function isScannerAvailable(scanner) {
20
+ try {
21
+ execFileSync('which', [scanner], { stdio: 'ignore' });
22
+ return true;
23
+ }
24
+ catch (e) {
25
+ return false;
26
+ }
27
+ }
28
+ // Map OSV severity to our severity levels
29
+ function mapOSVSeverity(severity) {
30
+ const s = severity?.toUpperCase() || '';
31
+ if (s.includes('CRITICAL') || s.includes('HIGH'))
32
+ return 'high';
33
+ if (s.includes('MEDIUM'))
34
+ return 'medium';
35
+ return 'low';
36
+ }
37
+ // Scan with Trivy
38
+ function scanWithTrivy(resolvedPath) {
39
+ const findings = [];
40
+ if (!isScannerAvailable('trivy')) {
41
+ return findings;
42
+ }
43
+ try {
44
+ const output = execFileSync('trivy', ['fs', '--format', 'json', '--severity', 'HIGH,CRITICAL', resolvedPath], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
45
+ const result = JSON.parse(output);
46
+ if (result.Results && result.Results.length > 0) {
47
+ for (const target of result.Results) {
48
+ if (target.Vulnerabilities && target.Vulnerabilities.length > 0) {
49
+ for (const vuln of target.Vulnerabilities) {
50
+ const severity = vuln.Severity === 'CRITICAL' ? 'critical' :
51
+ vuln.Severity === 'HIGH' ? 'high' : 'medium';
52
+ findings.push({
53
+ id: 'VULN-' + vuln.VulnerabilityID,
54
+ category: 'SC',
55
+ asixx: 'ASI04',
56
+ severity,
57
+ file: target.Target,
58
+ message: '[Trivy] Dependency vulnerability in ' + vuln.PackageName + ': ' + vuln.Title,
59
+ evidence: vuln.VulnerabilityID
60
+ });
61
+ }
62
+ }
63
+ }
64
+ }
65
+ }
66
+ catch (e) {
67
+ // Convert scanner failure to explicit finding for observability
68
+ findings.push({
69
+ id: 'SCAN-TRIVY-01',
70
+ category: 'SC',
71
+ asixx: 'ASI04',
72
+ severity: 'low',
73
+ file: resolvedPath,
74
+ message: 'Trivy scan completed with issues: ' + (e.message || String(e).slice(0, 100)),
75
+ evidence: e.stack || String(e)
76
+ });
77
+ }
78
+ return findings;
79
+ }
80
+ // Scan with OSV Scanner (Google's OSV.dev)
81
+ function scanWithOSV(resolvedPath) {
82
+ const findings = [];
83
+ if (!isScannerAvailable('osv-scanner')) {
84
+ return findings;
85
+ }
86
+ try {
87
+ // OSV Scanner can scan directories directly
88
+ const output = execFileSync('osv-scanner', ['--json', '-r', resolvedPath], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
89
+ const result = JSON.parse(output);
90
+ if (result.results && result.results.length > 0) {
91
+ for (const scanResult of result.results) {
92
+ if (scanResult.packages) {
93
+ for (const pkg of scanResult.packages) {
94
+ if (pkg.vulnerabilities && pkg.vulnerabilities.length > 0) {
95
+ for (const vuln of pkg.vulnerabilities) {
96
+ findings.push({
97
+ id: 'VULN-' + vuln.id,
98
+ category: 'SC',
99
+ asixx: 'ASI04',
100
+ severity: mapOSVSeverity(vuln.severity),
101
+ file: resolvedPath,
102
+ message: '[OSV] Vulnerability in ' + pkg.package.name +
103
+ (pkg.package.version ? '@' + pkg.package.version : '') + ': ' +
104
+ (vuln.summary || vuln.id),
105
+ evidence: vuln.id
106
+ });
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+ }
113
+ }
114
+ catch (e) {
115
+ findings.push({
116
+ id: 'SCAN-OSV-01',
117
+ category: 'SC',
118
+ asixx: 'ASI04',
119
+ severity: 'low',
120
+ file: resolvedPath,
121
+ message: 'OSV scan completed with issues: ' + (e.message || String(e).slice(0, 100)),
122
+ evidence: e.stack || String(e)
123
+ });
124
+ }
125
+ return findings;
126
+ }
127
+ // Scan with OSV using lockfile input (more precise)
128
+ function scanWithOSVLockfile(resolvedPath) {
129
+ const findings = [];
130
+ if (!isScannerAvailable('osv-scanner')) {
131
+ return findings;
132
+ }
133
+ const lockfiles = [
134
+ 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock',
135
+ 'requirements.txt', 'Pipfile.lock', 'poetry.lock',
136
+ 'go.sum', 'go.mod', 'Cargo.lock', 'Gemfile.lock'
137
+ ];
138
+ const files = readdirSync(resolvedPath);
139
+ const foundLockfiles = files.filter(f => lockfiles.includes(f));
140
+ for (const lockfile of foundLockfiles) {
141
+ try {
142
+ const output = execFileSync('osv-scanner', ['--json', '-r', join(resolvedPath, lockfile)], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
143
+ const result = JSON.parse(output);
144
+ if (result.results && result.results.length > 0) {
145
+ for (const scanResult of result.results) {
146
+ if (scanResult.packages) {
147
+ for (const pkg of scanResult.packages) {
148
+ if (pkg.vulnerabilities && pkg.vulnerabilities.length > 0) {
149
+ for (const vuln of pkg.vulnerabilities) {
150
+ findings.push({
151
+ id: 'VULN-' + vuln.id,
152
+ category: 'SC',
153
+ asixx: 'ASI04',
154
+ severity: mapOSVSeverity(vuln.severity),
155
+ file: lockfile,
156
+ message: '[OSV-LOCK] Vulnerability in ' + pkg.package.name +
157
+ (pkg.package.version ? '@' + pkg.package.version : '') + ': ' +
158
+ (vuln.summary || vuln.id),
159
+ evidence: vuln.id
160
+ });
161
+ }
162
+ }
163
+ }
164
+ }
165
+ }
166
+ }
167
+ }
168
+ catch (e) {
169
+ findings.push({
170
+ id: 'SCAN-OSV-LOCK-01',
171
+ category: 'SC',
172
+ asixx: 'ASI04',
173
+ severity: 'low',
174
+ file: lockfile,
175
+ message: 'OSV lockfile scan failed: ' + (e.message || String(e).slice(0, 100)),
176
+ evidence: e.stack || String(e)
177
+ });
178
+ }
179
+ }
180
+ return findings;
181
+ }
182
+ // Query OSV.dev API directly for vulnerabilities (no CLI needed)
183
+ function scanWithOSVAPI(resolvedPath) {
184
+ const findings = [];
185
+ // Parse lockfiles to get packages
186
+ const packages = extractPackagesFromLockfiles(resolvedPath);
187
+ if (packages.length === 0) {
188
+ return findings;
189
+ }
190
+ // Query OSV API in batches (max 1000 per request)
191
+ const batchSize = 100;
192
+ for (let i = 0; i < packages.length; i += batchSize) {
193
+ const batch = packages.slice(i, i + batchSize);
194
+ try {
195
+ // Query using OSV batch API
196
+ const query = {
197
+ queries: batch.map(pkg => ({
198
+ package: {
199
+ name: pkg.name,
200
+ ecosystem: pkg.ecosystem
201
+ },
202
+ version: pkg.version
203
+ }))
204
+ };
205
+ const response = execFileSync('curl', [
206
+ '-s', '-X', 'POST',
207
+ 'https://api.osv.dev/v1/querybatch',
208
+ '-H', 'Content-Type: application/json',
209
+ '-d', JSON.stringify(query)
210
+ ], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
211
+ const result = JSON.parse(response);
212
+ if (result.results) {
213
+ for (const queryResult of result.results) {
214
+ if (queryResult.vulns && queryResult.vulns.length > 0) {
215
+ for (const vuln of queryResult.vulns) {
216
+ // Get the package name from the query
217
+ const pkgInfo = batch.find(p => queryResult.vulns?.some(v => v.affected?.some(a => a.package.name === p.name)));
218
+ findings.push({
219
+ id: 'VULN-' + vuln.id,
220
+ category: 'SC',
221
+ asixx: 'ASI04',
222
+ severity: mapOSVSeverity(vuln.severity?.[0]?.type),
223
+ file: resolvedPath,
224
+ message: '[OSV-API] Vulnerability in ' + (pkgInfo?.name || 'unknown') +
225
+ (pkgInfo?.version ? '@' + pkgInfo.version : '') + ': ' +
226
+ (vuln.summary || vuln.id),
227
+ evidence: vuln.id
228
+ });
229
+ }
230
+ }
231
+ }
232
+ }
233
+ }
234
+ catch (e) {
235
+ // OSV API failure is OK - it's a fallback, but log for observability
236
+ findings.push({
237
+ id: 'SCAN-OSVAPI-01',
238
+ category: 'SC',
239
+ asixx: 'ASI04',
240
+ severity: 'low',
241
+ file: resolvedPath,
242
+ message: 'OSV API query failed: ' + (e.message || String(e).slice(0, 100)),
243
+ evidence: e.stack || String(e)
244
+ });
245
+ }
246
+ }
247
+ return findings;
248
+ }
249
+ // Extract packages from lockfiles for OSV API query
250
+ function extractPackagesFromLockfiles(resolvedPath) {
251
+ const packages = [];
252
+ try {
253
+ const files = readdirSync(resolvedPath);
254
+ // Parse package-lock.json
255
+ const pkgLock = files.find(f => f === 'package-lock.json');
256
+ if (pkgLock) {
257
+ const content = JSON.parse(readFileSync(join(resolvedPath, pkgLock), 'utf-8'));
258
+ if (content.packages) {
259
+ for (const [path, pkg] of Object.entries(content.packages)) {
260
+ const p = pkg;
261
+ if (p.version && path !== '') {
262
+ // Extract package name from path
263
+ const name = path.split('node_modules/').pop()?.split('/')[0];
264
+ if (name) {
265
+ packages.push({ name, version: p.version.replace(/^\^|~/, ''), ecosystem: 'npm' });
266
+ }
267
+ }
268
+ }
269
+ }
270
+ }
271
+ // Parse requirements.txt
272
+ const reqTxt = files.find(f => f === 'requirements.txt');
273
+ if (reqTxt) {
274
+ const content = readFileSync(join(resolvedPath, reqTxt), 'utf-8');
275
+ for (const line of content.split('\n')) {
276
+ const match = line.match(/^([a-zA-Z0-9_-]+)([=<>!~]+)(.+)$/);
277
+ if (match) {
278
+ packages.push({ name: match[1], version: match[3].trim(), ecosystem: 'PyPI' });
279
+ }
280
+ }
281
+ }
282
+ // Parse go.mod
283
+ const goMod = files.find(f => f === 'go.mod');
284
+ if (goMod) {
285
+ const content = readFileSync(join(resolvedPath, goMod), 'utf-8');
286
+ for (const line of content.split('\n')) {
287
+ const match = line.match(/^\s+([a-zA-Z0-9\/]+)\s+v?(.+)$/);
288
+ if (match && !match[1].startsWith('gopkg.in') && !match[1].startsWith('github.com/')) {
289
+ packages.push({ name: match[1], version: match[2].replace(/^v/, ''), ecosystem: 'Go' });
290
+ }
291
+ }
292
+ }
293
+ // Parse Cargo.lock
294
+ const cargoLock = files.find(f => f === 'Cargo.lock');
295
+ if (cargoLock) {
296
+ const content = JSON.parse(readFileSync(join(resolvedPath, cargoLock), 'utf-8'));
297
+ if (content.package) {
298
+ for (const pkg of content.package) {
299
+ if (pkg.name && pkg.version) {
300
+ packages.push({ name: pkg.name, version: pkg.version, ecosystem: 'crates.io' });
301
+ }
302
+ }
303
+ }
304
+ }
305
+ }
306
+ catch (e) {
307
+ // Ignore parse errors
308
+ }
309
+ return packages;
310
+ }
311
+ export function scanDependencies(skillPath) {
312
+ const findings = [];
313
+ let resolvedPath;
314
+ try {
315
+ resolvedPath = resolveSkillPath(skillPath);
316
+ resolvedPath = realpathSync(resolvedPath);
317
+ }
318
+ catch (e) {
319
+ findings.push({
320
+ id: 'SCAN-01',
321
+ category: 'SC',
322
+ asixx: 'ASI04',
323
+ severity: 'medium',
324
+ file: skillPath,
325
+ message: 'Could not resolve skill path - may be invalid symlink',
326
+ evidence: String(e)
327
+ });
328
+ return findings;
329
+ }
330
+ if (!existsSync(resolvedPath)) {
331
+ findings.push({
332
+ id: 'SCAN-02',
333
+ category: 'SC',
334
+ asixx: 'ASI04',
335
+ severity: 'medium',
336
+ file: skillPath,
337
+ message: 'Skill path does not exist',
338
+ evidence: resolvedPath
339
+ });
340
+ return findings;
341
+ }
342
+ // Run all available scanners and aggregate results
343
+ const trivyFindings = scanWithTrivy(resolvedPath);
344
+ const osvFindings = scanWithOSV(resolvedPath);
345
+ const osvLockFindings = scanWithOSVLockfile(resolvedPath);
346
+ const osvAPIFindings = scanWithOSVAPI(resolvedPath);
347
+ // Deduplicate by vulnerability ID (prefer OSV results as they're more current)
348
+ const seen = new Set();
349
+ const deduped = [];
350
+ // Add OSV API findings first (direct API = most current database)
351
+ for (const f of osvAPIFindings) {
352
+ if (!seen.has(f.id)) {
353
+ seen.add(f.id);
354
+ deduped.push(f);
355
+ }
356
+ }
357
+ // Add OSV CLI findings (if CLI available)
358
+ for (const f of [...osvLockFindings, ...osvFindings]) {
359
+ if (!seen.has(f.id)) {
360
+ seen.add(f.id);
361
+ deduped.push(f);
362
+ }
363
+ }
364
+ // Add Trivy findings if not already found
365
+ for (const f of trivyFindings) {
366
+ if (!seen.has(f.id)) {
367
+ seen.add(f.id);
368
+ deduped.push(f);
369
+ }
370
+ }
371
+ return deduped;
372
+ }
373
+ export function getDependencySummary(skillPath) {
374
+ const resolvedPath = resolveSkillPath(skillPath);
375
+ const result = { hasLockfile: false, packageManager: 'none', manifest: undefined };
376
+ try {
377
+ const files = readdirSync(resolvedPath);
378
+ if (files.includes('package-lock.json') || files.includes('pnpm-lock.yaml')) {
379
+ result.hasLockfile = true;
380
+ result.packageManager = 'npm';
381
+ }
382
+ else if (files.includes('yarn.lock')) {
383
+ result.hasLockfile = true;
384
+ result.packageManager = 'yarn';
385
+ }
386
+ else if (files.includes('poetry.lock') || files.includes('pyproject.toml')) {
387
+ result.hasLockfile = true;
388
+ result.packageManager = 'python';
389
+ }
390
+ else if (files.includes('requirements.txt')) {
391
+ result.hasLockfile = true;
392
+ result.packageManager = 'pip';
393
+ }
394
+ else if (files.includes('Gemfile.lock')) {
395
+ result.hasLockfile = true;
396
+ result.packageManager = 'ruby';
397
+ }
398
+ else if (files.includes('go.sum')) {
399
+ result.hasLockfile = true;
400
+ result.packageManager = 'go';
401
+ }
402
+ result.manifest = files.find(f => f.endsWith('.toml') || f.endsWith('.json') || f === 'requirements.txt');
403
+ }
404
+ catch (e) {
405
+ // ignore
406
+ }
407
+ return result;
408
+ }
@@ -0,0 +1,124 @@
1
+ import { existsSync, readdirSync, statSync, lstatSync, realpathSync } from "fs";
2
+ import { join, resolve } from "path";
3
+ import { execFileSync } from "child_process";
4
+ export function resolveSkillPath(skillPath) {
5
+ // Resolve symlinks to actual path, with boundary check
6
+ try {
7
+ const resolved = resolve(skillPath);
8
+ // Ensure we don't escape the repository
9
+ const realPath = realpathSync(resolved);
10
+ return realPath;
11
+ }
12
+ catch {
13
+ return skillPath;
14
+ }
15
+ }
16
+ export function getSkillFiles(skillPath, basePath) {
17
+ const files = [];
18
+ const root = basePath || skillPath;
19
+ if (!existsSync(skillPath)) {
20
+ return files;
21
+ }
22
+ const stat = statSync(skillPath);
23
+ if (stat.isFile()) {
24
+ return [skillPath];
25
+ }
26
+ // Recursively scan all directories with symlink boundary enforcement
27
+ function scanDir(dir) {
28
+ try {
29
+ const entries = readdirSync(dir);
30
+ for (const entry of entries) {
31
+ const fullPath = join(dir, entry);
32
+ // Use lstat to detect symlinks without following them
33
+ const lstat = lstatSync(fullPath);
34
+ // Check for symlinks - ensure they don't escape the base path
35
+ if (lstat.isSymbolicLink()) {
36
+ try {
37
+ const realPath = realpathSync(fullPath);
38
+ // Verify the resolved path is still within the skill directory
39
+ if (!realPath.startsWith(root)) {
40
+ // Symlink points outside - skip to prevent directory traversal
41
+ continue;
42
+ }
43
+ // Follow the symlink for scanning
44
+ const targetStat = statSync(fullPath);
45
+ if (targetStat.isDirectory()) {
46
+ if (!entry.startsWith(".")) {
47
+ scanDir(realPath);
48
+ }
49
+ }
50
+ else if (targetStat.isFile()) {
51
+ files.push(realPath);
52
+ }
53
+ }
54
+ catch {
55
+ // Broken symlink - skip
56
+ continue;
57
+ }
58
+ }
59
+ else if (lstat.isDirectory()) {
60
+ // Skip hidden directories
61
+ if (!entry.startsWith(".")) {
62
+ scanDir(fullPath);
63
+ }
64
+ }
65
+ else if (lstat.isFile()) {
66
+ files.push(fullPath);
67
+ }
68
+ }
69
+ }
70
+ catch (e) {
71
+ // Skip directories we cannot read
72
+ }
73
+ }
74
+ scanDir(skillPath);
75
+ return files;
76
+ }
77
+ export async function discoverSkills(scope = "global") {
78
+ const skills = [];
79
+ try {
80
+ // Use execFileSync with argv array to prevent command injection
81
+ const args = scope === "global"
82
+ ? ["skills", "list", "-g", "--json"]
83
+ : ["skills", "list", "--json"];
84
+ const output = execFileSync("npx", args, {
85
+ encoding: "utf-8",
86
+ stdio: ["pipe", "pipe", "pipe"],
87
+ timeout: 30000
88
+ });
89
+ const data = JSON.parse(output);
90
+ if (Array.isArray(data)) {
91
+ for (const item of data) {
92
+ // Handle different output formats:
93
+ // Format 1: { skill: { name, path, ... } }
94
+ // Format 2: { name, path, ... }
95
+ const skillData = item.skill || item;
96
+ if (skillData && skillData.name && skillData.path) {
97
+ // Filter by scope if project only
98
+ const isGlobal = skillData.scope === "global";
99
+ if (scope === "project" && isGlobal)
100
+ continue;
101
+ // Validate and sanitize the path to prevent traversal
102
+ let safePath = skillData.path;
103
+ try {
104
+ safePath = resolveSkillPath(skillData.path);
105
+ }
106
+ catch {
107
+ // Invalid path - skip this skill
108
+ continue;
109
+ }
110
+ skills.push({
111
+ name: skillData.name,
112
+ path: safePath,
113
+ agents: skillData.agents || [],
114
+ scope: skillData.scope || "unknown"
115
+ });
116
+ }
117
+ }
118
+ }
119
+ }
120
+ catch (e) {
121
+ console.error("Failed to discover skills:", e);
122
+ }
123
+ return skills;
124
+ }