muaddib-scanner 2.2.19 → 2.2.22

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 CHANGED
@@ -1,850 +1,869 @@
1
- #!/usr/bin/env node
2
- const { exec } = require('child_process');
3
- const { run } = require('../src/index.js');
4
- const { updateIOCs } = require('../src/ioc/updater.js');
5
- const { watch } = require('../src/watch.js');
6
- const { startDaemon } = require('../src/daemon.js');
7
- const { runScraper } = require('../src/ioc/scraper.js');
8
- const { safeInstall } = require('../src/safe-install.js');
9
- const { buildSandboxImage, runSandbox, generateNetworkReport } = require('../src/sandbox.js');
10
- const { diff, showRefs } = require('../src/diff.js');
11
- const { initHooks, removeHooks } = require('../src/hooks-init.js');
12
-
13
- const args = process.argv.slice(2);
14
- const command = args[0];
15
- const options = args.slice(1);
16
-
17
- // Parse options
18
- let target = '.';
19
- let jsonOutput = false;
20
- let htmlOutput = null;
21
- let sarifOutput = null;
22
- let explainMode = false;
23
- let failLevel = 'high';
24
- let webhookUrl = null;
25
- let paranoidMode = false;
26
- let excludeDirs = [];
27
- let entropyThreshold = null;
28
- let temporalMode = false;
29
- let temporalAstMode = false;
30
- let temporalPublishMode = false;
31
- let temporalMaintainerMode = false;
32
- let temporalFullMode = false;
33
- let breakdownMode = false;
34
- let noDeobfuscate = false;
35
- let noModuleGraph = false;
36
- let feedLimit = null;
37
- let feedSeverity = null;
38
- let feedSince = null;
39
- let servePort = null;
40
-
41
- for (let i = 0; i < options.length; i++) {
42
- if (options[i] === '--json') {
43
- jsonOutput = true;
44
- } else if (options[i] === '--html') {
45
- const htmlPath = options[i + 1] || 'muaddib-report.html';
46
- // CLI-001: Block path traversal
47
- if (htmlPath.includes('..')) {
48
- console.error('[ERROR] --html path must not contain path traversal (..)');
49
- process.exit(1);
50
- }
51
- htmlOutput = htmlPath;
52
- i++;
53
- } else if (options[i] === '--sarif') {
54
- const sarifPath = options[i + 1] || 'muaddib-results.sarif';
55
- // CLI-001: Block path traversal
56
- if (sarifPath.includes('..')) {
57
- console.error('[ERROR] --sarif path must not contain path traversal (..)');
58
- process.exit(1);
59
- }
60
- sarifOutput = sarifPath;
61
- i++;
62
- } else if (options[i] === '--explain') {
63
- explainMode = true;
64
- } else if (options[i] === '--fail-on') {
65
- failLevel = options[i + 1] || 'high';
66
- i++;
67
- } else if (options[i] === '--webhook') {
68
- const rawUrl = options[i + 1];
69
- // CLI-002: Validate webhook URL (HTTPS only, no private IPs)
70
- if (rawUrl) {
71
- try {
72
- const parsed = new URL(rawUrl);
73
- if (parsed.protocol !== 'https:') {
74
- console.error('[ERROR] --webhook URL must use HTTPS');
75
- process.exit(1);
76
- }
77
- const host = parsed.hostname.toLowerCase();
78
- if (/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|0\.|169\.254\.|localhost$|::1$)/.test(host)) {
79
- console.error('[ERROR] --webhook URL must not point to a private/local address');
80
- process.exit(1);
81
- }
82
- } catch {
83
- console.error('[ERROR] --webhook URL is invalid');
84
- process.exit(1);
85
- }
86
- }
87
- webhookUrl = rawUrl;
88
- i++;
89
- } else if (options[i] === '--exclude') {
90
- if (options[i + 1] && !options[i + 1].startsWith('-')) {
91
- excludeDirs.push(options[i + 1]);
92
- i++;
93
- }
94
- } else if (options[i] === '--entropy-threshold') {
95
- const val = parseFloat(options[i + 1]);
96
- if (!isNaN(val) && val > 0 && val <= 8) {
97
- entropyThreshold = val;
98
- } else {
99
- console.error('[ERROR] --entropy-threshold must be a number between 0 and 8');
100
- process.exit(1);
101
- }
102
- i++;
103
- } else if (options[i] === '--paranoid') {
104
- paranoidMode = true;
105
- } else if (options[i] === '--temporal-full') {
106
- temporalFullMode = true;
107
- } else if (options[i] === '--temporal-ast') {
108
- temporalAstMode = true;
109
- } else if (options[i] === '--temporal-publish') {
110
- temporalPublishMode = true;
111
- } else if (options[i] === '--temporal-maintainer') {
112
- temporalMaintainerMode = true;
113
- } else if (options[i] === '--breakdown') {
114
- breakdownMode = true;
115
- } else if (options[i] === '--no-deobfuscate') {
116
- noDeobfuscate = true;
117
- } else if (options[i] === '--no-module-graph') {
118
- noModuleGraph = true;
119
- } else if (options[i] === '--temporal') {
120
- temporalMode = true;
121
- } else if (options[i] === '--limit') {
122
- const val = parseInt(options[i + 1], 10);
123
- if (!isNaN(val) && val > 0) {
124
- feedLimit = val;
125
- }
126
- i++;
127
- } else if (options[i] === '--severity') {
128
- feedSeverity = options[i + 1] || null;
129
- i++;
130
- } else if (options[i] === '--since') {
131
- feedSince = options[i + 1] || null;
132
- i++;
133
- } else if (options[i] === '--port') {
134
- const val = parseInt(options[i + 1], 10);
135
- if (!isNaN(val) && val >= 1 && val <= 65535) {
136
- servePort = val;
137
- } else {
138
- console.error('[ERROR] --port must be a number between 1 and 65535');
139
- process.exit(1);
140
- }
141
- i++;
142
- } else if (options[i] === '--strict') {
143
- // Sandbox strict mode flag (parsed here, used by sandbox commands)
144
- } else if (options[i] === '--no-canary') {
145
- // Sandbox canary disable flag (parsed here, used by sandbox commands)
146
- } else if (!options[i].startsWith('-')) {
147
- target = options[i];
148
- }
149
- }
150
-
151
- // Version check (truly non-blocking, skip for machine-readable output)
152
- if (!jsonOutput && !sarifOutput && command !== 'feed' && command !== 'serve') {
153
- try {
154
- const currentVersion = require('../package.json').version;
155
- exec('npm view muaddib-scanner version', { timeout: 5000 }, (err, stdout) => {
156
- if (err) return; // No network or npm unavailable
157
- const latest = (stdout || '').toString().trim();
158
- if (latest && latest !== currentVersion) {
159
- console.log(`\n[UPDATE] New version available: ${currentVersion} -> ${latest}`);
160
- console.log(` Run: npm install -g muaddib-scanner@latest\n`);
161
- }
162
- });
163
- } catch {
164
- // Skip silently
165
- }
166
- }
167
-
168
- // Interactive menu
169
- async function interactiveMenu() {
170
- const { select, input, confirm } = await import('@inquirer/prompts');
171
-
172
- console.log(`
173
- ╔═══════════════════════════════════════════════╗
174
- ║ MUAD'DIB - npm & PyPI Supply Chain Hunter ║
175
- ║ "The worms must die." ║
176
- ╚═══════════════════════════════════════════════╝
177
- `);
178
-
179
- const action = await select({
180
- message: 'What do you want to do?',
181
- choices: [
182
- { name: 'Scan a project', value: 'scan' },
183
- { name: 'Scan with paranoid mode', value: 'scan-paranoid' },
184
- { name: 'Compare with previous version (diff)', value: 'diff' },
185
- { name: 'Install packages (safe)', value: 'install' },
186
- { name: 'Watch a project (real-time)', value: 'watch' },
187
- { name: 'Start daemon', value: 'daemon' },
188
- { name: 'Setup git hooks', value: 'init-hooks' },
189
- { name: 'Update IOCs', value: 'update' },
190
- { name: 'Scrape new IOCs', value: 'scrape' },
191
- { name: 'Sandbox analysis', value: 'sandbox' },
192
- { name: 'Threat feed (JSON)', value: 'feed' },
193
- { name: 'Threat feed server', value: 'serve' },
194
- { name: 'Quit', value: 'quit' }
195
- ]
196
- });
197
-
198
- if (action === 'quit') {
199
- console.log('Bye!');
200
- process.exit(0);
201
- }
202
-
203
- if (action === 'scan' || action === 'scan-paranoid') {
204
- const path = await input({
205
- message: 'Project path:',
206
- default: '.'
207
- });
208
-
209
- const outputFormat = await select({
210
- message: 'Output format:',
211
- choices: [
212
- { name: 'Console (default)', value: 'console' },
213
- { name: 'JSON', value: 'json' },
214
- { name: 'HTML', value: 'html' },
215
- { name: 'SARIF (GitHub Security)', value: 'sarif' }
216
- ]
217
- });
218
-
219
- const opts = {
220
- json: outputFormat === 'json',
221
- html: outputFormat === 'html' ? 'muaddib-report.html' : null,
222
- sarif: outputFormat === 'sarif' ? 'muaddib-results.sarif' : null,
223
- explain: true,
224
- failLevel: 'high',
225
- paranoid: action === 'scan-paranoid'
226
- };
227
-
228
- const exitCode = await run(path, opts);
229
- process.exit(exitCode);
230
- }
231
-
232
- if (action === 'install') {
233
- const pkgInput = await input({
234
- message: 'Package(s) to install (space-separated):'
235
- });
236
-
237
- const packages = pkgInput.split(' ').filter(p => p.trim());
238
- if (packages.length === 0) {
239
- console.log('No packages specified.');
240
- process.exit(1);
241
- }
242
-
243
- const result = await safeInstall(packages, {});
244
- process.exit(result.blocked ? 1 : 0);
245
- }
246
-
247
- if (action === 'watch') {
248
- const path = await input({
249
- message: 'Project path:',
250
- default: '.'
251
- });
252
- watch(path);
253
- }
254
-
255
- if (action === 'daemon') {
256
- const useWebhook = await confirm({
257
- message: 'Configure Discord/Slack webhook?',
258
- default: false
259
- });
260
-
261
- let webhook = null;
262
- if (useWebhook) {
263
- webhook = await input({
264
- message: 'Webhook URL:'
265
- });
266
- }
267
- startDaemon({ webhook });
268
- }
269
-
270
- if (action === 'update') {
271
- await updateIOCs();
272
- process.exit(0);
273
- }
274
-
275
- if (action === 'scrape') {
276
- const result = await runScraper();
277
- console.log(`[OK] ${result.added} new IOCs (total: ${result.total})`);
278
- process.exit(0);
279
- }
280
-
281
- if (action === 'sandbox') {
282
- const packageName = await input({
283
- message: 'Package name to analyze:'
284
- });
285
-
286
- if (!packageName.trim()) {
287
- console.log('No package specified.');
288
- process.exit(1);
289
- }
290
-
291
- const useStrict = await confirm({
292
- message: 'Enable strict mode? (blocks non-essential network)',
293
- default: false
294
- });
295
-
296
- await buildSandboxImage();
297
- const results = await runSandbox(packageName.trim(), { strict: useStrict });
298
- if (results.raw_report) {
299
- console.log(generateNetworkReport(results.raw_report));
300
- }
301
- process.exit(results.suspicious ? 1 : 0);
302
- }
303
-
304
- if (action === 'feed') {
305
- const { getFeed } = require('../src/threat-feed.js');
306
- const result = getFeed();
307
- console.log(JSON.stringify(result, null, 2));
308
- process.exit(0);
309
- }
310
-
311
- if (action === 'serve') {
312
- const { startServer } = require('../src/serve.js');
313
- startServer({ port: 3000 });
314
- // Server runs indefinitely
315
- }
316
-
317
- if (action === 'diff') {
318
- const baseRef = await input({
319
- message: 'Compare with (commit/tag/branch):',
320
- default: 'HEAD~1'
321
- });
322
-
323
- const projectPath = await input({
324
- message: 'Project path:',
325
- default: '.'
326
- });
327
-
328
- const exitCode = await diff(projectPath, baseRef, { explain: true });
329
- process.exit(exitCode);
330
- }
331
-
332
- if (action === 'init-hooks') {
333
- const hookMode = await select({
334
- message: 'Hook mode:',
335
- choices: [
336
- { name: 'Scan all threats', value: 'scan' },
337
- { name: 'Diff only (block only NEW threats)', value: 'diff' }
338
- ]
339
- });
340
-
341
- const hookType = await select({
342
- message: 'Hook system:',
343
- choices: [
344
- { name: 'Auto-detect', value: 'auto' },
345
- { name: 'Husky', value: 'husky' },
346
- { name: 'pre-commit framework', value: 'pre-commit' },
347
- { name: 'Native git hooks', value: 'git' }
348
- ]
349
- });
350
-
351
- await initHooks('.', { type: hookType, mode: hookMode });
352
- process.exit(0);
353
- }
354
- }
355
-
356
- const helpText = `
357
- MUAD'DIB - npm & PyPI Supply Chain Threat Hunter
358
-
359
- Usage:
360
- muaddib Interactive mode
361
- muaddib scan [path] [options] Scan a project
362
- muaddib diff <ref> [path] Compare threats with a previous version
363
- muaddib install <pkg> [options] Safe install (scan before install)
364
- muaddib watch [path] Watch in real-time
365
- muaddib daemon [options] Start daemon
366
- muaddib init-hooks [options] Setup git pre-commit hooks
367
- muaddib remove-hooks [path] Remove MUAD'DIB git hooks
368
- muaddib update Update IOCs
369
- muaddib scrape Scrape new IOCs
370
- muaddib sandbox <pkg> [--strict] [--no-canary] Analyze in isolated Docker container
371
- muaddib sandbox-report <pkg> Sandbox + detailed network report
372
- muaddib version Show version
373
-
374
- Replay Options:
375
- --verbose Show detailed findings per attack
376
- --json Machine-readable JSON output
377
- GT-NNN Replay single attack by ID
378
-
379
- Diff Examples:
380
- muaddib diff HEAD~1 Compare with previous commit
381
- muaddib diff v1.2.0 Compare with tag
382
- muaddib diff main Compare with branch
383
- muaddib diff abc1234 ./myproject Compare specific commit
384
-
385
- Init-hooks Options:
386
- --type [auto|husky|pre-commit|git] Hook system (default: auto)
387
- --mode [scan|diff] scan=all threats, diff=new only
388
-
389
- Options:
390
- --json JSON output
391
- --html [file] HTML report
392
- --sarif [file] SARIF report (GitHub Security)
393
- --explain Detailed explanations
394
- --breakdown Show score breakdown by threat
395
- --fail-on [level] Fail level (critical|high|medium|low)
396
- --webhook [url] Discord/Slack webhook
397
- --paranoid Ultra-strict mode
398
- --temporal Detect sudden lifecycle script changes (network requests per package)
399
- --temporal-ast Detect sudden dangerous API additions via AST diff (downloads tarballs)
400
- --temporal-publish Detect publish frequency anomalies (bursts, dormant spikes)
401
- --temporal-maintainer Detect maintainer changes (new maintainer, account takeover)
402
- --temporal-full All temporal analyses (lifecycle + AST + publish + maintainer)
403
- --no-canary Disable honey token injection in sandbox
404
- --no-deobfuscate Disable deobfuscation pre-processing
405
- --no-module-graph Disable cross-file dataflow analysis
406
- --exclude [dir] Exclude directory from scan (repeatable)
407
- --limit [n] Limit feed entries (default: 50)
408
- --severity [level] Filter by severity (CRITICAL|HIGH|MEDIUM|LOW)
409
- --since [date] Filter detections after date (ISO 8601)
410
- --port [n] HTTP server port (default: 3000, serve only)
411
- --entropy-threshold [n] Custom string-level entropy threshold (default: 5.5)
412
- --save-dev, -D Install as dev dependency
413
- -g, --global Install globally
414
- --force Force install despite threats
415
- `;
416
-
417
- // Main
418
- if (command === 'version' || command === '--version' || command === '-v') {
419
- const pkg = require('../package.json');
420
- console.log(`muaddib-scanner v${pkg.version}`);
421
- process.exit(0);
422
- } else if (!command || command === '--help' || command === '-h') {
423
- if (command === '--help' || command === '-h') {
424
- console.log(helpText);
425
- process.exit(0);
426
- }
427
- interactiveMenu().catch(err => {
428
- console.error('[ERROR]', err.message);
429
- process.exit(1);
430
- });
431
- } else if (command === 'scan') {
432
- run(target, {
433
- json: jsonOutput,
434
- html: htmlOutput,
435
- sarif: sarifOutput,
436
- explain: explainMode,
437
- failLevel: failLevel,
438
- webhook: webhookUrl,
439
- paranoid: paranoidMode,
440
- temporal: temporalMode || temporalFullMode,
441
- temporalAst: temporalAstMode || temporalFullMode,
442
- temporalPublish: temporalPublishMode || temporalFullMode,
443
- temporalMaintainer: temporalMaintainerMode || temporalFullMode,
444
- exclude: excludeDirs,
445
- entropyThreshold: entropyThreshold,
446
- breakdown: breakdownMode,
447
- noDeobfuscate: noDeobfuscate,
448
- noModuleGraph: noModuleGraph
449
- }).then(exitCode => {
450
- process.exit(exitCode);
451
- }).catch(err => {
452
- console.error('[ERROR]', err.message);
453
- process.exit(1);
454
- });
455
- } else if (command === 'feed') {
456
- const { getFeed } = require('../src/threat-feed.js');
457
- const feedOpts = {};
458
- if (feedLimit) feedOpts.limit = feedLimit;
459
- if (feedSeverity) feedOpts.severity = feedSeverity;
460
- if (feedSince) feedOpts.since = feedSince;
461
- const result = getFeed(feedOpts);
462
- console.log(JSON.stringify(result, null, 2));
463
- process.exit(0);
464
- } else if (command === 'serve') {
465
- const { startServer } = require('../src/serve.js');
466
- startServer({ port: servePort || 3000 });
467
- // Server runs indefinitely — no process.exit
468
- } else if (command === 'watch') {
469
- watch(target);
470
- } else if (command === 'update') {
471
- updateIOCs().then(() => {
472
- process.exit(0);
473
- }).catch(err => {
474
- console.error('[ERROR]', err.message);
475
- process.exit(1);
476
- });
477
- } else if (command === 'scrape') {
478
- runScraper().then(result => {
479
- console.log(`[OK] ${result.added} new IOCs (total: ${result.total})`);
480
- process.exit(0);
481
- }).catch(err => {
482
- console.error('[ERROR]', err.message);
483
- process.exit(1);
484
- });
485
- } else if (command === 'monitor') {
486
- const testPkg = options.filter(o => !o.startsWith('-'));
487
- const isTemporal = options.includes('--temporal');
488
- const isTemporalAst = options.includes('--temporal-ast');
489
- const isTest = options.includes('--test');
490
-
491
- if (isTemporalAst && isTest) {
492
- const actualPkg = options.filter(o => !o.startsWith('-')).pop();
493
- if (!actualPkg) {
494
- console.log('Usage: muaddib monitor --temporal-ast --test <package-name>');
495
- process.exit(1);
496
- }
497
- const { detectSuddenAstChanges } = require('../src/temporal-ast-diff.js');
498
- console.log(`[TEMPORAL-AST] Analyzing ${actualPkg}...\n`);
499
- detectSuddenAstChanges(actualPkg).then(result => {
500
- console.log(`Package: ${result.packageName}`);
501
- console.log(`Latest version: ${result.latestVersion || 'N/A'}`);
502
- console.log(`Previous version: ${result.previousVersion || 'N/A'}`);
503
- console.log(`Suspicious: ${result.suspicious ? 'YES' : 'NO'}`);
504
- if (result.metadata.latestPublishedAt) {
505
- console.log(`Published: ${result.metadata.latestPublishedAt}`);
506
- }
507
- if (result.findings.length > 0) {
508
- console.log(`\nFindings:`);
509
- for (const f of result.findings) {
510
- console.log(` [${f.severity}] ${f.pattern}: ${f.description}`);
511
- }
512
- } else {
513
- console.log(`\nNo dangerous API changes detected between the last two versions.`);
514
- }
515
- process.exit(result.suspicious ? 1 : 0);
516
- }).catch(err => {
517
- console.error(`[ERROR] ${err.message}`);
518
- process.exit(1);
519
- });
520
- } else if (isTemporal && isTest && testPkg.length > 0) {
521
- const { detectSuddenLifecycleChange } = require('../src/temporal-analysis.js');
522
- const pkgName = testPkg[testPkg.indexOf('--test') !== -1 ? testPkg.length - 1 : 0] || testPkg[0];
523
- // Find the package name: it's the non-flag argument
524
- const actualPkg = options.filter(o => !o.startsWith('-')).pop();
525
- if (!actualPkg) {
526
- console.log('Usage: muaddib monitor --temporal --test <package-name>');
527
- process.exit(1);
528
- }
529
- console.log(`[TEMPORAL] Analyzing ${actualPkg}...\n`);
530
- detectSuddenLifecycleChange(actualPkg).then(result => {
531
- console.log(`Package: ${result.packageName}`);
532
- console.log(`Latest version: ${result.latestVersion || 'N/A'}`);
533
- console.log(`Previous version: ${result.previousVersion || 'N/A'}`);
534
- console.log(`Suspicious: ${result.suspicious ? 'YES' : 'NO'}`);
535
- if (result.metadata.note) {
536
- console.log(`Note: ${result.metadata.note}`);
537
- }
538
- if (result.metadata.latestPublishedAt) {
539
- console.log(`Published: ${result.metadata.latestPublishedAt}`);
540
- }
541
- if (result.metadata.maintainers && result.metadata.maintainers.length > 0) {
542
- const names = result.metadata.maintainers.map(m => m.name || m.email).join(', ');
543
- console.log(`Maintainers: ${names}`);
544
- }
545
- if (result.findings.length > 0) {
546
- console.log(`\nFindings:`);
547
- for (const f of result.findings) {
548
- const action = f.type === 'lifecycle_added' ? 'ADDED' : f.type === 'lifecycle_modified' ? 'MODIFIED' : 'REMOVED';
549
- const value = f.type === 'lifecycle_modified' ? f.newValue : f.value;
550
- console.log(` [${f.severity}] ${f.script} script ${action}: "${value}"`);
551
- }
552
- } else {
553
- console.log(`\nNo lifecycle script changes detected between the last two versions.`);
554
- }
555
- process.exit(result.suspicious ? 1 : 0);
556
- }).catch(err => {
557
- console.error(`[ERROR] ${err.message}`);
558
- process.exit(1);
559
- });
560
- } else if (isTemporal && isTest) {
561
- console.log('Usage: muaddib monitor --temporal --test <package-name>');
562
- process.exit(1);
563
- } else {
564
- // Start full monitor
565
- const { startMonitor } = require('../src/monitor.js');
566
- const monitorOpts = {
567
- verbose: options.includes('--verbose')
568
- };
569
- startMonitor(monitorOpts).catch(err => {
570
- console.error('[ERROR]', err.message);
571
- process.exit(1);
572
- });
573
- }
574
- } else if (command === 'daemon') {
575
- startDaemon({ webhook: webhookUrl });
576
- } else if (command === 'install' || command === 'i') {
577
- const packages = options.filter(o => !o.startsWith('-'));
578
- const isDev = options.includes('--save-dev') || options.includes('-D');
579
- const isGlobal = options.includes('-g') || options.includes('--global');
580
- const force = options.includes('--force');
581
-
582
- if (packages.length === 0) {
583
- console.log('Usage: muaddib install <package> [<package>...] [--save-dev] [-g] [--force]');
584
- process.exit(1);
585
- }
586
-
587
- safeInstall(packages, { isDev, isGlobal, force }).then(result => {
588
- if (result.blocked && !force) {
589
- process.exit(1);
590
- }
591
- process.exit(0);
592
- }).catch(err => {
593
- console.error('[ERROR]', err.message);
594
- process.exit(1);
595
- });
596
- } else if (command === 'sandbox') {
597
- const sandboxOpts = options.filter(o => !o.startsWith('-'));
598
- const packageName = sandboxOpts[0];
599
- const strict = options.includes('--strict');
600
- const canary = !options.includes('--no-canary');
601
- if (!packageName) {
602
- console.log('Usage: muaddib sandbox <package-name> [--strict] [--no-canary]');
603
- process.exit(1);
604
- }
605
-
606
- buildSandboxImage()
607
- .then(() => runSandbox(packageName, { strict, canary }))
608
- .then((results) => {
609
- process.exit(results.suspicious ? 1 : 0);
610
- })
611
- .catch((err) => {
612
- console.error('[ERROR]', err.message);
613
- process.exit(1);
614
- });
615
- } else if (command === 'sandbox-report') {
616
- const sandboxOpts = options.filter(o => !o.startsWith('-'));
617
- const packageName = sandboxOpts[0];
618
- const strict = options.includes('--strict');
619
- const canary = !options.includes('--no-canary');
620
- if (!packageName) {
621
- console.log('Usage: muaddib sandbox-report <package-name> [--strict] [--no-canary]');
622
- process.exit(1);
623
- }
624
-
625
- buildSandboxImage()
626
- .then(() => runSandbox(packageName, { strict, canary }))
627
- .then((results) => {
628
- if (results.raw_report) {
629
- console.log(generateNetworkReport(results.raw_report));
630
- }
631
- process.exit(results.suspicious ? 1 : 0);
632
- })
633
- .catch((err) => {
634
- console.error('[ERROR]', err.message);
635
- process.exit(1);
636
- });
637
- } else if (command === 'diff') {
638
- // Parse diff arguments: muaddib diff <ref> [path] [options]
639
- const diffArgs = options.filter(o => !o.startsWith('-'));
640
- const baseRef = diffArgs[0];
641
- const diffTarget = diffArgs[1] || '.';
642
-
643
- if (!baseRef) {
644
- showRefs('.');
645
- process.exit(0);
646
- }
647
-
648
- diff(diffTarget, baseRef, {
649
- json: jsonOutput,
650
- explain: explainMode,
651
- failLevel: failLevel,
652
- paranoid: paranoidMode
653
- }).then(exitCode => {
654
- process.exit(exitCode);
655
- }).catch(err => {
656
- console.error('[ERROR]', err.message);
657
- process.exit(1);
658
- });
659
- } else if (command === 'detections') {
660
- const { loadDetections, getDetectionStats } = require('../src/monitor.js');
661
- const wantStats = options.includes('--stats');
662
- const wantJson = options.includes('--json');
663
-
664
- if (wantJson) {
665
- const data = loadDetections();
666
- console.log(JSON.stringify(data, null, 2));
667
- process.exit(0);
668
- }
669
-
670
- if (wantStats) {
671
- const s = getDetectionStats();
672
- console.log('\n MUAD\'DIB Detection Stats\n');
673
- console.log(` Total detections: ${s.total}`);
674
- if (Object.keys(s.bySeverity).length > 0) {
675
- console.log(' By severity:');
676
- for (const [sev, count] of Object.entries(s.bySeverity)) {
677
- console.log(` ${sev}: ${count}`);
678
- }
679
- }
680
- if (Object.keys(s.byEcosystem).length > 0) {
681
- console.log(' By ecosystem:');
682
- for (const [eco, count] of Object.entries(s.byEcosystem)) {
683
- console.log(` ${eco}: ${count}`);
684
- }
685
- }
686
- if (s.leadTime) {
687
- console.log(` Lead time (hours): avg=${s.leadTime.avg.toFixed(1)}, min=${s.leadTime.min.toFixed(1)}, max=${s.leadTime.max.toFixed(1)} (${s.leadTime.count} entries)`);
688
- } else {
689
- console.log(' Lead time: no advisory data yet');
690
- }
691
- console.log('');
692
- process.exit(0);
693
- }
694
-
695
- // Default: list recent detections
696
- const data = loadDetections();
697
- const recent = data.detections.slice(-20).reverse();
698
- if (recent.length === 0) {
699
- console.log('\n No detections recorded yet.\n');
700
- process.exit(0);
701
- }
702
- console.log(`\n MUAD'DIB Recent Detections (${recent.length} of ${data.detections.length})\n`);
703
- for (const d of recent) {
704
- const lead = d.lead_time_hours != null ? ` | lead: ${d.lead_time_hours.toFixed(1)}h` : '';
705
- console.log(` [${d.severity}] ${d.ecosystem}/${d.package}@${d.version} ${d.first_seen_at}${lead}`);
706
- console.log(` findings: ${d.findings.join(', ')}`);
707
- }
708
- console.log('');
709
- process.exit(0);
710
- } else if (command === 'stats') {
711
- const { loadScanStats } = require('../src/monitor.js');
712
- const wantDaily = options.includes('--daily');
713
- const wantJson = options.includes('--json');
714
-
715
- const data = loadScanStats();
716
-
717
- if (wantJson) {
718
- console.log(JSON.stringify(data, null, 2));
719
- process.exit(0);
720
- }
721
-
722
- if (wantDaily) {
723
- const last7 = data.daily.slice(-7);
724
- console.log('\n MUAD\'DIB Scan Stats — Daily Breakdown\n');
725
- if (last7.length === 0) {
726
- console.log(' No daily data recorded yet.\n');
727
- process.exit(0);
728
- }
729
- console.log(' Date Scanned Clean Suspect FP Confirmed FP Rate');
730
- console.log(' ' + '-'.repeat(60));
731
- for (const d of last7) {
732
- const fpRate = (d.fp_rate * 100).toFixed(1) + '%';
733
- console.log(` ${d.date} ${String(d.scanned).padStart(5)} ${String(d.clean).padStart(5)} ${String(d.suspect).padStart(7)} ${String(d.false_positive).padStart(2)} ${String(d.confirmed).padStart(9)} ${fpRate.padStart(7)}`);
734
- }
735
- console.log('');
736
- process.exit(0);
737
- }
738
-
739
- // Default: global stats
740
- const s = data.stats;
741
- const globalDenom = s.false_positive + s.confirmed_malicious;
742
- const globalFpRate = globalDenom > 0 ? ((s.false_positive / globalDenom) * 100).toFixed(1) + '%' : 'N/A';
743
-
744
- console.log('\n MUAD\'DIB Scan Stats\n');
745
- console.log(` Total scanned: ${s.total_scanned}`);
746
- console.log(` Clean: ${s.clean}`);
747
- console.log(` Suspect: ${s.suspect}`);
748
- console.log(` False positives: ${s.false_positive}`);
749
- console.log(` Confirmed malicious: ${s.confirmed_malicious}`);
750
- console.log(` FP rate: ${globalFpRate}`);
751
- console.log('');
752
- process.exit(0);
753
- } else if (command === 'evaluate') {
754
- const { evaluate } = require('../src/commands/evaluate.js');
755
- const evalOpts = { json: jsonOutput };
756
- for (let i = 0; i < options.length; i++) {
757
- if (options[i] === '--benign-limit' && options[i + 1]) {
758
- evalOpts.benignLimit = parseInt(options[i + 1], 10);
759
- i++;
760
- } else if (options[i] === '--refresh-benign') {
761
- evalOpts.refreshBenign = true;
762
- }
763
- }
764
- evaluate(evalOpts).then(() => {
765
- process.exit(0);
766
- }).catch(err => {
767
- console.error('[ERROR]', err.message);
768
- process.exit(1);
769
- });
770
- } else if (command === 'init-hooks') {
771
- // Parse init-hooks arguments
772
- let hookType = 'auto';
773
- let hookMode = 'scan';
774
-
775
- for (let i = 0; i < options.length; i++) {
776
- if (options[i] === '--type' && options[i + 1]) {
777
- hookType = options[i + 1];
778
- i++;
779
- } else if (options[i] === '--mode' && options[i + 1]) {
780
- hookMode = options[i + 1];
781
- i++;
782
- }
783
- }
784
-
785
- initHooks(target, { type: hookType, mode: hookMode }).then(success => {
786
- process.exit(success ? 0 : 1);
787
- }).catch(err => {
788
- console.error('[ERROR]', err.message);
789
- process.exit(1);
790
- });
791
- } else if (command === 'remove-hooks') {
792
- removeHooks(target).then(success => {
793
- process.exit(success ? 0 : 1);
794
- }).catch(err => {
795
- console.error('[ERROR]', err.message);
796
- process.exit(1);
797
- });
798
- } else if (command === 'replay' || command === 'ground-truth') {
799
- const { replay } = require('../tests/ground-truth/replay.js');
800
- const replayOpts = {};
801
- for (const o of options) {
802
- if (o === '--verbose' || o === '-v') replayOpts.verbose = true;
803
- else if (o === '--json') replayOpts.json = true;
804
- else if (o.startsWith('GT-')) replayOpts.filterId = o;
805
- }
806
- replay(replayOpts).then(result => {
807
- if (!replayOpts.json) {
808
- process.exit(result.missed > 0 ? 1 : 0);
809
- }
810
- process.exit(0);
811
- }).catch(err => {
812
- console.error('[ERROR]', err.message);
813
- process.exit(1);
814
- });
815
- } else if (command === 'report') {
816
- // Hidden/internal not in --help
817
- if (options.includes('--now')) {
818
- const { sendReportNow } = require('../src/monitor.js');
819
- sendReportNow().then(result => {
820
- if (result.sent) {
821
- console.log(`\n \x1b[32m\u2713\x1b[0m ${result.message}\n`);
822
- } else {
823
- console.log(`\n \x1b[33m!\x1b[0m ${result.message}\n`);
824
- }
825
- process.exit(result.sent ? 0 : 1);
826
- }).catch(err => {
827
- console.error('[ERROR]', err.message);
828
- process.exit(1);
829
- });
830
- } else if (options.includes('--status')) {
831
- const { getReportStatus } = require('../src/monitor.js');
832
- const status = getReportStatus();
833
- console.log('\n MUAD\'DIB Report Status\n');
834
- console.log(` Last report sent: ${status.lastDailyReportDate || 'Never'}`);
835
- console.log(` Packages scanned since: ${status.scannedSince}`);
836
- console.log(` Next scheduled report: ${status.nextReport}`);
837
- console.log('');
838
- process.exit(0);
839
- } else {
840
- console.log('Usage: muaddib report --now | --status');
841
- process.exit(1);
842
- }
843
- } else if (command === 'help') {
844
- console.log(helpText);
845
- process.exit(0);
846
- } else {
847
- console.log(`Unknown command: ${String(command).replace(/[\x00-\x1f\x7f-\x9f]/g, '')}`);
848
- console.log('Type "muaddib help" to see available commands.');
849
- process.exit(1);
1
+ #!/usr/bin/env node
2
+ const { exec } = require('child_process');
3
+ const { run } = require('../src/index.js');
4
+ const { updateIOCs } = require('../src/ioc/updater.js');
5
+ const { watch } = require('../src/watch.js');
6
+ const { startDaemon } = require('../src/daemon.js');
7
+ const { runScraper } = require('../src/ioc/scraper.js');
8
+ const { safeInstall } = require('../src/safe-install.js');
9
+ const { buildSandboxImage, runSandbox, generateNetworkReport } = require('../src/sandbox.js');
10
+ const { diff, showRefs } = require('../src/diff.js');
11
+ const { initHooks, removeHooks } = require('../src/hooks-init.js');
12
+
13
+ const args = process.argv.slice(2);
14
+ const command = args[0];
15
+ const options = args.slice(1);
16
+
17
+ // Parse options
18
+ let target = '.';
19
+ let jsonOutput = false;
20
+ let htmlOutput = null;
21
+ let sarifOutput = null;
22
+ let explainMode = false;
23
+ let failLevel = 'high';
24
+ let webhookUrl = null;
25
+ let paranoidMode = false;
26
+ let excludeDirs = [];
27
+ let entropyThreshold = null;
28
+ let temporalMode = false;
29
+ let temporalAstMode = false;
30
+ let temporalPublishMode = false;
31
+ let temporalMaintainerMode = false;
32
+ let temporalFullMode = false;
33
+ let breakdownMode = false;
34
+ let noDeobfuscate = false;
35
+ let noModuleGraph = false;
36
+ let feedLimit = null;
37
+ let feedSeverity = null;
38
+ let feedSince = null;
39
+ let servePort = null;
40
+
41
+ for (let i = 0; i < options.length; i++) {
42
+ if (options[i] === '--json') {
43
+ jsonOutput = true;
44
+ } else if (options[i] === '--html') {
45
+ const htmlPath = options[i + 1] || 'muaddib-report.html';
46
+ // CLI-001: Block path traversal
47
+ if (htmlPath.includes('..')) {
48
+ console.error('[ERROR] --html path must not contain path traversal (..)');
49
+ process.exit(1);
50
+ }
51
+ htmlOutput = htmlPath;
52
+ i++;
53
+ } else if (options[i] === '--sarif') {
54
+ const sarifPath = options[i + 1] || 'muaddib-results.sarif';
55
+ // CLI-001: Block path traversal
56
+ if (sarifPath.includes('..')) {
57
+ console.error('[ERROR] --sarif path must not contain path traversal (..)');
58
+ process.exit(1);
59
+ }
60
+ sarifOutput = sarifPath;
61
+ i++;
62
+ } else if (options[i] === '--explain') {
63
+ explainMode = true;
64
+ } else if (options[i] === '--fail-on') {
65
+ const val = (options[i + 1] || 'high').toLowerCase();
66
+ const validLevels = ['critical', 'high', 'medium', 'low'];
67
+ if (!validLevels.includes(val)) {
68
+ console.error(`[ERROR] --fail-on must be one of: ${validLevels.join(', ')} (got: "${val}")`);
69
+ process.exit(1);
70
+ }
71
+ failLevel = val;
72
+ i++;
73
+ } else if (options[i] === '--webhook') {
74
+ const rawUrl = options[i + 1];
75
+ // CLI-002: Validate webhook URL (HTTPS only, no private IPs)
76
+ if (rawUrl) {
77
+ try {
78
+ const parsed = new URL(rawUrl);
79
+ if (parsed.protocol !== 'https:') {
80
+ console.error('[ERROR] --webhook URL must use HTTPS');
81
+ process.exit(1);
82
+ }
83
+ const host = parsed.hostname.toLowerCase();
84
+ if (/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|0\.|169\.254\.|localhost$|::1$)/.test(host)) {
85
+ console.error('[ERROR] --webhook URL must not point to a private/local address');
86
+ process.exit(1);
87
+ }
88
+ } catch {
89
+ console.error('[ERROR] --webhook URL is invalid');
90
+ process.exit(1);
91
+ }
92
+ }
93
+ webhookUrl = rawUrl;
94
+ i++;
95
+ } else if (options[i] === '--exclude') {
96
+ if (options[i + 1] && !options[i + 1].startsWith('-')) {
97
+ excludeDirs.push(options[i + 1]);
98
+ i++;
99
+ }
100
+ } else if (options[i] === '--entropy-threshold') {
101
+ const val = parseFloat(options[i + 1]);
102
+ if (!isNaN(val) && val > 0 && val <= 8) {
103
+ entropyThreshold = val;
104
+ } else {
105
+ console.error('[ERROR] --entropy-threshold must be a number between 0 and 8');
106
+ process.exit(1);
107
+ }
108
+ i++;
109
+ } else if (options[i] === '--paranoid') {
110
+ paranoidMode = true;
111
+ } else if (options[i] === '--temporal-full') {
112
+ temporalFullMode = true;
113
+ } else if (options[i] === '--temporal-ast') {
114
+ temporalAstMode = true;
115
+ } else if (options[i] === '--temporal-publish') {
116
+ temporalPublishMode = true;
117
+ } else if (options[i] === '--temporal-maintainer') {
118
+ temporalMaintainerMode = true;
119
+ } else if (options[i] === '--breakdown') {
120
+ breakdownMode = true;
121
+ } else if (options[i] === '--no-deobfuscate') {
122
+ noDeobfuscate = true;
123
+ } else if (options[i] === '--no-module-graph') {
124
+ noModuleGraph = true;
125
+ } else if (options[i] === '--temporal') {
126
+ temporalMode = true;
127
+ } else if (options[i] === '--limit') {
128
+ const val = parseInt(options[i + 1], 10);
129
+ if (!isNaN(val) && val > 0) {
130
+ feedLimit = val;
131
+ }
132
+ i++;
133
+ } else if (options[i] === '--severity') {
134
+ feedSeverity = options[i + 1] || null;
135
+ i++;
136
+ } else if (options[i] === '--since') {
137
+ feedSince = options[i + 1] || null;
138
+ i++;
139
+ } else if (options[i] === '--port') {
140
+ const val = parseInt(options[i + 1], 10);
141
+ if (!isNaN(val) && val >= 1 && val <= 65535) {
142
+ servePort = val;
143
+ } else {
144
+ console.error('[ERROR] --port must be a number between 1 and 65535');
145
+ process.exit(1);
146
+ }
147
+ i++;
148
+ } else if (options[i] === '--strict') {
149
+ // Sandbox strict mode flag (parsed here, used by sandbox commands)
150
+ } else if (options[i] === '--no-canary') {
151
+ // Sandbox canary disable flag (parsed here, used by sandbox commands)
152
+ } else if (!options[i].startsWith('-')) {
153
+ target = options[i];
154
+ }
155
+ }
156
+
157
+ // Version check (truly non-blocking, skip for machine-readable output)
158
+ if (!jsonOutput && !sarifOutput && command !== 'feed' && command !== 'serve') {
159
+ try {
160
+ const currentVersion = require('../package.json').version;
161
+ exec('npm view muaddib-scanner version', { timeout: 5000 }, (err, stdout) => {
162
+ if (err) return; // No network or npm unavailable
163
+ const latest = (stdout || '').toString().trim();
164
+ if (!latest || latest === currentVersion) return;
165
+ // Semver comparison: only notify if remote is strictly newer
166
+ const parse = v => v.split('.').map(Number);
167
+ const [cM, cm, cp] = parse(currentVersion);
168
+ const [lM, lm, lp] = parse(latest);
169
+ const isNewer = lM > cM || (lM === cM && (lm > cm || (lm === cm && lp > cp)));
170
+ if (isNewer) {
171
+ console.log(`\n[UPDATE] New version available: ${currentVersion} -> ${latest}`);
172
+ console.log(` Run: npm install -g muaddib-scanner@latest\n`);
173
+ }
174
+ });
175
+ } catch {
176
+ // Skip silently
177
+ }
178
+ }
179
+
180
+ // Interactive menu
181
+ async function interactiveMenu() {
182
+ const { select, input, confirm } = await import('@inquirer/prompts');
183
+
184
+ console.log(`
185
+ ╔═══════════════════════════════════════════════╗
186
+ ║ MUAD'DIB - npm & PyPI Supply Chain Hunter ║
187
+ ║ "The worms must die." ║
188
+ ╚═══════════════════════════════════════════════╝
189
+ `);
190
+
191
+ const action = await select({
192
+ message: 'What do you want to do?',
193
+ choices: [
194
+ { name: 'Scan a project', value: 'scan' },
195
+ { name: 'Scan with paranoid mode', value: 'scan-paranoid' },
196
+ { name: 'Compare with previous version (diff)', value: 'diff' },
197
+ { name: 'Install packages (safe)', value: 'install' },
198
+ { name: 'Watch a project (real-time)', value: 'watch' },
199
+ { name: 'Start daemon', value: 'daemon' },
200
+ { name: 'Setup git hooks', value: 'init-hooks' },
201
+ { name: 'Update IOCs', value: 'update' },
202
+ { name: 'Scrape new IOCs', value: 'scrape' },
203
+ { name: 'Sandbox analysis', value: 'sandbox' },
204
+ { name: 'Threat feed (JSON)', value: 'feed' },
205
+ { name: 'Threat feed server', value: 'serve' },
206
+ { name: 'Quit', value: 'quit' }
207
+ ]
208
+ });
209
+
210
+ if (action === 'quit') {
211
+ console.log('Bye!');
212
+ process.exit(0);
213
+ }
214
+
215
+ if (action === 'scan' || action === 'scan-paranoid') {
216
+ const path = await input({
217
+ message: 'Project path:',
218
+ default: '.'
219
+ });
220
+
221
+ const outputFormat = await select({
222
+ message: 'Output format:',
223
+ choices: [
224
+ { name: 'Console (default)', value: 'console' },
225
+ { name: 'JSON', value: 'json' },
226
+ { name: 'HTML', value: 'html' },
227
+ { name: 'SARIF (GitHub Security)', value: 'sarif' }
228
+ ]
229
+ });
230
+
231
+ const opts = {
232
+ json: outputFormat === 'json',
233
+ html: outputFormat === 'html' ? 'muaddib-report.html' : null,
234
+ sarif: outputFormat === 'sarif' ? 'muaddib-results.sarif' : null,
235
+ explain: true,
236
+ failLevel: 'high',
237
+ paranoid: action === 'scan-paranoid'
238
+ };
239
+
240
+ const exitCode = await run(path, opts);
241
+ process.exit(exitCode);
242
+ }
243
+
244
+ if (action === 'install') {
245
+ const pkgInput = await input({
246
+ message: 'Package(s) to install (space-separated):'
247
+ });
248
+
249
+ const packages = pkgInput.split(' ').filter(p => p.trim());
250
+ if (packages.length === 0) {
251
+ console.log('No packages specified.');
252
+ process.exit(1);
253
+ }
254
+
255
+ const result = await safeInstall(packages, {});
256
+ process.exit(result.blocked ? 1 : 0);
257
+ }
258
+
259
+ if (action === 'watch') {
260
+ const path = await input({
261
+ message: 'Project path:',
262
+ default: '.'
263
+ });
264
+ watch(path);
265
+ }
266
+
267
+ if (action === 'daemon') {
268
+ const useWebhook = await confirm({
269
+ message: 'Configure Discord/Slack webhook?',
270
+ default: false
271
+ });
272
+
273
+ let webhook = null;
274
+ if (useWebhook) {
275
+ webhook = await input({
276
+ message: 'Webhook URL:'
277
+ });
278
+ }
279
+ startDaemon({ webhook });
280
+ }
281
+
282
+ if (action === 'update') {
283
+ await updateIOCs();
284
+ process.exit(0);
285
+ }
286
+
287
+ if (action === 'scrape') {
288
+ const result = await runScraper();
289
+ console.log(`[OK] ${result.added} new IOCs (total: ${result.total})`);
290
+ process.exit(0);
291
+ }
292
+
293
+ if (action === 'sandbox') {
294
+ const packageName = await input({
295
+ message: 'Package name to analyze:'
296
+ });
297
+
298
+ if (!packageName.trim()) {
299
+ console.log('No package specified.');
300
+ process.exit(1);
301
+ }
302
+
303
+ const useStrict = await confirm({
304
+ message: 'Enable strict mode? (blocks non-essential network)',
305
+ default: false
306
+ });
307
+
308
+ await buildSandboxImage();
309
+ const results = await runSandbox(packageName.trim(), { strict: useStrict });
310
+ if (results.raw_report) {
311
+ console.log(generateNetworkReport(results.raw_report));
312
+ }
313
+ process.exit(results.suspicious ? 1 : 0);
314
+ }
315
+
316
+ if (action === 'feed') {
317
+ const { getFeed } = require('../src/threat-feed.js');
318
+ const result = getFeed();
319
+ console.log(JSON.stringify(result, null, 2));
320
+ process.exit(0);
321
+ }
322
+
323
+ if (action === 'serve') {
324
+ const { startServer } = require('../src/serve.js');
325
+ startServer({ port: 3000 });
326
+ // Server runs indefinitely
327
+ }
328
+
329
+ if (action === 'diff') {
330
+ const baseRef = await input({
331
+ message: 'Compare with (commit/tag/branch):',
332
+ default: 'HEAD~1'
333
+ });
334
+
335
+ const projectPath = await input({
336
+ message: 'Project path:',
337
+ default: '.'
338
+ });
339
+
340
+ const exitCode = await diff(projectPath, baseRef, { explain: true });
341
+ process.exit(exitCode);
342
+ }
343
+
344
+ if (action === 'init-hooks') {
345
+ const hookMode = await select({
346
+ message: 'Hook mode:',
347
+ choices: [
348
+ { name: 'Scan all threats', value: 'scan' },
349
+ { name: 'Diff only (block only NEW threats)', value: 'diff' }
350
+ ]
351
+ });
352
+
353
+ const hookType = await select({
354
+ message: 'Hook system:',
355
+ choices: [
356
+ { name: 'Auto-detect', value: 'auto' },
357
+ { name: 'Husky', value: 'husky' },
358
+ { name: 'pre-commit framework', value: 'pre-commit' },
359
+ { name: 'Native git hooks', value: 'git' }
360
+ ]
361
+ });
362
+
363
+ await initHooks('.', { type: hookType, mode: hookMode });
364
+ process.exit(0);
365
+ }
366
+ }
367
+
368
+ const helpText = `
369
+ MUAD'DIB - npm & PyPI Supply Chain Threat Hunter
370
+
371
+ Usage:
372
+ muaddib Interactive mode
373
+ muaddib scan [path] [options] Scan a project
374
+ muaddib diff <ref> [path] Compare threats with a previous version
375
+ muaddib install <pkg> [options] Safe install (scan before install)
376
+ muaddib watch [path] Watch in real-time
377
+ muaddib daemon [options] Start daemon
378
+ muaddib init-hooks [options] Setup git pre-commit hooks
379
+ muaddib remove-hooks [path] Remove MUAD'DIB git hooks
380
+ muaddib update Update IOCs
381
+ muaddib scrape Scrape new IOCs
382
+ muaddib sandbox <pkg> [--strict] [--no-canary] Analyze in isolated Docker container
383
+ muaddib sandbox-report <pkg> Sandbox + detailed network report
384
+ muaddib version Show version
385
+
386
+ Replay Options:
387
+ --verbose Show detailed findings per attack
388
+ --json Machine-readable JSON output
389
+ GT-NNN Replay single attack by ID
390
+
391
+ Diff Examples:
392
+ muaddib diff HEAD~1 Compare with previous commit
393
+ muaddib diff v1.2.0 Compare with tag
394
+ muaddib diff main Compare with branch
395
+ muaddib diff abc1234 ./myproject Compare specific commit
396
+
397
+ Init-hooks Options:
398
+ --type [auto|husky|pre-commit|git] Hook system (default: auto)
399
+ --mode [scan|diff] scan=all threats, diff=new only
400
+
401
+ Options:
402
+ --json JSON output
403
+ --html [file] HTML report
404
+ --sarif [file] SARIF report (GitHub Security)
405
+ --explain Detailed explanations
406
+ --breakdown Show score breakdown by threat
407
+ --fail-on [level] Fail level (critical|high|medium|low)
408
+ --webhook [url] Discord/Slack webhook
409
+ --paranoid Ultra-strict mode
410
+ --temporal Detect sudden lifecycle script changes (network requests per package)
411
+ --temporal-ast Detect sudden dangerous API additions via AST diff (downloads tarballs)
412
+ --temporal-publish Detect publish frequency anomalies (bursts, dormant spikes)
413
+ --temporal-maintainer Detect maintainer changes (new maintainer, account takeover)
414
+ --temporal-full All temporal analyses (lifecycle + AST + publish + maintainer)
415
+ --no-canary Disable honey token injection in sandbox
416
+ --no-deobfuscate Disable deobfuscation pre-processing
417
+ --no-module-graph Disable cross-file dataflow analysis
418
+ --exclude [dir] Exclude directory from scan (repeatable)
419
+ --limit [n] Limit feed entries (default: 50)
420
+ --severity [level] Filter by severity (CRITICAL|HIGH|MEDIUM|LOW)
421
+ --since [date] Filter detections after date (ISO 8601)
422
+ --port [n] HTTP server port (default: 3000, serve only)
423
+ --entropy-threshold [n] Custom string-level entropy threshold (default: 5.5)
424
+ --save-dev, -D Install as dev dependency
425
+ -g, --global Install globally
426
+ --force Force install despite threats
427
+ `;
428
+
429
+ // Main
430
+ if (command === 'version' || command === '--version' || command === '-v') {
431
+ const pkg = require('../package.json');
432
+ console.log(`muaddib-scanner v${pkg.version}`);
433
+ process.exit(0);
434
+ } else if (!command || command === '--help' || command === '-h') {
435
+ if (command === '--help' || command === '-h') {
436
+ console.log(helpText);
437
+ process.exit(0);
438
+ }
439
+ interactiveMenu().catch(err => {
440
+ console.error('[ERROR]', err.message);
441
+ process.exit(1);
442
+ });
443
+ } else if (command === 'scan') {
444
+ if (options.includes('--help') || options.includes('-h')) {
445
+ console.log(helpText);
446
+ process.exit(0);
447
+ }
448
+ run(target, {
449
+ json: jsonOutput,
450
+ html: htmlOutput,
451
+ sarif: sarifOutput,
452
+ explain: explainMode,
453
+ failLevel: failLevel,
454
+ webhook: webhookUrl,
455
+ paranoid: paranoidMode,
456
+ temporal: temporalMode || temporalFullMode,
457
+ temporalAst: temporalAstMode || temporalFullMode,
458
+ temporalPublish: temporalPublishMode || temporalFullMode,
459
+ temporalMaintainer: temporalMaintainerMode || temporalFullMode,
460
+ exclude: excludeDirs,
461
+ entropyThreshold: entropyThreshold,
462
+ breakdown: breakdownMode,
463
+ noDeobfuscate: noDeobfuscate,
464
+ noModuleGraph: noModuleGraph
465
+ }).then(exitCode => {
466
+ process.exit(exitCode);
467
+ }).catch(err => {
468
+ console.error('[ERROR]', err.message);
469
+ process.exit(1);
470
+ });
471
+ } else if (command === 'feed') {
472
+ const { getFeed } = require('../src/threat-feed.js');
473
+ const feedOpts = {};
474
+ if (feedLimit) feedOpts.limit = feedLimit;
475
+ if (feedSeverity) feedOpts.severity = feedSeverity;
476
+ if (feedSince) feedOpts.since = feedSince;
477
+ const result = getFeed(feedOpts);
478
+ console.log(JSON.stringify(result, null, 2));
479
+ process.exit(0);
480
+ } else if (command === 'serve') {
481
+ const { startServer } = require('../src/serve.js');
482
+ startServer({ port: servePort || 3000 });
483
+ // Server runs indefinitely — no process.exit
484
+ } else if (command === 'watch') {
485
+ watch(target);
486
+ } else if (command === 'update') {
487
+ updateIOCs().then(() => {
488
+ process.exit(0);
489
+ }).catch(err => {
490
+ console.error('[ERROR]', err.message);
491
+ process.exit(1);
492
+ });
493
+ } else if (command === 'scrape') {
494
+ runScraper().then(result => {
495
+ console.log(`[OK] ${result.added} new IOCs (total: ${result.total})`);
496
+ process.exit(0);
497
+ }).catch(err => {
498
+ console.error('[ERROR]', err.message);
499
+ process.exit(1);
500
+ });
501
+ } else if (command === 'monitor') {
502
+ const testPkg = options.filter(o => !o.startsWith('-'));
503
+ const isTemporal = options.includes('--temporal');
504
+ const isTemporalAst = options.includes('--temporal-ast');
505
+ const isTest = options.includes('--test');
506
+
507
+ if (isTemporalAst && isTest) {
508
+ const actualPkg = options.filter(o => !o.startsWith('-')).pop();
509
+ if (!actualPkg) {
510
+ console.log('Usage: muaddib monitor --temporal-ast --test <package-name>');
511
+ process.exit(1);
512
+ }
513
+ const { detectSuddenAstChanges } = require('../src/temporal-ast-diff.js');
514
+ console.log(`[TEMPORAL-AST] Analyzing ${actualPkg}...\n`);
515
+ detectSuddenAstChanges(actualPkg).then(result => {
516
+ console.log(`Package: ${result.packageName}`);
517
+ console.log(`Latest version: ${result.latestVersion || 'N/A'}`);
518
+ console.log(`Previous version: ${result.previousVersion || 'N/A'}`);
519
+ console.log(`Suspicious: ${result.suspicious ? 'YES' : 'NO'}`);
520
+ if (result.metadata.latestPublishedAt) {
521
+ console.log(`Published: ${result.metadata.latestPublishedAt}`);
522
+ }
523
+ if (result.findings.length > 0) {
524
+ console.log(`\nFindings:`);
525
+ for (const f of result.findings) {
526
+ console.log(` [${f.severity}] ${f.pattern}: ${f.description}`);
527
+ }
528
+ } else {
529
+ console.log(`\nNo dangerous API changes detected between the last two versions.`);
530
+ }
531
+ process.exit(result.suspicious ? 1 : 0);
532
+ }).catch(err => {
533
+ console.error(`[ERROR] ${err.message}`);
534
+ process.exit(1);
535
+ });
536
+ } else if (isTemporal && isTest && testPkg.length > 0) {
537
+ const { detectSuddenLifecycleChange } = require('../src/temporal-analysis.js');
538
+ const pkgName = testPkg[testPkg.indexOf('--test') !== -1 ? testPkg.length - 1 : 0] || testPkg[0];
539
+ // Find the package name: it's the non-flag argument
540
+ const actualPkg = options.filter(o => !o.startsWith('-')).pop();
541
+ if (!actualPkg) {
542
+ console.log('Usage: muaddib monitor --temporal --test <package-name>');
543
+ process.exit(1);
544
+ }
545
+ console.log(`[TEMPORAL] Analyzing ${actualPkg}...\n`);
546
+ detectSuddenLifecycleChange(actualPkg).then(result => {
547
+ console.log(`Package: ${result.packageName}`);
548
+ console.log(`Latest version: ${result.latestVersion || 'N/A'}`);
549
+ console.log(`Previous version: ${result.previousVersion || 'N/A'}`);
550
+ console.log(`Suspicious: ${result.suspicious ? 'YES' : 'NO'}`);
551
+ if (result.metadata.note) {
552
+ console.log(`Note: ${result.metadata.note}`);
553
+ }
554
+ if (result.metadata.latestPublishedAt) {
555
+ console.log(`Published: ${result.metadata.latestPublishedAt}`);
556
+ }
557
+ if (result.metadata.maintainers && result.metadata.maintainers.length > 0) {
558
+ const names = result.metadata.maintainers.map(m => m.name || m.email).join(', ');
559
+ console.log(`Maintainers: ${names}`);
560
+ }
561
+ if (result.findings.length > 0) {
562
+ console.log(`\nFindings:`);
563
+ for (const f of result.findings) {
564
+ const action = f.type === 'lifecycle_added' ? 'ADDED' : f.type === 'lifecycle_modified' ? 'MODIFIED' : 'REMOVED';
565
+ const value = f.type === 'lifecycle_modified' ? f.newValue : f.value;
566
+ console.log(` [${f.severity}] ${f.script} script ${action}: "${value}"`);
567
+ }
568
+ } else {
569
+ console.log(`\nNo lifecycle script changes detected between the last two versions.`);
570
+ }
571
+ process.exit(result.suspicious ? 1 : 0);
572
+ }).catch(err => {
573
+ console.error(`[ERROR] ${err.message}`);
574
+ process.exit(1);
575
+ });
576
+ } else if (isTemporal && isTest) {
577
+ console.log('Usage: muaddib monitor --temporal --test <package-name>');
578
+ process.exit(1);
579
+ } else {
580
+ // Start full monitor
581
+ const { startMonitor } = require('../src/monitor.js');
582
+ const monitorOpts = {
583
+ verbose: options.includes('--verbose')
584
+ };
585
+ startMonitor(monitorOpts).catch(err => {
586
+ console.error('[ERROR]', err.message);
587
+ process.exit(1);
588
+ });
589
+ }
590
+ } else if (command === 'daemon') {
591
+ startDaemon({ webhook: webhookUrl });
592
+ } else if (command === 'install' || command === 'i') {
593
+ const packages = options.filter(o => !o.startsWith('-'));
594
+ const isDev = options.includes('--save-dev') || options.includes('-D');
595
+ const isGlobal = options.includes('-g') || options.includes('--global');
596
+ const force = options.includes('--force');
597
+
598
+ if (packages.length === 0) {
599
+ console.log('Usage: muaddib install <package> [<package>...] [--save-dev] [-g] [--force]');
600
+ process.exit(1);
601
+ }
602
+
603
+ safeInstall(packages, { isDev, isGlobal, force }).then(result => {
604
+ if (result.blocked && !force) {
605
+ process.exit(1);
606
+ }
607
+ process.exit(0);
608
+ }).catch(err => {
609
+ console.error('[ERROR]', err.message);
610
+ process.exit(1);
611
+ });
612
+ } else if (command === 'sandbox') {
613
+ const sandboxOpts = options.filter(o => !o.startsWith('-'));
614
+ const packageName = sandboxOpts[0];
615
+ const strict = options.includes('--strict');
616
+ const canary = !options.includes('--no-canary');
617
+ if (!packageName) {
618
+ console.log('Usage: muaddib sandbox <package-name> [--strict] [--no-canary]');
619
+ process.exit(1);
620
+ }
621
+
622
+ buildSandboxImage()
623
+ .then(() => runSandbox(packageName, { strict, canary }))
624
+ .then((results) => {
625
+ process.exit(results.suspicious ? 1 : 0);
626
+ })
627
+ .catch((err) => {
628
+ console.error('[ERROR]', err.message);
629
+ process.exit(1);
630
+ });
631
+ } else if (command === 'sandbox-report') {
632
+ const sandboxOpts = options.filter(o => !o.startsWith('-'));
633
+ const packageName = sandboxOpts[0];
634
+ const strict = options.includes('--strict');
635
+ const canary = !options.includes('--no-canary');
636
+ if (!packageName) {
637
+ console.log('Usage: muaddib sandbox-report <package-name> [--strict] [--no-canary]');
638
+ process.exit(1);
639
+ }
640
+
641
+ buildSandboxImage()
642
+ .then(() => runSandbox(packageName, { strict, canary }))
643
+ .then((results) => {
644
+ if (results.raw_report) {
645
+ console.log(generateNetworkReport(results.raw_report));
646
+ }
647
+ process.exit(results.suspicious ? 1 : 0);
648
+ })
649
+ .catch((err) => {
650
+ console.error('[ERROR]', err.message);
651
+ process.exit(1);
652
+ });
653
+ } else if (command === 'diff') {
654
+ // Parse diff arguments: muaddib diff <ref> [path] [options]
655
+ const diffArgs = options.filter(o => !o.startsWith('-'));
656
+ const baseRef = diffArgs[0];
657
+ const diffTarget = diffArgs[1] || '.';
658
+
659
+ if (!baseRef) {
660
+ showRefs('.');
661
+ process.exit(0);
662
+ }
663
+
664
+ diff(diffTarget, baseRef, {
665
+ json: jsonOutput,
666
+ explain: explainMode,
667
+ failLevel: failLevel,
668
+ paranoid: paranoidMode
669
+ }).then(exitCode => {
670
+ process.exit(exitCode);
671
+ }).catch(err => {
672
+ console.error('[ERROR]', err.message);
673
+ process.exit(1);
674
+ });
675
+ } else if (command === 'detections') {
676
+ const { loadDetections, getDetectionStats } = require('../src/monitor.js');
677
+ const wantStats = options.includes('--stats');
678
+ const wantJson = options.includes('--json');
679
+
680
+ if (wantJson) {
681
+ const data = loadDetections();
682
+ console.log(JSON.stringify(data, null, 2));
683
+ process.exit(0);
684
+ }
685
+
686
+ if (wantStats) {
687
+ const s = getDetectionStats();
688
+ console.log('\n MUAD\'DIB Detection Stats\n');
689
+ console.log(` Total detections: ${s.total}`);
690
+ if (Object.keys(s.bySeverity).length > 0) {
691
+ console.log(' By severity:');
692
+ for (const [sev, count] of Object.entries(s.bySeverity)) {
693
+ console.log(` ${sev}: ${count}`);
694
+ }
695
+ }
696
+ if (Object.keys(s.byEcosystem).length > 0) {
697
+ console.log(' By ecosystem:');
698
+ for (const [eco, count] of Object.entries(s.byEcosystem)) {
699
+ console.log(` ${eco}: ${count}`);
700
+ }
701
+ }
702
+ if (s.leadTime) {
703
+ console.log(` Lead time (hours): avg=${s.leadTime.avg.toFixed(1)}, min=${s.leadTime.min.toFixed(1)}, max=${s.leadTime.max.toFixed(1)} (${s.leadTime.count} entries)`);
704
+ } else {
705
+ console.log(' Lead time: no advisory data yet');
706
+ }
707
+ console.log('');
708
+ process.exit(0);
709
+ }
710
+
711
+ // Default: list recent detections
712
+ const data = loadDetections();
713
+ const recent = data.detections.slice(-20).reverse();
714
+ if (recent.length === 0) {
715
+ console.log('\n No detections recorded yet.\n');
716
+ process.exit(0);
717
+ }
718
+ console.log(`\n MUAD'DIB Recent Detections (${recent.length} of ${data.detections.length})\n`);
719
+ for (const d of recent) {
720
+ const lead = d.lead_time_hours != null ? ` | lead: ${d.lead_time_hours.toFixed(1)}h` : '';
721
+ console.log(` [${d.severity}] ${d.ecosystem}/${d.package}@${d.version} — ${d.first_seen_at}${lead}`);
722
+ console.log(` findings: ${d.findings.join(', ')}`);
723
+ }
724
+ console.log('');
725
+ process.exit(0);
726
+ } else if (command === 'stats') {
727
+ const { loadScanStats } = require('../src/monitor.js');
728
+ const wantDaily = options.includes('--daily');
729
+ const wantJson = options.includes('--json');
730
+
731
+ const data = loadScanStats();
732
+
733
+ if (wantJson) {
734
+ console.log(JSON.stringify(data, null, 2));
735
+ process.exit(0);
736
+ }
737
+
738
+ if (wantDaily) {
739
+ const last7 = data.daily.slice(-7);
740
+ console.log('\n MUAD\'DIB Scan Stats — Daily Breakdown\n');
741
+ if (last7.length === 0) {
742
+ console.log(' No daily data recorded yet.\n');
743
+ process.exit(0);
744
+ }
745
+ console.log(' Date Scanned Clean Suspect FP Confirmed FP Rate');
746
+ console.log(' ' + '-'.repeat(60));
747
+ for (const d of last7) {
748
+ const fpRate = (d.fp_rate * 100).toFixed(1) + '%';
749
+ console.log(` ${d.date} ${String(d.scanned).padStart(5)} ${String(d.clean).padStart(5)} ${String(d.suspect).padStart(7)} ${String(d.false_positive).padStart(2)} ${String(d.confirmed).padStart(9)} ${fpRate.padStart(7)}`);
750
+ }
751
+ console.log('');
752
+ process.exit(0);
753
+ }
754
+
755
+ // Default: global stats
756
+ const s = data.stats;
757
+ const globalDenom = s.false_positive + s.confirmed_malicious;
758
+ const globalFpRate = globalDenom > 0 ? ((s.false_positive / globalDenom) * 100).toFixed(1) + '%' : 'N/A';
759
+
760
+ console.log('\n MUAD\'DIB Scan Stats\n');
761
+ console.log(` Total scanned: ${s.total_scanned}`);
762
+ console.log(` Clean: ${s.clean}`);
763
+ console.log(` Suspect: ${s.suspect}`);
764
+ console.log(` False positives: ${s.false_positive}`);
765
+ console.log(` Confirmed malicious: ${s.confirmed_malicious}`);
766
+ console.log(` FP rate: ${globalFpRate}`);
767
+ console.log('');
768
+ process.exit(0);
769
+ } else if (command === 'evaluate') {
770
+ const { evaluate } = require('../src/commands/evaluate.js');
771
+ const evalOpts = { json: jsonOutput };
772
+ for (let i = 0; i < options.length; i++) {
773
+ if (options[i] === '--benign-limit' && options[i + 1]) {
774
+ evalOpts.benignLimit = parseInt(options[i + 1], 10);
775
+ i++;
776
+ } else if (options[i] === '--refresh-benign') {
777
+ evalOpts.refreshBenign = true;
778
+ }
779
+ }
780
+ evaluate(evalOpts).then(() => {
781
+ process.exit(0);
782
+ }).catch(err => {
783
+ console.error('[ERROR]', err.message);
784
+ process.exit(1);
785
+ });
786
+ } else if (command === 'init-hooks') {
787
+ // Parse init-hooks arguments
788
+ let hookType = 'auto';
789
+ let hookMode = 'scan';
790
+
791
+ for (let i = 0; i < options.length; i++) {
792
+ if (options[i] === '--type' && options[i + 1]) {
793
+ hookType = options[i + 1];
794
+ i++;
795
+ } else if (options[i] === '--mode' && options[i + 1]) {
796
+ hookMode = options[i + 1];
797
+ i++;
798
+ }
799
+ }
800
+
801
+ initHooks(target, { type: hookType, mode: hookMode }).then(success => {
802
+ process.exit(success ? 0 : 1);
803
+ }).catch(err => {
804
+ console.error('[ERROR]', err.message);
805
+ process.exit(1);
806
+ });
807
+ } else if (command === 'remove-hooks') {
808
+ removeHooks(target).then(success => {
809
+ process.exit(success ? 0 : 1);
810
+ }).catch(err => {
811
+ console.error('[ERROR]', err.message);
812
+ process.exit(1);
813
+ });
814
+ } else if (command === 'replay' || command === 'ground-truth') {
815
+ const { replay } = require('../tests/ground-truth/replay.js');
816
+ const replayOpts = {};
817
+ for (const o of options) {
818
+ if (o === '--verbose' || o === '-v') replayOpts.verbose = true;
819
+ else if (o === '--json') replayOpts.json = true;
820
+ else if (o.startsWith('GT-')) replayOpts.filterId = o;
821
+ }
822
+ replay(replayOpts).then(result => {
823
+ if (!replayOpts.json) {
824
+ process.exit(result.missed > 0 ? 1 : 0);
825
+ }
826
+ process.exit(0);
827
+ }).catch(err => {
828
+ console.error('[ERROR]', err.message);
829
+ process.exit(1);
830
+ });
831
+ } else if (command === 'report') {
832
+ // Hidden/internal not in --help
833
+ if (options.includes('--now')) {
834
+ const { sendReportNow } = require('../src/monitor.js');
835
+ sendReportNow().then(result => {
836
+ const color = process.stdout.isTTY;
837
+ if (result.sent) {
838
+ const check = color ? '\x1b[32m\u2713\x1b[0m' : '\u2713';
839
+ console.log(`\n ${check} ${result.message}\n`);
840
+ } else {
841
+ const warn = color ? '\x1b[33m!\x1b[0m' : '!';
842
+ console.log(`\n ${warn} ${result.message}\n`);
843
+ }
844
+ process.exit(result.sent ? 0 : 1);
845
+ }).catch(err => {
846
+ console.error('[ERROR]', err.message);
847
+ process.exit(1);
848
+ });
849
+ } else if (options.includes('--status')) {
850
+ const { getReportStatus } = require('../src/monitor.js');
851
+ const status = getReportStatus();
852
+ console.log('\n MUAD\'DIB Report Status\n');
853
+ console.log(` Last report sent: ${status.lastDailyReportDate || 'Never'}`);
854
+ console.log(` Packages scanned since: ${status.scannedSince}`);
855
+ console.log(` Next scheduled report: ${status.nextReport}`);
856
+ console.log('');
857
+ process.exit(0);
858
+ } else {
859
+ console.log('Usage: muaddib report --now | --status');
860
+ process.exit(1);
861
+ }
862
+ } else if (command === 'help') {
863
+ console.log(helpText);
864
+ process.exit(0);
865
+ } else {
866
+ console.log(`Unknown command: ${String(command).replace(/[\x00-\x1f\x7f-\x9f]/g, '')}`);
867
+ console.log('Type "muaddib help" to see available commands.');
868
+ process.exit(1);
850
869
  }