muaddib-scanner 2.6.5 → 2.6.7
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.md +3 -3
- package/package.json +1 -1
- package/src/index.js +6 -7
- package/src/intent-graph.js +1 -6
- package/src/response/playbooks.js +3 -0
- package/src/rules/index.js +20 -1
- package/src/sandbox/index.js +1 -1
- package/src/scanner/entropy.js +1 -1
- package/src/scanner/github-actions.js +13 -1
- package/src/scanner/shell.js +63 -18
package/README.md
CHANGED
|
@@ -270,7 +270,7 @@ With pre-commit framework:
|
|
|
270
270
|
```yaml
|
|
271
271
|
repos:
|
|
272
272
|
- repo: https://github.com/DNSZLSK/muad-dib
|
|
273
|
-
rev: v2.6.
|
|
273
|
+
rev: v2.6.6
|
|
274
274
|
hooks:
|
|
275
275
|
- id: muaddib-scan
|
|
276
276
|
```
|
|
@@ -286,7 +286,7 @@ repos:
|
|
|
286
286
|
| **FPR** (Benign) | **12.1%** (64/529) | 529 npm packages, real source via `npm pack` |
|
|
287
287
|
| **ADR** (Adversarial + Holdout) | **92.2%** (71/77) | 53 adversarial + 40 holdout (77 available on disk), global threshold=20 |
|
|
288
288
|
|
|
289
|
-
**
|
|
289
|
+
**2009 tests** across 46 files, 86% code coverage. **130 rules** (125 RULES + 5 PARANOID).
|
|
290
290
|
|
|
291
291
|
> **Methodology caveats:**
|
|
292
292
|
> - TPR measured on 49 Node.js attack samples (3 browser-only excluded from 51 total)
|
|
@@ -327,7 +327,7 @@ npm test
|
|
|
327
327
|
|
|
328
328
|
### Testing
|
|
329
329
|
|
|
330
|
-
- **
|
|
330
|
+
- **2009 tests** across 46 modular test files - 86% code coverage
|
|
331
331
|
- **56 fuzz tests** - Malformed inputs, ReDoS, unicode, binary
|
|
332
332
|
- **Datadog 17K benchmark** - 17,922 real malware samples
|
|
333
333
|
- **Ground truth validation** - 51 real-world attacks (93.9% TPR)
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -34,10 +34,6 @@ const { buildIntentPairs } = require('./intent-graph.js');
|
|
|
34
34
|
const { MAX_FILE_SIZE, safeParse } = require('./shared/constants.js');
|
|
35
35
|
const walk = require('acorn-walk');
|
|
36
36
|
|
|
37
|
-
// Timeout constants for scan safety
|
|
38
|
-
const SCANNER_TIMEOUT = 15000; // 15s per individual scanner
|
|
39
|
-
const SCAN_TIMEOUT = 60000; // 60s global scan timeout
|
|
40
|
-
|
|
41
37
|
// Paranoid mode scanner
|
|
42
38
|
function scanParanoid(targetPath) {
|
|
43
39
|
const threats = [];
|
|
@@ -398,9 +394,10 @@ async function run(targetPath, options = {}) {
|
|
|
398
394
|
const emitterFlows = await yieldThen(() => detectEventEmitterFlows(graph, tainted, sinkAnnotations, targetPath));
|
|
399
395
|
crossFileFlows = crossFileFlows.concat(emitterFlows);
|
|
400
396
|
};
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
397
|
+
let graphTimerId;
|
|
398
|
+
const timeout = new Promise((_, reject) => {
|
|
399
|
+
graphTimerId = setTimeout(() => reject(new Error('Module graph timeout')), MODULE_GRAPH_TIMEOUT_MS);
|
|
400
|
+
});
|
|
404
401
|
try {
|
|
405
402
|
await Promise.race([moduleGraphWork(), timeout]);
|
|
406
403
|
} catch (e) {
|
|
@@ -409,6 +406,8 @@ async function run(targetPath, options = {}) {
|
|
|
409
406
|
if (e && e.message === 'Module graph timeout') {
|
|
410
407
|
warnings.push(`Module graph analysis timed out (${MODULE_GRAPH_TIMEOUT_MS / 1000}s) — cross-file flows may be incomplete`);
|
|
411
408
|
}
|
|
409
|
+
} finally {
|
|
410
|
+
clearTimeout(graphTimerId);
|
|
412
411
|
}
|
|
413
412
|
}
|
|
414
413
|
|
package/src/intent-graph.js
CHANGED
|
@@ -97,10 +97,6 @@ const COHERENCE_MATRIX = {
|
|
|
97
97
|
},
|
|
98
98
|
};
|
|
99
99
|
|
|
100
|
-
// Kept for backward compatibility but no longer used in pairing
|
|
101
|
-
// Cross-file detection is handled by module-graph.js (cross_file_dataflow)
|
|
102
|
-
const CROSS_FILE_MULTIPLIER = 0.5;
|
|
103
|
-
|
|
104
100
|
/**
|
|
105
101
|
* Classify a threat as a source type.
|
|
106
102
|
* Only high-confidence credential access patterns.
|
|
@@ -239,6 +235,5 @@ module.exports = {
|
|
|
239
235
|
classifySource,
|
|
240
236
|
classifySink,
|
|
241
237
|
buildIntentPairs,
|
|
242
|
-
COHERENCE_MATRIX
|
|
243
|
-
CROSS_FILE_MULTIPLIER
|
|
238
|
+
COHERENCE_MATRIX
|
|
244
239
|
};
|
|
@@ -180,6 +180,9 @@ const PLAYBOOKS = {
|
|
|
180
180
|
workflow_injection:
|
|
181
181
|
'Injection potentielle dans GitHub Actions via input non sanitise sur self-hosted runner. Supprimer ou corriger le workflow.',
|
|
182
182
|
|
|
183
|
+
workflow_pwn_request:
|
|
184
|
+
'CRITIQUE: Pwn request detecte — pull_request_target avec checkout du head de la PR permet l\'execution de code arbitraire. Remplacer par pull_request ou utiliser une strategie de checkout securisee (base ref uniquement).',
|
|
185
|
+
|
|
183
186
|
sandbox_sensitive_file_read:
|
|
184
187
|
'CRITIQUE: Package lit des fichiers sensibles (credentials) lors de l\'installation. Ne pas installer. Supprimer immediatement.',
|
|
185
188
|
sandbox_sensitive_file_write:
|
package/src/rules/index.js
CHANGED
|
@@ -844,6 +844,18 @@ const RULES = {
|
|
|
844
844
|
references: ['https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions'],
|
|
845
845
|
mitre: 'T1195.002'
|
|
846
846
|
},
|
|
847
|
+
workflow_pwn_request: {
|
|
848
|
+
id: 'MUADDIB-GHA-003',
|
|
849
|
+
name: 'GitHub Actions Pwn Request',
|
|
850
|
+
severity: 'CRITICAL',
|
|
851
|
+
confidence: 'high',
|
|
852
|
+
description: 'Workflow pull_request_target avec checkout du head ref/sha de la PR — permet execution de code arbitraire (pwn request)',
|
|
853
|
+
references: [
|
|
854
|
+
'https://securitylab.github.com/research/github-actions-preventing-pwn-requests/',
|
|
855
|
+
'https://attack.mitre.org/techniques/T1195/002/'
|
|
856
|
+
],
|
|
857
|
+
mitre: 'T1195.002'
|
|
858
|
+
},
|
|
847
859
|
|
|
848
860
|
// Sandbox detections
|
|
849
861
|
sandbox_sensitive_file_read: {
|
|
@@ -1104,7 +1116,7 @@ const RULES = {
|
|
|
1104
1116
|
description: 'Package inactif depuis 6+ mois avec une nouvelle version soudaine. Possible changement de mainteneur ou compromission.',
|
|
1105
1117
|
references: [
|
|
1106
1118
|
'https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incident',
|
|
1107
|
-
'https://snyk.io/blog/
|
|
1119
|
+
'https://snyk.io/blog/malicious-npm-packages-targeting-developers/'
|
|
1108
1120
|
],
|
|
1109
1121
|
mitre: 'T1195.002'
|
|
1110
1122
|
},
|
|
@@ -1387,6 +1399,7 @@ const RULES = {
|
|
|
1387
1399
|
function getRule(type) {
|
|
1388
1400
|
if (RULES[type]) return RULES[type];
|
|
1389
1401
|
if (PARANOID_RULES[type]) return PARANOID_RULES[type];
|
|
1402
|
+
if (PARANOID_RULES_BY_ID[type]) return PARANOID_RULES_BY_ID[type];
|
|
1390
1403
|
return {
|
|
1391
1404
|
id: 'MUADDIB-UNK-001',
|
|
1392
1405
|
name: 'Unknown Threat',
|
|
@@ -1437,4 +1450,10 @@ const PARANOID_RULES = {
|
|
|
1437
1450
|
}
|
|
1438
1451
|
};
|
|
1439
1452
|
|
|
1453
|
+
// Reverse-map: PARANOID rule ID → rule object (for scanParanoid threats)
|
|
1454
|
+
const PARANOID_RULES_BY_ID = {};
|
|
1455
|
+
for (const [, rule] of Object.entries(PARANOID_RULES)) {
|
|
1456
|
+
PARANOID_RULES_BY_ID[rule.id] = rule;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1440
1459
|
module.exports = { RULES, getRule, PARANOID_RULES };
|
package/src/sandbox/index.js
CHANGED
|
@@ -273,7 +273,7 @@ async function runSingleSandbox(packageName, options = {}) {
|
|
|
273
273
|
let report;
|
|
274
274
|
try {
|
|
275
275
|
const REPORT_DELIMITER = '---MUADDIB-REPORT-START---';
|
|
276
|
-
const delimIdx = stdout.
|
|
276
|
+
const delimIdx = stdout.lastIndexOf(REPORT_DELIMITER);
|
|
277
277
|
let jsonStr;
|
|
278
278
|
if (delimIdx !== -1) {
|
|
279
279
|
// Reliable: use delimiter to skip any package output before the report
|
package/src/scanner/entropy.js
CHANGED
|
@@ -232,7 +232,7 @@ function scanEntropy(targetPath, options = {}) {
|
|
|
232
232
|
// B12: Windowed analysis for strings > MAX_STRING_LENGTH
|
|
233
233
|
if (str.length > MAX_STRING_LENGTH) {
|
|
234
234
|
if (SOURCE_MAP_REGEX.test(str) || SHA256_HEX_REGEX.test(str)) continue;
|
|
235
|
-
const WINDOW = 500, WIN_THRESHOLD =
|
|
235
|
+
const WINDOW = 500, WIN_THRESHOLD = 5.5;
|
|
236
236
|
for (let i = 0; i < str.length; i += WINDOW) {
|
|
237
237
|
const w = str.slice(i, i + WINDOW);
|
|
238
238
|
if (w.length < 20) continue;
|
|
@@ -76,7 +76,7 @@ function scanDirRecursive(dirPath, targetPath, threats, depth = 0) {
|
|
|
76
76
|
|
|
77
77
|
// GHA-002: Detect attacker-controlled context injection on ALL runners (not just self-hosted)
|
|
78
78
|
const injectionPatterns = [
|
|
79
|
-
{ regex: /\$\{\{\s*github\.event\.(comment\.body|issue\.body|issue\.title|pull_request\.body|pull_request\.title|discussion\.body|discussion\.title)/, msg: 'Attacker-controlled GitHub event context used in workflow' },
|
|
79
|
+
{ regex: /\$\{\{\s*github\.event\.(comment\.body|issue\.body|issue\.title|pull_request\.body|pull_request\.title|discussion\.body|discussion\.title|pages\[\]\.html_url)/, msg: 'Attacker-controlled GitHub event context used in workflow' },
|
|
80
80
|
{ regex: /\$\{\{\s*github\.head_ref/, msg: 'github.head_ref is attacker-controlled in pull_request workflows' }
|
|
81
81
|
];
|
|
82
82
|
|
|
@@ -90,6 +90,18 @@ function scanDirRecursive(dirPath, targetPath, threats, depth = 0) {
|
|
|
90
90
|
});
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
+
|
|
94
|
+
// GHA-003: Compound — pull_request_target + checkout of PR head (pwn request)
|
|
95
|
+
const hasPRTarget = /pull_request_target/m.test(activeContent);
|
|
96
|
+
const hasCheckoutPRHead = /actions\/checkout[\s\S]*?ref:\s*\$\{\{\s*github\.event\.pull_request\.head\.(ref|sha)\s*\}\}/m.test(activeContent);
|
|
97
|
+
if (hasPRTarget && hasCheckoutPRHead) {
|
|
98
|
+
threats.push({
|
|
99
|
+
type: 'workflow_pwn_request',
|
|
100
|
+
severity: 'CRITICAL',
|
|
101
|
+
message: 'Pwn request: pull_request_target with checkout of PR head ref/sha allows arbitrary code execution',
|
|
102
|
+
file: relFile
|
|
103
|
+
});
|
|
104
|
+
}
|
|
93
105
|
}
|
|
94
106
|
}
|
|
95
107
|
|
package/src/scanner/shell.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const { findFiles, forEachSafeFile } = require('../utils.js');
|
|
4
|
+
const { MAX_FILE_SIZE } = require('../shared/constants.js');
|
|
4
5
|
|
|
5
6
|
const SHELL_EXCLUDED_DIRS = ['node_modules', '.git', '.muaddib-cache'];
|
|
6
7
|
|
|
@@ -22,31 +23,75 @@ const MALICIOUS_PATTERNS = [
|
|
|
22
23
|
{ pattern: /wget\s+\S+.*&&.*base64\s+-d/m, name: 'wget_base64_decode', severity: 'HIGH' }
|
|
23
24
|
];
|
|
24
25
|
|
|
26
|
+
const SHEBANG_RE = /^#!.*\b(?:ba)?sh\b/;
|
|
27
|
+
|
|
28
|
+
function scanFileContent(file, content, targetPath, threats) {
|
|
29
|
+
// Strip comment lines to avoid false positives on documentation
|
|
30
|
+
const activeContent = content.split(/\r?\n/)
|
|
31
|
+
.filter(line => !line.trimStart().startsWith('#'))
|
|
32
|
+
.join('\n');
|
|
33
|
+
|
|
34
|
+
for (const { pattern, name, severity } of MALICIOUS_PATTERNS) {
|
|
35
|
+
if (pattern.test(activeContent)) {
|
|
36
|
+
threats.push({
|
|
37
|
+
type: name,
|
|
38
|
+
severity: severity,
|
|
39
|
+
message: `Pattern malveillant "${name}" detecte.`,
|
|
40
|
+
file: path.relative(targetPath, file)
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Find extensionless files in a directory (non-recursive into excluded dirs).
|
|
48
|
+
* Used for shebang-based shell script detection.
|
|
49
|
+
*/
|
|
50
|
+
function findExtensionlessFiles(dir, excludedDirs, results = [], depth = 0) {
|
|
51
|
+
if (depth > 20) return results;
|
|
52
|
+
let items;
|
|
53
|
+
try { items = fs.readdirSync(dir); } catch { return results; }
|
|
54
|
+
|
|
55
|
+
for (const item of items) {
|
|
56
|
+
if (excludedDirs.includes(item)) continue;
|
|
57
|
+
const fullPath = path.join(dir, item);
|
|
58
|
+
try {
|
|
59
|
+
const lstat = fs.lstatSync(fullPath);
|
|
60
|
+
if (lstat.isSymbolicLink()) continue;
|
|
61
|
+
if (lstat.isDirectory()) {
|
|
62
|
+
findExtensionlessFiles(fullPath, excludedDirs, results, depth + 1);
|
|
63
|
+
} else if (lstat.isFile() && !path.extname(item) && lstat.size <= MAX_FILE_SIZE) {
|
|
64
|
+
results.push(fullPath);
|
|
65
|
+
}
|
|
66
|
+
} catch { /* permission error */ }
|
|
67
|
+
}
|
|
68
|
+
return results;
|
|
69
|
+
}
|
|
70
|
+
|
|
25
71
|
async function scanShellScripts(targetPath) {
|
|
26
72
|
const threats = [];
|
|
27
|
-
|
|
28
|
-
//
|
|
73
|
+
|
|
74
|
+
// Pass 1: files with shell extensions
|
|
29
75
|
const files = findFiles(targetPath, { extensions: ['.sh', '.bash', '.zsh', '.command'], excludedDirs: SHELL_EXCLUDED_DIRS });
|
|
30
76
|
|
|
31
77
|
forEachSafeFile(files, (file, content) => {
|
|
32
|
-
|
|
33
|
-
const activeContent = content.split(/\r?\n/)
|
|
34
|
-
.filter(line => !line.trimStart().startsWith('#'))
|
|
35
|
-
.join('\n');
|
|
36
|
-
|
|
37
|
-
for (const { pattern, name, severity } of MALICIOUS_PATTERNS) {
|
|
38
|
-
if (pattern.test(activeContent)) {
|
|
39
|
-
threats.push({
|
|
40
|
-
type: name,
|
|
41
|
-
severity: severity,
|
|
42
|
-
message: `Pattern malveillant "${name}" detecte.`,
|
|
43
|
-
file: path.relative(targetPath, file)
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
}
|
|
78
|
+
scanFileContent(file, content, targetPath, threats);
|
|
47
79
|
});
|
|
48
80
|
|
|
81
|
+
// Pass 2: extensionless files with sh/bash shebang
|
|
82
|
+
const extensionless = findExtensionlessFiles(targetPath, SHELL_EXCLUDED_DIRS);
|
|
83
|
+
|
|
84
|
+
for (const file of extensionless) {
|
|
85
|
+
try {
|
|
86
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
87
|
+
const firstLine = content.split(/\r?\n/, 1)[0];
|
|
88
|
+
if (SHEBANG_RE.test(firstLine)) {
|
|
89
|
+
scanFileContent(file, content, targetPath, threats);
|
|
90
|
+
}
|
|
91
|
+
} catch { /* ignore unreadable files */ }
|
|
92
|
+
}
|
|
93
|
+
|
|
49
94
|
return threats;
|
|
50
95
|
}
|
|
51
96
|
|
|
52
|
-
module.exports = { scanShellScripts };
|
|
97
|
+
module.exports = { scanShellScripts };
|