agent-security-scanner-mcp 4.1.0 → 4.2.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 +394 -1
- package/compliance/gdpr-technical-controls.json +112 -0
- package/compliance/soc2-technical-controls.json +148 -0
- package/index.js +148 -1
- package/openclaw.plugin.json +21 -1
- package/package.json +1 -1
- package/src/lib/compliance-controls.js +100 -21
- package/src/lib/compliance-evaluator.js +150 -9
- package/src/lib/compliance-evidence.js +321 -0
- package/src/lib/cyclonedx.js +113 -0
- package/src/lib/lockfile-parsers.js +671 -0
- package/src/lib/osv-client.js +254 -0
- package/src/lib/purl.js +90 -0
- package/src/lib/sbom-component.js +88 -0
- package/src/tools/compliance-controls.js +22 -12
- package/src/tools/evaluate-compliance.js +161 -0
- package/src/tools/sbom-diff.js +199 -0
- package/src/tools/sbom-generate.js +116 -0
- package/src/tools/sbom-hallucinations.js +117 -0
- package/src/tools/sbom-report.js +271 -0
- package/src/tools/sbom-vulnerabilities.js +121 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
// Lock file parsers for transitive dependency extraction.
|
|
2
|
+
// Each parser returns an array of { name, version, isDev, scope } objects.
|
|
3
|
+
// Uses regex/string-split — no external TOML/YAML libraries.
|
|
4
|
+
|
|
5
|
+
import { readFileSync, existsSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { execFileSync } from 'child_process';
|
|
8
|
+
import { createComponent, createEdge, createComponentList } from './sbom-component.js';
|
|
9
|
+
|
|
10
|
+
// ─── package-lock.json (npm, v2/v3) ─────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export function parsePackageLockJson(root) {
|
|
13
|
+
const lockPath = join(root, 'package-lock.json');
|
|
14
|
+
if (!existsSync(lockPath)) return null;
|
|
15
|
+
|
|
16
|
+
const lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
|
17
|
+
const deps = [];
|
|
18
|
+
const edges = [];
|
|
19
|
+
const packages = lock.packages || {};
|
|
20
|
+
const projectPurl = `pkg:npm/${lock.name || 'root'}@${lock.version || '0.0.0'}`;
|
|
21
|
+
|
|
22
|
+
// Collect direct dependency names from the root entry
|
|
23
|
+
const rootEntry = packages[''] || {};
|
|
24
|
+
const directNames = new Set([
|
|
25
|
+
...Object.keys(rootEntry.dependencies || {}),
|
|
26
|
+
...Object.keys(rootEntry.devDependencies || {}),
|
|
27
|
+
...Object.keys(rootEntry.optionalDependencies || {}),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
for (const [key, info] of Object.entries(packages)) {
|
|
31
|
+
if (key === '') continue; // root entry
|
|
32
|
+
// key looks like "node_modules/@scope/pkg" or "node_modules/pkg"
|
|
33
|
+
const name = key.replace(/^node_modules\//, '').replace(/^.*node_modules\//, '');
|
|
34
|
+
if (!name || !info.version) continue;
|
|
35
|
+
|
|
36
|
+
const isDev = !!info.dev || !!info.devOptional;
|
|
37
|
+
// A package is direct only if it appears in the root's dependency lists
|
|
38
|
+
const isDirect = directNames.has(name);
|
|
39
|
+
const comp = createComponent({ name, version: info.version, ecosystem: 'npm', isDev, isDirect });
|
|
40
|
+
deps.push(comp);
|
|
41
|
+
|
|
42
|
+
// Build edge from root to direct deps only
|
|
43
|
+
if (isDirect) {
|
|
44
|
+
edges.push(createEdge(projectPurl, comp.purl));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { ecosystem: 'npm', deps, edges, projectName: lock.name, projectVersion: lock.version };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── yarn.lock (Classic v1 and Berry v2+) ────────────────────────────
|
|
52
|
+
|
|
53
|
+
export function parseYarnLock(root) {
|
|
54
|
+
const lockPath = join(root, 'yarn.lock');
|
|
55
|
+
if (!existsSync(lockPath)) return null;
|
|
56
|
+
|
|
57
|
+
const content = readFileSync(lockPath, 'utf-8');
|
|
58
|
+
const deps = [];
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
|
|
61
|
+
// Detect Berry format: starts with __metadata
|
|
62
|
+
const isBerry = content.includes('__metadata:');
|
|
63
|
+
|
|
64
|
+
if (isBerry) {
|
|
65
|
+
// Berry format: "express@npm:^4.18.2":
|
|
66
|
+
// version: 4.18.2
|
|
67
|
+
const blockRe = /^"(@?[^@\n]+)@npm:[^"]*":\s*$/gm;
|
|
68
|
+
let blockMatch;
|
|
69
|
+
while ((blockMatch = blockRe.exec(content))) {
|
|
70
|
+
const name = blockMatch[1].trim();
|
|
71
|
+
if (name === '__metadata') continue;
|
|
72
|
+
// Find version: line after the block header
|
|
73
|
+
const after = content.slice(blockMatch.index + blockMatch[0].length, blockMatch.index + blockMatch[0].length + 200);
|
|
74
|
+
const verMatch = after.match(/^\s+version:\s+"?([^"\n\s]+)"?\s*$/m);
|
|
75
|
+
if (verMatch) {
|
|
76
|
+
const key = `${name}@${verMatch[1]}`;
|
|
77
|
+
if (!seen.has(key)) {
|
|
78
|
+
seen.add(key);
|
|
79
|
+
deps.push(createComponent({ name, version: verMatch[1], ecosystem: 'npm' }));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
// Classic format: "name@version", "name@version":
|
|
85
|
+
// version "x.y.z"
|
|
86
|
+
const blockRe = /^"?(@?[^@\s][^@\n]*?)@[^:\n]+"?(?:,\s*"?@?[^@\s][^@\n]*?@[^:\n]+"?)*:\s*$/gm;
|
|
87
|
+
const versionRe = /^\s+version\s+"([^"]+)"/gm;
|
|
88
|
+
let blockMatch;
|
|
89
|
+
while ((blockMatch = blockRe.exec(content))) {
|
|
90
|
+
const rawNames = blockMatch[0].replace(/:$/, '');
|
|
91
|
+
// Extract first name from the resolution
|
|
92
|
+
const nameMatch = rawNames.match(/^"?(@?[^@\s]+)/);
|
|
93
|
+
if (!nameMatch) continue;
|
|
94
|
+
const name = nameMatch[1];
|
|
95
|
+
|
|
96
|
+
versionRe.lastIndex = blockMatch.index;
|
|
97
|
+
const verMatch = versionRe.exec(content);
|
|
98
|
+
if (verMatch && verMatch.index - blockMatch.index < 500) {
|
|
99
|
+
const key = `${name}@${verMatch[1]}`;
|
|
100
|
+
if (!seen.has(key)) {
|
|
101
|
+
seen.add(key);
|
|
102
|
+
deps.push(createComponent({ name, version: verMatch[1], ecosystem: 'npm' }));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return deps.length ? { ecosystem: 'npm', deps, edges: [] } : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── pnpm-lock.yaml ──────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
export function parsePnpmLock(root) {
|
|
114
|
+
const lockPath = join(root, 'pnpm-lock.yaml');
|
|
115
|
+
if (!existsSync(lockPath)) return null;
|
|
116
|
+
|
|
117
|
+
const content = readFileSync(lockPath, 'utf-8');
|
|
118
|
+
const deps = [];
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
|
|
121
|
+
// v6+: packages section with entries like:
|
|
122
|
+
// /@scope/name@version: or /name@version:
|
|
123
|
+
// v9+: entries like:
|
|
124
|
+
// '@scope/name@version': or name@version:
|
|
125
|
+
const patterns = [
|
|
126
|
+
/^\s+\/?(@?[^@\s:][^@:]*?)@(\d[^:\s]*)\s*:/gm, // /name@version: or /@scope/name@version:
|
|
127
|
+
/^\s+'(@?[^@'\s]+)@(\d[^']*)':\s*$/gm, // 'name@version':
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
for (const re of patterns) {
|
|
131
|
+
let m;
|
|
132
|
+
while ((m = re.exec(content))) {
|
|
133
|
+
const name = m[1].replace(/^\//, '');
|
|
134
|
+
const version = m[2];
|
|
135
|
+
const key = `${name}@${version}`;
|
|
136
|
+
if (!seen.has(key)) {
|
|
137
|
+
seen.add(key);
|
|
138
|
+
deps.push(createComponent({ name, version, ecosystem: 'npm' }));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return deps.length ? { ecosystem: 'npm', deps, edges: [] } : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── poetry.lock (Python) ────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
export function parsePoetryLock(root) {
|
|
149
|
+
const lockPath = join(root, 'poetry.lock');
|
|
150
|
+
if (!existsSync(lockPath)) return null;
|
|
151
|
+
|
|
152
|
+
const content = readFileSync(lockPath, 'utf-8');
|
|
153
|
+
const blocks = content.split(/^\[\[package\]\]\s*$/m).slice(1);
|
|
154
|
+
const deps = [];
|
|
155
|
+
|
|
156
|
+
for (const block of blocks) {
|
|
157
|
+
const nameMatch = block.match(/^name\s*=\s*"([^"]+)"/m);
|
|
158
|
+
const versionMatch = block.match(/^version\s*=\s*"([^"]+)"/m);
|
|
159
|
+
const categoryMatch = block.match(/^category\s*=\s*"([^"]+)"/m);
|
|
160
|
+
if (!nameMatch || !versionMatch) continue;
|
|
161
|
+
|
|
162
|
+
const isDev = categoryMatch ? categoryMatch[1] === 'dev' : false;
|
|
163
|
+
deps.push(createComponent({
|
|
164
|
+
name: nameMatch[1],
|
|
165
|
+
version: versionMatch[1],
|
|
166
|
+
ecosystem: 'pypi',
|
|
167
|
+
isDev,
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return deps.length ? { ecosystem: 'pypi', deps, edges: [] } : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── Pipfile.lock (Python) ───────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
export function parsePipfileLock(root) {
|
|
177
|
+
const lockPath = join(root, 'Pipfile.lock');
|
|
178
|
+
if (!existsSync(lockPath)) return null;
|
|
179
|
+
|
|
180
|
+
const lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
|
181
|
+
const deps = [];
|
|
182
|
+
|
|
183
|
+
for (const [section, isDev] of [['default', false], ['develop', true]]) {
|
|
184
|
+
const packages = lock[section] || {};
|
|
185
|
+
for (const [name, info] of Object.entries(packages)) {
|
|
186
|
+
const version = (info.version || '').replace(/^==/, '');
|
|
187
|
+
if (!version) continue;
|
|
188
|
+
deps.push(createComponent({ name, version, ecosystem: 'pypi', isDev }));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return deps.length ? { ecosystem: 'pypi', deps, edges: [] } : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── Cargo.lock (Rust) ──────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
export function parseCargoLock(root) {
|
|
198
|
+
const lockPath = join(root, 'Cargo.lock');
|
|
199
|
+
if (!existsSync(lockPath)) return null;
|
|
200
|
+
|
|
201
|
+
const content = readFileSync(lockPath, 'utf-8');
|
|
202
|
+
const blocks = content.split(/^\[\[package\]\]\s*$/m).slice(1);
|
|
203
|
+
const deps = [];
|
|
204
|
+
|
|
205
|
+
for (const block of blocks) {
|
|
206
|
+
const nameMatch = block.match(/^name\s*=\s*"([^"]+)"/m);
|
|
207
|
+
const versionMatch = block.match(/^version\s*=\s*"([^"]+)"/m);
|
|
208
|
+
if (!nameMatch || !versionMatch) continue;
|
|
209
|
+
deps.push(createComponent({
|
|
210
|
+
name: nameMatch[1],
|
|
211
|
+
version: versionMatch[1],
|
|
212
|
+
ecosystem: 'crates',
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return deps.length ? { ecosystem: 'crates', deps, edges: [] } : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── go.sum (Go) ─────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
export function parseGoSum(root) {
|
|
222
|
+
const sumPath = join(root, 'go.sum');
|
|
223
|
+
if (!existsSync(sumPath)) return null;
|
|
224
|
+
|
|
225
|
+
const content = readFileSync(sumPath, 'utf-8');
|
|
226
|
+
const deps = [];
|
|
227
|
+
const seen = new Set();
|
|
228
|
+
|
|
229
|
+
for (const line of content.split('\n')) {
|
|
230
|
+
const parts = line.trim().split(/\s+/);
|
|
231
|
+
if (parts.length < 3) continue;
|
|
232
|
+
const [mod, rawVersion] = parts;
|
|
233
|
+
// go.sum has entries like: module v1.2.3 hash and module v1.2.3/go.mod hash
|
|
234
|
+
const version = rawVersion.replace(/\/go\.mod$/, '');
|
|
235
|
+
const key = `${mod}@${version}`;
|
|
236
|
+
if (!seen.has(key)) {
|
|
237
|
+
seen.add(key);
|
|
238
|
+
deps.push(createComponent({ name: mod, version, ecosystem: 'go' }));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return deps.length ? { ecosystem: 'go', deps, edges: [] } : null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ─── Gemfile.lock (Ruby) ─────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
export function parseGemfileLock(root) {
|
|
248
|
+
const lockPath = join(root, 'Gemfile.lock');
|
|
249
|
+
if (!existsSync(lockPath)) return null;
|
|
250
|
+
|
|
251
|
+
const content = readFileSync(lockPath, 'utf-8');
|
|
252
|
+
const deps = [];
|
|
253
|
+
|
|
254
|
+
// Find the SPECS section under GEM
|
|
255
|
+
const specsMatch = content.match(/GEM[\s\S]*?specs:\n([\s\S]*?)(?:\n\S|\n$)/);
|
|
256
|
+
if (!specsMatch) return null;
|
|
257
|
+
|
|
258
|
+
const specsBlock = specsMatch[1];
|
|
259
|
+
// Direct gems are indented 4 spaces, transitive 6+
|
|
260
|
+
const gemRe = /^\s{4}(\S+)\s+\(([^)]+)\)/gm;
|
|
261
|
+
let m;
|
|
262
|
+
while ((m = gemRe.exec(specsBlock))) {
|
|
263
|
+
deps.push(createComponent({ name: m[1], version: m[2], ecosystem: 'rubygems' }));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return deps.length ? { ecosystem: 'rubygems', deps, edges: [] } : null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─── Manifest parsers with versions (direct deps, fallback) ─────────
|
|
270
|
+
|
|
271
|
+
export function parseManifestWithVersions(root) {
|
|
272
|
+
const results = [];
|
|
273
|
+
|
|
274
|
+
// package.json
|
|
275
|
+
const pkgPath = join(root, 'package.json');
|
|
276
|
+
if (existsSync(pkgPath)) {
|
|
277
|
+
try {
|
|
278
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
279
|
+
for (const [name, ver] of Object.entries(pkg.dependencies || {})) {
|
|
280
|
+
results.push(createComponent({ name, version: ver.replace(/^[\^~>=<\s]+/, ''), ecosystem: 'npm', isDirect: true }));
|
|
281
|
+
}
|
|
282
|
+
for (const [name, ver] of Object.entries(pkg.devDependencies || {})) {
|
|
283
|
+
results.push(createComponent({ name, version: ver.replace(/^[\^~>=<\s]+/, ''), ecosystem: 'npm', isDev: true, isDirect: true }));
|
|
284
|
+
}
|
|
285
|
+
} catch { /* skip malformed */ }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// requirements.txt
|
|
289
|
+
const reqPath = join(root, 'requirements.txt');
|
|
290
|
+
if (existsSync(reqPath)) {
|
|
291
|
+
try {
|
|
292
|
+
const lines = readFileSync(reqPath, 'utf-8').split('\n');
|
|
293
|
+
for (const line of lines) {
|
|
294
|
+
const trimmed = line.trim();
|
|
295
|
+
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-')) continue;
|
|
296
|
+
const match = trimmed.match(/^([a-zA-Z0-9_.-]+)\s*(?:[=!><~]+\s*(.+))?/);
|
|
297
|
+
if (match) {
|
|
298
|
+
results.push(createComponent({
|
|
299
|
+
name: match[1],
|
|
300
|
+
version: (match[2] || 'unknown').split(',')[0].trim(),
|
|
301
|
+
ecosystem: 'pypi',
|
|
302
|
+
isDirect: true,
|
|
303
|
+
}));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
} catch { /* skip */ }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// pyproject.toml — [project.dependencies] and [tool.poetry.dependencies]
|
|
310
|
+
const pyprojectPath = join(root, 'pyproject.toml');
|
|
311
|
+
if (existsSync(pyprojectPath)) {
|
|
312
|
+
try {
|
|
313
|
+
const content = readFileSync(pyprojectPath, 'utf-8');
|
|
314
|
+
|
|
315
|
+
// PEP 621: [project] dependencies = ["flask>=2.0", ...]
|
|
316
|
+
const projDepsMatch = content.match(/\[project\][\s\S]*?dependencies\s*=\s*\[([\s\S]*?)\]/);
|
|
317
|
+
if (projDepsMatch) {
|
|
318
|
+
const items = projDepsMatch[1].match(/"([^"]+)"/g) || [];
|
|
319
|
+
for (const item of items) {
|
|
320
|
+
const raw = item.replace(/"/g, '');
|
|
321
|
+
const m = raw.match(/^([a-zA-Z0-9_.-]+)\s*(?:[=!><~]+\s*(.+))?/);
|
|
322
|
+
if (m) {
|
|
323
|
+
results.push(createComponent({
|
|
324
|
+
name: m[1],
|
|
325
|
+
version: (m[2] || 'unknown').split(',')[0].trim(),
|
|
326
|
+
ecosystem: 'pypi',
|
|
327
|
+
isDirect: true,
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Poetry: [tool.poetry.dependencies]
|
|
334
|
+
const poetryMatch = content.match(/\[tool\.poetry\.dependencies\]\s*\n([\s\S]*?)(?:\n\[|\n$)/);
|
|
335
|
+
if (poetryMatch) {
|
|
336
|
+
const lines = poetryMatch[1].split('\n');
|
|
337
|
+
for (const line of lines) {
|
|
338
|
+
const m = line.match(/^([a-zA-Z0-9_.-]+)\s*=\s*"([^"]+)"/);
|
|
339
|
+
if (m && m[1] !== 'python') {
|
|
340
|
+
results.push(createComponent({
|
|
341
|
+
name: m[1],
|
|
342
|
+
version: m[2].replace(/^[\^~>=<\s]+/, ''),
|
|
343
|
+
ecosystem: 'pypi',
|
|
344
|
+
isDirect: true,
|
|
345
|
+
}));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
} catch { /* skip */ }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// go.mod
|
|
353
|
+
const goModPath = join(root, 'go.mod');
|
|
354
|
+
if (existsSync(goModPath)) {
|
|
355
|
+
try {
|
|
356
|
+
const content = readFileSync(goModPath, 'utf-8');
|
|
357
|
+
// Single requires: require module v1.2.3
|
|
358
|
+
const singleRe = /^require\s+(\S+)\s+(v[\d.]+\S*)/gm;
|
|
359
|
+
let m;
|
|
360
|
+
while ((m = singleRe.exec(content))) {
|
|
361
|
+
results.push(createComponent({ name: m[1], version: m[2], ecosystem: 'go', isDirect: true }));
|
|
362
|
+
}
|
|
363
|
+
// Block requires
|
|
364
|
+
const blockMatch = content.match(/require\s*\(([\s\S]*?)\)/g) || [];
|
|
365
|
+
for (const block of blockMatch) {
|
|
366
|
+
const lineRe = /^\s*(\S+)\s+(v[\d.]+\S*)/gm;
|
|
367
|
+
while ((m = lineRe.exec(block))) {
|
|
368
|
+
results.push(createComponent({ name: m[1], version: m[2], ecosystem: 'go', isDirect: true }));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
} catch { /* skip */ }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Gemfile
|
|
375
|
+
const gemfilePath = join(root, 'Gemfile');
|
|
376
|
+
if (existsSync(gemfilePath)) {
|
|
377
|
+
try {
|
|
378
|
+
const content = readFileSync(gemfilePath, 'utf-8');
|
|
379
|
+
const gemRe = /gem\s+['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]+)['"])?/g;
|
|
380
|
+
let m;
|
|
381
|
+
while ((m = gemRe.exec(content))) {
|
|
382
|
+
results.push(createComponent({
|
|
383
|
+
name: m[1],
|
|
384
|
+
version: m[2] ? m[2].replace(/^[~>=<\s]+/, '') : 'unknown',
|
|
385
|
+
ecosystem: 'rubygems',
|
|
386
|
+
isDirect: true,
|
|
387
|
+
}));
|
|
388
|
+
}
|
|
389
|
+
} catch { /* skip */ }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// pom.xml (Maven)
|
|
393
|
+
const pomPath = join(root, 'pom.xml');
|
|
394
|
+
if (existsSync(pomPath)) {
|
|
395
|
+
try {
|
|
396
|
+
const content = readFileSync(pomPath, 'utf-8');
|
|
397
|
+
const depRe = /<dependency>\s*<groupId>([^<]+)<\/groupId>\s*<artifactId>([^<]+)<\/artifactId>\s*(?:<version>([^<]+)<\/version>)?/g;
|
|
398
|
+
let m;
|
|
399
|
+
while ((m = depRe.exec(content))) {
|
|
400
|
+
results.push(createComponent({
|
|
401
|
+
name: m[2],
|
|
402
|
+
version: m[3] || 'unknown',
|
|
403
|
+
ecosystem: 'java',
|
|
404
|
+
namespace: m[1],
|
|
405
|
+
isDirect: true,
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
408
|
+
} catch { /* skip */ }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// build.gradle / build.gradle.kts (Gradle)
|
|
412
|
+
for (const gradleFile of ['build.gradle', 'build.gradle.kts']) {
|
|
413
|
+
const gradlePath = join(root, gradleFile);
|
|
414
|
+
if (existsSync(gradlePath)) {
|
|
415
|
+
try {
|
|
416
|
+
const content = readFileSync(gradlePath, 'utf-8');
|
|
417
|
+
// implementation 'group:artifact:version' or implementation("group:artifact:version")
|
|
418
|
+
const depRe = /(?:implementation|api|compile|runtimeOnly|testImplementation|compileOnly)\s*[\('"]+([^:'"]+):([^:'"]+):([^)'"]+)/g;
|
|
419
|
+
let m;
|
|
420
|
+
while ((m = depRe.exec(content))) {
|
|
421
|
+
results.push(createComponent({
|
|
422
|
+
name: m[2],
|
|
423
|
+
version: m[3],
|
|
424
|
+
ecosystem: 'java',
|
|
425
|
+
namespace: m[1],
|
|
426
|
+
isDev: m[0].startsWith('test'),
|
|
427
|
+
isDirect: true,
|
|
428
|
+
}));
|
|
429
|
+
}
|
|
430
|
+
} catch { /* skip */ }
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return results;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ─── Package-manager CLI fallbacks ───────────────────────────────────
|
|
438
|
+
|
|
439
|
+
function tryExec(cmd, args, cwd, timeout = 30000) {
|
|
440
|
+
try {
|
|
441
|
+
const result = execFileSync(cmd, args, { cwd, encoding: 'utf-8', timeout, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
442
|
+
return result;
|
|
443
|
+
} catch {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function fallbackNpmLs(root) {
|
|
449
|
+
const output = tryExec('npm', ['ls', '--all', '--json', '--silent'], root);
|
|
450
|
+
if (!output) return null;
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
const tree = JSON.parse(output);
|
|
454
|
+
const deps = [];
|
|
455
|
+
const seen = new Set();
|
|
456
|
+
|
|
457
|
+
function walk(node) {
|
|
458
|
+
for (const [name, info] of Object.entries(node.dependencies || {})) {
|
|
459
|
+
const key = `${name}@${info.version}`;
|
|
460
|
+
if (seen.has(key)) continue;
|
|
461
|
+
seen.add(key);
|
|
462
|
+
deps.push(createComponent({ name, version: info.version || 'unknown', ecosystem: 'npm' }));
|
|
463
|
+
walk(info);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
walk(tree);
|
|
467
|
+
return deps.length ? { ecosystem: 'npm', deps, edges: [] } : null;
|
|
468
|
+
} catch {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function fallbackPnpmList(root) {
|
|
474
|
+
const output = tryExec('pnpm', ['list', '--json', '--depth', 'Infinity'], root);
|
|
475
|
+
if (!output) return null;
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
const data = JSON.parse(output);
|
|
479
|
+
const deps = [];
|
|
480
|
+
const seen = new Set();
|
|
481
|
+
const list = Array.isArray(data) ? data : [data];
|
|
482
|
+
|
|
483
|
+
function walk(node) {
|
|
484
|
+
for (const [name, info] of Object.entries(node.dependencies || {})) {
|
|
485
|
+
const version = info.version;
|
|
486
|
+
if (!version) continue;
|
|
487
|
+
const key = `${name}@${version}`;
|
|
488
|
+
if (seen.has(key)) continue;
|
|
489
|
+
seen.add(key);
|
|
490
|
+
deps.push(createComponent({ name, version, ecosystem: 'npm' }));
|
|
491
|
+
walk(info);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
for (const entry of list) walk(entry);
|
|
495
|
+
return deps.length ? { ecosystem: 'npm', deps, edges: [] } : null;
|
|
496
|
+
} catch {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function fallbackCargoMetadata(root) {
|
|
502
|
+
const output = tryExec('cargo', ['metadata', '--format-version', '1'], root, 60000);
|
|
503
|
+
if (!output) return null;
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
const meta = JSON.parse(output);
|
|
507
|
+
const deps = [];
|
|
508
|
+
const edges = [];
|
|
509
|
+
const seen = new Set();
|
|
510
|
+
|
|
511
|
+
// All packages in the resolve graph
|
|
512
|
+
for (const pkg of meta.packages || []) {
|
|
513
|
+
if (seen.has(`${pkg.name}@${pkg.version}`)) continue;
|
|
514
|
+
seen.add(`${pkg.name}@${pkg.version}`);
|
|
515
|
+
deps.push(createComponent({ name: pkg.name, version: pkg.version, ecosystem: 'crates' }));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Build dependency edges from resolve.nodes
|
|
519
|
+
if (meta.resolve && meta.resolve.nodes) {
|
|
520
|
+
for (const node of meta.resolve.nodes) {
|
|
521
|
+
const fromMatch = node.id.match(/^([^\s]+)\s+(\S+)/);
|
|
522
|
+
if (!fromMatch) continue;
|
|
523
|
+
const fromPurl = `pkg:cargo/${fromMatch[1]}@${fromMatch[2]}`;
|
|
524
|
+
for (const dep of node.deps || []) {
|
|
525
|
+
const toMatch = dep.pkg.match(/^([^\s]+)\s+(\S+)/);
|
|
526
|
+
if (toMatch) {
|
|
527
|
+
edges.push(createEdge(fromPurl, `pkg:cargo/${toMatch[1]}@${toMatch[2]}`));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return deps.length ? { ecosystem: 'crates', deps, edges } : null;
|
|
534
|
+
} catch {
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export function fallbackGoListModules(root) {
|
|
540
|
+
const output = tryExec('go', ['list', '-m', 'all'], root);
|
|
541
|
+
if (!output) return null;
|
|
542
|
+
|
|
543
|
+
const deps = [];
|
|
544
|
+
for (const line of output.split('\n')) {
|
|
545
|
+
const parts = line.trim().split(/\s+/);
|
|
546
|
+
if (parts.length >= 2) {
|
|
547
|
+
deps.push(createComponent({ name: parts[0], version: parts[1], ecosystem: 'go' }));
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return deps.length ? { ecosystem: 'go', deps, edges: [] } : null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
export function fallbackMvnDependencyTree(root) {
|
|
554
|
+
const output = tryExec('mvn', ['dependency:tree', '-DoutputType=text', '-q'], root, 60000);
|
|
555
|
+
if (!output) return null;
|
|
556
|
+
|
|
557
|
+
const deps = [];
|
|
558
|
+
// Lines like: [INFO] +- group:artifact:type:version:scope
|
|
559
|
+
const depRe = /[+-\\|]\s+([^:]+):([^:]+):([^:]+):([^:]+)/g;
|
|
560
|
+
let m;
|
|
561
|
+
while ((m = depRe.exec(output))) {
|
|
562
|
+
deps.push(createComponent({
|
|
563
|
+
name: m[2],
|
|
564
|
+
version: m[4],
|
|
565
|
+
ecosystem: 'java',
|
|
566
|
+
namespace: m[1],
|
|
567
|
+
}));
|
|
568
|
+
}
|
|
569
|
+
return deps.length ? { ecosystem: 'java', deps, edges: [] } : null;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ─── Top-level discovery ─────────────────────────────────────────────
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Discover all dependencies in a project.
|
|
576
|
+
* Tries lock file parsers first, then CLI fallbacks, then manifest-only.
|
|
577
|
+
* @param {string} projectRoot
|
|
578
|
+
* @param {{ includeDev?: boolean }} [options={}]
|
|
579
|
+
* @returns {ComponentList}
|
|
580
|
+
*/
|
|
581
|
+
export function discoverDependencies(projectRoot, options = {}) {
|
|
582
|
+
const { includeDev = true } = options;
|
|
583
|
+
|
|
584
|
+
const lockParsers = [
|
|
585
|
+
parsePackageLockJson,
|
|
586
|
+
parseYarnLock,
|
|
587
|
+
parsePnpmLock,
|
|
588
|
+
parsePoetryLock,
|
|
589
|
+
parsePipfileLock,
|
|
590
|
+
parseCargoLock,
|
|
591
|
+
parseGoSum,
|
|
592
|
+
parseGemfileLock,
|
|
593
|
+
];
|
|
594
|
+
|
|
595
|
+
const cliFallbacks = [
|
|
596
|
+
fallbackNpmLs,
|
|
597
|
+
fallbackPnpmList,
|
|
598
|
+
fallbackCargoMetadata,
|
|
599
|
+
fallbackGoListModules,
|
|
600
|
+
fallbackMvnDependencyTree,
|
|
601
|
+
];
|
|
602
|
+
|
|
603
|
+
let allDeps = [];
|
|
604
|
+
let allEdges = [];
|
|
605
|
+
const discoveredEcosystems = new Set();
|
|
606
|
+
|
|
607
|
+
// Phase 1: Lock file parsers
|
|
608
|
+
for (const parser of lockParsers) {
|
|
609
|
+
const result = parser(projectRoot);
|
|
610
|
+
if (result) {
|
|
611
|
+
discoveredEcosystems.add(result.ecosystem);
|
|
612
|
+
allDeps.push(...result.deps);
|
|
613
|
+
if (result.edges) allEdges.push(...result.edges);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Phase 2: CLI fallbacks for ecosystems not covered by lock files
|
|
618
|
+
for (const fallback of cliFallbacks) {
|
|
619
|
+
const result = fallback(projectRoot);
|
|
620
|
+
if (result && !discoveredEcosystems.has(result.ecosystem)) {
|
|
621
|
+
discoveredEcosystems.add(result.ecosystem);
|
|
622
|
+
allDeps.push(...result.deps);
|
|
623
|
+
if (result.edges) allEdges.push(...result.edges);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Phase 3: Manifest parsers for any remaining direct deps
|
|
628
|
+
const manifestDeps = parseManifestWithVersions(projectRoot);
|
|
629
|
+
const existingPurls = new Set(allDeps.map(d => d.purl));
|
|
630
|
+
for (const dep of manifestDeps) {
|
|
631
|
+
// Only add if not already discovered from lock file/CLI
|
|
632
|
+
if (!existingPurls.has(dep.purl)) {
|
|
633
|
+
// Check if same package (different version) was already found
|
|
634
|
+
const sameName = allDeps.find(d => d.name === dep.name && d.ecosystem === dep.ecosystem);
|
|
635
|
+
if (!sameName) {
|
|
636
|
+
allDeps.push(dep);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Filter dev dependencies if requested
|
|
642
|
+
if (!includeDev) {
|
|
643
|
+
allDeps = allDeps.filter(d => !d.isDev);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Deduplicate by purl
|
|
647
|
+
const deduped = new Map();
|
|
648
|
+
for (const dep of allDeps) {
|
|
649
|
+
if (!deduped.has(dep.purl)) {
|
|
650
|
+
deduped.set(dep.purl, dep);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Read project metadata
|
|
655
|
+
let projectName = 'unknown';
|
|
656
|
+
let projectVersion = '0.0.0';
|
|
657
|
+
const pkgPath = join(projectRoot, 'package.json');
|
|
658
|
+
if (existsSync(pkgPath)) {
|
|
659
|
+
try {
|
|
660
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
661
|
+
projectName = pkg.name || projectName;
|
|
662
|
+
projectVersion = pkg.version || projectVersion;
|
|
663
|
+
} catch { /* skip */ }
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return createComponentList(
|
|
667
|
+
[...deduped.values()],
|
|
668
|
+
allEdges,
|
|
669
|
+
{ name: projectName, version: projectVersion }
|
|
670
|
+
);
|
|
671
|
+
}
|