@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/README.md +124 -0
- package/SKILL.md +227 -0
- package/dist/audit.js +464 -0
- package/dist/deps.js +408 -0
- package/dist/discover.js +124 -0
- package/dist/index.js +195 -0
- package/dist/intel.js +416 -0
- package/dist/reporter.js +77 -0
- package/dist/scoring.js +129 -0
- package/dist/security.js +341 -0
- package/dist/spec.js +271 -0
- package/dist/types.js +1 -0
- package/package.json +56 -0
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
|
+
}
|
package/dist/discover.js
ADDED
|
@@ -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
|
+
}
|