muaddib-scanner 2.10.37 → 2.10.39
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/bin/muaddib.js +4 -2
- package/package.json +1 -1
- package/scripts/ossf-benchmark.js +548 -0
- package/src/integrations/publish-anomaly.js +1 -1
- package/src/ioc/scraper.js +87 -0
- package/src/ioc/updater.js +9 -8
- package/src/ml/llm-detective.js +24 -0
- package/src/monitor/classify.js +30 -0
- package/src/monitor/queue.js +68 -10
- package/src/scoring.js +17 -1
package/bin/muaddib.js
CHANGED
|
@@ -260,10 +260,12 @@ if (command === 'version' || command === '--version' || command === '-v') {
|
|
|
260
260
|
configPath: configPath,
|
|
261
261
|
autoSandbox: autoSandbox
|
|
262
262
|
}).then(exitCode => {
|
|
263
|
-
process.exit(
|
|
263
|
+
// Use process.exitCode instead of process.exit() to let pending async work
|
|
264
|
+
// (the non-blocking version update check) complete before the process exits.
|
|
265
|
+
process.exitCode = exitCode;
|
|
264
266
|
}).catch(err => {
|
|
265
267
|
console.error('[ERROR]', err.message);
|
|
266
|
-
process.
|
|
268
|
+
process.exitCode = 1;
|
|
267
269
|
});
|
|
268
270
|
} else if (command === 'feed') {
|
|
269
271
|
if (wantHelp) showHelp('feed');
|
package/package.json
CHANGED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* MUAD'DIB OpenSSF Benchmark
|
|
6
|
+
*
|
|
7
|
+
* Fetches the OpenSSF malicious-packages dataset (via OSV.dev API),
|
|
8
|
+
* downloads available npm packages, scans them with MUAD'DIB, and
|
|
9
|
+
* produces a benchmark results file consumed by `muaddib evaluate`.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node scripts/ossf-benchmark.js [--sample N] [--seed N] [--refresh]
|
|
13
|
+
*
|
|
14
|
+
* Options:
|
|
15
|
+
* --sample N Number of packages to sample (default: 500)
|
|
16
|
+
* --seed N Random seed for reproducibility (default: 42)
|
|
17
|
+
* --refresh Force re-download of cached tarballs
|
|
18
|
+
*
|
|
19
|
+
* Output:
|
|
20
|
+
* datasets/real-world/ossf-benchmark-results.json
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const zlib = require('zlib');
|
|
26
|
+
const { execSync } = require('child_process');
|
|
27
|
+
|
|
28
|
+
const ROOT = path.join(__dirname, '..');
|
|
29
|
+
const RESULTS_FILE = path.join(ROOT, 'datasets', 'real-world', 'ossf-benchmark-results.json');
|
|
30
|
+
const CACHE_DIR = path.join(ROOT, '.muaddib-cache', 'ossf-tarballs');
|
|
31
|
+
const PACK_TIMEOUT_MS = 30000;
|
|
32
|
+
const SCAN_TIMEOUT_MS = 30000;
|
|
33
|
+
const SAFE_PKG_RE = /^(@[\w._-]+\/)?[\w._-]+$/;
|
|
34
|
+
|
|
35
|
+
// --- CLI args ---
|
|
36
|
+
const SAMPLE_SIZE = parseInt(process.argv.find((a, i) => process.argv[i - 1] === '--sample') || '500', 10);
|
|
37
|
+
const SEED = parseInt(process.argv.find((a, i) => process.argv[i - 1] === '--seed') || '42', 10);
|
|
38
|
+
const REFRESH = process.argv.includes('--refresh');
|
|
39
|
+
|
|
40
|
+
// --- Seeded PRNG (Mulberry32) ---
|
|
41
|
+
function mulberry32(seed) {
|
|
42
|
+
let s = seed | 0;
|
|
43
|
+
return function() {
|
|
44
|
+
s = (s + 0x6D2B79F5) | 0;
|
|
45
|
+
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
|
46
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
47
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// --- Native tgz extraction (same as evaluate.js) ---
|
|
52
|
+
function extractTgz(tgzPath, destDir) {
|
|
53
|
+
const compressed = fs.readFileSync(tgzPath);
|
|
54
|
+
const tarData = zlib.gunzipSync(compressed);
|
|
55
|
+
|
|
56
|
+
let offset = 0;
|
|
57
|
+
while (offset + 512 <= tarData.length) {
|
|
58
|
+
const header = tarData.subarray(offset, offset + 512);
|
|
59
|
+
if (header.every(b => b === 0)) break;
|
|
60
|
+
|
|
61
|
+
const name = header.subarray(0, 100).toString('utf8').replace(/\0+$/, '');
|
|
62
|
+
const sizeOctal = header.subarray(124, 136).toString('utf8').replace(/\0+$/, '').trim();
|
|
63
|
+
const size = parseInt(sizeOctal, 8) || 0;
|
|
64
|
+
const typeFlag = String.fromCharCode(header[156]);
|
|
65
|
+
|
|
66
|
+
offset += 512;
|
|
67
|
+
|
|
68
|
+
if (name && (typeFlag === '0' || typeFlag === '\0') && size > 0) {
|
|
69
|
+
const resolved = path.resolve(destDir, name);
|
|
70
|
+
const rel = path.relative(path.resolve(destDir), resolved);
|
|
71
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
72
|
+
offset += Math.ceil(size / 512) * 512;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
76
|
+
fs.writeFileSync(resolved, tarData.subarray(offset, offset + size));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
offset += Math.ceil(size / 512) * 512;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pkgToCacheName(name, version) {
|
|
84
|
+
return (name + '@' + version).replace(/\//g, '_').replace(/@/g, '_');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- Step 1: Fetch OSV npm MAL-* index via zip dump ---
|
|
88
|
+
async function fetchOSSFIndex() {
|
|
89
|
+
console.log('\n[1/5] Fetching OSV npm malware index...');
|
|
90
|
+
|
|
91
|
+
// Use the OSV zip dump (same as scrapeOSVDataDump) for the full index
|
|
92
|
+
// This is more complete than the query API which has pagination limits
|
|
93
|
+
const https = require('https');
|
|
94
|
+
const AdmZip = require('adm-zip');
|
|
95
|
+
|
|
96
|
+
const zipUrl = 'https://osv-vulnerabilities.storage.googleapis.com/npm/all.zip';
|
|
97
|
+
|
|
98
|
+
console.log(' Downloading npm OSV zip...');
|
|
99
|
+
const zipBuffer = await new Promise(function(resolve, reject) {
|
|
100
|
+
const chunks = [];
|
|
101
|
+
let totalBytes = 0;
|
|
102
|
+
|
|
103
|
+
https.get(zipUrl, { headers: { 'User-Agent': 'MUADDIB-Scanner/3.0' } }, function(res) {
|
|
104
|
+
if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location) {
|
|
105
|
+
https.get(res.headers.location, { headers: { 'User-Agent': 'MUADDIB-Scanner/3.0' } }, function(res2) {
|
|
106
|
+
res2.on('data', function(chunk) {
|
|
107
|
+
chunks.push(chunk);
|
|
108
|
+
totalBytes += chunk.length;
|
|
109
|
+
if (totalBytes % (10 * 1024 * 1024) < chunk.length) {
|
|
110
|
+
process.stdout.write('\r Downloaded: ' + (totalBytes / 1024 / 1024).toFixed(1) + ' MB');
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
res2.on('end', function() {
|
|
114
|
+
process.stdout.write('\r Downloaded: ' + (totalBytes / 1024 / 1024).toFixed(1) + ' MB\n');
|
|
115
|
+
resolve(Buffer.concat(chunks));
|
|
116
|
+
});
|
|
117
|
+
res2.on('error', reject);
|
|
118
|
+
}).on('error', reject);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
res.on('data', function(chunk) {
|
|
123
|
+
chunks.push(chunk);
|
|
124
|
+
totalBytes += chunk.length;
|
|
125
|
+
if (totalBytes % (10 * 1024 * 1024) < chunk.length) {
|
|
126
|
+
process.stdout.write('\r Downloaded: ' + (totalBytes / 1024 / 1024).toFixed(1) + ' MB');
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
res.on('end', function() {
|
|
130
|
+
process.stdout.write('\r Downloaded: ' + (totalBytes / 1024 / 1024).toFixed(1) + ' MB\n');
|
|
131
|
+
resolve(Buffer.concat(chunks));
|
|
132
|
+
});
|
|
133
|
+
res.on('error', reject);
|
|
134
|
+
}).on('error', reject);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
console.log(' Parsing MAL-* entries...');
|
|
138
|
+
const zip = new AdmZip(zipBuffer);
|
|
139
|
+
const entries = zip.getEntries();
|
|
140
|
+
|
|
141
|
+
// Deduplicate by name@version, keep first MAL-* ID encountered
|
|
142
|
+
const dedupMap = new Map(); // key: "name@version" -> entry
|
|
143
|
+
let malCount = 0;
|
|
144
|
+
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
const entryName = entry.entryName;
|
|
147
|
+
if (!entryName.startsWith('MAL-') || !entryName.endsWith('.json')) continue;
|
|
148
|
+
|
|
149
|
+
// Size guard
|
|
150
|
+
const entrySize = entry.header ? entry.header.size : 0;
|
|
151
|
+
if (entrySize > 10 * 1024 * 1024) continue; // skip >10MB entries
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const vuln = JSON.parse(entry.getData().toString('utf8'));
|
|
155
|
+
if (!vuln.affected) continue;
|
|
156
|
+
|
|
157
|
+
for (const affected of vuln.affected) {
|
|
158
|
+
if (!affected.package || affected.package.ecosystem !== 'npm') continue;
|
|
159
|
+
|
|
160
|
+
const pkgName = affected.package.name;
|
|
161
|
+
const versions = [];
|
|
162
|
+
|
|
163
|
+
// Extract versions from ranges or explicit list
|
|
164
|
+
if (affected.versions && affected.versions.length > 0) {
|
|
165
|
+
for (const v of affected.versions) versions.push(v);
|
|
166
|
+
} else if (affected.ranges) {
|
|
167
|
+
for (const range of affected.ranges) {
|
|
168
|
+
if (range.events) {
|
|
169
|
+
for (const evt of range.events) {
|
|
170
|
+
if (evt.introduced && evt.introduced !== '0') versions.push(evt.introduced);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// If no specific version, use wildcard marker
|
|
177
|
+
if (versions.length === 0) versions.push('*');
|
|
178
|
+
|
|
179
|
+
// Determine source from database_specific
|
|
180
|
+
let source = 'unknown';
|
|
181
|
+
if (vuln.database_specific && vuln.database_specific['malicious-packages-origins']) {
|
|
182
|
+
const origins = vuln.database_specific['malicious-packages-origins'];
|
|
183
|
+
if (origins.length > 0 && origins[0].source) {
|
|
184
|
+
source = origins[0].source;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const ver of versions) {
|
|
189
|
+
const key = pkgName + '@' + ver;
|
|
190
|
+
if (!dedupMap.has(key)) {
|
|
191
|
+
dedupMap.set(key, {
|
|
192
|
+
name: pkgName,
|
|
193
|
+
version: ver,
|
|
194
|
+
osv_id: vuln.id,
|
|
195
|
+
source: source,
|
|
196
|
+
summary: (vuln.summary || '').slice(0, 200),
|
|
197
|
+
published: vuln.published || null
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
malCount++;
|
|
204
|
+
} catch { /* skip unparseable */ }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const index = Array.from(dedupMap.values());
|
|
208
|
+
console.log(' Parsed ' + malCount + ' MAL-* reports -> ' + index.length + ' unique name@version entries');
|
|
209
|
+
|
|
210
|
+
return index;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- Step 2: Stratified sampling ---
|
|
214
|
+
function stratifySample(index, sampleSize, seed) {
|
|
215
|
+
console.log('\n[2/5] Stratified sampling (' + sampleSize + ' packages, seed=' + seed + ')...');
|
|
216
|
+
|
|
217
|
+
const rng = mulberry32(seed);
|
|
218
|
+
|
|
219
|
+
// Filter out wildcard versions (can't download *)
|
|
220
|
+
const downloadable = index.filter(e => e.version !== '*' && SAFE_PKG_RE.test(e.name));
|
|
221
|
+
console.log(' Downloadable (non-wildcard, valid name): ' + downloadable.length);
|
|
222
|
+
|
|
223
|
+
// Group by source
|
|
224
|
+
const bySource = {};
|
|
225
|
+
for (const entry of downloadable) {
|
|
226
|
+
const src = entry.source || 'unknown';
|
|
227
|
+
if (!bySource[src]) bySource[src] = [];
|
|
228
|
+
bySource[src].push(entry);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
console.log(' Sources: ' + Object.entries(bySource).map(([k, v]) => k + '=' + v.length).join(', '));
|
|
232
|
+
|
|
233
|
+
// Shuffle each source group with seeded RNG
|
|
234
|
+
for (const src of Object.keys(bySource)) {
|
|
235
|
+
const arr = bySource[src];
|
|
236
|
+
for (let i = arr.length - 1; i > 0; i--) {
|
|
237
|
+
const j = Math.floor(rng() * (i + 1));
|
|
238
|
+
const tmp = arr[i];
|
|
239
|
+
arr[i] = arr[j];
|
|
240
|
+
arr[j] = tmp;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Proportional allocation per source
|
|
245
|
+
const sources = Object.keys(bySource);
|
|
246
|
+
const totalDownloadable = downloadable.length;
|
|
247
|
+
const sample = [];
|
|
248
|
+
|
|
249
|
+
for (const src of sources) {
|
|
250
|
+
const proportion = bySource[src].length / totalDownloadable;
|
|
251
|
+
const count = Math.max(1, Math.round(proportion * sampleSize));
|
|
252
|
+
const take = bySource[src].slice(0, count);
|
|
253
|
+
sample.push(...take);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Trim to exact sample size
|
|
257
|
+
while (sample.length > sampleSize) sample.pop();
|
|
258
|
+
|
|
259
|
+
console.log(' Sampled: ' + sample.length + ' packages');
|
|
260
|
+
return sample;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// --- Step 3: Check npm availability ---
|
|
264
|
+
async function checkAvailability(sample) {
|
|
265
|
+
console.log('\n[3/5] Checking npm availability...');
|
|
266
|
+
|
|
267
|
+
let available = 0;
|
|
268
|
+
let unavailable = 0;
|
|
269
|
+
let errors = 0;
|
|
270
|
+
|
|
271
|
+
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
272
|
+
|
|
273
|
+
for (let i = 0; i < sample.length; i++) {
|
|
274
|
+
const entry = sample[i];
|
|
275
|
+
|
|
276
|
+
if (process.stdout.isTTY) {
|
|
277
|
+
process.stdout.write('\r Checking [' + (i + 1) + '/' + sample.length + '] ' + entry.name + '@' + entry.version + ' ');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
execSync(npmCmd + ' view ' + entry.name + '@' + entry.version + ' version --json', {
|
|
282
|
+
encoding: 'utf8',
|
|
283
|
+
timeout: 10000,
|
|
284
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
285
|
+
});
|
|
286
|
+
entry.status = 'available';
|
|
287
|
+
available++;
|
|
288
|
+
} catch (err) {
|
|
289
|
+
const stderr = (err.stderr || '').toLowerCase();
|
|
290
|
+
if (stderr.includes('404') || stderr.includes('not found') || stderr.includes('not in this registry')) {
|
|
291
|
+
entry.status = 'unavailable';
|
|
292
|
+
unavailable++;
|
|
293
|
+
} else {
|
|
294
|
+
entry.status = 'error';
|
|
295
|
+
entry.error = (err.message || '').slice(0, 100);
|
|
296
|
+
errors++;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (process.stdout.isTTY) {
|
|
302
|
+
process.stdout.write('\r' + ''.padEnd(80) + '\r');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
console.log(' Available: ' + available + ', Unavailable: ' + unavailable + ', Errors: ' + errors);
|
|
306
|
+
return sample;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// --- Step 4: Download and scan ---
|
|
310
|
+
async function downloadAndScan(sample) {
|
|
311
|
+
console.log('\n[4/5] Downloading and scanning available packages...');
|
|
312
|
+
|
|
313
|
+
const { run } = require('../src/index.js');
|
|
314
|
+
const { clearFileListCache } = require('../src/utils.js');
|
|
315
|
+
|
|
316
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
317
|
+
|
|
318
|
+
const scannable = sample.filter(e => e.status === 'available');
|
|
319
|
+
console.log(' Scannable: ' + scannable.length + ' packages');
|
|
320
|
+
|
|
321
|
+
let scanned = 0;
|
|
322
|
+
let detected = 0;
|
|
323
|
+
let scanErrors = 0;
|
|
324
|
+
let scanCount = 0;
|
|
325
|
+
|
|
326
|
+
for (let i = 0; i < scannable.length; i++) {
|
|
327
|
+
const entry = scannable[i];
|
|
328
|
+
const progress = '[' + (i + 1) + '/' + scannable.length + ']';
|
|
329
|
+
|
|
330
|
+
if (process.stdout.isTTY) {
|
|
331
|
+
process.stdout.write('\r Scanning ' + progress + ' ' + entry.name + '@' + entry.version + ' ');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Download
|
|
335
|
+
const cacheName = pkgToCacheName(entry.name, entry.version);
|
|
336
|
+
const pkgCacheDir = path.join(CACHE_DIR, cacheName);
|
|
337
|
+
let extractedDir = null;
|
|
338
|
+
|
|
339
|
+
if (!REFRESH && fs.existsSync(path.join(pkgCacheDir, 'package'))) {
|
|
340
|
+
extractedDir = path.join(pkgCacheDir, 'package');
|
|
341
|
+
} else {
|
|
342
|
+
fs.mkdirSync(pkgCacheDir, { recursive: true });
|
|
343
|
+
try {
|
|
344
|
+
const output = execSync('npm pack ' + entry.name + '@' + entry.version, {
|
|
345
|
+
cwd: pkgCacheDir,
|
|
346
|
+
encoding: 'utf8',
|
|
347
|
+
timeout: PACK_TIMEOUT_MS,
|
|
348
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
349
|
+
});
|
|
350
|
+
const tgzFilename = output.trim().split(/\r?\n/).pop().trim();
|
|
351
|
+
const tgzPath = path.join(pkgCacheDir, tgzFilename);
|
|
352
|
+
|
|
353
|
+
if (fs.existsSync(tgzPath)) {
|
|
354
|
+
extractTgz(tgzPath, pkgCacheDir);
|
|
355
|
+
try { fs.unlinkSync(tgzPath); } catch { /* ignore */ }
|
|
356
|
+
if (fs.existsSync(path.join(pkgCacheDir, 'package'))) {
|
|
357
|
+
extractedDir = path.join(pkgCacheDir, 'package');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
} catch {
|
|
361
|
+
// Download failed — mark as unavailable (possibly removed between check and download)
|
|
362
|
+
entry.status = 'unavailable';
|
|
363
|
+
entry.error = 'npm pack failed';
|
|
364
|
+
fs.rmSync(pkgCacheDir, { recursive: true, force: true });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!extractedDir) {
|
|
369
|
+
if (entry.status === 'available') {
|
|
370
|
+
entry.status = 'error';
|
|
371
|
+
entry.error = 'extraction failed';
|
|
372
|
+
scanErrors++;
|
|
373
|
+
}
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Scan
|
|
378
|
+
try {
|
|
379
|
+
const result = await Promise.race([
|
|
380
|
+
run(extractedDir, { _capture: true }),
|
|
381
|
+
new Promise(function(_, reject) {
|
|
382
|
+
setTimeout(function() { reject(new Error('scan timeout')); }, SCAN_TIMEOUT_MS);
|
|
383
|
+
})
|
|
384
|
+
]);
|
|
385
|
+
|
|
386
|
+
const score = result.summary.riskScore || 0;
|
|
387
|
+
entry.score = score;
|
|
388
|
+
entry.detected = score >= 20;
|
|
389
|
+
entry.threat_count = result.summary.total || 0;
|
|
390
|
+
entry.threats = (result.threats || []).slice(0, 20).map(function(t) {
|
|
391
|
+
return { type: t.type, severity: t.severity, file: t.file };
|
|
392
|
+
});
|
|
393
|
+
entry.status = 'scanned';
|
|
394
|
+
|
|
395
|
+
scanned++;
|
|
396
|
+
if (entry.detected) detected++;
|
|
397
|
+
} catch (err) {
|
|
398
|
+
entry.status = 'error';
|
|
399
|
+
entry.error = (err.message || '').slice(0, 100);
|
|
400
|
+
entry.score = 0;
|
|
401
|
+
entry.detected = false;
|
|
402
|
+
scanErrors++;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Memory management
|
|
406
|
+
clearFileListCache();
|
|
407
|
+
scanCount++;
|
|
408
|
+
if (scanCount % 20 === 0 && global.gc) {
|
|
409
|
+
global.gc();
|
|
410
|
+
const used = Math.round(process.memoryUsage().heapUsed / 1024 / 1024);
|
|
411
|
+
console.log('\n [Memory] ' + used + ' MB after ' + scanCount + ' scans');
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (process.stdout.isTTY) {
|
|
416
|
+
process.stdout.write('\r' + ''.padEnd(80) + '\r');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
console.log(' Scanned: ' + scanned + ', Detected: ' + detected + ', Errors: ' + scanErrors);
|
|
420
|
+
return { scanned, detected, scanErrors };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// --- Step 5: Save results ---
|
|
424
|
+
function saveResults(sample, index, stats) {
|
|
425
|
+
console.log('\n[5/5] Saving results...');
|
|
426
|
+
|
|
427
|
+
// Compute per-source breakdown
|
|
428
|
+
const bySource = {};
|
|
429
|
+
for (const entry of sample) {
|
|
430
|
+
const src = entry.source || 'unknown';
|
|
431
|
+
if (!bySource[src]) bySource[src] = { total: 0, scanned: 0, detected: 0, unavailable: 0 };
|
|
432
|
+
bySource[src].total++;
|
|
433
|
+
if (entry.status === 'scanned') {
|
|
434
|
+
bySource[src].scanned++;
|
|
435
|
+
if (entry.detected) bySource[src].detected++;
|
|
436
|
+
} else if (entry.status === 'unavailable') {
|
|
437
|
+
bySource[src].unavailable++;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Compute TPR per source
|
|
442
|
+
for (const src of Object.keys(bySource)) {
|
|
443
|
+
const d = bySource[src];
|
|
444
|
+
d.tpr = d.scanned > 0 ? d.detected / d.scanned : 0;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Score distribution
|
|
448
|
+
const scannedEntries = sample.filter(e => e.status === 'scanned');
|
|
449
|
+
const scoreDistribution = { '0': 0, '1-9': 0, '10-19': 0, '20-49': 0, '50-74': 0, '75-100': 0 };
|
|
450
|
+
for (const e of scannedEntries) {
|
|
451
|
+
const s = e.score || 0;
|
|
452
|
+
if (s === 0) scoreDistribution['0']++;
|
|
453
|
+
else if (s <= 9) scoreDistribution['1-9']++;
|
|
454
|
+
else if (s <= 19) scoreDistribution['10-19']++;
|
|
455
|
+
else if (s <= 49) scoreDistribution['20-49']++;
|
|
456
|
+
else if (s <= 74) scoreDistribution['50-74']++;
|
|
457
|
+
else scoreDistribution['75-100']++;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const results = {
|
|
461
|
+
metadata: {
|
|
462
|
+
benchmark: 'OpenSSF Malicious Packages',
|
|
463
|
+
version: 'v1',
|
|
464
|
+
repo: 'https://github.com/ossf/malicious-packages',
|
|
465
|
+
scanned_at: new Date().toISOString(),
|
|
466
|
+
seed: SEED,
|
|
467
|
+
total_osv_npm_unique: index.length,
|
|
468
|
+
sampled: sample.length,
|
|
469
|
+
available_on_npm: sample.filter(e => e.status === 'scanned' || e.status === 'available').length,
|
|
470
|
+
scanned: stats.scanned,
|
|
471
|
+
detected: stats.detected,
|
|
472
|
+
missed: stats.scanned - stats.detected,
|
|
473
|
+
errors: stats.scanErrors,
|
|
474
|
+
unavailable: sample.filter(e => e.status === 'unavailable').length,
|
|
475
|
+
threshold: 20,
|
|
476
|
+
tpr: stats.scanned > 0 ? ((stats.detected / stats.scanned * 100).toFixed(1) + '%') : 'N/A',
|
|
477
|
+
coverage: ((stats.scanned / sample.length * 100).toFixed(1) + '%'),
|
|
478
|
+
by_source: bySource,
|
|
479
|
+
score_distribution: scoreDistribution
|
|
480
|
+
},
|
|
481
|
+
results: sample.map(function(e) {
|
|
482
|
+
return {
|
|
483
|
+
name: e.name,
|
|
484
|
+
version: e.version,
|
|
485
|
+
osv_id: e.osv_id,
|
|
486
|
+
source: e.source,
|
|
487
|
+
status: e.status,
|
|
488
|
+
score: e.score || 0,
|
|
489
|
+
detected: e.detected || false,
|
|
490
|
+
threat_count: e.threat_count || 0,
|
|
491
|
+
threats: e.threats || [],
|
|
492
|
+
error: e.error || undefined
|
|
493
|
+
};
|
|
494
|
+
})
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
fs.mkdirSync(path.dirname(RESULTS_FILE), { recursive: true });
|
|
498
|
+
fs.writeFileSync(RESULTS_FILE, JSON.stringify(results, null, 2));
|
|
499
|
+
console.log(' Saved to: ' + RESULTS_FILE);
|
|
500
|
+
|
|
501
|
+
return results;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// --- Main ---
|
|
505
|
+
async function main() {
|
|
506
|
+
const startTime = Date.now();
|
|
507
|
+
|
|
508
|
+
console.log('='.repeat(60));
|
|
509
|
+
console.log(' MUAD\'DIB OpenSSF Benchmark');
|
|
510
|
+
console.log(' Sample: ' + SAMPLE_SIZE + ', Seed: ' + SEED);
|
|
511
|
+
console.log('='.repeat(60));
|
|
512
|
+
|
|
513
|
+
// 1. Fetch full OSV npm index
|
|
514
|
+
const index = await fetchOSSFIndex();
|
|
515
|
+
|
|
516
|
+
// 2. Stratified sample
|
|
517
|
+
const sample = stratifySample(index, SAMPLE_SIZE, SEED);
|
|
518
|
+
|
|
519
|
+
// 3. Check npm availability
|
|
520
|
+
await checkAvailability(sample);
|
|
521
|
+
|
|
522
|
+
// 4. Download + scan
|
|
523
|
+
const stats = await downloadAndScan(sample);
|
|
524
|
+
|
|
525
|
+
// 5. Save results
|
|
526
|
+
const results = saveResults(sample, index, stats);
|
|
527
|
+
|
|
528
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
529
|
+
|
|
530
|
+
console.log('\n' + '='.repeat(60));
|
|
531
|
+
console.log(' RESULTS');
|
|
532
|
+
console.log('='.repeat(60));
|
|
533
|
+
console.log(' Total OSV npm unique: ' + index.length);
|
|
534
|
+
console.log(' Sampled: ' + sample.length);
|
|
535
|
+
console.log(' Available on npm: ' + results.metadata.available_on_npm);
|
|
536
|
+
console.log(' Scanned: ' + stats.scanned);
|
|
537
|
+
console.log(' Detected (score>=20): ' + stats.detected);
|
|
538
|
+
console.log(' TPR: ' + results.metadata.tpr);
|
|
539
|
+
console.log(' Coverage: ' + results.metadata.coverage);
|
|
540
|
+
console.log(' Elapsed: ' + elapsed + 's');
|
|
541
|
+
console.log('='.repeat(60) + '\n');
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
main().catch(function(err) {
|
|
545
|
+
console.error('[FATAL] ' + err.message);
|
|
546
|
+
console.error(err.stack);
|
|
547
|
+
process.exit(1);
|
|
548
|
+
});
|
|
@@ -132,7 +132,7 @@ async function detectPublishAnomaly(packageName) {
|
|
|
132
132
|
const spanHours = Math.round(spanMs / MS_PER_HOUR * 10) / 10;
|
|
133
133
|
findings.push({
|
|
134
134
|
type: 'publish_burst',
|
|
135
|
-
severity: '
|
|
135
|
+
severity: 'LOW',
|
|
136
136
|
description: `${inWindow.length} versions published in ${spanHours} hours (avg interval: ${stats.avgIntervalDays} days)`,
|
|
137
137
|
versions: inWindow.map(e => e.version)
|
|
138
138
|
});
|
package/src/ioc/scraper.js
CHANGED
|
@@ -1283,12 +1283,99 @@ async function runScraper() {
|
|
|
1283
1283
|
};
|
|
1284
1284
|
}
|
|
1285
1285
|
|
|
1286
|
+
// ============================================
|
|
1287
|
+
// SOURCE 5: OSV.dev Lightweight API
|
|
1288
|
+
// Used by `muaddib update` (fast, no zip download)
|
|
1289
|
+
// ============================================
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* Lightweight OSV.dev query — fetches recent npm MAL-* entries via REST API.
|
|
1293
|
+
* Used by `muaddib update` as a fast complement to the full zip scrape.
|
|
1294
|
+
* @returns {Promise<Array>} Parsed IOC package entries
|
|
1295
|
+
*/
|
|
1296
|
+
async function scrapeOSVLightweightAPI() {
|
|
1297
|
+
console.log('[SCRAPER] OSV.dev lightweight API...');
|
|
1298
|
+
const packages = [];
|
|
1299
|
+
|
|
1300
|
+
try {
|
|
1301
|
+
const resp = await fetchJSON('https://api.osv.dev/v1/query', {
|
|
1302
|
+
method: 'POST',
|
|
1303
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1304
|
+
body: { package: { ecosystem: 'npm' } }
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
if (resp.status === 200 && resp.data && resp.data.vulns) {
|
|
1308
|
+
for (const vuln of resp.data.vulns) {
|
|
1309
|
+
if (vuln.id && vuln.id.startsWith('MAL-')) {
|
|
1310
|
+
const parsed = parseOSVEntry(vuln, 'osv-api');
|
|
1311
|
+
for (const p of parsed) packages.push(p);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
console.log('[SCRAPER] ' + packages.length + ' MAL-* packages from OSV API');
|
|
1317
|
+
} catch (e) {
|
|
1318
|
+
console.log('[SCRAPER] OSV API error: ' + e.message);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
return packages;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Batch query OSV.dev for specific package names.
|
|
1326
|
+
* Returns all MAL-* entries matching the given packages.
|
|
1327
|
+
* Used by the OpenSSF benchmark script (W1).
|
|
1328
|
+
* @param {string[]} packageNames - npm package names to query
|
|
1329
|
+
* @returns {Promise<Array>} Parsed IOC entries with osv_id
|
|
1330
|
+
*/
|
|
1331
|
+
async function queryOSVBatch(packageNames) {
|
|
1332
|
+
const BATCH_SIZE = 1000;
|
|
1333
|
+
const allResults = [];
|
|
1334
|
+
|
|
1335
|
+
for (let i = 0; i < packageNames.length; i += BATCH_SIZE) {
|
|
1336
|
+
const batch = packageNames.slice(i, i + BATCH_SIZE);
|
|
1337
|
+
const queries = batch.map(function(name) {
|
|
1338
|
+
return { package: { name: name, ecosystem: 'npm' } };
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
try {
|
|
1342
|
+
const resp = await fetchJSON('https://api.osv.dev/v1/querybatch', {
|
|
1343
|
+
method: 'POST',
|
|
1344
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1345
|
+
body: { queries: queries }
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
if (resp.status === 200 && resp.data && resp.data.results) {
|
|
1349
|
+
for (let j = 0; j < resp.data.results.length; j++) {
|
|
1350
|
+
const vulns = resp.data.results[j].vulns || [];
|
|
1351
|
+
for (const vuln of vulns) {
|
|
1352
|
+
if (vuln.id && vuln.id.startsWith('MAL-')) {
|
|
1353
|
+
const parsed = parseOSVEntry(vuln, 'osv-batch');
|
|
1354
|
+
for (const p of parsed) allResults.push(p);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
} catch (e) {
|
|
1360
|
+
console.log('[SCRAPER] OSV batch error (offset ' + i + '): ' + e.message);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Courtesy delay between batches
|
|
1364
|
+
if (i + BATCH_SIZE < packageNames.length) {
|
|
1365
|
+
await new Promise(function(r) { setTimeout(r, 200); });
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
return allResults;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1286
1372
|
// Test helpers for aggregated warning counters
|
|
1287
1373
|
function getNoVersionSkipCount() { return _noVersionSkipCount; }
|
|
1288
1374
|
function resetNoVersionSkipCount() { _noVersionSkipCount = 0; }
|
|
1289
1375
|
|
|
1290
1376
|
module.exports = {
|
|
1291
1377
|
runScraper, scrapeShaiHuludDetector, scrapeDatadogIOCs,
|
|
1378
|
+
scrapeOSVLightweightAPI, queryOSVBatch,
|
|
1292
1379
|
// Pure utility functions (exported for testing)
|
|
1293
1380
|
parseCSVLine, parseCSV, extractVersions, parseOSVEntry,
|
|
1294
1381
|
createFreshness, isAllowedRedirect,
|
package/src/ioc/updater.js
CHANGED
|
@@ -39,24 +39,25 @@ async function updateIOCs() {
|
|
|
39
39
|
mergeIOCs(baseIOCs, yamlStandard);
|
|
40
40
|
console.log('[2/4] YAML IOCs: ' + yamlStandard.packages.length + ' packages, ' + yamlStandard.hashes.length + ' hashes');
|
|
41
41
|
|
|
42
|
-
// Step 3: Download additional IOCs from GitHub (GenSecAI + DataDog
|
|
43
|
-
const { scrapeShaiHuludDetector, scrapeDatadogIOCs } = require('./scraper.js');
|
|
44
|
-
console.log('[3/4] Downloading GitHub IOCs...');
|
|
42
|
+
// Step 3: Download additional IOCs from GitHub + OSV API (GenSecAI + DataDog + OSV lightweight)
|
|
43
|
+
const { scrapeShaiHuludDetector, scrapeDatadogIOCs, scrapeOSVLightweightAPI } = require('./scraper.js');
|
|
44
|
+
console.log('[3/4] Downloading GitHub + OSV API IOCs...');
|
|
45
45
|
|
|
46
|
-
const [shaiHulud, datadog] = await Promise.all([
|
|
46
|
+
const [shaiHulud, datadog, osvApi] = await Promise.all([
|
|
47
47
|
scrapeShaiHuludDetector(),
|
|
48
|
-
scrapeDatadogIOCs()
|
|
48
|
+
scrapeDatadogIOCs(),
|
|
49
|
+
scrapeOSVLightweightAPI()
|
|
49
50
|
]);
|
|
50
51
|
|
|
51
52
|
const githubIOCs = {
|
|
52
|
-
packages: [].concat(shaiHulud.packages, datadog.packages),
|
|
53
|
+
packages: [].concat(shaiHulud.packages, datadog.packages, osvApi),
|
|
53
54
|
pypi_packages: [],
|
|
54
55
|
hashes: [].concat(shaiHulud.hashes || [], datadog.hashes || []),
|
|
55
56
|
markers: [],
|
|
56
57
|
files: []
|
|
57
58
|
};
|
|
58
59
|
mergeIOCs(baseIOCs, githubIOCs);
|
|
59
|
-
console.log(' +' + shaiHulud.packages.length + ' GenSecAI, +' + datadog.packages.length + ' DataDog');
|
|
60
|
+
console.log(' +' + shaiHulud.packages.length + ' GenSecAI, +' + datadog.packages.length + ' DataDog, +' + osvApi.length + ' OSV API');
|
|
60
61
|
|
|
61
62
|
// Step 3b: Load existing cache IOCs (from bootstrap download or previous update)
|
|
62
63
|
if (fs.existsSync(CACHE_IOC_FILE)) {
|
|
@@ -90,7 +91,7 @@ async function updateIOCs() {
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
baseIOCs.updated = new Date().toISOString();
|
|
93
|
-
baseIOCs.sources = ['compact', 'yaml', 'shai-hulud-detector', 'datadog', 'cache'];
|
|
94
|
+
baseIOCs.sources = ['compact', 'yaml', 'shai-hulud-detector', 'datadog', 'osv-api', 'cache'];
|
|
94
95
|
|
|
95
96
|
// Clean internal dedup sets before serialization
|
|
96
97
|
delete baseIOCs._pkgKeys;
|
package/src/ml/llm-detective.js
CHANGED
|
@@ -92,9 +92,15 @@ function resetDailyCounter() {
|
|
|
92
92
|
_dailyCounter.resetDate = null;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// ── Credit exhaustion kill switch (session-level) ──
|
|
96
|
+
// When the Anthropic API returns a 400 with "credit balance is too low",
|
|
97
|
+
// we disable the LLM for the rest of the session to avoid error spam.
|
|
98
|
+
let _creditExhausted = false;
|
|
99
|
+
|
|
95
100
|
// ── Feature flags ──
|
|
96
101
|
|
|
97
102
|
function isLlmEnabled() {
|
|
103
|
+
if (_creditExhausted) return false;
|
|
98
104
|
if (!process.env.ANTHROPIC_API_KEY) return false;
|
|
99
105
|
const env = process.env.MUADDIB_LLM_ENABLED;
|
|
100
106
|
if (env !== undefined && env.toLowerCase() === 'false') return false;
|
|
@@ -441,6 +447,14 @@ async function callAnthropicAPI(system, messages) {
|
|
|
441
447
|
}
|
|
442
448
|
|
|
443
449
|
const errorText = await response.text().catch(() => '');
|
|
450
|
+
|
|
451
|
+
// Credit exhaustion: disable LLM for entire session (not just this call)
|
|
452
|
+
if (response.status === 400 && /credit balance is too low/i.test(errorText)) {
|
|
453
|
+
_creditExhausted = true;
|
|
454
|
+
console.warn('[LLM] API credits exhausted — LLM Detective disabled for this session');
|
|
455
|
+
throw new Error('API credits exhausted');
|
|
456
|
+
}
|
|
457
|
+
|
|
444
458
|
throw new Error(`API ${response.status}: ${errorText.slice(0, 200)}`);
|
|
445
459
|
} catch (err) {
|
|
446
460
|
clearTimeout(timeout);
|
|
@@ -602,6 +616,14 @@ function resetLlmLimiter() {
|
|
|
602
616
|
_semaphore.queue.length = 0;
|
|
603
617
|
}
|
|
604
618
|
|
|
619
|
+
function resetCreditExhausted() {
|
|
620
|
+
_creditExhausted = false;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function isCreditExhausted() {
|
|
624
|
+
return _creditExhausted;
|
|
625
|
+
}
|
|
626
|
+
|
|
605
627
|
module.exports = {
|
|
606
628
|
investigatePackage,
|
|
607
629
|
isLlmEnabled,
|
|
@@ -614,6 +636,8 @@ module.exports = {
|
|
|
614
636
|
resetStats,
|
|
615
637
|
resetDailyCounter,
|
|
616
638
|
resetLlmLimiter,
|
|
639
|
+
resetCreditExhausted,
|
|
640
|
+
isCreditExhausted,
|
|
617
641
|
// Exported for testing
|
|
618
642
|
collectSourceContext,
|
|
619
643
|
buildPrompt,
|
package/src/monitor/classify.js
CHANGED
|
@@ -331,6 +331,35 @@ function evaluateCacheTrigger(name, docMeta, doc) {
|
|
|
331
331
|
return { shouldCache: false, reason: '', retentionDays: 0 };
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Determine if a first-publish package is high-risk and should be sandboxed
|
|
336
|
+
* even with a clean static scan (0 findings).
|
|
337
|
+
*
|
|
338
|
+
* First-publish is where malware concentrates: new packages from unknown
|
|
339
|
+
* maintainers with no linked repository are the highest-risk population.
|
|
340
|
+
*
|
|
341
|
+
* @param {Object|null} cacheTrigger - From evaluateCacheTrigger()
|
|
342
|
+
* @param {Object|null} npmRegistryMeta - From getPackageMetadata()
|
|
343
|
+
* @returns {boolean} true if package should be sandboxed regardless of static score
|
|
344
|
+
*/
|
|
345
|
+
function isFirstPublishHighRisk(cacheTrigger, npmRegistryMeta) {
|
|
346
|
+
if (!cacheTrigger || cacheTrigger.reason !== 'first_publish') return false;
|
|
347
|
+
|
|
348
|
+
// With registry metadata, require at least one additional risk signal
|
|
349
|
+
if (npmRegistryMeta) {
|
|
350
|
+
// No linked repository — high risk
|
|
351
|
+
if (!npmRegistryMeta.has_repository) return true;
|
|
352
|
+
// New maintainer (only 1 package ever published)
|
|
353
|
+
if (npmRegistryMeta.author_package_count <= 1) return true;
|
|
354
|
+
// Package age < 1 day with registry metadata but no strong signals — skip sandbox
|
|
355
|
+
// (has repo + experienced maintainer = likely legitimate)
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Without registry metadata, sandbox by default (precautionary)
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
|
|
334
363
|
module.exports = {
|
|
335
364
|
// Constants
|
|
336
365
|
IOC_MATCH_TYPES,
|
|
@@ -367,4 +396,5 @@ module.exports = {
|
|
|
367
396
|
setVerboseMode,
|
|
368
397
|
quickTyposquatCheck,
|
|
369
398
|
evaluateCacheTrigger,
|
|
399
|
+
isFirstPublishHighRisk,
|
|
370
400
|
};
|
package/src/monitor/queue.js
CHANGED
|
@@ -54,6 +54,7 @@ const {
|
|
|
54
54
|
classifyError,
|
|
55
55
|
formatFindings,
|
|
56
56
|
evaluateCacheTrigger,
|
|
57
|
+
isFirstPublishHighRisk,
|
|
57
58
|
POPULAR_THRESHOLD,
|
|
58
59
|
downloadsCache: classifyDownloadsCache,
|
|
59
60
|
DOWNLOADS_CACHE_TTL,
|
|
@@ -109,6 +110,11 @@ const SCAN_TIMEOUT_MS = 180_000; // 3 minutes per package
|
|
|
109
110
|
const STATIC_SCAN_TIMEOUT_MS = 45_000; // 45s for static analysis only
|
|
110
111
|
const LARGE_PACKAGE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
111
112
|
|
|
113
|
+
// First-publish sandbox: max pending sandbox items before deferring first-publish clean scans
|
|
114
|
+
// Prevents starving T1a sandbox capacity when many first-publish packages arrive at once
|
|
115
|
+
const FIRST_PUBLISH_SANDBOX_MAX_QUEUE = parseInt(process.env.MUADDIB_FIRST_PUBLISH_SANDBOX_MAX_QUEUE, 10) || 10;
|
|
116
|
+
const FIRST_PUBLISH_SANDBOX_ENABLED = process.env.MUADDIB_FIRST_PUBLISH_SANDBOX !== '0';
|
|
117
|
+
|
|
112
118
|
// --- Bundled tooling false-positive filter ---
|
|
113
119
|
|
|
114
120
|
const KNOWN_BUNDLED_FILES = ['yarn.js', 'webpack.js', 'terser.js', 'esbuild.js', 'polyfills.js'];
|
|
@@ -401,10 +407,14 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
|
|
|
401
407
|
throw staticErr;
|
|
402
408
|
}
|
|
403
409
|
|
|
404
|
-
//
|
|
410
|
+
// First-publish detection: used for sandbox priority below
|
|
411
|
+
const isFirstPublish = cacheTrigger && cacheTrigger.reason === 'first_publish';
|
|
412
|
+
|
|
413
|
+
// ML Phase 2a: Fetch npm registry metadata once for packages with findings
|
|
414
|
+
// OR for first-publish packages (needed for isFirstPublishHighRisk decision).
|
|
405
415
|
// Reused for both training records (enriched features) and reputation scoring.
|
|
406
416
|
let npmRegistryMeta = null;
|
|
407
|
-
if (result.summary.total > 0 && ecosystem === 'npm') {
|
|
417
|
+
if ((result.summary.total > 0 || isFirstPublish) && ecosystem === 'npm') {
|
|
408
418
|
try {
|
|
409
419
|
const { getPackageMetadata } = require('../scanner/npm-registry.js');
|
|
410
420
|
npmRegistryMeta = await getPackageMetadata(name);
|
|
@@ -413,15 +423,61 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
|
|
|
413
423
|
}
|
|
414
424
|
}
|
|
415
425
|
|
|
426
|
+
// First-publish sandbox priority: sandbox even with 0 static findings
|
|
427
|
+
// if the package is from a new/unknown maintainer without a linked repository.
|
|
428
|
+
const firstPublishSandbox = isFirstPublish &&
|
|
429
|
+
FIRST_PUBLISH_SANDBOX_ENABLED &&
|
|
430
|
+
isFirstPublishHighRisk(cacheTrigger, npmRegistryMeta) &&
|
|
431
|
+
isSandboxEnabled() && sandboxAvailable &&
|
|
432
|
+
scanQueue.length < FIRST_PUBLISH_SANDBOX_MAX_QUEUE;
|
|
433
|
+
|
|
416
434
|
if (result.summary.total === 0) {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
435
|
+
if (firstPublishSandbox) {
|
|
436
|
+
// First-publish sandbox priority: run sandbox even with 0 static findings
|
|
437
|
+
console.log(`[MONITOR] FIRST-PUBLISH SANDBOX: ${name}@${version} (0 findings, sandboxing anyway)`);
|
|
438
|
+
stats.firstPublishSandboxed = (stats.firstPublishSandboxed || 0) + 1;
|
|
439
|
+
|
|
440
|
+
let sandboxResult = null;
|
|
441
|
+
try {
|
|
442
|
+
const canary = isCanaryEnabled();
|
|
443
|
+
console.log(`[MONITOR] SANDBOX (first-publish): launching for ${name}@${version}${canary ? ' (canary: on)' : ''}...`);
|
|
444
|
+
sandboxResult = await runSandbox(name, { canary });
|
|
445
|
+
console.log(`[MONITOR] SANDBOX: ${name}@${version} → score: ${sandboxResult.score}, severity: ${sandboxResult.severity}`);
|
|
446
|
+
} catch (err) {
|
|
447
|
+
console.error(`[MONITOR] SANDBOX ERROR: ${name}@${version} — ${err.message}`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const sandboxScore = sandboxResult ? (sandboxResult.score || 0) : 0;
|
|
451
|
+
if (sandboxScore > 0) {
|
|
452
|
+
// Sandbox found something — treat as suspect
|
|
453
|
+
stats.suspect++;
|
|
454
|
+
stats.scanned++;
|
|
455
|
+
const elapsed = Date.now() - startTime;
|
|
456
|
+
stats.totalTimeMs += elapsed;
|
|
457
|
+
updateScanStats('suspect');
|
|
458
|
+
recordTrainingSample(result, { name, version, ecosystem, label: 'suspect', registryMeta: meta, unpackedSize: meta.unpackedSize, npmRegistryMeta, fileCountTotal, hasTests });
|
|
459
|
+
return { sandboxResult, staticClean: false, firstPublishSandbox: true };
|
|
460
|
+
} else {
|
|
461
|
+
// Sandbox clean — still CLEAN
|
|
462
|
+
stats.scanned++;
|
|
463
|
+
const elapsed = Date.now() - startTime;
|
|
464
|
+
stats.totalTimeMs += elapsed;
|
|
465
|
+
stats.clean++;
|
|
466
|
+
console.log(`[MONITOR] CLEAN (first-publish sandbox OK): ${name}@${version} (${(elapsed / 1000).toFixed(1)}s)`);
|
|
467
|
+
updateScanStats('clean');
|
|
468
|
+
recordTrainingSample(result, { name, version, ecosystem, label: 'clean', registryMeta: meta, unpackedSize: meta.unpackedSize, npmRegistryMeta, fileCountTotal, hasTests });
|
|
469
|
+
return { sandboxResult, staticClean: true, firstPublishSandbox: true };
|
|
470
|
+
}
|
|
471
|
+
} else {
|
|
472
|
+
stats.scanned++;
|
|
473
|
+
const elapsed = Date.now() - startTime;
|
|
474
|
+
stats.totalTimeMs += elapsed;
|
|
475
|
+
stats.clean++;
|
|
476
|
+
console.log(`[MONITOR] CLEAN: ${name}@${version} (0 findings, ${(elapsed / 1000).toFixed(1)}s)`);
|
|
477
|
+
updateScanStats('clean');
|
|
478
|
+
recordTrainingSample(result, { name, version, ecosystem, label: 'clean', registryMeta: meta, unpackedSize: meta.unpackedSize, npmRegistryMeta, fileCountTotal, hasTests });
|
|
479
|
+
return { sandboxResult: null, staticClean: true };
|
|
480
|
+
}
|
|
425
481
|
} else {
|
|
426
482
|
const counts = [];
|
|
427
483
|
if (result.summary.critical > 0) counts.push(`${result.summary.critical} CRITICAL`);
|
|
@@ -1023,6 +1079,8 @@ module.exports = {
|
|
|
1023
1079
|
SCAN_TIMEOUT_MS,
|
|
1024
1080
|
STATIC_SCAN_TIMEOUT_MS,
|
|
1025
1081
|
LARGE_PACKAGE_SIZE,
|
|
1082
|
+
FIRST_PUBLISH_SANDBOX_MAX_QUEUE,
|
|
1083
|
+
FIRST_PUBLISH_SANDBOX_ENABLED,
|
|
1026
1084
|
KNOWN_BUNDLED_FILES,
|
|
1027
1085
|
KNOWN_BUNDLED_PATHS,
|
|
1028
1086
|
ML_EXCLUDED_DIRS,
|
package/src/scoring.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { getRule } = require('./rules/index.js');
|
|
2
|
+
const { HIGH_CONFIDENCE_MALICE_TYPES } = require('./monitor/classify.js');
|
|
2
3
|
|
|
3
4
|
// ============================================
|
|
4
5
|
// SCORING CONSTANTS
|
|
@@ -873,7 +874,22 @@ function calculateRiskScore(deduped, intentResult) {
|
|
|
873
874
|
}
|
|
874
875
|
|
|
875
876
|
// 7. Final score = max file score + cross-file bonus + intent bonus + package-level score + lifecycle boost, capped at 100
|
|
876
|
-
|
|
877
|
+
let riskScore = Math.min(MAX_RISK_SCORE, maxFileScore + crossFileBonus + intentBonus + packageScore + lifecycleBoost);
|
|
878
|
+
|
|
879
|
+
// 7b. MT-1: Score ceiling for packages without lifecycle scripts.
|
|
880
|
+
// 56% of real malware uses install scripts. Packages without lifecycle that score high
|
|
881
|
+
// (minified bundles, frameworks) are quasi-exclusively false positives.
|
|
882
|
+
// Cap at 35 to prevent webhook triggers (threshold ~20-25 post-reputation).
|
|
883
|
+
// Bypass: HC malice types, compound detections — these are never benign regardless of lifecycle.
|
|
884
|
+
const _hasLifecycle = deduped.some(t =>
|
|
885
|
+
t.type === 'lifecycle_script' || t.type === 'lifecycle_file_exec' ||
|
|
886
|
+
t.type === 'lifecycle_shell_pipe' || t.type === 'lifecycle_remote_fetch'
|
|
887
|
+
);
|
|
888
|
+
const _hasHC = deduped.some(t => HIGH_CONFIDENCE_MALICE_TYPES.has(t.type));
|
|
889
|
+
const _hasCompound = deduped.some(t => t.compound === true);
|
|
890
|
+
if (!_hasLifecycle && !_hasHC && !_hasCompound) {
|
|
891
|
+
riskScore = Math.min(riskScore, 35);
|
|
892
|
+
}
|
|
877
893
|
|
|
878
894
|
// 8. Old global score for comparison (sum of ALL findings)
|
|
879
895
|
const globalRiskScore = computeGroupScore(deduped);
|