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 +18 -4
- package/package.json +1 -1
- package/{self-scan-v2.11.77.json → self-scan-v2.11.79.json} +1 -1
- package/src/commands/interactive.js +5 -6
- package/src/commands/safe-install.js +11 -16
- package/src/ioc/scraper.js +46 -10
- package/src/monitor/daemon.js +5 -6
- package/src/output/formatter.js +3 -4
- package/src/pipeline/executor.js +9 -1
- package/src/runtime/daemon.js +27 -28
- package/src/runtime/watch.js +7 -7
- package/src/sandbox/index.js +11 -9
- package/src/utils.js +60 -1
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(
|
|
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
|
@@ -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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
package/src/ioc/scraper.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
1303
|
-
console.log('\n [
|
|
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 [
|
|
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
|
};
|
package/src/monitor/daemon.js
CHANGED
|
@@ -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
|
-
|
|
793
|
-
|
|
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');
|
package/src/output/formatter.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/pipeline/executor.js
CHANGED
|
@@ -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
|
|
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 };
|
package/src/runtime/daemon.js
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
╚════════════════════════════════════════════╝
|
|
15
|
-
`);
|
|
11
|
+
console.log('\n' + banner([
|
|
12
|
+
"MUAD'DIB Security Daemon",
|
|
13
|
+
'Monitoring npm installs'
|
|
14
|
+
]) + '\n');
|
|
16
15
|
|
|
17
|
-
console.log('[DAEMON]
|
|
18
|
-
console.log(`[DAEMON] Webhook: ${webhookUrl ? '
|
|
19
|
-
console.log('[DAEMON] Ctrl+C
|
|
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
|
-
//
|
|
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]
|
|
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]
|
|
49
|
+
console.log(`[DAEMON] Watching ${dir}`);
|
|
51
50
|
|
|
52
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
63
|
+
// Watch node_modules
|
|
65
64
|
if (fs.existsSync(nodeModulesPath)) {
|
|
66
65
|
watchers.push(watchNodeModules(nodeModulesPath, dir));
|
|
67
66
|
}
|
|
68
67
|
|
|
69
|
-
//
|
|
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
|
|
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}
|
|
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)}
|
|
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]
|
|
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:
|
|
149
|
+
// Debounce: wait 3 seconds before scanning
|
|
151
150
|
if (state.timeout) {
|
|
152
151
|
clearTimeout(state.timeout);
|
|
153
152
|
}
|
|
154
153
|
|
|
155
|
-
//
|
|
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
|
|
164
|
-
console.log(`[DAEMON]
|
|
165
|
-
console.log(`[DAEMON]
|
|
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]
|
|
169
|
+
console.log(`[DAEMON] Scan error: ${err.message}`);
|
|
171
170
|
}
|
|
172
171
|
|
|
173
172
|
console.log(`\n[DAEMON] ======================================\n`);
|
|
174
|
-
console.log('[DAEMON]
|
|
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 };
|
package/src/runtime/watch.js
CHANGED
|
@@ -6,13 +6,13 @@ function watch(targetPath) {
|
|
|
6
6
|
let debounceTimer = null;
|
|
7
7
|
const watchers = [];
|
|
8
8
|
|
|
9
|
-
console.log(`[MUADDIB]
|
|
10
|
-
console.log('[INFO] Ctrl+C
|
|
9
|
+
console.log(`[MUADDIB] Watching ${targetPath}\n`);
|
|
10
|
+
console.log('[INFO] Press Ctrl+C to stop\n');
|
|
11
11
|
|
|
12
|
-
//
|
|
12
|
+
// Initial scan
|
|
13
13
|
run(targetPath, { json: false }).catch(err => console.error('[ERROR]', err.message));
|
|
14
14
|
|
|
15
|
-
//
|
|
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'}
|
|
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]
|
|
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 };
|
package/src/sandbox/index.js
CHANGED
|
@@ -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
|
-
|
|
1041
|
-
|
|
1042
|
-
const
|
|
1043
|
-
const
|
|
1044
|
-
const
|
|
1045
|
-
const
|
|
1046
|
-
const
|
|
1047
|
-
const
|
|
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
|
};
|