muaddib-scanner 1.6.8 → 1.6.10
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/README.fr.md +6 -4
- package/README.md +6 -4
- package/bin/muaddib.js +59 -14
- package/package.json +2 -2
- package/src/daemon.js +35 -15
- package/src/diff.js +16 -26
- package/src/hooks-init.js +26 -11
- package/src/index.js +27 -7
- package/src/ioc/scraper.js +72 -16
- package/src/ioc/updater.js +30 -8
- package/src/ioc/yaml-loader.js +8 -2
- package/src/report.js +8 -1
- package/src/response/playbooks.js +17 -0
- package/src/rules/index.js +3 -1
- package/src/safe-install.js +65 -32
- package/src/sandbox.js +17 -16
- package/src/sarif.js +10 -3
- package/src/scanner/ast.js +13 -1
- package/src/scanner/dataflow.js +30 -4
- package/src/scanner/dependencies.js +11 -8
- package/src/scanner/github-actions.js +75 -39
- package/src/scanner/hash.js +11 -1
- package/src/scanner/npm-registry.js +61 -16
- package/src/scanner/obfuscation.js +60 -10
- package/src/scanner/package.js +47 -12
- package/src/scanner/python.js +35 -14
- package/src/scanner/shell.js +31 -15
- package/src/scanner/typosquat.js +83 -42
- package/src/shared/constants.js +2 -17
- package/src/utils.js +30 -12
- package/src/watch.js +15 -1
- package/src/webhook.js +44 -19
- package/test-output.txt +369 -0
package/README.fr.md
CHANGED
|
@@ -202,10 +202,12 @@ muaddib sandbox <nom-package>
|
|
|
202
202
|
muaddib sandbox <nom-package> --strict
|
|
203
203
|
```
|
|
204
204
|
|
|
205
|
-
Analyse un package dans un container Docker
|
|
206
|
-
-
|
|
207
|
-
-
|
|
208
|
-
-
|
|
205
|
+
Analyse un package dans un container Docker isolé avec monitoring multi-couches :
|
|
206
|
+
- **Traçage système** (strace) : accès fichiers, spawn de processus, monitoring syscalls
|
|
207
|
+
- **Capture réseau** (tcpdump) : résolutions DNS avec IPs résolues, requêtes HTTP (méthode, host, path, body), détection TLS SNI
|
|
208
|
+
- **Diff filesystem** : snapshot avant/après install, détecte les fichiers créés dans des emplacements suspects
|
|
209
|
+
- **Détection exfiltration de données** : 16 patterns sensibles (tokens, credentials, clés SSH, clés privées, .env)
|
|
210
|
+
- **Moteur de scoring** : score de risque 0-100 basé sur la sévérité des comportements
|
|
209
211
|
|
|
210
212
|
Utilisez `--strict` pour bloquer tout trafic réseau sortant non essentiel via iptables.
|
|
211
213
|
|
package/README.md
CHANGED
|
@@ -202,10 +202,12 @@ muaddib sandbox <package-name>
|
|
|
202
202
|
muaddib sandbox <package-name> --strict
|
|
203
203
|
```
|
|
204
204
|
|
|
205
|
-
Analyzes a package in an isolated Docker container
|
|
206
|
-
-
|
|
207
|
-
-
|
|
208
|
-
-
|
|
205
|
+
Analyzes a package in an isolated Docker container with multi-layer monitoring:
|
|
206
|
+
- **System tracing** (strace): file access, process spawns, syscall monitoring
|
|
207
|
+
- **Network capture** (tcpdump): DNS resolutions with resolved IPs, HTTP requests (method, host, path, body), TLS SNI detection
|
|
208
|
+
- **Filesystem diff**: snapshot before/after install, detects files created in suspicious locations
|
|
209
|
+
- **Data exfiltration detection**: 16 sensitive patterns (tokens, credentials, SSH keys, private keys, .env)
|
|
210
|
+
- **Scoring engine**: 0-100 risk score based on behavioral severity
|
|
209
211
|
|
|
210
212
|
Use `--strict` to block all non-essential outbound network traffic via iptables.
|
|
211
213
|
|
package/bin/muaddib.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
const {
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const { exec } = require('child_process');
|
|
3
3
|
const { run } = require('../src/index.js');
|
|
4
4
|
const { updateIOCs } = require('../src/ioc/updater.js');
|
|
5
5
|
const { watch } = require('../src/watch.js');
|
|
@@ -23,15 +23,28 @@ let explainMode = false;
|
|
|
23
23
|
let failLevel = 'high';
|
|
24
24
|
let webhookUrl = null;
|
|
25
25
|
let paranoidMode = false;
|
|
26
|
+
let excludeDirs = [];
|
|
26
27
|
|
|
27
28
|
for (let i = 0; i < options.length; i++) {
|
|
28
29
|
if (options[i] === '--json') {
|
|
29
30
|
jsonOutput = true;
|
|
30
31
|
} else if (options[i] === '--html') {
|
|
31
|
-
|
|
32
|
+
const htmlPath = options[i + 1] || 'muaddib-report.html';
|
|
33
|
+
// CLI-001: Block path traversal
|
|
34
|
+
if (htmlPath.includes('..')) {
|
|
35
|
+
console.error('[ERROR] --html path must not contain path traversal (..)');
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
htmlOutput = htmlPath;
|
|
32
39
|
i++;
|
|
33
40
|
} else if (options[i] === '--sarif') {
|
|
34
|
-
|
|
41
|
+
const sarifPath = options[i + 1] || 'muaddib-results.sarif';
|
|
42
|
+
// CLI-001: Block path traversal
|
|
43
|
+
if (sarifPath.includes('..')) {
|
|
44
|
+
console.error('[ERROR] --sarif path must not contain path traversal (..)');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
sarifOutput = sarifPath;
|
|
35
48
|
i++;
|
|
36
49
|
} else if (options[i] === '--explain') {
|
|
37
50
|
explainMode = true;
|
|
@@ -39,8 +52,32 @@ for (let i = 0; i < options.length; i++) {
|
|
|
39
52
|
failLevel = options[i + 1] || 'high';
|
|
40
53
|
i++;
|
|
41
54
|
} else if (options[i] === '--webhook') {
|
|
42
|
-
|
|
55
|
+
const rawUrl = options[i + 1];
|
|
56
|
+
// CLI-002: Validate webhook URL (HTTPS only, no private IPs)
|
|
57
|
+
if (rawUrl) {
|
|
58
|
+
try {
|
|
59
|
+
const parsed = new URL(rawUrl);
|
|
60
|
+
if (parsed.protocol !== 'https:') {
|
|
61
|
+
console.error('[ERROR] --webhook URL must use HTTPS');
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
const host = parsed.hostname.toLowerCase();
|
|
65
|
+
if (/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|0\.|169\.254\.|localhost$|::1$)/.test(host)) {
|
|
66
|
+
console.error('[ERROR] --webhook URL must not point to a private/local address');
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
console.error('[ERROR] --webhook URL is invalid');
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
webhookUrl = rawUrl;
|
|
43
75
|
i++;
|
|
76
|
+
} else if (options[i] === '--exclude') {
|
|
77
|
+
if (options[i + 1] && !options[i + 1].startsWith('-')) {
|
|
78
|
+
excludeDirs.push(options[i + 1]);
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
44
81
|
} else if (options[i] === '--paranoid') {
|
|
45
82
|
paranoidMode = true;
|
|
46
83
|
} else if (options[i] === '--strict') {
|
|
@@ -50,17 +87,20 @@ for (let i = 0; i < options.length; i++) {
|
|
|
50
87
|
}
|
|
51
88
|
}
|
|
52
89
|
|
|
53
|
-
// Version check (non-blocking, skip for machine-readable output)
|
|
90
|
+
// Version check (truly non-blocking, skip for machine-readable output)
|
|
54
91
|
if (!jsonOutput && !sarifOutput) {
|
|
55
92
|
try {
|
|
56
93
|
const currentVersion = require('../package.json').version;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
94
|
+
exec('npm view muaddib-scanner version', { timeout: 5000 }, (err, stdout) => {
|
|
95
|
+
if (err) return; // No network or npm unavailable
|
|
96
|
+
const latest = (stdout || '').toString().trim();
|
|
97
|
+
if (latest && latest !== currentVersion) {
|
|
98
|
+
console.log(`\n[UPDATE] New version available: ${currentVersion} -> ${latest}`);
|
|
99
|
+
console.log(` Run: npm install -g muaddib-scanner@latest\n`);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
62
102
|
} catch {
|
|
63
|
-
//
|
|
103
|
+
// Skip silently
|
|
64
104
|
}
|
|
65
105
|
}
|
|
66
106
|
|
|
@@ -273,6 +313,7 @@ const helpText = `
|
|
|
273
313
|
--fail-on [level] Fail level (critical|high|medium|low)
|
|
274
314
|
--webhook [url] Discord/Slack webhook
|
|
275
315
|
--paranoid Ultra-strict mode
|
|
316
|
+
--exclude [dir] Exclude directory from scan (repeatable)
|
|
276
317
|
--save-dev, -D Install as dev dependency
|
|
277
318
|
-g, --global Install globally
|
|
278
319
|
--force Force install despite threats
|
|
@@ -300,9 +341,13 @@ if (command === 'version' || command === '--version' || command === '-v') {
|
|
|
300
341
|
explain: explainMode,
|
|
301
342
|
failLevel: failLevel,
|
|
302
343
|
webhook: webhookUrl,
|
|
303
|
-
paranoid: paranoidMode
|
|
344
|
+
paranoid: paranoidMode,
|
|
345
|
+
exclude: excludeDirs
|
|
304
346
|
}).then(exitCode => {
|
|
305
347
|
process.exit(exitCode);
|
|
348
|
+
}).catch(err => {
|
|
349
|
+
console.error('[ERROR]', err.message);
|
|
350
|
+
process.exit(1);
|
|
306
351
|
});
|
|
307
352
|
} else if (command === 'watch') {
|
|
308
353
|
watch(target);
|
|
@@ -436,7 +481,7 @@ if (command === 'version' || command === '--version' || command === '-v') {
|
|
|
436
481
|
console.log(helpText);
|
|
437
482
|
process.exit(0);
|
|
438
483
|
} else {
|
|
439
|
-
console.log(`Unknown command: ${command}`);
|
|
484
|
+
console.log(`Unknown command: ${String(command).replace(/[\x00-\x1f\x7f-\x9f]/g, '')}`);
|
|
440
485
|
console.log('Type "muaddib help" to see available commands.');
|
|
441
486
|
process.exit(1);
|
|
442
487
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muaddib-scanner",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.10",
|
|
4
4
|
"description": "Supply-chain threat detection & response for npm & PyPI/Python",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@eslint/js": "9.39.2",
|
|
55
|
-
"eslint": "
|
|
55
|
+
"eslint": "10.0.0",
|
|
56
56
|
"eslint-plugin-security": "^3.0.1",
|
|
57
57
|
"globals": "17.3.0"
|
|
58
58
|
}
|
package/src/daemon.js
CHANGED
|
@@ -3,11 +3,9 @@ const path = require('path');
|
|
|
3
3
|
const { run } = require('./index.js');
|
|
4
4
|
|
|
5
5
|
let webhookUrl = null;
|
|
6
|
-
let isRunning = false;
|
|
7
6
|
|
|
8
7
|
async function startDaemon(options = {}) {
|
|
9
8
|
webhookUrl = options.webhook || null;
|
|
10
|
-
isRunning = true;
|
|
11
9
|
|
|
12
10
|
console.log(`
|
|
13
11
|
╔════════════════════════════════════════════╗
|
|
@@ -33,9 +31,8 @@ async function startDaemon(options = {}) {
|
|
|
33
31
|
|
|
34
32
|
// Keep process alive until SIGINT
|
|
35
33
|
await new Promise((resolve) => {
|
|
36
|
-
process.
|
|
34
|
+
process.once('SIGINT', () => {
|
|
37
35
|
console.log('\n[DAEMON] Arret...');
|
|
38
|
-
isRunning = false;
|
|
39
36
|
cleanup();
|
|
40
37
|
resolve();
|
|
41
38
|
});
|
|
@@ -70,6 +67,10 @@ function watchDirectory(dir) {
|
|
|
70
67
|
}
|
|
71
68
|
|
|
72
69
|
// Surveille la creation de node_modules
|
|
70
|
+
if (process.platform === 'linux') {
|
|
71
|
+
console.log('[DAEMON] Note: recursive fs.watch may not work on Linux');
|
|
72
|
+
}
|
|
73
|
+
|
|
73
74
|
const dirWatcher = fs.watch(dir, (eventType, filename) => {
|
|
74
75
|
if (filename === 'node_modules' && eventType === 'rename') {
|
|
75
76
|
const nmPath = path.join(dir, 'node_modules');
|
|
@@ -83,6 +84,9 @@ function watchDirectory(dir) {
|
|
|
83
84
|
triggerScan(dir);
|
|
84
85
|
}
|
|
85
86
|
});
|
|
87
|
+
dirWatcher.on('error', (err) => {
|
|
88
|
+
console.log(`[DAEMON] Watcher error on ${dir}: ${err.message}`);
|
|
89
|
+
});
|
|
86
90
|
watchers.push(dirWatcher);
|
|
87
91
|
|
|
88
92
|
return watchers;
|
|
@@ -96,7 +100,7 @@ function watchFile(filePath, projectDir) {
|
|
|
96
100
|
return null; // File deleted between existsSync and statSync
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
|
|
103
|
+
const watcher = fs.watch(filePath, (eventType) => {
|
|
100
104
|
if (eventType === 'change') {
|
|
101
105
|
try {
|
|
102
106
|
const currentMtime = fs.statSync(filePath).mtime.getTime();
|
|
@@ -110,36 +114,52 @@ function watchFile(filePath, projectDir) {
|
|
|
110
114
|
}
|
|
111
115
|
}
|
|
112
116
|
});
|
|
117
|
+
watcher.on('error', (err) => {
|
|
118
|
+
console.log(`[DAEMON] Watcher error on ${filePath}: ${err.message}`);
|
|
119
|
+
});
|
|
120
|
+
return watcher;
|
|
113
121
|
}
|
|
114
122
|
|
|
115
123
|
function watchNodeModules(nodeModulesPath, projectDir) {
|
|
116
|
-
|
|
124
|
+
const watcher = fs.watch(nodeModulesPath, { recursive: true }, (eventType, filename) => {
|
|
117
125
|
if (filename && filename.includes('package.json')) {
|
|
118
126
|
console.log(`[DAEMON] Nouveau package detecte: ${filename}`);
|
|
119
127
|
triggerScan(projectDir);
|
|
120
128
|
}
|
|
121
129
|
});
|
|
130
|
+
watcher.on('error', (err) => {
|
|
131
|
+
console.log(`[DAEMON] Watcher error on ${nodeModulesPath}: ${err.message}`);
|
|
132
|
+
});
|
|
133
|
+
return watcher;
|
|
122
134
|
}
|
|
123
135
|
|
|
124
|
-
|
|
125
|
-
|
|
136
|
+
// Per-directory scan state to prevent cross-directory scan suppression
|
|
137
|
+
const scanState = new Map();
|
|
138
|
+
|
|
139
|
+
function getScanState(dir) {
|
|
140
|
+
if (!scanState.has(dir)) {
|
|
141
|
+
scanState.set(dir, { timeout: null, lastScanTime: 0 });
|
|
142
|
+
}
|
|
143
|
+
return scanState.get(dir);
|
|
144
|
+
}
|
|
126
145
|
|
|
127
146
|
function triggerScan(dir) {
|
|
128
147
|
const now = Date.now();
|
|
129
|
-
|
|
148
|
+
const state = getScanState(dir);
|
|
149
|
+
|
|
130
150
|
// Debounce: attend 3 secondes avant de scanner
|
|
131
|
-
if (
|
|
132
|
-
clearTimeout(
|
|
151
|
+
if (state.timeout) {
|
|
152
|
+
clearTimeout(state.timeout);
|
|
133
153
|
}
|
|
134
154
|
|
|
135
155
|
// Evite les scans trop frequents (minimum 10 secondes entre chaque)
|
|
136
|
-
if (now - lastScanTime < 10000) {
|
|
137
|
-
|
|
156
|
+
if (now - state.lastScanTime < 10000) {
|
|
157
|
+
state.timeout = setTimeout(() => triggerScan(dir), 10000 - (now - state.lastScanTime));
|
|
138
158
|
return;
|
|
139
159
|
}
|
|
140
160
|
|
|
141
|
-
|
|
142
|
-
lastScanTime = Date.now();
|
|
161
|
+
state.timeout = setTimeout(async () => {
|
|
162
|
+
state.lastScanTime = Date.now();
|
|
143
163
|
console.log(`\n[DAEMON] ========== SCAN AUTOMATIQUE ==========`);
|
|
144
164
|
console.log(`[DAEMON] Cible: ${dir}`);
|
|
145
165
|
console.log(`[DAEMON] Heure: ${new Date().toLocaleTimeString()}\n`);
|
package/src/diff.js
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const { execFileSync } = require('child_process');
|
|
2
2
|
const { run } = require('./index.js');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const os = require('os');
|
|
6
6
|
|
|
7
7
|
// Only allow safe characters in git refs (prevents command injection)
|
|
8
|
-
const SAFE_REF_REGEX = /^[a-zA-Z0-9._\-/~^@{}
|
|
8
|
+
const SAFE_REF_REGEX = /^[a-zA-Z0-9._\-/~^@{}]+$/;
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Get the list of commits/tags for comparison suggestions
|
|
12
12
|
*/
|
|
13
13
|
function getRecentRefs(targetPath, limit = 10) {
|
|
14
14
|
try {
|
|
15
|
-
const tags =
|
|
15
|
+
const tags = execFileSync('git', ['tag', '--sort=-creatordate'], {
|
|
16
16
|
cwd: targetPath,
|
|
17
17
|
encoding: 'utf8',
|
|
18
18
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
19
19
|
}).trim().split('\n').filter(Boolean).slice(0, 5);
|
|
20
20
|
|
|
21
|
-
const commits =
|
|
21
|
+
const commits = execFileSync('git', ['log', '--oneline', `-${Number(limit) || 10}`], {
|
|
22
22
|
cwd: targetPath,
|
|
23
23
|
encoding: 'utf8',
|
|
24
24
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
@@ -35,7 +35,7 @@ function getRecentRefs(targetPath, limit = 10) {
|
|
|
35
35
|
*/
|
|
36
36
|
function isGitRepo(targetPath) {
|
|
37
37
|
try {
|
|
38
|
-
|
|
38
|
+
execFileSync('git', ['rev-parse', '--git-dir'], {
|
|
39
39
|
cwd: targetPath,
|
|
40
40
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
41
41
|
});
|
|
@@ -50,7 +50,7 @@ function isGitRepo(targetPath) {
|
|
|
50
50
|
*/
|
|
51
51
|
function getCurrentCommit(targetPath) {
|
|
52
52
|
try {
|
|
53
|
-
return
|
|
53
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
54
54
|
cwd: targetPath,
|
|
55
55
|
encoding: 'utf8',
|
|
56
56
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
@@ -68,7 +68,7 @@ function resolveRef(targetPath, ref) {
|
|
|
68
68
|
return null;
|
|
69
69
|
}
|
|
70
70
|
try {
|
|
71
|
-
return
|
|
71
|
+
return execFileSync('git', ['rev-parse', ref], {
|
|
72
72
|
cwd: targetPath,
|
|
73
73
|
encoding: 'utf8',
|
|
74
74
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
@@ -83,7 +83,7 @@ function resolveRef(targetPath, ref) {
|
|
|
83
83
|
*/
|
|
84
84
|
function hasUncommittedChanges(targetPath) {
|
|
85
85
|
try {
|
|
86
|
-
const status =
|
|
86
|
+
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
87
87
|
cwd: targetPath,
|
|
88
88
|
encoding: 'utf8',
|
|
89
89
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
@@ -106,13 +106,13 @@ function createTempCopyAtCommit(targetPath, commitHash) {
|
|
|
106
106
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'muaddib-diff-'));
|
|
107
107
|
|
|
108
108
|
try {
|
|
109
|
-
// Clone the repo to temp directory (use
|
|
110
|
-
|
|
109
|
+
// Clone the repo to temp directory (use execFileSync to prevent injection)
|
|
110
|
+
execFileSync('git', ['clone', '--quiet', '--', targetPath, tempDir], {
|
|
111
111
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
112
112
|
});
|
|
113
113
|
|
|
114
114
|
// Checkout the specific commit
|
|
115
|
-
|
|
115
|
+
execFileSync('git', ['checkout', '--quiet', commitHash], {
|
|
116
116
|
cwd: tempDir,
|
|
117
117
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
118
118
|
});
|
|
@@ -122,7 +122,7 @@ function createTempCopyAtCommit(targetPath, commitHash) {
|
|
|
122
122
|
const packageJsonPath = path.join(tempDir, 'package.json');
|
|
123
123
|
if (fs.existsSync(packageJsonPath)) {
|
|
124
124
|
try {
|
|
125
|
-
|
|
125
|
+
execFileSync('npm', ['install', '--quiet', '--no-audit', '--no-fund', '--ignore-scripts'], {
|
|
126
126
|
cwd: tempDir,
|
|
127
127
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
128
128
|
timeout: 60000
|
|
@@ -204,21 +204,11 @@ function compareThreats(oldThreats, newThreats) {
|
|
|
204
204
|
* Run scan and capture results (without console output)
|
|
205
205
|
*/
|
|
206
206
|
async function runSilentScan(targetPath, options = {}) {
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
console.log = (...args) => logs.push(args.join(' '));
|
|
211
|
-
await run(targetPath, { ...options, json: true });
|
|
212
|
-
} finally {
|
|
213
|
-
console.log = originalLog;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const jsonOutput = logs.join('\n');
|
|
217
|
-
try {
|
|
218
|
-
return JSON.parse(jsonOutput);
|
|
219
|
-
} catch {
|
|
220
|
-
return { threats: [], summary: { total: 0 } };
|
|
207
|
+
const result = await run(targetPath, { ...options, _capture: true });
|
|
208
|
+
if (result && typeof result === 'object' && result.threats) {
|
|
209
|
+
return result;
|
|
221
210
|
}
|
|
211
|
+
return { threats: [], summary: { total: 0 } };
|
|
222
212
|
}
|
|
223
213
|
|
|
224
214
|
/**
|
package/src/hooks-init.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const { execFileSync } = require('child_process');
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
|
|
@@ -29,10 +29,16 @@ function detectHookSystem(targetPath) {
|
|
|
29
29
|
/**
|
|
30
30
|
* Initialize hooks for a project
|
|
31
31
|
*/
|
|
32
|
+
const VALID_MODES = ['scan', 'diff'];
|
|
33
|
+
const HOOK_COMMANDS = {
|
|
34
|
+
scan: 'npx muaddib scan . --fail-on high',
|
|
35
|
+
diff: 'npx muaddib diff HEAD --fail-on high'
|
|
36
|
+
};
|
|
37
|
+
|
|
32
38
|
async function initHooks(targetPath, options = {}) {
|
|
33
39
|
const resolvedPath = path.resolve(targetPath);
|
|
34
40
|
const hookType = options.type || 'auto';
|
|
35
|
-
const mode = options.mode
|
|
41
|
+
const mode = VALID_MODES.includes(options.mode) ? options.mode : 'scan';
|
|
36
42
|
|
|
37
43
|
console.log('\n[MUADDIB] Initializing git hooks...\n');
|
|
38
44
|
|
|
@@ -92,9 +98,10 @@ async function initHusky(targetPath, mode) {
|
|
|
92
98
|
if (!fs.existsSync(huskyDir)) {
|
|
93
99
|
console.log('[INFO] Husky not detected. Installing...');
|
|
94
100
|
try {
|
|
95
|
-
|
|
101
|
+
execFileSync('npx', ['husky', 'install'], {
|
|
96
102
|
cwd: targetPath,
|
|
97
|
-
stdio: 'inherit'
|
|
103
|
+
stdio: 'inherit',
|
|
104
|
+
shell: false
|
|
98
105
|
});
|
|
99
106
|
} catch {
|
|
100
107
|
throw new Error('Failed to install husky. Run: npm install -D husky && npx husky install');
|
|
@@ -103,9 +110,7 @@ async function initHusky(targetPath, mode) {
|
|
|
103
110
|
|
|
104
111
|
// Create pre-commit hook
|
|
105
112
|
const preCommitPath = path.join(huskyDir, 'pre-commit');
|
|
106
|
-
const command = mode
|
|
107
|
-
? 'npx muaddib diff HEAD --fail-on high'
|
|
108
|
-
: 'npx muaddib scan . --fail-on high';
|
|
113
|
+
const command = HOOK_COMMANDS[mode];
|
|
109
114
|
|
|
110
115
|
const hookContent = `#!/usr/bin/env sh
|
|
111
116
|
. "$(dirname -- "$0")/_/husky.sh"
|
|
@@ -169,9 +174,7 @@ async function initGitHook(targetPath, mode) {
|
|
|
169
174
|
}
|
|
170
175
|
|
|
171
176
|
const preCommitPath = path.join(gitHooksDir, 'pre-commit');
|
|
172
|
-
const command = mode
|
|
173
|
-
? 'npx muaddib diff HEAD --fail-on high'
|
|
174
|
-
: 'npx muaddib scan . --fail-on high';
|
|
177
|
+
const command = HOOK_COMMANDS[mode];
|
|
175
178
|
|
|
176
179
|
const hookContent = `#!/bin/sh
|
|
177
180
|
# MUAD'DIB pre-commit hook
|
|
@@ -193,11 +196,23 @@ fi
|
|
|
193
196
|
exit 0
|
|
194
197
|
`;
|
|
195
198
|
|
|
196
|
-
// Backup existing hook
|
|
199
|
+
// Backup existing hook (limit to 3 backups)
|
|
197
200
|
if (fs.existsSync(preCommitPath)) {
|
|
198
201
|
const backup = `${preCommitPath}.backup.${Date.now()}`;
|
|
199
202
|
fs.copyFileSync(preCommitPath, backup);
|
|
200
203
|
console.log(`[INFO] Backed up existing hook to ${backup}`);
|
|
204
|
+
|
|
205
|
+
// Cleanup old backups, keep only 3 most recent
|
|
206
|
+
try {
|
|
207
|
+
const hooksDir = path.dirname(preCommitPath);
|
|
208
|
+
const backups = fs.readdirSync(hooksDir)
|
|
209
|
+
.filter(f => f.startsWith('pre-commit.backup.'))
|
|
210
|
+
.sort()
|
|
211
|
+
.reverse();
|
|
212
|
+
for (const old of backups.slice(3)) {
|
|
213
|
+
fs.unlinkSync(path.join(hooksDir, old));
|
|
214
|
+
}
|
|
215
|
+
} catch { /* ignore cleanup errors */ }
|
|
201
216
|
}
|
|
202
217
|
|
|
203
218
|
fs.writeFileSync(preCommitPath, hookContent, { mode: 0o755 });
|
package/src/index.js
CHANGED
|
@@ -16,6 +16,7 @@ const path = require('path');
|
|
|
16
16
|
const { scanGitHubActions } = require('./scanner/github-actions.js');
|
|
17
17
|
const { detectPythonProject, normalizePythonName } = require('./scanner/python.js');
|
|
18
18
|
const { loadCachedIOCs } = require('./ioc/updater.js');
|
|
19
|
+
const { setExtraExcludes, getExtraExcludes } = require('./utils.js');
|
|
19
20
|
|
|
20
21
|
// ============================================
|
|
21
22
|
// SCORING CONSTANTS
|
|
@@ -52,12 +53,16 @@ const RISK_THRESHOLDS = {
|
|
|
52
53
|
// Maximum score (capped)
|
|
53
54
|
const MAX_RISK_SCORE = 100;
|
|
54
55
|
|
|
56
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
57
|
+
|
|
55
58
|
// Paranoid mode scanner
|
|
56
59
|
function scanParanoid(targetPath) {
|
|
57
60
|
const threats = [];
|
|
58
61
|
|
|
59
62
|
function scanFile(filePath) {
|
|
60
63
|
try {
|
|
64
|
+
const stat = fs.statSync(filePath);
|
|
65
|
+
if (stat.size > MAX_FILE_SIZE) return;
|
|
61
66
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
62
67
|
|
|
63
68
|
// Ignore URLs (they often contain patterns like .git)
|
|
@@ -81,8 +86,9 @@ function scanParanoid(targetPath) {
|
|
|
81
86
|
}
|
|
82
87
|
}
|
|
83
88
|
|
|
84
|
-
function walkDir(dir) {
|
|
85
|
-
|
|
89
|
+
function walkDir(dir, depth) {
|
|
90
|
+
if (depth > 50) return; // Max depth guard (IDX-06)
|
|
91
|
+
const excluded = ['node_modules', '.git', '.muaddib-cache', ...getExtraExcludes()];
|
|
86
92
|
try {
|
|
87
93
|
const files = fs.readdirSync(dir);
|
|
88
94
|
for (const file of files) {
|
|
@@ -94,7 +100,7 @@ function scanParanoid(targetPath) {
|
|
|
94
100
|
|
|
95
101
|
if (stat.isDirectory()) {
|
|
96
102
|
if (!excluded.includes(file)) {
|
|
97
|
-
walkDir(fullPath);
|
|
103
|
+
walkDir(fullPath, depth + 1);
|
|
98
104
|
}
|
|
99
105
|
} else if (file.endsWith('.js') || file.endsWith('.json') || file.endsWith('.sh')) {
|
|
100
106
|
scanFile(fullPath);
|
|
@@ -105,7 +111,7 @@ function scanParanoid(targetPath) {
|
|
|
105
111
|
}
|
|
106
112
|
}
|
|
107
113
|
|
|
108
|
-
walkDir(targetPath);
|
|
114
|
+
walkDir(targetPath, 0);
|
|
109
115
|
return threats;
|
|
110
116
|
}
|
|
111
117
|
|
|
@@ -187,6 +193,11 @@ function checkPyPITyposquatting(deps, targetPath) {
|
|
|
187
193
|
}
|
|
188
194
|
|
|
189
195
|
async function run(targetPath, options = {}) {
|
|
196
|
+
// Apply --exclude dirs for this scan
|
|
197
|
+
if (options.exclude && options.exclude.length > 0) {
|
|
198
|
+
setExtraExcludes(options.exclude);
|
|
199
|
+
}
|
|
200
|
+
|
|
190
201
|
// Detect Python project (synchronous, fast file reads)
|
|
191
202
|
const pythonDeps = detectPythonProject(targetPath);
|
|
192
203
|
|
|
@@ -240,7 +251,7 @@ async function run(targetPath, options = {}) {
|
|
|
240
251
|
|
|
241
252
|
// Sandbox integration
|
|
242
253
|
let sandboxData = null;
|
|
243
|
-
if (options.sandboxResult && options.sandboxResult.findings) {
|
|
254
|
+
if (options.sandboxResult && Array.isArray(options.sandboxResult.findings)) {
|
|
244
255
|
const sr = options.sandboxResult;
|
|
245
256
|
const pkg = sr.raw_report?.package || 'unknown';
|
|
246
257
|
sandboxData = {
|
|
@@ -331,6 +342,12 @@ async function run(targetPath, options = {}) {
|
|
|
331
342
|
sandbox: sandboxData
|
|
332
343
|
};
|
|
333
344
|
|
|
345
|
+
// _capture mode: return result directly without printing (used by diff.js)
|
|
346
|
+
if (options._capture) {
|
|
347
|
+
setExtraExcludes([]);
|
|
348
|
+
return result;
|
|
349
|
+
}
|
|
350
|
+
|
|
334
351
|
// JSON output
|
|
335
352
|
if (options.json) {
|
|
336
353
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -451,7 +468,7 @@ async function run(targetPath, options = {}) {
|
|
|
451
468
|
}
|
|
452
469
|
|
|
453
470
|
// Send webhook if configured
|
|
454
|
-
if (options.webhook &&
|
|
471
|
+
if (options.webhook && enrichedThreats.length > 0) {
|
|
455
472
|
try {
|
|
456
473
|
await sendWebhook(options.webhook, result);
|
|
457
474
|
console.log(`[OK] Alert sent to webhook`);
|
|
@@ -472,7 +489,10 @@ async function run(targetPath, options = {}) {
|
|
|
472
489
|
const levelsToCheck = severityLevels[failLevel] || severityLevels.high;
|
|
473
490
|
const failingThreats = deduped.filter(t => levelsToCheck.includes(t.severity));
|
|
474
491
|
|
|
475
|
-
|
|
492
|
+
// Clear runtime excludes
|
|
493
|
+
setExtraExcludes([]);
|
|
494
|
+
|
|
495
|
+
return Math.min(failingThreats.length, 125);
|
|
476
496
|
}
|
|
477
497
|
|
|
478
498
|
module.exports = { run };
|