supply-chain-attack 0.1.2 → 0.1.8

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.
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ const OSV_BATCH_SIZE = 1000;
4
+
5
+ const MALICIOUS_KEYWORDS = [
6
+ 'malware',
7
+ 'malicious',
8
+ 'supply chain',
9
+ 'supply-chain',
10
+ 'compromise',
11
+ 'compromised',
12
+ 'credential',
13
+ 'exfiltrat',
14
+ 'trojan',
15
+ 'backdoor',
16
+ 'worm',
17
+ 'typosquat',
18
+ 'dependency confusion',
19
+ 'postinstall',
20
+ 'remote access trojan',
21
+ ];
22
+
23
+ const ECOSYSTEMS = new Map([
24
+ ['npm', 'npm'],
25
+ ['pypi', 'PyPI'],
26
+ ['packagist', 'Packagist'],
27
+ ]);
28
+
29
+ async function queryOsv(packages, options = {}) {
30
+ if (typeof fetch !== 'function') {
31
+ return { enabled: false, findings: [], error: 'Node fetch is unavailable' };
32
+ }
33
+
34
+ const prepared = prepareQueries(packages);
35
+ if (!prepared.queries.length) return { enabled: true, findings: [], error: null };
36
+
37
+ const timeoutMs = options.timeoutMs || 8000;
38
+ const deadline = Date.now() + timeoutMs;
39
+ const findings = [];
40
+
41
+ try {
42
+ for (let start = 0; start < prepared.queries.length; start += OSV_BATCH_SIZE) {
43
+ const remainingMs = deadline - Date.now();
44
+ if (remainingMs < 500) throw new Error('OSV query timed out');
45
+
46
+ const batchQueries = prepared.queries.slice(start, start + OSV_BATCH_SIZE);
47
+ const batchPackages = prepared.packages.slice(start, start + OSV_BATCH_SIZE);
48
+ findings.push(...await queryOsvBatch(batchQueries, batchPackages, remainingMs));
49
+ }
50
+ return { enabled: true, findings, error: null };
51
+ } catch (error) {
52
+ return { enabled: true, findings, error: error.message || String(error) };
53
+ }
54
+ }
55
+
56
+ function prepareQueries(packages) {
57
+ const queries = [];
58
+ const packageByQuery = [];
59
+ const seen = new Set();
60
+
61
+ for (const item of packages) {
62
+ const ecosystem = ECOSYSTEMS.get(item.ecosystem);
63
+ if (!ecosystem || !item.name || !item.version) continue;
64
+
65
+ const key = `${ecosystem}:${item.name}:${item.version}`;
66
+ if (seen.has(key)) continue;
67
+ seen.add(key);
68
+
69
+ queries.push({ package: { ecosystem, name: item.name }, version: item.version });
70
+ packageByQuery.push(item);
71
+ }
72
+
73
+ return { queries, packages: packageByQuery };
74
+ }
75
+
76
+ async function queryOsvBatch(queries, packages, timeoutMs) {
77
+ const controller = new AbortController();
78
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
79
+
80
+ try {
81
+ const response = await fetch('https://api.osv.dev/v1/querybatch', {
82
+ method: 'POST',
83
+ headers: { 'content-type': 'application/json' },
84
+ body: JSON.stringify({ queries }),
85
+ signal: controller.signal,
86
+ });
87
+ if (!response.ok) throw new Error(`OSV returned HTTP ${response.status}`);
88
+
89
+ const body = await response.json();
90
+ const findings = [];
91
+ for (let index = 0; index < (body.results || []).length; index += 1) {
92
+ const vulns = body.results[index].vulns || [];
93
+ for (const vuln of vulns) {
94
+ if (isSupplyChainOrAiSecurity(vuln)) findings.push(toLiveFinding(packages[index], vuln));
95
+ }
96
+ }
97
+ return findings;
98
+ } finally {
99
+ clearTimeout(timer);
100
+ }
101
+ }
102
+
103
+ function isSupplyChainOrAiSecurity(vuln) {
104
+ const text = [
105
+ vuln.id,
106
+ ...(vuln.aliases || []),
107
+ vuln.summary,
108
+ vuln.details,
109
+ ...(vuln.references || []).map((reference) => reference.url),
110
+ ].filter(Boolean).join('\n').toLowerCase();
111
+
112
+ if ((vuln.id || '').startsWith('MAL-')) return true;
113
+ if (/\b(ai|llm|mcp|agent|model)\b/.test(text) && /\b(security|vulnerab|injection|exfiltrat|credential|rce|malicious)\b/.test(text)) {
114
+ return true;
115
+ }
116
+ return MALICIOUS_KEYWORDS.some((keyword) => text.includes(keyword));
117
+ }
118
+
119
+ function toLiveFinding(item, vuln) {
120
+ const firstReference = (vuln.references || []).find((reference) => reference.url);
121
+ return {
122
+ advisoryId: vuln.id,
123
+ title: vuln.summary || vuln.id,
124
+ severity: severityFromDatabaseSpecific(vuln),
125
+ type: 'live-osv',
126
+ source: firstReference ? firstReference.url : `https://osv.dev/vulnerability/${vuln.id}`,
127
+ published: vuln.published ? vuln.published.slice(0, 10) : undefined,
128
+ summary: vuln.details || vuln.summary || '',
129
+ ecosystem: item.ecosystem,
130
+ name: item.name,
131
+ version: item.version,
132
+ locations: item.locations || [item.location].filter(Boolean),
133
+ sources: item.sources || [item.source].filter(Boolean),
134
+ binaries: item.bins || [],
135
+ live: true,
136
+ };
137
+ }
138
+
139
+ function severityFromDatabaseSpecific(vuln) {
140
+ if ((vuln.id || '').startsWith('MAL-')) return 'critical';
141
+ const severity = (vuln.severity || []).find((entry) => entry.score);
142
+ if (severity) return severity.score;
143
+ return vuln.database_specific && vuln.database_specific.severity
144
+ ? String(vuln.database_specific.severity).toLowerCase()
145
+ : 'unknown';
146
+ }
147
+
148
+ module.exports = {
149
+ queryOsv,
150
+ isSupplyChainOrAiSecurity,
151
+ };
package/lib/parsers.js ADDED
@@ -0,0 +1,348 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const MAX_JSON_BYTES = 1024 * 1024;
7
+ const MAX_LOCK_BYTES = 10 * 1024 * 1024;
8
+ const MAX_REQUIREMENTS_BYTES = 1024 * 1024;
9
+
10
+ function readJson(filePath, maxBytes = MAX_JSON_BYTES) {
11
+ const text = safeReadText(filePath, maxBytes);
12
+ if (!text) return null;
13
+ try {
14
+ return JSON.parse(text);
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ function addPackage(found, ecosystem, name, version, source, extra = {}) {
21
+ if (!name || !version) return;
22
+ found.push({
23
+ ecosystem,
24
+ name: normalizeName(ecosystem, name),
25
+ version: normalizeVersion(version),
26
+ source,
27
+ ...extra,
28
+ });
29
+ }
30
+
31
+ function normalizeName(ecosystem, name) {
32
+ if (ecosystem === 'pypi') return name.replace(/_/g, '-').toLowerCase();
33
+ return name;
34
+ }
35
+
36
+ function normalizeVersion(version) {
37
+ return String(version).trim().replace(/^v/i, '');
38
+ }
39
+
40
+ function exactVersionFromSpec(spec) {
41
+ if (typeof spec !== 'string') return null;
42
+ const trimmed = spec.trim();
43
+ const exact = trimmed.match(/^(?:=|==)?\s*v?([0-9][0-9A-Za-z.+-]*)$/);
44
+ return exact ? exact[1] : null;
45
+ }
46
+
47
+ function packageNameFromNodeModulesPath(lockPath) {
48
+ const marker = 'node_modules/';
49
+ const index = lockPath.lastIndexOf(marker);
50
+ if (index === -1) return null;
51
+ const tail = lockPath.slice(index + marker.length);
52
+ const segments = tail.split('/');
53
+ if (segments[0] && segments[0].startsWith('@')) return `${segments[0]}/${segments[1]}`;
54
+ return segments[0];
55
+ }
56
+
57
+ function parsePackageLock(root) {
58
+ const file = path.join(root, 'package-lock.json');
59
+ const lock = readJson(file, MAX_LOCK_BYTES);
60
+ const found = [];
61
+ if (!lock) return found;
62
+
63
+ if (lock.packages && typeof lock.packages === 'object') {
64
+ for (const [lockPath, info] of Object.entries(lock.packages)) {
65
+ if (!lockPath || !info || !info.version) continue;
66
+ const name = info.name || packageNameFromNodeModulesPath(lockPath);
67
+ addPackage(found, 'npm', name, info.version, 'package-lock.json');
68
+ }
69
+ }
70
+
71
+ if (lock.dependencies && typeof lock.dependencies === 'object') {
72
+ collectLockDependencies(lock.dependencies, found, 'package-lock.json');
73
+ }
74
+
75
+ return found;
76
+ }
77
+
78
+ function parseShrinkwrap(root) {
79
+ const file = path.join(root, 'npm-shrinkwrap.json');
80
+ const lock = readJson(file, MAX_LOCK_BYTES);
81
+ const found = [];
82
+ if (!lock || !lock.dependencies) return found;
83
+ collectLockDependencies(lock.dependencies, found, 'npm-shrinkwrap.json');
84
+ return found;
85
+ }
86
+
87
+ function collectLockDependencies(dependencies, found, source) {
88
+ for (const [name, info] of Object.entries(dependencies)) {
89
+ if (info && info.version) addPackage(found, 'npm', name, info.version, source);
90
+ if (info && info.dependencies) collectLockDependencies(info.dependencies, found, source);
91
+ }
92
+ }
93
+
94
+ function parsePackageJson(root) {
95
+ const pkg = readJson(path.join(root, 'package.json'));
96
+ const found = [];
97
+ if (!pkg) return found;
98
+ for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
99
+ const deps = pkg[field];
100
+ if (!deps || typeof deps !== 'object') continue;
101
+ for (const [name, spec] of Object.entries(deps)) {
102
+ const version = exactVersionFromSpec(spec);
103
+ if (version) addPackage(found, 'npm', name, version, 'package.json');
104
+ }
105
+ }
106
+ return found;
107
+ }
108
+
109
+ function parseYarnLock(root) {
110
+ const file = path.join(root, 'yarn.lock');
111
+ if (!fs.existsSync(file)) return [];
112
+ const text = safeReadText(file, MAX_LOCK_BYTES);
113
+ if (!text) return [];
114
+ const found = [];
115
+ let currentNames = [];
116
+ for (const rawLine of text.split(/\r?\n/)) {
117
+ const line = rawLine.trimEnd();
118
+ if (!line.startsWith(' ') && line.endsWith(':')) {
119
+ currentNames = namesFromYarnHeader(line.slice(0, -1));
120
+ continue;
121
+ }
122
+ const versionMatch = line.match(/^\s+version\s+"?([^"\s]+)"?/);
123
+ if (versionMatch && currentNames.length) {
124
+ for (const name of currentNames) addPackage(found, 'npm', name, versionMatch[1], 'yarn.lock');
125
+ }
126
+ }
127
+ return found;
128
+ }
129
+
130
+ function namesFromYarnHeader(header) {
131
+ return header.split(',').map((part) => {
132
+ const spec = part.trim().replace(/^["']|["']$/g, '');
133
+ return npmNameFromSpec(spec);
134
+ }).filter(Boolean);
135
+ }
136
+
137
+ function npmNameFromSpec(spec) {
138
+ const cleaned = spec.replace(/^npm:/, '');
139
+ if (cleaned.startsWith('@')) {
140
+ const secondAt = cleaned.indexOf('@', cleaned.indexOf('/') + 1);
141
+ return secondAt === -1 ? cleaned : cleaned.slice(0, secondAt);
142
+ }
143
+ const at = cleaned.indexOf('@');
144
+ return at === -1 ? cleaned : cleaned.slice(0, at);
145
+ }
146
+
147
+ function parsePnpmLock(root) {
148
+ const file = path.join(root, 'pnpm-lock.yaml');
149
+ if (!fs.existsSync(file)) return [];
150
+ const text = safeReadText(file, MAX_LOCK_BYTES);
151
+ if (!text) return [];
152
+ const found = [];
153
+ let section = null;
154
+ for (const rawLine of text.split(/\r?\n/)) {
155
+ if (!rawLine.trim() || rawLine.trimStart().startsWith('#')) continue;
156
+ const sectionMatch = rawLine.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*$/);
157
+ if (sectionMatch) {
158
+ section = sectionMatch[1];
159
+ continue;
160
+ }
161
+ if (section !== 'packages' && section !== 'snapshots') continue;
162
+
163
+ const keyMatch = rawLine.match(/^ {2}(.+?):(?:\s+.*)?$/);
164
+ if (!keyMatch) continue;
165
+ const key = keyMatch[1].replace(/^['"]|['"]$/g, '');
166
+ const parsed = parsePnpmPackageKey(key);
167
+ if (parsed) addPackage(found, 'npm', parsed.name, parsed.version, 'pnpm-lock.yaml');
168
+ }
169
+ return found;
170
+ }
171
+
172
+ function parsePnpmPackageKey(key) {
173
+ let value = key;
174
+ if (value.startsWith('/')) value = value.slice(1);
175
+ if (value.includes('(')) value = value.slice(0, value.indexOf('('));
176
+ if (value.startsWith('@') && value.includes('/')) {
177
+ const slash = value.indexOf('/');
178
+ const versionSlash = value.indexOf('/', slash + 1);
179
+ if (versionSlash !== -1) {
180
+ return validNpmPackage(value.slice(0, versionSlash), value.slice(versionSlash + 1));
181
+ }
182
+ }
183
+ if (!value.startsWith('@') && value.includes('/')) {
184
+ const slash = value.indexOf('/');
185
+ return validNpmPackage(value.slice(0, slash), value.slice(slash + 1));
186
+ }
187
+ if (value.startsWith('@')) {
188
+ const slash = value.indexOf('/');
189
+ if (slash === -1) return null;
190
+ const at = value.indexOf('@', slash + 1);
191
+ if (at === -1) return null;
192
+ return validNpmPackage(value.slice(0, at), value.slice(at + 1));
193
+ }
194
+ const at = value.indexOf('@');
195
+ if (at <= 0) return null;
196
+ return validNpmPackage(value.slice(0, at), value.slice(at + 1));
197
+ }
198
+
199
+ function validNpmPackage(name, version) {
200
+ if (!name || !version) return null;
201
+ if (!/^(?:@[A-Za-z0-9._~-]+\/)?[A-Za-z0-9._~-]+$/.test(name)) return null;
202
+ if (!/^[0-9][0-9A-Za-z.+-]*$/.test(version)) return null;
203
+ return { name, version };
204
+ }
205
+
206
+ function parseRequirements(root) {
207
+ const found = [];
208
+ for (const file of safeReaddir(root)) {
209
+ if (!/^requirements.*\.txt$/i.test(file)) continue;
210
+ const text = safeReadText(path.join(root, file), MAX_REQUIREMENTS_BYTES);
211
+ if (!text) continue;
212
+ for (const rawLine of text.split(/\r?\n/)) {
213
+ const line = rawLine.replace(/\s+#.*$/, '').trim();
214
+ const match = line.match(/^([A-Za-z0-9_.-]+)\s*==\s*([A-Za-z0-9_.!+-]+)/);
215
+ if (match) addPackage(found, 'pypi', match[1], match[2], file);
216
+ }
217
+ }
218
+ return found;
219
+ }
220
+
221
+ function parsePoetryLock(root) {
222
+ const file = path.join(root, 'poetry.lock');
223
+ if (!fs.existsSync(file)) return [];
224
+ const text = safeReadText(file, MAX_LOCK_BYTES);
225
+ if (!text) return [];
226
+ const found = [];
227
+ let name = null;
228
+ let version = null;
229
+ for (const rawLine of text.split(/\r?\n/)) {
230
+ const line = rawLine.trim();
231
+ if (line === '[[package]]') {
232
+ if (name && version) addPackage(found, 'pypi', name, version, 'poetry.lock');
233
+ name = null;
234
+ version = null;
235
+ continue;
236
+ }
237
+ const nameMatch = line.match(/^name\s*=\s*"([^"]+)"/);
238
+ const versionMatch = line.match(/^version\s*=\s*"([^"]+)"/);
239
+ if (nameMatch) name = nameMatch[1];
240
+ if (versionMatch) version = versionMatch[1];
241
+ }
242
+ if (name && version) addPackage(found, 'pypi', name, version, 'poetry.lock');
243
+ return found;
244
+ }
245
+
246
+ function parseComposerLock(root) {
247
+ const lock = readJson(path.join(root, 'composer.lock'), MAX_LOCK_BYTES);
248
+ const found = [];
249
+ if (!lock) return found;
250
+ for (const section of ['packages', 'packages-dev']) {
251
+ for (const info of lock[section] || []) {
252
+ addPackage(found, 'packagist', info.name, info.version, 'composer.lock');
253
+ }
254
+ }
255
+ return found;
256
+ }
257
+
258
+ function parseNodeModules(root) {
259
+ const modulesRoot = path.join(root, 'node_modules');
260
+ const found = [];
261
+ if (!fs.existsSync(modulesRoot)) return found;
262
+ for (const packageJson of listNodePackageJsons(modulesRoot)) {
263
+ const info = readJson(packageJson);
264
+ if (!info || !info.name || !info.version) continue;
265
+ const bins = binsFromPackageJson(info);
266
+ addPackage(found, 'npm', info.name, info.version, path.relative(root, packageJson), { bins, packageJson });
267
+ }
268
+ return found;
269
+ }
270
+
271
+ function listNodePackageJsons(modulesRoot) {
272
+ const result = [];
273
+ for (const entry of safeReaddir(modulesRoot)) {
274
+ if (entry === '.bin' || entry.startsWith('.')) continue;
275
+ const full = path.join(modulesRoot, entry);
276
+ const stat = safeStat(full);
277
+ if (!stat || !stat.isDirectory()) continue;
278
+ if (entry.startsWith('@')) {
279
+ for (const scoped of safeReaddir(full)) {
280
+ const packageJson = path.join(full, scoped, 'package.json');
281
+ if (fs.existsSync(packageJson)) result.push(packageJson);
282
+ }
283
+ continue;
284
+ }
285
+ const packageJson = path.join(full, 'package.json');
286
+ if (fs.existsSync(packageJson)) result.push(packageJson);
287
+ }
288
+ return result;
289
+ }
290
+
291
+ function binsFromPackageJson(info) {
292
+ if (!info || !info.bin) return [];
293
+ if (typeof info.bin === 'string') return [info.name.split('/').pop()];
294
+ if (typeof info.bin === 'object') return Object.keys(info.bin);
295
+ return [];
296
+ }
297
+
298
+ function safeReadText(filePath, maxBytes) {
299
+ let fd = null;
300
+ try {
301
+ fd = fs.openSync(filePath, 'r');
302
+ const stat = fs.fstatSync(fd);
303
+ if (!stat.isFile() || stat.size > maxBytes) return '';
304
+ if (stat.size <= 0) return '';
305
+
306
+ const buffer = Buffer.allocUnsafe(stat.size);
307
+ const bytesRead = fs.readSync(fd, buffer, 0, stat.size, 0);
308
+ return buffer.subarray(0, bytesRead).toString('utf8');
309
+ } catch {
310
+ return '';
311
+ } finally {
312
+ if (fd !== null) {
313
+ try {
314
+ fs.closeSync(fd);
315
+ } catch {}
316
+ }
317
+ }
318
+ }
319
+
320
+ function safeReaddir(dir) {
321
+ try {
322
+ return fs.readdirSync(dir);
323
+ } catch {
324
+ return [];
325
+ }
326
+ }
327
+
328
+ function safeStat(filePath) {
329
+ try {
330
+ return fs.statSync(filePath);
331
+ } catch {
332
+ return null;
333
+ }
334
+ }
335
+
336
+ module.exports = {
337
+ parsePackageLock,
338
+ parseShrinkwrap,
339
+ parsePackageJson,
340
+ parseYarnLock,
341
+ parsePnpmLock,
342
+ parseRequirements,
343
+ parsePoetryLock,
344
+ parseComposerLock,
345
+ parseNodeModules,
346
+ binsFromPackageJson,
347
+ readJson,
348
+ };