muaddib-scanner 2.2.16 → 2.2.18
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 +28 -0
- package/package.json +1 -1
- package/src/index.js +10 -9
- package/src/monitor.js +172 -11
- package/src/scanner/package.js +2 -0
- package/testssampleslink-deppackage.json +1 -0
package/bin/muaddib.js
CHANGED
|
@@ -812,6 +812,34 @@ if (command === 'version' || command === '--version' || command === '-v') {
|
|
|
812
812
|
console.error('[ERROR]', err.message);
|
|
813
813
|
process.exit(1);
|
|
814
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
|
+
}
|
|
815
843
|
} else if (command === 'help') {
|
|
816
844
|
console.log(helpText);
|
|
817
845
|
process.exit(0);
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -213,19 +213,20 @@ async function run(targetPath, options = {}) {
|
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
//
|
|
217
|
-
//
|
|
216
|
+
// Sequential execution of scanners with event loop yields between each.
|
|
217
|
+
// All scanners (even "async" ones) are effectively synchronous (readFileSync, readdirSync).
|
|
218
|
+
// Running them via yieldThen ensures the spinner animates between each scanner.
|
|
218
219
|
let scanResult;
|
|
219
220
|
try {
|
|
220
221
|
scanResult = await Promise.all([
|
|
221
|
-
scanPackageJson(targetPath),
|
|
222
|
-
scanShellScripts(targetPath),
|
|
223
|
-
analyzeAST(targetPath, { deobfuscate: deobfuscateFn }),
|
|
222
|
+
yieldThen(() => scanPackageJson(targetPath)),
|
|
223
|
+
yieldThen(() => scanShellScripts(targetPath)),
|
|
224
|
+
yieldThen(() => analyzeAST(targetPath, { deobfuscate: deobfuscateFn })),
|
|
224
225
|
yieldThen(() => detectObfuscation(targetPath)),
|
|
225
|
-
scanDependencies(targetPath),
|
|
226
|
-
scanHashes(targetPath),
|
|
227
|
-
analyzeDataFlow(targetPath, { deobfuscate: deobfuscateFn }),
|
|
228
|
-
scanTyposquatting(targetPath),
|
|
226
|
+
yieldThen(() => scanDependencies(targetPath)),
|
|
227
|
+
yieldThen(() => scanHashes(targetPath)),
|
|
228
|
+
yieldThen(() => analyzeDataFlow(targetPath, { deobfuscate: deobfuscateFn })),
|
|
229
|
+
yieldThen(() => scanTyposquatting(targetPath)),
|
|
229
230
|
yieldThen(() => scanGitHubActions(targetPath)),
|
|
230
231
|
yieldThen(() => matchPythonIOCs(pythonDeps, targetPath)),
|
|
231
232
|
yieldThen(() => checkPyPITyposquatting(pythonDeps, targetPath)),
|
package/src/monitor.js
CHANGED
|
@@ -1061,18 +1061,26 @@ function isDailyReportDue() {
|
|
|
1061
1061
|
}
|
|
1062
1062
|
|
|
1063
1063
|
function buildDailyReportEmbed() {
|
|
1064
|
-
|
|
1064
|
+
// Use disk-based daily entries filtered by lastDailyReportDate for accurate delta
|
|
1065
|
+
const { agg, top3: diskTop3 } = buildReportFromDisk();
|
|
1065
1066
|
|
|
1066
|
-
//
|
|
1067
|
-
const top3 = dailyAlerts
|
|
1068
|
-
.slice()
|
|
1069
|
-
|
|
1070
|
-
.slice(0, 3);
|
|
1067
|
+
// Prefer in-memory dailyAlerts for top suspects (richer data), fallback to disk
|
|
1068
|
+
const top3 = dailyAlerts.length > 0
|
|
1069
|
+
? dailyAlerts.slice().sort((a, b) => b.findingsCount - a.findingsCount).slice(0, 3)
|
|
1070
|
+
: diskTop3;
|
|
1071
1071
|
|
|
1072
1072
|
const top3Text = top3.length > 0
|
|
1073
|
-
? top3.map((a, i) =>
|
|
1073
|
+
? top3.map((a, i) => {
|
|
1074
|
+
const name = a.ecosystem ? `${a.ecosystem}/${a.name || a.package}` : (a.name || a.package);
|
|
1075
|
+
const version = a.version || 'N/A';
|
|
1076
|
+
const count = a.findingsCount || (a.findings ? a.findings.length : 0);
|
|
1077
|
+
return `${i + 1}. **${name}@${version}** — ${count} finding(s)`;
|
|
1078
|
+
}).join('\n')
|
|
1074
1079
|
: 'None';
|
|
1075
1080
|
|
|
1081
|
+
// Avg scan time from in-memory stats (not available on disk)
|
|
1082
|
+
const avg = stats.scanned > 0 ? (stats.totalTimeMs / stats.scanned / 1000).toFixed(1) : '0.0';
|
|
1083
|
+
|
|
1076
1084
|
const now = new Date();
|
|
1077
1085
|
const readableTime = now.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
|
|
1078
1086
|
|
|
@@ -1081,9 +1089,9 @@ function buildDailyReportEmbed() {
|
|
|
1081
1089
|
title: '\uD83D\uDCCA MUAD\'DIB Daily Report',
|
|
1082
1090
|
color: 0x3498db,
|
|
1083
1091
|
fields: [
|
|
1084
|
-
{ name: 'Packages Scanned', value: `${
|
|
1085
|
-
{ name: 'Clean', value: `${
|
|
1086
|
-
{ name: 'Suspects', value: `${
|
|
1092
|
+
{ name: 'Packages Scanned', value: `${agg.scanned}`, inline: true },
|
|
1093
|
+
{ name: 'Clean', value: `${agg.clean}`, inline: true },
|
|
1094
|
+
{ name: 'Suspects', value: `${agg.suspect}`, inline: true },
|
|
1087
1095
|
{ name: 'Errors', value: `${stats.errors}`, inline: true },
|
|
1088
1096
|
{ name: 'Avg Scan Time', value: `${avg}s/pkg`, inline: true },
|
|
1089
1097
|
{ name: 'Top Suspects', value: top3Text, inline: false }
|
|
@@ -1119,6 +1127,155 @@ async function sendDailyReport() {
|
|
|
1119
1127
|
stats.lastDailyReportDate = getParisDateString();
|
|
1120
1128
|
}
|
|
1121
1129
|
|
|
1130
|
+
// --- CLI report helpers (muaddib report --now / --status) ---
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Read raw state file (without restoring into stats).
|
|
1134
|
+
*/
|
|
1135
|
+
function loadStateRaw() {
|
|
1136
|
+
try {
|
|
1137
|
+
const raw = fs.readFileSync(STATE_FILE, 'utf8');
|
|
1138
|
+
return JSON.parse(raw);
|
|
1139
|
+
} catch {
|
|
1140
|
+
return {};
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Reconstruct daily report data from persisted files (no in-memory stats needed).
|
|
1146
|
+
* Used by `muaddib report --now` to send a report from a separate CLI process.
|
|
1147
|
+
*/
|
|
1148
|
+
function buildReportFromDisk() {
|
|
1149
|
+
const scanData = loadScanStats();
|
|
1150
|
+
const stateRaw = loadStateRaw();
|
|
1151
|
+
// Default to today if no report ever sent (avoids summing entire history)
|
|
1152
|
+
const lastDate = stateRaw.lastDailyReportDate || getParisDateString();
|
|
1153
|
+
|
|
1154
|
+
// Filter daily entries strictly AFTER last report date
|
|
1155
|
+
const sinceDays = scanData.daily.filter(d => d.date > lastDate);
|
|
1156
|
+
|
|
1157
|
+
// Aggregate counters
|
|
1158
|
+
const agg = { scanned: 0, clean: 0, suspect: 0 };
|
|
1159
|
+
for (const d of sinceDays) {
|
|
1160
|
+
agg.scanned += d.scanned || 0;
|
|
1161
|
+
agg.clean += d.clean || 0;
|
|
1162
|
+
agg.suspect += d.suspect || 0;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Load detections since last report for top suspects
|
|
1166
|
+
const detections = loadDetections();
|
|
1167
|
+
const recentDetections = detections.detections.filter(
|
|
1168
|
+
d => d.first_seen_at && d.first_seen_at.slice(0, 10) > lastDate
|
|
1169
|
+
);
|
|
1170
|
+
|
|
1171
|
+
const top3 = recentDetections
|
|
1172
|
+
.slice()
|
|
1173
|
+
.sort((a, b) => (b.findings ? b.findings.length : 0) - (a.findings ? a.findings.length : 0))
|
|
1174
|
+
.slice(0, 3);
|
|
1175
|
+
|
|
1176
|
+
return { agg, top3, hasData: agg.scanned > 0 };
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* Build a Discord embed from disk data (same format as buildDailyReportEmbed).
|
|
1181
|
+
*/
|
|
1182
|
+
function buildReportEmbedFromDisk() {
|
|
1183
|
+
const { agg, top3, hasData } = buildReportFromDisk();
|
|
1184
|
+
if (!hasData) return null;
|
|
1185
|
+
|
|
1186
|
+
const top3Text = top3.length > 0
|
|
1187
|
+
? top3.map((a, i) => `${i + 1}. **${a.ecosystem}/${a.package}@${a.version}** — ${a.findings ? a.findings.length : 0} finding(s)`).join('\n')
|
|
1188
|
+
: 'None';
|
|
1189
|
+
|
|
1190
|
+
const now = new Date();
|
|
1191
|
+
const readableTime = now.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
|
|
1192
|
+
|
|
1193
|
+
return {
|
|
1194
|
+
embeds: [{
|
|
1195
|
+
title: '\uD83D\uDCCA MUAD\'DIB Daily Report (manual)',
|
|
1196
|
+
color: 0x3498db,
|
|
1197
|
+
fields: [
|
|
1198
|
+
{ name: 'Packages Scanned', value: `${agg.scanned}`, inline: true },
|
|
1199
|
+
{ name: 'Clean', value: `${agg.clean}`, inline: true },
|
|
1200
|
+
{ name: 'Suspects', value: `${agg.suspect}`, inline: true },
|
|
1201
|
+
{ name: 'Top Suspects', value: top3Text, inline: false }
|
|
1202
|
+
],
|
|
1203
|
+
footer: {
|
|
1204
|
+
text: `MUAD'DIB - Manual report | ${readableTime}`
|
|
1205
|
+
},
|
|
1206
|
+
timestamp: now.toISOString()
|
|
1207
|
+
}]
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* Force send a daily report from persisted data.
|
|
1213
|
+
* Returns { sent: boolean, message: string }.
|
|
1214
|
+
*/
|
|
1215
|
+
async function sendReportNow() {
|
|
1216
|
+
const url = getWebhookUrl();
|
|
1217
|
+
if (!url) {
|
|
1218
|
+
return { sent: false, message: 'MUADDIB_WEBHOOK_URL not configured' };
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
const payload = buildReportEmbedFromDisk();
|
|
1222
|
+
if (!payload) {
|
|
1223
|
+
return { sent: false, message: 'No data to report' };
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
try {
|
|
1227
|
+
await sendWebhook(url, payload, { rawPayload: true });
|
|
1228
|
+
} catch (err) {
|
|
1229
|
+
return { sent: false, message: `Webhook failed: ${err.message}` };
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// Update lastDailyReportDate on disk
|
|
1233
|
+
const stateRaw = loadStateRaw();
|
|
1234
|
+
const state = {
|
|
1235
|
+
npmLastPackage: stateRaw.npmLastPackage || '',
|
|
1236
|
+
pypiLastPackage: stateRaw.pypiLastPackage || ''
|
|
1237
|
+
};
|
|
1238
|
+
stats.lastDailyReportDate = getParisDateString();
|
|
1239
|
+
saveState(state);
|
|
1240
|
+
|
|
1241
|
+
return { sent: true, message: 'Daily report sent' };
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* Get report status for `muaddib report --status`.
|
|
1246
|
+
*/
|
|
1247
|
+
function getReportStatus() {
|
|
1248
|
+
const stateRaw = loadStateRaw();
|
|
1249
|
+
const lastDate = stateRaw.lastDailyReportDate || null;
|
|
1250
|
+
|
|
1251
|
+
// Count packages scanned since last report (default to today if never sent)
|
|
1252
|
+
const scanData = loadScanStats();
|
|
1253
|
+
const sinceDate = lastDate || getParisDateString();
|
|
1254
|
+
const sinceDays = scanData.daily.filter(d => d.date > sinceDate);
|
|
1255
|
+
|
|
1256
|
+
let scannedSince = 0;
|
|
1257
|
+
for (const d of sinceDays) {
|
|
1258
|
+
scannedSince += d.scanned || 0;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// Compute next report time
|
|
1262
|
+
const today = getParisDateString();
|
|
1263
|
+
const parisHour = getParisHour();
|
|
1264
|
+
let nextReport;
|
|
1265
|
+
if (lastDate === today || (lastDate !== today && parisHour >= DAILY_REPORT_HOUR)) {
|
|
1266
|
+
// Already sent today OR past 08:00 but not sent (will fire soon if monitor runs)
|
|
1267
|
+
if (lastDate === today) {
|
|
1268
|
+
nextReport = 'Tomorrow 08:00 (Europe/Paris)';
|
|
1269
|
+
} else {
|
|
1270
|
+
nextReport = 'Today 08:00 (Europe/Paris) — pending, monitor must be running';
|
|
1271
|
+
}
|
|
1272
|
+
} else {
|
|
1273
|
+
nextReport = 'Today 08:00 (Europe/Paris)';
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
return { lastDailyReportDate: lastDate, scannedSince, nextReport };
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1122
1279
|
// --- npm polling ---
|
|
1123
1280
|
|
|
1124
1281
|
/**
|
|
@@ -1588,7 +1745,11 @@ module.exports = {
|
|
|
1588
1745
|
getDetectionStats,
|
|
1589
1746
|
SCAN_STATS_FILE,
|
|
1590
1747
|
loadScanStats,
|
|
1591
|
-
updateScanStats
|
|
1748
|
+
updateScanStats,
|
|
1749
|
+
buildReportFromDisk,
|
|
1750
|
+
buildReportEmbedFromDisk,
|
|
1751
|
+
sendReportNow,
|
|
1752
|
+
getReportStatus
|
|
1592
1753
|
};
|
|
1593
1754
|
|
|
1594
1755
|
// Standalone entry point: node src/monitor.js
|
package/src/scanner/package.js
CHANGED
|
@@ -121,6 +121,8 @@ async function scanPackageJson(targetPath) {
|
|
|
121
121
|
|
|
122
122
|
for (const [depName, depVersion] of Object.entries(allDeps)) {
|
|
123
123
|
if (DANGEROUS_KEYS.has(depName)) continue;
|
|
124
|
+
// Skip local dependencies (link:, file:, workspace:) — they're local code, not npm packages
|
|
125
|
+
if (typeof depVersion === 'string' && /^(link:|file:|workspace:)/.test(depVersion)) continue;
|
|
124
126
|
let malicious = null;
|
|
125
127
|
|
|
126
128
|
// Use optimized Map for O(1) lookup if available
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"name":"test-link","dependencies":{"eslint-plugin-react-internal":"link:./scripts/eslint-rules","lodash":"^4.17.21"}}
|