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 +1 -1
- package/src/index.js +31 -18
- package/src/ioc/scraper.js +60 -46
- package/src/monitor.js +56 -10
package/package.json
CHANGED
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
|
-
] =
|
|
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) {
|
package/src/ioc/scraper.js
CHANGED
|
@@ -375,9 +375,13 @@ function fetchBufferWithProgress(url, label, redirectCount = 0) {
|
|
|
375
375
|
});
|
|
376
376
|
});
|
|
377
377
|
|
|
378
|
-
req.on('error',
|
|
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
|
-
|
|
729
|
+
try {
|
|
730
|
+
spinner.start('Parsing npm entries... 0/' + total);
|
|
726
731
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
732
|
+
for (let i = 0; i < entries.length; i++) {
|
|
733
|
+
const entry = entries[i];
|
|
734
|
+
const name = entry.entryName;
|
|
730
735
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
750
|
-
|
|
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
|
-
|
|
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
|
-
|
|
790
|
+
try {
|
|
791
|
+
spinner.start('Parsing PyPI entries... 0/' + total);
|
|
782
792
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
793
|
+
for (let i = 0; i < entries.length; i++) {
|
|
794
|
+
const entry = entries[i];
|
|
795
|
+
const name = entry.entryName;
|
|
786
796
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
-
|
|
803
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 —
|
|
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 (
|
|
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
|
-
|
|
1562
|
+
DAILY_REPORT_HOUR,
|
|
1563
|
+
isDailyReportDue,
|
|
1564
|
+
getParisHour,
|
|
1565
|
+
getParisDateString,
|
|
1520
1566
|
isTemporalEnabled,
|
|
1521
1567
|
buildTemporalWebhookEmbed,
|
|
1522
1568
|
runTemporalCheck,
|