muaddib-scanner 2.11.77 → 2.11.79

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
@@ -31,6 +31,23 @@ const { diff, showRefs } = require('../src/diff.js');
31
31
  const { initHooks, removeHooks } = require('../src/hooks-init.js');
32
32
  const { showHelp, commandHelp } = require('../src/commands/help.js');
33
33
  const { interactiveMenu } = require('../src/commands/interactive.js');
34
+ const { isPromptCancellation } = require('../src/utils.js');
35
+
36
+ // Global safety net: turn an unhandled async error into a clean one-line message
37
+ // instead of a raw stack trace. Ctrl-C inside an interactive prompt exits 130
38
+ // (POSIX SIGINT convention); any other error exits 1. Set MUADDIB_DEBUG=1 to see
39
+ // the full stack.
40
+ function handleFatal(err) {
41
+ if (isPromptCancellation(err)) {
42
+ console.log('\nCancelled.');
43
+ process.exit(130);
44
+ }
45
+ console.error('[ERROR]', err && err.message ? err.message : String(err));
46
+ if (process.env.MUADDIB_DEBUG && err && err.stack) console.error(err.stack);
47
+ process.exit(1);
48
+ }
49
+ process.on('unhandledRejection', handleFatal);
50
+ process.on('uncaughtException', handleFatal);
34
51
 
35
52
  const args = process.argv.slice(2);
36
53
  const command = args[0];
@@ -274,10 +291,7 @@ if (command === 'version' || command === '--version' || command === '-v') {
274
291
  if (command === '--help' || command === '-h') {
275
292
  showHelp();
276
293
  }
277
- interactiveMenu().catch(err => {
278
- console.error('[ERROR]', err.message);
279
- process.exit(1);
280
- });
294
+ interactiveMenu().catch(handleFatal);
281
295
  } else if (command === 'scan') {
282
296
  if (wantHelp) showHelp('scan');
283
297
  run(target, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.77",
3
+ "version": "2.11.79",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "node_modules",
3
- "timestamp": "2026-06-08T14:52:28.323Z",
3
+ "timestamp": "2026-06-10T12:04:24.243Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -8,16 +8,15 @@ const { safeInstall } = require('../safe-install.js');
8
8
  const { buildSandboxImage, runSandbox, generateNetworkReport } = require('../sandbox/index.js');
9
9
  const { diff } = require('../diff.js');
10
10
  const { initHooks } = require('../hooks-init.js');
11
+ const { banner } = require('../utils.js');
11
12
 
12
13
  async function interactiveMenu() {
13
14
  const { select, input, confirm } = await import('@inquirer/prompts');
14
15
 
15
- console.log(`
16
- ╔═══════════════════════════════════════════════╗
17
- ║ MUAD'DIB - npm & PyPI Supply Chain Hunter ║
18
- ║ "The worms must die." ║
19
- ╚═══════════════════════════════════════════════╝
20
- `);
16
+ console.log('\n' + banner([
17
+ "MUAD'DIB - npm & PyPI Supply Chain Hunter",
18
+ '"The worms must die."'
19
+ ]) + '\n');
21
20
 
22
21
  const action = await select({
23
22
  message: 'What do you want to do?',
@@ -8,6 +8,7 @@ const path = require('path');
8
8
  const cp = require('child_process');
9
9
  const { loadCachedIOCs } = require('../ioc/updater.js');
10
10
  const { REHABILITATED_PACKAGES, NPM_PACKAGE_REGEX } = require('../shared/constants.js');
11
+ const { banner } = require('../utils.js');
11
12
 
12
13
  /**
13
14
  * Validates that a package name is safe (no command injection)
@@ -212,12 +213,10 @@ async function scanPackageRecursive(pkg, depth = 0, maxDepth = 3) {
212
213
  async function safeInstall(packages, options = {}) {
213
214
  const { isDev, isGlobal, force } = options;
214
215
 
215
- console.log(`
216
- ╔══════════════════════════════════════════╗
217
- ║ MUAD'DIB Safe Install ║
218
- ║ Scanning packages + dependencies... ║
219
- ╚══════════════════════════════════════════╝
220
- `);
216
+ console.log('\n' + banner([
217
+ "MUAD'DIB Safe Install",
218
+ 'Scanning packages + dependencies...'
219
+ ]) + '\n');
221
220
 
222
221
  // Reset the cache for each install
223
222
  scannedPackages.clear();
@@ -226,11 +225,7 @@ async function safeInstall(packages, options = {}) {
226
225
  const result = await scanPackageRecursive(pkg);
227
226
 
228
227
  if (!result.safe) {
229
- console.log(`
230
- ╔══════════════════════════════════════════╗
231
- ║ [!] MALICIOUS PACKAGE DETECTED ║
232
- ╚══════════════════════════════════════════╝
233
- `);
228
+ console.log('\n' + banner(['[!] MALICIOUS PACKAGE DETECTED']) + '\n');
234
229
  if (result.depth > 0) {
235
230
  console.log(`Requested package: ${pkg}`);
236
231
  console.log(`Malicious dependency: ${result.package} (depth: ${result.depth})`);
@@ -245,11 +240,11 @@ async function safeInstall(packages, options = {}) {
245
240
  console.log('[!] Installation BLOCKED.');
246
241
  return { blocked: true, package: result.package, threats: [{ type: 'known_malicious', severity: 'CRITICAL', message: result.description }] };
247
242
  } else {
248
- console.log('╔══════════════════════════════════════════╗');
249
- console.log('[!!!] WARNING: FORCE INSTALL ACTIVE');
250
- console.log('Known malicious package detected!');
251
- console.log('Installing despite security threats.');
252
- console.log('╚══════════════════════════════════════════╝');
243
+ console.log(banner([
244
+ '[!!!] WARNING: FORCE INSTALL ACTIVE',
245
+ 'Known malicious package detected!',
246
+ 'Installing despite security threats.'
247
+ ]));
253
248
  console.log('[AUDIT] Force-install override for malicious package: ' + result.package);
254
249
 
255
250
  // SFI-004: Write audit log for force-install overrides
@@ -7,9 +7,10 @@ const AdmZip = require('adm-zip');
7
7
  const IOC_FILE = path.join(__dirname, 'data/iocs.json');
8
8
  const COMPACT_IOC_FILE = path.join(__dirname, 'data/iocs-compact.json');
9
9
  const HOME_IOC_FILE = path.join(os.homedir(), '.muaddib', 'data', 'iocs.json');
10
- const { generateCompactIOCs, NEVER_WILDCARD } = require('./updater.js');
10
+ const { generateCompactIOCs, NEVER_WILDCARD, expandCompactIOCs } = require('./updater.js');
11
11
  const { Spinner } = require('../utils.js');
12
12
  const { NPM_PACKAGE_REGEX } = require('../shared/constants.js');
13
+ const { version: PKG_VERSION } = require('../../package.json');
13
14
 
14
15
  // Version format validation (semver-like + wildcard)
15
16
  // Permissive version validator — accepts:
@@ -43,6 +44,8 @@ const VERSION_RE = { test: isValidVersion };
43
44
  let _noVersionSkipCount = 0;
44
45
  let _invalidVersionSkipCount = 0;
45
46
  let _invalidVersionSamples = []; // first 3 samples for context
47
+ let _invalidNameSkipCount = 0;
48
+ let _invalidNameSamples = []; // first 3 samples for context
46
49
 
47
50
  /**
48
51
  * Validate an IOC package entry before insertion.
@@ -53,14 +56,18 @@ function validateIOCEntry(pkgName, version, ecosystem) {
53
56
  // npm: validate with NPM_PACKAGE_REGEX
54
57
  if (ecosystem === 'npm' || !ecosystem) {
55
58
  if (!NPM_PACKAGE_REGEX.test(pkgName)) {
56
- console.warn(`[WARN] Invalid ${ecosystem || 'npm'} package name skipped: ${pkgName}`);
59
+ // Aggregated counter (summary emitted by runScraper) was a per-line
60
+ // console.warn that spammed 100+ lines on feeds carrying non-spec names.
61
+ _invalidNameSkipCount++;
62
+ if (_invalidNameSamples.length < 3) _invalidNameSamples.push(pkgName);
57
63
  return false;
58
64
  }
59
65
  }
60
66
  // PyPI: basic check — no path traversal, no slashes
61
67
  if (ecosystem === 'pypi') {
62
68
  if (/[/\\]|\.\./.test(pkgName)) {
63
- console.warn(`[WARN] Invalid PyPI package name skipped: ${pkgName}`);
69
+ _invalidNameSkipCount++;
70
+ if (_invalidNameSamples.length < 3) _invalidNameSamples.push(pkgName);
64
71
  return false;
65
72
  }
66
73
  }
@@ -955,14 +962,16 @@ async function scrapeGitHubAdvisory() {
955
962
  // ============================================
956
963
  async function runScraper() {
957
964
  console.log('\n' + '='.repeat(60));
958
- console.log(' MUAD\'DIB IOC Scraper v4.1');
959
- console.log(' OSV + OSSF + GenSecAI + DataDog + Aikido + OSM');
965
+ console.log(' MUAD\'DIB IOC Scraper v' + PKG_VERSION);
966
+ console.log(' OSV + OSSF + GitHub Advisory + GenSecAI + DataDog + Aikido + OSM');
960
967
  console.log('='.repeat(60) + '\n');
961
968
 
962
969
  // Reset aggregated warning counters
963
970
  _noVersionSkipCount = 0;
964
971
  _invalidVersionSkipCount = 0;
965
972
  _invalidVersionSamples = [];
973
+ _invalidNameSkipCount = 0;
974
+ _invalidNameSamples = [];
966
975
 
967
976
  // Create data directory if needed
968
977
  const dataDir = path.dirname(IOC_FILE);
@@ -997,11 +1006,28 @@ async function runScraper() {
997
1006
  }
998
1007
  }
999
1008
 
1009
+ // Fresh-install fallback: the full iocs.json is gitignored / not shipped in the
1010
+ // npm package, but the compact baseline IS. Seed from it (the same path that
1011
+ // `muaddib update` uses) so a first scrape augments the shipped baseline instead
1012
+ // of appearing to start from zero and re-downloading everything.
1013
+ let seededFromCompact = false;
1014
+ if (existingIOCs.packages.length === 0 && fs.existsSync(COMPACT_IOC_FILE)) {
1015
+ try {
1016
+ const compactData = JSON.parse(fs.readFileSync(COMPACT_IOC_FILE, 'utf8'));
1017
+ existingIOCs = expandCompactIOCs(compactData);
1018
+ if (!existingIOCs.pypi_packages) existingIOCs.pypi_packages = [];
1019
+ seededFromCompact = true;
1020
+ } catch {
1021
+ console.log('[WARN] Compact IOC baseline unreadable, starting fresh');
1022
+ }
1023
+ }
1024
+
1000
1025
  const initialCount = existingIOCs.packages.length;
1001
1026
  const initialPyPICount = existingIOCs.pypi_packages.length;
1002
1027
  const initialHashCount = existingIOCs.hashes ? existingIOCs.hashes.length : 0;
1003
1028
 
1004
- console.log('[INFO] Existing IOCs: ' + initialCount + ' packages, ' + initialHashCount + ' hashes\n');
1029
+ const baselineLabel = seededFromCompact ? 'Baseline IOCs loaded (shipped compact)' : 'Existing IOCs';
1030
+ console.log('[INFO] ' + baselineLabel + ': ' + initialCount + ' packages, ' + initialHashCount + ' hashes\n');
1005
1031
 
1006
1032
  // Phase 1: OSV data dump first (bulk, primary source)
1007
1033
  // This returns knownIds so OSSF can skip already-known entries
@@ -1037,6 +1063,12 @@ async function runScraper() {
1037
1063
  : '';
1038
1064
  console.log('[SCRAPER] WARN: ' + _invalidVersionSkipCount + ' entries skipped (malformed version)' + samples);
1039
1065
  }
1066
+ if (_invalidNameSkipCount > 0) {
1067
+ const nameSamples = _invalidNameSamples.length > 0
1068
+ ? ' (samples: ' + _invalidNameSamples.join(', ') + ')'
1069
+ : '';
1070
+ console.log('[SCRAPER] WARN: ' + _invalidNameSkipCount + ' invalid package names skipped' + nameSamples);
1071
+ }
1040
1072
 
1041
1073
  // Merge all scraped packages
1042
1074
  const allPackages = [
@@ -1297,12 +1329,13 @@ async function runScraper() {
1297
1329
  console.log(' - ' + source + ': ' + count);
1298
1330
  }
1299
1331
 
1300
- // Target check
1332
+ // Sanity check: a drop vs the previously-loaded baseline is a real signal of a
1333
+ // feed outage or a corrupted merge — surface that instead of a meaningless target.
1301
1334
  const total = existingIOCs.packages.length;
1302
- if (total >= 5000) {
1303
- console.log('\n [OK] Target reached: ' + total + ' IOCs (>= 5000)');
1335
+ if (total < initialCount) {
1336
+ console.log('\n [WARN] IOC count decreased: ' + total + ' (was ' + initialCount + ') — possible source outage');
1304
1337
  } else {
1305
- console.log('\n [WARN] Target NOT reached: ' + total + ' IOCs (< 5000)');
1338
+ console.log('\n [OK] IOC database: ' + total + ' npm IOCs');
1306
1339
  }
1307
1340
 
1308
1341
  console.log('\n');
@@ -1606,6 +1639,8 @@ async function queryOSVBatch(packageNames) {
1606
1639
  // Test helpers for aggregated warning counters
1607
1640
  function getNoVersionSkipCount() { return _noVersionSkipCount; }
1608
1641
  function resetNoVersionSkipCount() { _noVersionSkipCount = 0; }
1642
+ function getInvalidNameSkipCount() { return _invalidNameSkipCount; }
1643
+ function resetInvalidNameSkipCount() { _invalidNameSkipCount = 0; _invalidNameSamples = []; }
1609
1644
 
1610
1645
  /**
1611
1646
  * Source-aware confidence: a package reported by N distinct feeds is more
@@ -1643,6 +1678,7 @@ module.exports = {
1643
1678
  createFreshness, isAllowedRedirect,
1644
1679
  validateIOCEntry,
1645
1680
  getNoVersionSkipCount, resetNoVersionSkipCount,
1681
+ getInvalidNameSkipCount, resetInvalidNameSkipCount,
1646
1682
  CONFIDENCE_ORDER, ALLOWED_REDIRECT_DOMAINS,
1647
1683
  MAX_ENTRY_UNCOMPRESSED, MAX_TOTAL_UNCOMPRESSED
1648
1684
  };
@@ -4,6 +4,7 @@ const path = require('path');
4
4
  const os = require('os');
5
5
  const v8 = require('v8');
6
6
  const { isDockerAvailable, SANDBOX_CONCURRENCY_MAX, killAllSandboxContainers } = require('../sandbox/index.js');
7
+ const { banner } = require('../utils.js');
7
8
  const { setVerboseMode, isSandboxEnabled, isCanaryEnabled, isLlmDetectiveEnabled, getLlmDetectiveMode, DOWNLOADS_CACHE_TTL } = require('./classify.js');
8
9
  const { loadState, saveState, loadDailyStats, saveDailyStats, purgeTarballCache, isDailyReportDue, atomicWriteFileSync, saveNpmSeq, ALERTS_FILE, runStateMigrations, loadRecentlyScanned, saveRecentlyScanned } = require('./state.js');
9
10
  const { isTemporalEnabled, isTemporalAstEnabled, isTemporalPublishEnabled, isTemporalMaintainerEnabled } = require('./temporal.js');
@@ -787,12 +788,10 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
787
788
  console.warn(`[Archive] Failed to start periodic cleanup: ${err.message}`);
788
789
  }
789
790
 
790
- console.log(`
791
- ╔════════════════════════════════════════════╗
792
- ║ MUAD'DIB - Registry Monitor ║
793
- ║ Scanning npm + PyPI new packages ║
794
- ╚════════════════════════════════════════════╝
795
- `);
791
+ console.log('\n' + banner([
792
+ "MUAD'DIB - Registry Monitor",
793
+ 'Scanning npm + PyPI new packages'
794
+ ]) + '\n');
796
795
 
797
796
  // Note: alerts file migrated from .json to .jsonl in v2.10.89
798
797
  const oldAlertsJson = ALERTS_FILE.replace('.jsonl', '.json');
@@ -3,6 +3,7 @@ const { saveSARIF } = require('../sarif.js');
3
3
  const { saveCycloneDX } = require('./cyclonedx.js');
4
4
  const { getPlaybook } = require('../response/playbooks.js');
5
5
  const { DOMAIN_CODES, getRuleDomain } = require('../rules/index.js');
6
+ const { renderScoreBar } = require('../utils.js');
6
7
 
7
8
  // P0a — domain tag formatter for CLI text output.
8
9
  // Returns a bracketed 3-letter code like "[MAL]" / "[AUT]" / "[ENG]" / "[VUL]"
@@ -63,8 +64,7 @@ function formatOutput(result, options, ctx) {
63
64
  if (!spinner) console.log(`\n[MUADDIB] Scanning ${targetPath}\n`);
64
65
  else console.log('');
65
66
 
66
- const explainScoreBar = '█'.repeat(Math.floor(result.summary.riskScore / 5)) + '░'.repeat(20 - Math.floor(result.summary.riskScore / 5));
67
- console.log(`[SCORE] ${result.summary.riskScore}/100 [${explainScoreBar}] ${result.summary.riskLevel}`);
67
+ console.log(`[SCORE] ${result.summary.riskScore}/100 [${renderScoreBar(result.summary.riskScore)}] ${result.summary.riskLevel}`);
68
68
  if (mostSuspiciousFile) {
69
69
  console.log(` Max file: ${mostSuspiciousFile} (${maxFileScore} pts)`);
70
70
  if (packageScore > 0) {
@@ -140,8 +140,7 @@ function formatOutput(result, options, ctx) {
140
140
  if (!spinner) console.log(`\n[MUADDIB] Scanning ${targetPath}\n`);
141
141
  else console.log('');
142
142
 
143
- const scoreBar = '█'.repeat(Math.floor(result.summary.riskScore / 5)) + '░'.repeat(20 - Math.floor(result.summary.riskScore / 5));
144
- console.log(`[SCORE] ${result.summary.riskScore}/100 [${scoreBar}] ${result.summary.riskLevel}`);
143
+ console.log(`[SCORE] ${result.summary.riskScore}/100 [${renderScoreBar(result.summary.riskScore)}] ${result.summary.riskLevel}`);
145
144
  if (mostSuspiciousFile) {
146
145
  console.log(` Max file: ${mostSuspiciousFile} (${maxFileScore} pts)`);
147
146
  if (packageScore > 0) {
@@ -121,6 +121,11 @@ async function execute(targetPath, options, pythonDeps, warnings) {
121
121
  spinner.start(`[MUADDIB] Scanning ${targetPath}...`);
122
122
  }
123
123
 
124
+ // try/finally guarantees the spinner's setInterval is always cleared. A scanner
125
+ // throwing before the succeed() below would otherwise leave it animating AND keep
126
+ // the event loop alive (process hang). _stop() is idempotent.
127
+ try {
128
+
124
129
  // Deobfuscation pre-processor (pass to AST/dataflow scanners unless disabled)
125
130
  const deobfuscateFn = options.noDeobfuscate ? null : deobfuscate;
126
131
 
@@ -152,7 +157,7 @@ async function execute(targetPath, options, pythonDeps, warnings) {
152
157
  moduleGraphThreats.push({
153
158
  type: 'large_package_graph_truncated',
154
159
  severity: 'MEDIUM',
155
- message: `Cross-file analysis désactivée : ${graphMeta.fileCount} fichiers dépassent la limite (${graphMeta.maxNodes}). Risque de blind spot sur monorepo / large package — auditer les sous-modules manuellement.`,
160
+ message: `Cross-file analysis disabled: ${graphMeta.fileCount} files exceed the limit (${graphMeta.maxNodes}). Risk of a blind spot on a monorepo / large package — audit the sub-modules manually.`,
156
161
  file: 'package.json',
157
162
  line: 0,
158
163
  fileCount: graphMeta.fileCount,
@@ -441,6 +446,9 @@ async function execute(targetPath, options, pythonDeps, warnings) {
441
446
  }
442
447
 
443
448
  return { threats, scannerErrors };
449
+ } finally {
450
+ if (spinner) spinner._stop();
451
+ }
444
452
  }
445
453
 
446
454
  module.exports = { execute, matchPythonIOCs, checkPyPITyposquatting };
@@ -1,24 +1,23 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { run } = require('../index.js');
4
+ const { banner } = require('../utils.js');
4
5
 
5
6
  let webhookUrl = null;
6
7
 
7
8
  async function startDaemon(options = {}) {
8
9
  webhookUrl = options.webhook || null;
9
10
 
10
- console.log(`
11
- ╔════════════════════════════════════════════╗
12
- ║ MUAD'DIB Security Daemon ║
13
- ║ Surveillance npm install active ║
14
- ╚════════════════════════════════════════════╝
15
- `);
11
+ console.log('\n' + banner([
12
+ "MUAD'DIB Security Daemon",
13
+ 'Monitoring npm installs'
14
+ ]) + '\n');
16
15
 
17
- console.log('[DAEMON] Demarrage...');
18
- console.log(`[DAEMON] Webhook: ${webhookUrl ? 'Configure' : 'Non configure'}`);
19
- console.log('[DAEMON] Ctrl+C pour arreter\n');
16
+ console.log('[DAEMON] Starting...');
17
+ console.log(`[DAEMON] Webhook: ${webhookUrl ? 'Configured' : 'Not configured'}`);
18
+ console.log('[DAEMON] Press Ctrl+C to stop\n');
20
19
 
21
- // Surveille le dossier courant
20
+ // Watch the current directory
22
21
  const cwd = process.cwd();
23
22
  const watchers = watchDirectory(cwd);
24
23
 
@@ -32,7 +31,7 @@ async function startDaemon(options = {}) {
32
31
  // Keep process alive until SIGINT
33
32
  await new Promise((resolve) => {
34
33
  process.once('SIGINT', () => {
35
- console.log('\n[DAEMON] Arret...');
34
+ console.log('\n[DAEMON] Stopping...');
36
35
  cleanup();
37
36
  resolve();
38
37
  });
@@ -47,26 +46,26 @@ function watchDirectory(dir) {
47
46
  const packageLockPath = path.join(dir, 'package-lock.json');
48
47
  const yarnLockPath = path.join(dir, 'yarn.lock');
49
48
 
50
- console.log(`[DAEMON] Surveillance de ${dir}`);
49
+ console.log(`[DAEMON] Watching ${dir}`);
51
50
 
52
- // Surveille package-lock.json
51
+ // Watch package-lock.json
53
52
  if (fs.existsSync(packageLockPath)) {
54
53
  const w = watchFile(packageLockPath, dir);
55
54
  if (w) watchers.push(w);
56
55
  }
57
56
 
58
- // Surveille yarn.lock
57
+ // Watch yarn.lock
59
58
  if (fs.existsSync(yarnLockPath)) {
60
59
  const w = watchFile(yarnLockPath, dir);
61
60
  if (w) watchers.push(w);
62
61
  }
63
62
 
64
- // Surveille node_modules
63
+ // Watch node_modules
65
64
  if (fs.existsSync(nodeModulesPath)) {
66
65
  watchers.push(watchNodeModules(nodeModulesPath, dir));
67
66
  }
68
67
 
69
- // Surveille la creation de node_modules
68
+ // Watch for node_modules creation
70
69
  if (process.platform === 'linux') {
71
70
  console.log('[DAEMON] Note: recursive fs.watch may not work on Linux');
72
71
  }
@@ -75,12 +74,12 @@ function watchDirectory(dir) {
75
74
  if (filename === 'node_modules' && eventType === 'rename') {
76
75
  const nmPath = path.join(dir, 'node_modules');
77
76
  if (fs.existsSync(nmPath)) {
78
- console.log('[DAEMON] node_modules detecte, scan en cours...');
77
+ console.log('[DAEMON] node_modules detected, scanning...');
79
78
  triggerScan(dir);
80
79
  }
81
80
  }
82
81
  if (filename === 'package-lock.json' || filename === 'yarn.lock') {
83
- console.log(`[DAEMON] ${filename} modifie, scan en cours...`);
82
+ console.log(`[DAEMON] ${filename} modified, scanning...`);
84
83
  triggerScan(dir);
85
84
  }
86
85
  });
@@ -106,7 +105,7 @@ function watchFile(filePath, projectDir) {
106
105
  const currentMtime = fs.statSync(filePath).mtime.getTime();
107
106
  if (currentMtime !== lastMtime) {
108
107
  lastMtime = currentMtime;
109
- console.log(`[DAEMON] ${path.basename(filePath)} modifie`);
108
+ console.log(`[DAEMON] ${path.basename(filePath)} modified`);
110
109
  triggerScan(projectDir);
111
110
  }
112
111
  } catch {
@@ -123,7 +122,7 @@ function watchFile(filePath, projectDir) {
123
122
  function watchNodeModules(nodeModulesPath, projectDir) {
124
123
  const watcher = fs.watch(nodeModulesPath, { recursive: true }, (eventType, filename) => {
125
124
  if (filename && filename.includes('package.json')) {
126
- console.log(`[DAEMON] Nouveau package detecte: ${filename}`);
125
+ console.log(`[DAEMON] New package detected: ${filename}`);
127
126
  triggerScan(projectDir);
128
127
  }
129
128
  });
@@ -147,12 +146,12 @@ function triggerScan(dir) {
147
146
  const now = Date.now();
148
147
  const state = getScanState(dir);
149
148
 
150
- // Debounce: attend 3 secondes avant de scanner
149
+ // Debounce: wait 3 seconds before scanning
151
150
  if (state.timeout) {
152
151
  clearTimeout(state.timeout);
153
152
  }
154
153
 
155
- // Evite les scans trop frequents (minimum 10 secondes entre chaque)
154
+ // Avoid over-frequent scans (minimum 10 seconds between each)
156
155
  if (now - state.lastScanTime < 10000) {
157
156
  state.timeout = setTimeout(() => triggerScan(dir), 10000 - (now - state.lastScanTime));
158
157
  return;
@@ -160,19 +159,19 @@ function triggerScan(dir) {
160
159
 
161
160
  state.timeout = setTimeout(async () => {
162
161
  state.lastScanTime = Date.now();
163
- console.log(`\n[DAEMON] ========== SCAN AUTOMATIQUE ==========`);
164
- console.log(`[DAEMON] Cible: ${dir}`);
165
- console.log(`[DAEMON] Heure: ${new Date().toLocaleTimeString()}\n`);
162
+ console.log(`\n[DAEMON] ========== AUTOMATIC SCAN ==========`);
163
+ console.log(`[DAEMON] Target: ${dir}`);
164
+ console.log(`[DAEMON] Time: ${new Date().toLocaleTimeString()}\n`);
166
165
 
167
166
  try {
168
167
  await run(dir, { webhook: webhookUrl });
169
168
  } catch (err) {
170
- console.log(`[DAEMON] Erreur scan: ${err.message}`);
169
+ console.log(`[DAEMON] Scan error: ${err.message}`);
171
170
  }
172
171
 
173
172
  console.log(`\n[DAEMON] ======================================\n`);
174
- console.log('[DAEMON] En attente de modifications...');
173
+ console.log('[DAEMON] Waiting for changes...');
175
174
  }, 3000);
176
175
  }
177
176
 
178
- module.exports = { startDaemon, watchDirectory, watchFile, watchNodeModules, triggerScan, getScanState };
177
+ module.exports = { startDaemon, watchDirectory, watchFile, watchNodeModules, triggerScan, getScanState };
@@ -6,13 +6,13 @@ function watch(targetPath) {
6
6
  let debounceTimer = null;
7
7
  const watchers = [];
8
8
 
9
- console.log(`[MUADDIB] Surveillance de ${targetPath}\n`);
10
- console.log('[INFO] Ctrl+C pour arreter\n');
9
+ console.log(`[MUADDIB] Watching ${targetPath}\n`);
10
+ console.log('[INFO] Press Ctrl+C to stop\n');
11
11
 
12
- // Scan initial
12
+ // Initial scan
13
13
  run(targetPath, { json: false }).catch(err => console.error('[ERROR]', err.message));
14
14
 
15
- // Surveille les changements
15
+ // Watch for changes
16
16
  const watchPaths = [
17
17
  path.join(targetPath, 'package.json'),
18
18
  path.join(targetPath, 'package-lock.json'),
@@ -30,7 +30,7 @@ function watch(targetPath) {
30
30
  if (debounceTimer) clearTimeout(debounceTimer);
31
31
 
32
32
  debounceTimer = setTimeout(() => {
33
- console.log(`\n[CHANGE] ${filename || 'unknown file'} modifie`);
33
+ console.log(`\n[CHANGE] ${filename || 'unknown file'} modified`);
34
34
  console.log('[MUADDIB] Re-scan...\n');
35
35
  run(targetPath, { json: false }).catch(err => console.error('[ERROR]', err.message));
36
36
  }, 1000);
@@ -45,7 +45,7 @@ function watch(targetPath) {
45
45
 
46
46
  // Cleanup on SIGINT
47
47
  process.once('SIGINT', () => {
48
- console.log('\n[MUADDIB] Arret surveillance...');
48
+ console.log('\n[MUADDIB] Stopping watch...');
49
49
  for (const w of watchers) {
50
50
  try { w.close(); } catch { /* ignore */ }
51
51
  }
@@ -53,4 +53,4 @@ function watch(targetPath) {
53
53
  });
54
54
  }
55
55
 
56
- module.exports = { watch };
56
+ module.exports = { watch };
@@ -1035,16 +1035,18 @@ function scoreFindings(report) {
1035
1035
 
1036
1036
  // ── Network report (detailed, colored) ──
1037
1037
 
1038
- function generateNetworkReport(report) {
1038
+ function generateNetworkReport(report, useColor = process.stdout.isTTY) {
1039
1039
  const lines = [];
1040
- const RED = '\x1b[31m';
1041
- const YELLOW = '\x1b[33m';
1042
- const GREEN = '\x1b[32m';
1043
- const CYAN = '\x1b[36m';
1044
- const MAGENTA = '\x1b[35m';
1045
- const BOLD = '\x1b[1m';
1046
- const DIM = '\x1b[2m';
1047
- const RESET = '\x1b[0m';
1040
+ // Gate ANSI on TTY so piping `sandbox-report` to a file yields clean text
1041
+ // (was unconditionally colored — escape codes leaked into redirected output).
1042
+ const RED = useColor ? '\x1b[31m' : '';
1043
+ const YELLOW = useColor ? '\x1b[33m' : '';
1044
+ const GREEN = useColor ? '\x1b[32m' : '';
1045
+ const CYAN = useColor ? '\x1b[36m' : '';
1046
+ const MAGENTA = useColor ? '\x1b[35m' : '';
1047
+ const BOLD = useColor ? '\x1b[1m' : '';
1048
+ const DIM = useColor ? '\x1b[2m' : '';
1049
+ const RESET = useColor ? '\x1b[0m' : '';
1048
1050
 
1049
1051
  lines.push('');
1050
1052
  lines.push(`${BOLD}${MAGENTA}╔══════════════════════════════════════════════════╗${RESET}`);
package/src/utils.js CHANGED
@@ -392,6 +392,62 @@ function debugLog(...args) {
392
392
  if (process.env.MUADDIB_DEBUG) console.error('[DEBUG]', ...args);
393
393
  }
394
394
 
395
+ // eslint-disable-next-line no-control-regex -- ESC (\x1b) is required to strip ANSI color sequences before measuring visible width
396
+ const _ANSI_RE = /\x1b\[[0-9;]*m/g;
397
+
398
+ /**
399
+ * Draws an aligned box-drawing banner around the given content line(s).
400
+ * Width auto-fits the widest VISIBLE line (ANSI stripped before measuring),
401
+ * so the right border is always straight — replacing the hand-padded boxes
402
+ * that drifted out of alignment. Returns the banner as a string (no leading or
403
+ * trailing blank lines — callers add spacing as needed).
404
+ *
405
+ * @param {string[]|string} lines - content line(s)
406
+ * @returns {string}
407
+ */
408
+ function banner(lines) {
409
+ const content = Array.isArray(lines) ? lines : [String(lines)];
410
+ const visibleLen = (s) => String(s).replace(_ANSI_RE, '').length;
411
+ const PAD = 1; // spaces of padding on each side
412
+ const inner = content.reduce((max, l) => Math.max(max, visibleLen(l)), 0) + PAD * 2;
413
+ const top = '╔' + '═'.repeat(inner) + '╗';
414
+ const bottom = '╚' + '═'.repeat(inner) + '╝';
415
+ const body = content.map((l) => {
416
+ const trailing = inner - PAD - visibleLen(l);
417
+ return '║' + ' '.repeat(PAD) + l + ' '.repeat(Math.max(0, trailing)) + '║';
418
+ });
419
+ return [top, ...body, bottom].join('\n');
420
+ }
421
+
422
+ /**
423
+ * True when an error represents a user-initiated prompt cancellation — Ctrl-C
424
+ * inside an @inquirer/prompts prompt, which throws an ExitPromptError whose
425
+ * message contains "force closed the prompt with SIGINT". Lets the CLI exit
426
+ * cleanly (code 130) instead of dumping an [ERROR] line or a stack trace.
427
+ *
428
+ * @param {*} err
429
+ * @returns {boolean}
430
+ */
431
+ function isPromptCancellation(err) {
432
+ if (!err) return false;
433
+ if (err.name === 'ExitPromptError') return true;
434
+ return /SIGINT|force closed the prompt/i.test(err.message || '');
435
+ }
436
+
437
+ /**
438
+ * Renders a fixed-width 20-cell [██░░] bar for a 0–100 risk score. Clamps and
439
+ * guards against undefined / NaN / out-of-range so the CLI never throws a
440
+ * RangeError from String.prototype.repeat on a malformed score.
441
+ *
442
+ * @param {number} score
443
+ * @returns {string} a 20-character bar
444
+ */
445
+ function renderScoreBar(score) {
446
+ const s = Number.isFinite(score) ? Math.max(0, Math.min(100, score)) : 0;
447
+ const filled = Math.floor(s / 5);
448
+ return '█'.repeat(filled) + '░'.repeat(20 - filled);
449
+ }
450
+
395
451
  module.exports = {
396
452
  EXCLUDED_DIRS,
397
453
  MAX_SCAN_FILES,
@@ -409,5 +465,8 @@ module.exports = {
409
465
  getExtraExcludes,
410
466
  forEachSafeFile,
411
467
  listInstalledPackages,
412
- debugLog
468
+ debugLog,
469
+ banner,
470
+ isPromptCancellation,
471
+ renderScoreBar
413
472
  };