muaddib-scanner 2.2.15 → 2.2.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.2.15",
3
+ "version": "2.2.16",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -197,19 +197,46 @@ async function run(targetPath, options = {}) {
197
197
  // Deobfuscation pre-processor (pass to AST/dataflow scanners unless disabled)
198
198
  const deobfuscateFn = options.noDeobfuscate ? null : deobfuscate;
199
199
 
200
+ // Helper: yield to event loop so spinner can animate between sync operations
201
+ const yieldThen = (fn) => new Promise(resolve => setImmediate(() => resolve(fn())));
202
+
200
203
  // Cross-file module graph analysis (before individual scanners)
204
+ // Wrapped in yieldThen to unblock spinner animation
201
205
  let crossFileFlows = [];
202
206
  if (!options.noModuleGraph) {
203
207
  try {
204
- const graph = buildModuleGraph(targetPath);
205
- const tainted = annotateTaintedExports(graph, targetPath);
206
- crossFileFlows = detectCrossFileFlows(graph, tainted, targetPath);
208
+ const graph = await yieldThen(() => buildModuleGraph(targetPath));
209
+ const tainted = await yieldThen(() => annotateTaintedExports(graph, targetPath));
210
+ crossFileFlows = await yieldThen(() => detectCrossFileFlows(graph, tainted, targetPath));
207
211
  } catch {
208
212
  // Graceful fallback — module graph is best-effort
209
213
  }
210
214
  }
211
215
 
212
216
  // Parallel execution of all independent scanners
217
+ // Sync scanners use yieldThen() to yield to event loop (keeps spinner animating)
218
+ let scanResult;
219
+ try {
220
+ scanResult = await Promise.all([
221
+ scanPackageJson(targetPath),
222
+ scanShellScripts(targetPath),
223
+ analyzeAST(targetPath, { deobfuscate: deobfuscateFn }),
224
+ yieldThen(() => detectObfuscation(targetPath)),
225
+ scanDependencies(targetPath),
226
+ scanHashes(targetPath),
227
+ analyzeDataFlow(targetPath, { deobfuscate: deobfuscateFn }),
228
+ scanTyposquatting(targetPath),
229
+ yieldThen(() => scanGitHubActions(targetPath)),
230
+ yieldThen(() => matchPythonIOCs(pythonDeps, targetPath)),
231
+ yieldThen(() => checkPyPITyposquatting(pythonDeps, targetPath)),
232
+ yieldThen(() => scanEntropy(targetPath, { entropyThreshold: options.entropyThreshold || undefined })),
233
+ yieldThen(() => scanAIConfig(targetPath))
234
+ ]);
235
+ } catch (err) {
236
+ if (spinner) spinner.fail(`[MUADDIB] Scan failed: ${err.message}`);
237
+ throw err;
238
+ }
239
+
213
240
  const [
214
241
  packageThreats,
215
242
  shellThreats,
@@ -224,21 +251,7 @@ async function run(targetPath, options = {}) {
224
251
  pypiTyposquatThreats,
225
252
  entropyThreats,
226
253
  aiConfigThreats
227
- ] = await Promise.all([
228
- scanPackageJson(targetPath),
229
- scanShellScripts(targetPath),
230
- analyzeAST(targetPath, { deobfuscate: deobfuscateFn }),
231
- Promise.resolve(detectObfuscation(targetPath)),
232
- scanDependencies(targetPath),
233
- scanHashes(targetPath),
234
- analyzeDataFlow(targetPath, { deobfuscate: deobfuscateFn }),
235
- scanTyposquatting(targetPath),
236
- Promise.resolve(scanGitHubActions(targetPath)),
237
- Promise.resolve(matchPythonIOCs(pythonDeps, targetPath)),
238
- Promise.resolve(checkPyPITyposquatting(pythonDeps, targetPath)),
239
- Promise.resolve(scanEntropy(targetPath, { entropyThreshold: options.entropyThreshold || undefined })),
240
- Promise.resolve(scanAIConfig(targetPath))
241
- ]);
254
+ ] = scanResult;
242
255
 
243
256
  // Stop spinner now that scanning is complete
244
257
  if (spinner) {
@@ -375,9 +375,13 @@ function fetchBufferWithProgress(url, label, redirectCount = 0) {
375
375
  });
376
376
  });
377
377
 
378
- req.on('error', reject);
378
+ req.on('error', (err) => {
379
+ spinner.fail('Download failed: ' + err.message);
380
+ reject(err);
381
+ });
379
382
  req.setTimeout(300000, () => {
380
383
  req.destroy();
384
+ spinner.fail('Download timed out');
381
385
  reject(new Error('Timeout downloading ' + label));
382
386
  });
383
387
 
@@ -722,36 +726,41 @@ async function scrapeOSVDataDump() {
722
726
  let skippedCount = 0;
723
727
 
724
728
  const spinner = new Spinner();
725
- spinner.start('Parsing npm entries... 0/' + total);
729
+ try {
730
+ spinner.start('Parsing npm entries... 0/' + total);
726
731
 
727
- for (let i = 0; i < entries.length; i++) {
728
- const entry = entries[i];
729
- const name = entry.entryName;
732
+ for (let i = 0; i < entries.length; i++) {
733
+ const entry = entries[i];
734
+ const name = entry.entryName;
730
735
 
731
- // Only process MAL-*.json files (malware), skip GHSA-*, CVE-*, PYSEC-* etc.
732
- if (!name.startsWith('MAL-') || !name.endsWith('.json')) {
733
- skippedCount++;
734
- } else {
735
- try {
736
- const content = entry.getData().toString('utf8');
737
- const vuln = JSON.parse(content);
738
- const parsed = parseOSVEntry(vuln, 'osv-malicious');
739
- for (const p of parsed) packages.push(p);
740
-
741
- // Track known IDs so OSSF can skip them
742
- knownIds.add(vuln.id || path.basename(name, '.json'));
743
- malCount++;
744
- } catch {
745
- // Skip unparseable entries
736
+ // Only process MAL-*.json files (malware), skip GHSA-*, CVE-*, PYSEC-* etc.
737
+ if (!name.startsWith('MAL-') || !name.endsWith('.json')) {
738
+ skippedCount++;
739
+ } else {
740
+ try {
741
+ const content = entry.getData().toString('utf8');
742
+ const vuln = JSON.parse(content);
743
+ const parsed = parseOSVEntry(vuln, 'osv-malicious');
744
+ for (const p of parsed) packages.push(p);
745
+
746
+ // Track known IDs so OSSF can skip them
747
+ knownIds.add(vuln.id || path.basename(name, '.json'));
748
+ malCount++;
749
+ } catch {
750
+ // Skip unparseable entries
751
+ }
746
752
  }
747
- }
748
753
 
749
- if ((i + 1) % 1000 === 0 || i === entries.length - 1) {
750
- spinner.update('Parsing npm entries... ' + (i + 1) + '/' + total);
754
+ if ((i + 1) % 1000 === 0 || i === entries.length - 1) {
755
+ spinner.update('Parsing npm entries... ' + (i + 1) + '/' + total);
756
+ }
751
757
  }
752
- }
753
758
 
754
- spinner.succeed('Parsed npm entries: ' + malCount + ' MAL-* (' + skippedCount + ' skipped) \u2192 ' + packages.length + ' packages');
759
+ spinner.succeed('Parsed npm entries: ' + malCount + ' MAL-* (' + skippedCount + ' skipped) \u2192 ' + packages.length + ' packages');
760
+ } catch (innerErr) {
761
+ spinner.fail('Parsing npm entries failed');
762
+ throw innerErr;
763
+ }
755
764
  } catch (e) {
756
765
  console.log('[SCRAPER] Error: ' + e.message);
757
766
  }
@@ -778,33 +787,38 @@ async function scrapeOSVPyPIDataDump() {
778
787
  let skippedCount = 0;
779
788
 
780
789
  const spinner = new Spinner();
781
- spinner.start('Parsing PyPI entries... 0/' + total);
790
+ try {
791
+ spinner.start('Parsing PyPI entries... 0/' + total);
782
792
 
783
- for (let i = 0; i < entries.length; i++) {
784
- const entry = entries[i];
785
- const name = entry.entryName;
793
+ for (let i = 0; i < entries.length; i++) {
794
+ const entry = entries[i];
795
+ const name = entry.entryName;
786
796
 
787
- // Only process MAL-*.json files (malware)
788
- if (!name.startsWith('MAL-') || !name.endsWith('.json')) {
789
- skippedCount++;
790
- } else {
791
- try {
792
- const content = entry.getData().toString('utf8');
793
- const vuln = JSON.parse(content);
794
- const parsed = parseOSVEntry(vuln, 'osv-malicious-pypi', 'PyPI');
795
- for (const p of parsed) packages.push(p);
796
- malCount++;
797
- } catch {
798
- // Skip unparseable entries
797
+ // Only process MAL-*.json files (malware)
798
+ if (!name.startsWith('MAL-') || !name.endsWith('.json')) {
799
+ skippedCount++;
800
+ } else {
801
+ try {
802
+ const content = entry.getData().toString('utf8');
803
+ const vuln = JSON.parse(content);
804
+ const parsed = parseOSVEntry(vuln, 'osv-malicious-pypi', 'PyPI');
805
+ for (const p of parsed) packages.push(p);
806
+ malCount++;
807
+ } catch {
808
+ // Skip unparseable entries
809
+ }
799
810
  }
800
- }
801
811
 
802
- if ((i + 1) % 1000 === 0 || i === entries.length - 1) {
803
- spinner.update('Parsing PyPI entries... ' + (i + 1) + '/' + total);
812
+ if ((i + 1) % 1000 === 0 || i === entries.length - 1) {
813
+ spinner.update('Parsing PyPI entries... ' + (i + 1) + '/' + total);
814
+ }
804
815
  }
805
- }
806
816
 
807
- spinner.succeed('Parsed PyPI entries: ' + malCount + ' MAL-* (' + skippedCount + ' skipped) \u2192 ' + packages.length + ' packages');
817
+ spinner.succeed('Parsed PyPI entries: ' + malCount + ' MAL-* (' + skippedCount + ' skipped) \u2192 ' + packages.length + ' packages');
818
+ } catch (innerErr) {
819
+ spinner.fail('Parsing PyPI entries failed');
820
+ throw innerErr;
821
+ }
808
822
  } catch (e) {
809
823
  console.log('[SCRAPER] Error: ' + e.message);
810
824
  }
package/src/monitor.js CHANGED
@@ -28,7 +28,7 @@ const stats = {
28
28
  errors: 0,
29
29
  totalTimeMs: 0,
30
30
  lastReportTime: Date.now(),
31
- lastDailyReportTime: Date.now()
31
+ lastDailyReportDate: null // YYYY-MM-DD (Paris) of last daily report sent
32
32
  };
33
33
 
34
34
  // Track daily suspects for the daily report (name, version, ecosystem, findingsCount)
@@ -615,6 +615,10 @@ function loadState() {
615
615
  try {
616
616
  const raw = fs.readFileSync(STATE_FILE, 'utf8');
617
617
  const state = JSON.parse(raw);
618
+ // Restore daily report date so it survives restarts (auto-update, crashes)
619
+ if (typeof state.lastDailyReportDate === 'string') {
620
+ stats.lastDailyReportDate = state.lastDailyReportDate;
621
+ }
618
622
  return {
619
623
  npmLastPackage: typeof state.npmLastPackage === 'string' ? state.npmLastPackage : '',
620
624
  pypiLastPackage: typeof state.pypiLastPackage === 'string' ? state.pypiLastPackage : ''
@@ -630,7 +634,12 @@ function saveState(state) {
630
634
  if (!fs.existsSync(dir)) {
631
635
  fs.mkdirSync(dir, { recursive: true });
632
636
  }
633
- fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf8');
637
+ // Persist daily report date so it survives restarts
638
+ const persistedState = {
639
+ ...state,
640
+ lastDailyReportDate: stats.lastDailyReportDate
641
+ };
642
+ fs.writeFileSync(STATE_FILE, JSON.stringify(persistedState, null, 2), 'utf8');
634
643
  } catch (err) {
635
644
  console.error(`[MONITOR] Failed to save state: ${err.message}`);
636
645
  }
@@ -1018,7 +1027,38 @@ function reportStats() {
1018
1027
  stats.lastReportTime = Date.now();
1019
1028
  }
1020
1029
 
1021
- const DAILY_REPORT_INTERVAL = 24 * 3600_000; // 24 hours
1030
+ const DAILY_REPORT_HOUR = 8; // 08:00 Paris time (Europe/Paris)
1031
+
1032
+ /**
1033
+ * Returns the current hour in Europe/Paris timezone (0-23).
1034
+ */
1035
+ function getParisHour() {
1036
+ const formatter = new Intl.DateTimeFormat('en-GB', {
1037
+ timeZone: 'Europe/Paris',
1038
+ hour: 'numeric',
1039
+ hour12: false
1040
+ });
1041
+ return parseInt(formatter.format(new Date()), 10);
1042
+ }
1043
+
1044
+ /**
1045
+ * Returns today's date string in Europe/Paris timezone (YYYY-MM-DD).
1046
+ */
1047
+ function getParisDateString() {
1048
+ const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Paris' });
1049
+ return formatter.format(new Date());
1050
+ }
1051
+
1052
+ /**
1053
+ * Check if the daily report is due: Paris hour matches DAILY_REPORT_HOUR
1054
+ * and we haven't already sent one today.
1055
+ */
1056
+ function isDailyReportDue() {
1057
+ const parisHour = getParisHour();
1058
+ if (parisHour !== DAILY_REPORT_HOUR) return false;
1059
+ const today = getParisDateString();
1060
+ return stats.lastDailyReportDate !== today;
1061
+ }
1022
1062
 
1023
1063
  function buildDailyReportEmbed() {
1024
1064
  const avg = stats.scanned > 0 ? (stats.totalTimeMs / stats.scanned / 1000).toFixed(1) : '0.0';
@@ -1076,7 +1116,7 @@ async function sendDailyReport() {
1076
1116
  stats.totalTimeMs = 0;
1077
1117
  dailyAlerts.length = 0;
1078
1118
  recentlyScanned.clear();
1079
- stats.lastDailyReportTime = Date.now();
1119
+ stats.lastDailyReportDate = getParisDateString();
1080
1120
  }
1081
1121
 
1082
1122
  // --- npm polling ---
@@ -1311,9 +1351,12 @@ async function startMonitor(options) {
1311
1351
 
1312
1352
  let running = true;
1313
1353
 
1314
- // SIGINT: save state and exit
1315
- process.on('SIGINT', () => {
1316
- console.log('\n[MONITOR] Stopping — saving state...');
1354
+ // SIGINT: send pending daily report, save state and exit
1355
+ process.on('SIGINT', async () => {
1356
+ console.log('\n[MONITOR] Stopping — sending pending daily report...');
1357
+ if (stats.scanned > 0) {
1358
+ await sendDailyReport();
1359
+ }
1317
1360
  saveState(state);
1318
1361
  reportStats();
1319
1362
  console.log('[MONITOR] State saved. Bye!');
@@ -1339,8 +1382,8 @@ async function startMonitor(options) {
1339
1382
  reportStats();
1340
1383
  }
1341
1384
 
1342
- // Daily webhook report
1343
- if (Date.now() - stats.lastDailyReportTime >= DAILY_REPORT_INTERVAL) {
1385
+ // Daily webhook report at 08:00 Paris time
1386
+ if (isDailyReportDue()) {
1344
1387
  await sendDailyReport();
1345
1388
  }
1346
1389
  }
@@ -1516,7 +1559,10 @@ module.exports = {
1516
1559
  computeRiskScore,
1517
1560
  buildDailyReportEmbed,
1518
1561
  sendDailyReport,
1519
- DAILY_REPORT_INTERVAL,
1562
+ DAILY_REPORT_HOUR,
1563
+ isDailyReportDue,
1564
+ getParisHour,
1565
+ getParisDateString,
1520
1566
  isTemporalEnabled,
1521
1567
  buildTemporalWebhookEmbed,
1522
1568
  runTemporalCheck,