muaddib-scanner 1.4.0 → 1.4.1
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 +9 -1
- package/package.json +1 -1
- package/src/daemon.js +19 -17
- package/src/hooks-init.js +10 -1
- package/src/index.js +1 -0
- package/src/ioc/data/iocs.json +1 -1
- package/src/ioc/scraper.js +16 -6
- package/src/ioc/updater.js +51 -16
- package/src/ioc/yaml-loader.js +45 -29
- package/src/response/playbooks.js +0 -15
- package/src/rules/index.js +170 -0
- package/src/safe-install.js +3 -3
- package/src/scanner/ast.js +7 -2
- package/src/scanner/dependencies.js +11 -12
- package/src/scanner/github-actions.js +15 -2
- package/src/scanner/hash.js +3 -78
- package/src/scanner/obfuscation.js +6 -1
- package/src/scanner/package.js +22 -8
- package/src/scanner/typosquat.js +10 -4
- package/src/utils.js +2 -1
- package/src/watch.js +6 -1
package/bin/muaddib.js
CHANGED
|
@@ -8,7 +8,7 @@ const { runScraper } = require('../src/ioc/scraper.js');
|
|
|
8
8
|
const { safeInstall } = require('../src/safe-install.js');
|
|
9
9
|
const { buildSandboxImage, runSandbox } = require('../src/sandbox.js');
|
|
10
10
|
const { diff, showRefs } = require('../src/diff.js');
|
|
11
|
-
const { initHooks } = require('../src/hooks-init.js');
|
|
11
|
+
const { initHooks, removeHooks } = require('../src/hooks-init.js');
|
|
12
12
|
|
|
13
13
|
const args = process.argv.slice(2);
|
|
14
14
|
const command = args[0];
|
|
@@ -238,6 +238,7 @@ const helpText = `
|
|
|
238
238
|
muaddib watch [path] Watch in real-time
|
|
239
239
|
muaddib daemon [options] Start daemon
|
|
240
240
|
muaddib init-hooks [options] Setup git pre-commit hooks
|
|
241
|
+
muaddib remove-hooks [path] Remove MUAD'DIB git hooks
|
|
241
242
|
muaddib update Update IOCs
|
|
242
243
|
muaddib scrape Scrape new IOCs
|
|
243
244
|
muaddib sandbox <pkg> Analyze in isolated Docker container
|
|
@@ -385,6 +386,13 @@ if (!command || command === '--help' || command === '-h') {
|
|
|
385
386
|
console.error('[ERROR]', err.message);
|
|
386
387
|
process.exit(1);
|
|
387
388
|
});
|
|
389
|
+
} else if (command === 'remove-hooks') {
|
|
390
|
+
removeHooks(target).then(success => {
|
|
391
|
+
process.exit(success ? 0 : 1);
|
|
392
|
+
}).catch(err => {
|
|
393
|
+
console.error('[ERROR]', err.message);
|
|
394
|
+
process.exit(1);
|
|
395
|
+
});
|
|
388
396
|
} else if (command === 'help') {
|
|
389
397
|
console.log(helpText);
|
|
390
398
|
process.exit(0);
|
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -31,18 +31,17 @@ async function startDaemon(options = {}) {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
// Keep process alive until SIGINT
|
|
35
|
+
await new Promise((resolve) => {
|
|
36
|
+
process.on('SIGINT', () => {
|
|
37
|
+
console.log('\n[DAEMON] Arret...');
|
|
38
|
+
isRunning = false;
|
|
39
|
+
cleanup();
|
|
40
|
+
resolve();
|
|
41
|
+
});
|
|
40
42
|
});
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
while (isRunning) {
|
|
44
|
-
await sleep(1000);
|
|
45
|
-
}
|
|
44
|
+
process.exit(0);
|
|
46
45
|
}
|
|
47
46
|
|
|
48
47
|
function watchDirectory(dir) {
|
|
@@ -55,12 +54,14 @@ function watchDirectory(dir) {
|
|
|
55
54
|
|
|
56
55
|
// Surveille package-lock.json
|
|
57
56
|
if (fs.existsSync(packageLockPath)) {
|
|
58
|
-
|
|
57
|
+
const w = watchFile(packageLockPath, dir);
|
|
58
|
+
if (w) watchers.push(w);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
// Surveille yarn.lock
|
|
62
62
|
if (fs.existsSync(yarnLockPath)) {
|
|
63
|
-
|
|
63
|
+
const w = watchFile(yarnLockPath, dir);
|
|
64
|
+
if (w) watchers.push(w);
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
// Surveille node_modules
|
|
@@ -88,7 +89,12 @@ function watchDirectory(dir) {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
function watchFile(filePath, projectDir) {
|
|
91
|
-
let lastMtime
|
|
92
|
+
let lastMtime;
|
|
93
|
+
try {
|
|
94
|
+
lastMtime = fs.statSync(filePath).mtime.getTime();
|
|
95
|
+
} catch {
|
|
96
|
+
return null; // File deleted between existsSync and statSync
|
|
97
|
+
}
|
|
92
98
|
|
|
93
99
|
return fs.watch(filePath, (eventType) => {
|
|
94
100
|
if (eventType === 'change') {
|
|
@@ -149,8 +155,4 @@ function triggerScan(dir) {
|
|
|
149
155
|
}, 3000);
|
|
150
156
|
}
|
|
151
157
|
|
|
152
|
-
function sleep(ms) {
|
|
153
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
154
|
-
}
|
|
155
|
-
|
|
156
158
|
module.exports = { startDaemon };
|
package/src/hooks-init.js
CHANGED
|
@@ -2,6 +2,15 @@ const { execSync } = require('child_process');
|
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
|
|
5
|
+
// Read version from package.json for pre-commit config
|
|
6
|
+
const PKG_VERSION = (() => {
|
|
7
|
+
try {
|
|
8
|
+
return 'v' + JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
|
|
9
|
+
} catch {
|
|
10
|
+
return 'v1.0.0';
|
|
11
|
+
}
|
|
12
|
+
})();
|
|
13
|
+
|
|
5
14
|
/**
|
|
6
15
|
* Detect which hook system is available
|
|
7
16
|
*/
|
|
@@ -131,7 +140,7 @@ async function initPreCommit(targetPath, mode) {
|
|
|
131
140
|
const hookId = mode === 'diff' ? 'muaddib-diff' : 'muaddib-scan';
|
|
132
141
|
const muaddibConfig = `
|
|
133
142
|
- repo: https://github.com/DNSZLSK/muad-dib
|
|
134
|
-
rev:
|
|
143
|
+
rev: ${PKG_VERSION}
|
|
135
144
|
hooks:
|
|
136
145
|
- id: ${hookId}
|
|
137
146
|
`;
|
package/src/index.js
CHANGED
package/src/ioc/data/iocs.json
CHANGED
|
@@ -17564,7 +17564,7 @@
|
|
|
17564
17564
|
"pigS3cr3ts.json"
|
|
17565
17565
|
],
|
|
17566
17566
|
"files": [],
|
|
17567
|
-
"updated": "2026-02-
|
|
17567
|
+
"updated": "2026-02-09T22:02:04.174Z",
|
|
17568
17568
|
"sources": [
|
|
17569
17569
|
"shai-hulud-detector",
|
|
17570
17570
|
"datadog-consolidated",
|
package/src/ioc/scraper.js
CHANGED
|
@@ -111,7 +111,9 @@ function loadStaticIOCs() {
|
|
|
111
111
|
return { socket: [], phylum: [], npmRemoved: [] };
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
const MAX_REDIRECTS = 5;
|
|
115
|
+
|
|
116
|
+
function fetchJSON(url, options = {}, redirectCount = 0) {
|
|
115
117
|
return new Promise((resolve, reject) => {
|
|
116
118
|
const urlObj = new URL(url);
|
|
117
119
|
const reqOptions = {
|
|
@@ -126,14 +128,18 @@ function fetchJSON(url, options = {}) {
|
|
|
126
128
|
};
|
|
127
129
|
|
|
128
130
|
const req = https.request(reqOptions, (res) => {
|
|
129
|
-
// Handle redirects (with security validation)
|
|
131
|
+
// Handle redirects (with security validation and limit)
|
|
130
132
|
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
133
|
+
if (redirectCount >= MAX_REDIRECTS) {
|
|
134
|
+
reject(new Error('Too many redirects'));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
131
137
|
const redirectUrl = res.headers.location;
|
|
132
138
|
if (!isAllowedRedirect(redirectUrl)) {
|
|
133
139
|
reject(new Error(`Unauthorized redirect to: ${redirectUrl}`));
|
|
134
140
|
return;
|
|
135
141
|
}
|
|
136
|
-
fetchJSON(redirectUrl, options).then(resolve).catch(reject);
|
|
142
|
+
fetchJSON(redirectUrl, options, redirectCount + 1).then(resolve).catch(reject);
|
|
137
143
|
return;
|
|
138
144
|
}
|
|
139
145
|
|
|
@@ -162,7 +168,7 @@ function fetchJSON(url, options = {}) {
|
|
|
162
168
|
});
|
|
163
169
|
}
|
|
164
170
|
|
|
165
|
-
function fetchText(url) {
|
|
171
|
+
function fetchText(url, redirectCount = 0) {
|
|
166
172
|
return new Promise((resolve, reject) => {
|
|
167
173
|
const urlObj = new URL(url);
|
|
168
174
|
const reqOptions = {
|
|
@@ -175,14 +181,18 @@ function fetchText(url) {
|
|
|
175
181
|
};
|
|
176
182
|
|
|
177
183
|
const req = https.request(reqOptions, (res) => {
|
|
178
|
-
// Handle redirects (with security validation)
|
|
184
|
+
// Handle redirects (with security validation and limit)
|
|
179
185
|
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
186
|
+
if (redirectCount >= MAX_REDIRECTS) {
|
|
187
|
+
reject(new Error('Too many redirects'));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
180
190
|
const redirectUrl = res.headers.location;
|
|
181
191
|
if (!isAllowedRedirect(redirectUrl)) {
|
|
182
192
|
reject(new Error(`Unauthorized redirect to: ${redirectUrl}`));
|
|
183
193
|
return;
|
|
184
194
|
}
|
|
185
|
-
fetchText(redirectUrl).then(resolve).catch(reject);
|
|
195
|
+
fetchText(redirectUrl, redirectCount + 1).then(resolve).catch(reject);
|
|
186
196
|
return;
|
|
187
197
|
}
|
|
188
198
|
|
package/src/ioc/updater.js
CHANGED
|
@@ -72,58 +72,93 @@ async function updateIOCs() {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
* Merge source IOCs into target without duplicates
|
|
76
|
-
*
|
|
75
|
+
* Merge source IOCs into target without duplicates.
|
|
76
|
+
* Uses Sets for O(1) dedup. Lazily initializes _sets on target.
|
|
77
|
+
* Returns number of packages added.
|
|
77
78
|
*/
|
|
78
79
|
function mergeIOCs(target, source) {
|
|
80
|
+
// Lazily initialize dedup sets on the target object
|
|
81
|
+
if (!target._pkgKeys) {
|
|
82
|
+
target._pkgKeys = new Set(target.packages.map(p => p.name + '@' + p.version));
|
|
83
|
+
target._hashSet = new Set(target.hashes);
|
|
84
|
+
target._markerSet = new Set(target.markers);
|
|
85
|
+
target._fileSet = new Set(target.files);
|
|
86
|
+
}
|
|
87
|
+
|
|
79
88
|
let added = 0;
|
|
80
|
-
|
|
89
|
+
|
|
81
90
|
// Merge packages
|
|
82
91
|
for (const pkg of source.packages || []) {
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
});
|
|
86
|
-
if (!exists) {
|
|
92
|
+
const key = pkg.name + '@' + pkg.version;
|
|
93
|
+
if (!target._pkgKeys.has(key)) {
|
|
87
94
|
target.packages.push(pkg);
|
|
95
|
+
target._pkgKeys.add(key);
|
|
88
96
|
added++;
|
|
89
97
|
}
|
|
90
98
|
}
|
|
91
|
-
|
|
99
|
+
|
|
92
100
|
// Merge hashes
|
|
93
101
|
for (const hash of source.hashes || []) {
|
|
94
|
-
if (!target.
|
|
102
|
+
if (!target._hashSet.has(hash)) {
|
|
95
103
|
target.hashes.push(hash);
|
|
104
|
+
target._hashSet.add(hash);
|
|
96
105
|
}
|
|
97
106
|
}
|
|
98
|
-
|
|
107
|
+
|
|
99
108
|
// Merge markers
|
|
100
109
|
for (const marker of source.markers || []) {
|
|
101
|
-
if (!target.
|
|
110
|
+
if (!target._markerSet.has(marker)) {
|
|
102
111
|
target.markers.push(marker);
|
|
112
|
+
target._markerSet.add(marker);
|
|
103
113
|
}
|
|
104
114
|
}
|
|
105
|
-
|
|
115
|
+
|
|
106
116
|
// Merge files
|
|
107
117
|
for (const file of source.files || []) {
|
|
108
|
-
if (!target.
|
|
118
|
+
if (!target._fileSet.has(file)) {
|
|
109
119
|
target.files.push(file);
|
|
120
|
+
target._fileSet.add(file);
|
|
110
121
|
}
|
|
111
122
|
}
|
|
112
|
-
|
|
123
|
+
|
|
113
124
|
return added;
|
|
114
125
|
}
|
|
115
126
|
|
|
127
|
+
// Allowed redirect domains for fetchUrl (SSRF protection)
|
|
128
|
+
const ALLOWED_FETCH_DOMAINS = [
|
|
129
|
+
'raw.githubusercontent.com',
|
|
130
|
+
'github.com',
|
|
131
|
+
'objects.githubusercontent.com'
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
function isAllowedFetchRedirect(redirectUrl, originalUrl) {
|
|
135
|
+
try {
|
|
136
|
+
// Handle relative URLs by resolving against original
|
|
137
|
+
const resolved = new URL(redirectUrl, originalUrl);
|
|
138
|
+
return ALLOWED_FETCH_DOMAINS.includes(resolved.hostname);
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
116
144
|
function fetchUrl(url, redirectCount = 0) {
|
|
117
145
|
const MAX_REDIRECTS = 5;
|
|
118
146
|
return new Promise(function(resolve, reject) {
|
|
119
147
|
https.get(url, function(res) {
|
|
120
|
-
// Handle redirects with limit
|
|
148
|
+
// Handle redirects with limit and domain validation
|
|
121
149
|
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
122
150
|
if (redirectCount >= MAX_REDIRECTS) {
|
|
123
151
|
reject(new Error('Too many redirects'));
|
|
124
152
|
return;
|
|
125
153
|
}
|
|
126
|
-
|
|
154
|
+
const redirectTarget = res.headers.location;
|
|
155
|
+
if (!isAllowedFetchRedirect(redirectTarget, url)) {
|
|
156
|
+
reject(new Error('Redirect to unauthorized domain: ' + redirectTarget));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Resolve relative URLs against original
|
|
160
|
+
const resolvedUrl = new URL(redirectTarget, url).href;
|
|
161
|
+
fetchUrl(resolvedUrl, redirectCount + 1).then(resolve).catch(reject);
|
|
127
162
|
return;
|
|
128
163
|
}
|
|
129
164
|
if (res.statusCode !== 200) {
|
package/src/ioc/yaml-loader.js
CHANGED
|
@@ -12,26 +12,34 @@ function loadYAMLIOCs() {
|
|
|
12
12
|
files: []
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
// Dedup sets for O(1) lookup during loading
|
|
16
|
+
const seenPkgs = new Set();
|
|
17
|
+
const seenHashes = new Set();
|
|
18
|
+
const seenMarkers = new Set();
|
|
19
|
+
const seenFiles = new Set();
|
|
20
|
+
|
|
15
21
|
// Charger packages.yaml
|
|
16
|
-
loadPackagesYAML(path.join(IOCS_DIR, 'packages.yaml'), iocs);
|
|
17
|
-
|
|
22
|
+
loadPackagesYAML(path.join(IOCS_DIR, 'packages.yaml'), iocs, seenPkgs);
|
|
23
|
+
|
|
18
24
|
// Charger builtin.yaml (fallback)
|
|
19
|
-
loadBuiltinYAML(path.join(IOCS_DIR, 'builtin.yaml'), iocs);
|
|
25
|
+
loadBuiltinYAML(path.join(IOCS_DIR, 'builtin.yaml'), iocs, seenPkgs, seenHashes, seenMarkers, seenFiles);
|
|
20
26
|
|
|
21
27
|
// Charger hashes.yaml
|
|
22
|
-
loadHashesYAML(path.join(IOCS_DIR, 'hashes.yaml'), iocs);
|
|
28
|
+
loadHashesYAML(path.join(IOCS_DIR, 'hashes.yaml'), iocs, seenHashes, seenMarkers, seenFiles);
|
|
23
29
|
|
|
24
30
|
return iocs;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
|
-
function loadPackagesYAML(filePath, iocs) {
|
|
33
|
+
function loadPackagesYAML(filePath, iocs, seenPkgs) {
|
|
28
34
|
if (!fs.existsSync(filePath)) return;
|
|
29
|
-
|
|
35
|
+
|
|
30
36
|
try {
|
|
31
|
-
const data = yaml.load(fs.readFileSync(filePath, 'utf8'));
|
|
37
|
+
const data = yaml.load(fs.readFileSync(filePath, 'utf8'), { schema: yaml.JSON_SCHEMA });
|
|
32
38
|
if (data && data.packages) {
|
|
33
39
|
for (const p of data.packages) {
|
|
34
|
-
|
|
40
|
+
const key = p.name + '@' + p.version;
|
|
41
|
+
if (!seenPkgs.has(key)) {
|
|
42
|
+
seenPkgs.add(key);
|
|
35
43
|
iocs.packages.push({
|
|
36
44
|
id: p.id,
|
|
37
45
|
name: p.name,
|
|
@@ -51,16 +59,18 @@ function loadPackagesYAML(filePath, iocs) {
|
|
|
51
59
|
}
|
|
52
60
|
}
|
|
53
61
|
|
|
54
|
-
function loadBuiltinYAML(filePath, iocs) {
|
|
62
|
+
function loadBuiltinYAML(filePath, iocs, seenPkgs, seenHashes, seenMarkers, seenFiles) {
|
|
55
63
|
if (!fs.existsSync(filePath)) return;
|
|
56
|
-
|
|
64
|
+
|
|
57
65
|
try {
|
|
58
|
-
const data = yaml.load(fs.readFileSync(filePath, 'utf8'));
|
|
59
|
-
|
|
66
|
+
const data = yaml.load(fs.readFileSync(filePath, 'utf8'), { schema: yaml.JSON_SCHEMA });
|
|
67
|
+
|
|
60
68
|
// Packages
|
|
61
69
|
if (data && data.packages) {
|
|
62
70
|
for (const p of data.packages) {
|
|
63
|
-
|
|
71
|
+
const key = p.name + '@' + p.version;
|
|
72
|
+
if (!seenPkgs.has(key)) {
|
|
73
|
+
seenPkgs.add(key);
|
|
64
74
|
iocs.packages.push({
|
|
65
75
|
id: `BUILTIN-${p.name}`,
|
|
66
76
|
name: p.name,
|
|
@@ -75,12 +85,13 @@ function loadBuiltinYAML(filePath, iocs) {
|
|
|
75
85
|
}
|
|
76
86
|
}
|
|
77
87
|
}
|
|
78
|
-
|
|
88
|
+
|
|
79
89
|
// Files
|
|
80
90
|
if (data && data.files) {
|
|
81
91
|
for (const f of data.files) {
|
|
82
92
|
const fileName = typeof f === 'string' ? f : f.name;
|
|
83
|
-
if (!
|
|
93
|
+
if (!seenFiles.has(fileName)) {
|
|
94
|
+
seenFiles.add(fileName);
|
|
84
95
|
iocs.files.push({
|
|
85
96
|
id: `BUILTIN-FILE-${fileName}`,
|
|
86
97
|
name: fileName,
|
|
@@ -92,12 +103,13 @@ function loadBuiltinYAML(filePath, iocs) {
|
|
|
92
103
|
}
|
|
93
104
|
}
|
|
94
105
|
}
|
|
95
|
-
|
|
106
|
+
|
|
96
107
|
// Hashes
|
|
97
108
|
if (data && data.hashes) {
|
|
98
109
|
for (const h of data.hashes) {
|
|
99
110
|
const hash = typeof h === 'string' ? h : h.sha256;
|
|
100
|
-
if (!
|
|
111
|
+
if (!seenHashes.has(hash)) {
|
|
112
|
+
seenHashes.add(hash);
|
|
101
113
|
iocs.hashes.push({
|
|
102
114
|
id: `BUILTIN-HASH-${hash.slice(0, 8)}`,
|
|
103
115
|
sha256: hash,
|
|
@@ -109,12 +121,13 @@ function loadBuiltinYAML(filePath, iocs) {
|
|
|
109
121
|
}
|
|
110
122
|
}
|
|
111
123
|
}
|
|
112
|
-
|
|
124
|
+
|
|
113
125
|
// Markers
|
|
114
126
|
if (data && data.markers) {
|
|
115
127
|
for (const m of data.markers) {
|
|
116
128
|
const pattern = typeof m === 'string' ? m : m.pattern;
|
|
117
|
-
if (!
|
|
129
|
+
if (!seenMarkers.has(pattern)) {
|
|
130
|
+
seenMarkers.add(pattern);
|
|
118
131
|
iocs.markers.push({
|
|
119
132
|
id: `BUILTIN-MARKER-${pattern.slice(0, 10)}`,
|
|
120
133
|
pattern: pattern,
|
|
@@ -131,15 +144,16 @@ function loadBuiltinYAML(filePath, iocs) {
|
|
|
131
144
|
}
|
|
132
145
|
}
|
|
133
146
|
|
|
134
|
-
function loadHashesYAML(filePath, iocs) {
|
|
147
|
+
function loadHashesYAML(filePath, iocs, seenHashes, seenMarkers, seenFiles) {
|
|
135
148
|
if (!fs.existsSync(filePath)) return;
|
|
136
|
-
|
|
149
|
+
|
|
137
150
|
try {
|
|
138
|
-
const data = yaml.load(fs.readFileSync(filePath, 'utf8'));
|
|
139
|
-
|
|
151
|
+
const data = yaml.load(fs.readFileSync(filePath, 'utf8'), { schema: yaml.JSON_SCHEMA });
|
|
152
|
+
|
|
140
153
|
if (data && data.hashes) {
|
|
141
154
|
for (const h of data.hashes) {
|
|
142
|
-
if (!
|
|
155
|
+
if (!seenHashes.has(h.sha256)) {
|
|
156
|
+
seenHashes.add(h.sha256);
|
|
143
157
|
iocs.hashes.push({
|
|
144
158
|
id: h.id,
|
|
145
159
|
sha256: h.sha256,
|
|
@@ -153,10 +167,11 @@ function loadHashesYAML(filePath, iocs) {
|
|
|
153
167
|
}
|
|
154
168
|
}
|
|
155
169
|
}
|
|
156
|
-
|
|
170
|
+
|
|
157
171
|
if (data && data.markers) {
|
|
158
172
|
for (const m of data.markers) {
|
|
159
|
-
if (!
|
|
173
|
+
if (!seenMarkers.has(m.pattern)) {
|
|
174
|
+
seenMarkers.add(m.pattern);
|
|
160
175
|
iocs.markers.push({
|
|
161
176
|
id: m.id,
|
|
162
177
|
pattern: m.pattern,
|
|
@@ -168,10 +183,11 @@ function loadHashesYAML(filePath, iocs) {
|
|
|
168
183
|
}
|
|
169
184
|
}
|
|
170
185
|
}
|
|
171
|
-
|
|
186
|
+
|
|
172
187
|
if (data && data.files) {
|
|
173
188
|
for (const f of data.files) {
|
|
174
|
-
if (!
|
|
189
|
+
if (!seenFiles.has(f.name)) {
|
|
190
|
+
seenFiles.add(f.name);
|
|
175
191
|
iocs.files.push({
|
|
176
192
|
id: f.id,
|
|
177
193
|
name: f.name,
|
|
@@ -198,4 +214,4 @@ function getIOCStats() {
|
|
|
198
214
|
};
|
|
199
215
|
}
|
|
200
216
|
|
|
201
|
-
module.exports = { loadYAMLIOCs, getIOCStats };
|
|
217
|
+
module.exports = { loadYAMLIOCs, getIOCStats };
|
|
@@ -20,9 +20,6 @@ const PLAYBOOKS = {
|
|
|
20
20
|
npmrc_access:
|
|
21
21
|
'Acces au fichier .npmrc detecte. Risque de vol de token npm. Regenerer le token.',
|
|
22
22
|
|
|
23
|
-
npmrc_read:
|
|
24
|
-
'Lecture du .npmrc. Regenerer immediatement: npm token revoke && npm login',
|
|
25
|
-
|
|
26
23
|
github_token_access:
|
|
27
24
|
'Acces au GITHUB_TOKEN. Verifier les permissions. Regenerer si compromis.',
|
|
28
25
|
|
|
@@ -41,24 +38,15 @@ const PLAYBOOKS = {
|
|
|
41
38
|
reverse_shell:
|
|
42
39
|
'CRITIQUE: Reverse shell detecte. Machine potentiellement compromise. Isoler immediatement.',
|
|
43
40
|
|
|
44
|
-
netcat_shell:
|
|
45
|
-
'CRITIQUE: Shell netcat detecte. Machine potentiellement compromise. Isoler immediatement.',
|
|
46
|
-
|
|
47
41
|
home_deletion:
|
|
48
42
|
'CRITIQUE: Tentative de suppression du repertoire home. Dead man\'s switch probable.',
|
|
49
43
|
|
|
50
|
-
shred_home:
|
|
51
|
-
'CRITIQUE: Destruction de donnees detectee. Dead man\'s switch de Shai-Hulud.',
|
|
52
|
-
|
|
53
44
|
curl_exfiltration:
|
|
54
45
|
'Exfiltration de donnees via curl. Verifier les donnees envoyees et la destination.',
|
|
55
46
|
|
|
56
47
|
ssh_access:
|
|
57
48
|
'Acces aux cles SSH. Regenerer les cles si compromis: ssh-keygen -t ed25519',
|
|
58
49
|
|
|
59
|
-
ssh_key_read:
|
|
60
|
-
'Lecture des cles SSH. Regenerer immediatement toutes les cles.',
|
|
61
|
-
|
|
62
50
|
github_api_call:
|
|
63
51
|
'Appel a l\'API GitHub. Verifier le contexte. Peut etre legitime ou exfiltration.',
|
|
64
52
|
|
|
@@ -68,9 +56,6 @@ const PLAYBOOKS = {
|
|
|
68
56
|
exec_wget:
|
|
69
57
|
'Execution de wget via child_process. Verifier l\'URL et les donnees.',
|
|
70
58
|
|
|
71
|
-
wget_chmod_exec:
|
|
72
|
-
'Telechargement et execution de binaire. Ne pas executer. Analyser le fichier.',
|
|
73
|
-
|
|
74
59
|
known_malicious_package:
|
|
75
60
|
'CRITIQUE: Supprimer immediatement. rm -rf node_modules && npm cache clean --force && npm install',
|
|
76
61
|
|
package/src/rules/index.js
CHANGED
|
@@ -193,6 +193,176 @@ const RULES = {
|
|
|
193
193
|
],
|
|
194
194
|
mitre: 'T1195.002'
|
|
195
195
|
},
|
|
196
|
+
|
|
197
|
+
// Package.json script patterns
|
|
198
|
+
curl_pipe_sh: {
|
|
199
|
+
id: 'MUADDIB-PKG-002',
|
|
200
|
+
name: 'Curl Pipe to Shell in Script',
|
|
201
|
+
severity: 'CRITICAL',
|
|
202
|
+
confidence: 'high',
|
|
203
|
+
description: 'Script lifecycle execute curl | sh - telechargement et execution de code distant',
|
|
204
|
+
references: ['https://blog.phylum.io/shai-hulud-npm-worm'],
|
|
205
|
+
mitre: 'T1105'
|
|
206
|
+
},
|
|
207
|
+
wget_pipe_sh: {
|
|
208
|
+
id: 'MUADDIB-PKG-003',
|
|
209
|
+
name: 'Wget Pipe to Shell in Script',
|
|
210
|
+
severity: 'CRITICAL',
|
|
211
|
+
confidence: 'high',
|
|
212
|
+
description: 'Script lifecycle execute wget | sh - telechargement et execution de code distant',
|
|
213
|
+
references: ['https://blog.phylum.io/shai-hulud-npm-worm'],
|
|
214
|
+
mitre: 'T1105'
|
|
215
|
+
},
|
|
216
|
+
eval_usage: {
|
|
217
|
+
id: 'MUADDIB-PKG-004',
|
|
218
|
+
name: 'Eval in Lifecycle Script',
|
|
219
|
+
severity: 'HIGH',
|
|
220
|
+
confidence: 'medium',
|
|
221
|
+
description: 'Utilisation de eval() dans un script lifecycle - execution de code dynamique',
|
|
222
|
+
references: ['https://owasp.org/www-community/attacks/Command_Injection'],
|
|
223
|
+
mitre: 'T1059.007'
|
|
224
|
+
},
|
|
225
|
+
child_process: {
|
|
226
|
+
id: 'MUADDIB-PKG-005',
|
|
227
|
+
name: 'Child Process in Lifecycle Script',
|
|
228
|
+
severity: 'HIGH',
|
|
229
|
+
confidence: 'medium',
|
|
230
|
+
description: 'Reference a child_process dans un script lifecycle',
|
|
231
|
+
references: ['https://owasp.org/www-community/attacks/Command_Injection'],
|
|
232
|
+
mitre: 'T1059'
|
|
233
|
+
},
|
|
234
|
+
npmrc_access: {
|
|
235
|
+
id: 'MUADDIB-PKG-006',
|
|
236
|
+
name: 'npmrc Access',
|
|
237
|
+
severity: 'HIGH',
|
|
238
|
+
confidence: 'high',
|
|
239
|
+
description: 'Acces au fichier .npmrc detecte - risque de vol de token npm',
|
|
240
|
+
references: ['https://blog.phylum.io/shai-hulud-npm-worm'],
|
|
241
|
+
mitre: 'T1552.001'
|
|
242
|
+
},
|
|
243
|
+
github_token_access: {
|
|
244
|
+
id: 'MUADDIB-PKG-007',
|
|
245
|
+
name: 'GitHub Token Access',
|
|
246
|
+
severity: 'HIGH',
|
|
247
|
+
confidence: 'high',
|
|
248
|
+
description: 'Acces au GITHUB_TOKEN detecte',
|
|
249
|
+
references: ['https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions'],
|
|
250
|
+
mitre: 'T1552.001'
|
|
251
|
+
},
|
|
252
|
+
aws_credential_access: {
|
|
253
|
+
id: 'MUADDIB-PKG-008',
|
|
254
|
+
name: 'AWS Credential Access',
|
|
255
|
+
severity: 'HIGH',
|
|
256
|
+
confidence: 'high',
|
|
257
|
+
description: 'Acces aux credentials AWS detecte',
|
|
258
|
+
references: ['https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html'],
|
|
259
|
+
mitre: 'T1552.001'
|
|
260
|
+
},
|
|
261
|
+
base64_encoding: {
|
|
262
|
+
id: 'MUADDIB-PKG-009',
|
|
263
|
+
name: 'Base64 Encoding in Script',
|
|
264
|
+
severity: 'MEDIUM',
|
|
265
|
+
confidence: 'low',
|
|
266
|
+
description: 'Encodage base64 dans un script lifecycle - souvent utilise pour obfusquer du code malveillant',
|
|
267
|
+
references: ['https://attack.mitre.org/techniques/T1027/'],
|
|
268
|
+
mitre: 'T1027'
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
// Shell script patterns
|
|
272
|
+
curl_pipe_shell: {
|
|
273
|
+
id: 'MUADDIB-SHELL-004',
|
|
274
|
+
name: 'Curl Pipe to Shell',
|
|
275
|
+
severity: 'CRITICAL',
|
|
276
|
+
confidence: 'high',
|
|
277
|
+
description: 'Telechargement et execution via curl | sh dans un script shell',
|
|
278
|
+
references: ['https://blog.phylum.io/shai-hulud-npm-worm'],
|
|
279
|
+
mitre: 'T1105'
|
|
280
|
+
},
|
|
281
|
+
wget_chmod_exec: {
|
|
282
|
+
id: 'MUADDIB-SHELL-005',
|
|
283
|
+
name: 'Wget Download and Execute',
|
|
284
|
+
severity: 'CRITICAL',
|
|
285
|
+
confidence: 'high',
|
|
286
|
+
description: 'Telechargement et execution de binaire via wget + chmod',
|
|
287
|
+
references: ['https://blog.phylum.io/shai-hulud-npm-worm'],
|
|
288
|
+
mitre: 'T1105'
|
|
289
|
+
},
|
|
290
|
+
netcat_shell: {
|
|
291
|
+
id: 'MUADDIB-SHELL-006',
|
|
292
|
+
name: 'Netcat Shell',
|
|
293
|
+
severity: 'CRITICAL',
|
|
294
|
+
confidence: 'high',
|
|
295
|
+
description: 'Shell netcat detecte - acces distant non autorise',
|
|
296
|
+
references: ['https://attack.mitre.org/techniques/T1059/004/'],
|
|
297
|
+
mitre: 'T1059.004'
|
|
298
|
+
},
|
|
299
|
+
shred_home: {
|
|
300
|
+
id: 'MUADDIB-SHELL-007',
|
|
301
|
+
name: 'Home Directory Destruction',
|
|
302
|
+
severity: 'CRITICAL',
|
|
303
|
+
confidence: 'high',
|
|
304
|
+
description: 'Destruction de donnees (shred $HOME) - dead man\'s switch de Shai-Hulud',
|
|
305
|
+
references: ['https://www.wiz.io/blog/shai-hulud-npm-supply-chain-attack'],
|
|
306
|
+
mitre: 'T1485'
|
|
307
|
+
},
|
|
308
|
+
curl_exfiltration: {
|
|
309
|
+
id: 'MUADDIB-SHELL-008',
|
|
310
|
+
name: 'Data Exfiltration via Curl',
|
|
311
|
+
severity: 'HIGH',
|
|
312
|
+
confidence: 'high',
|
|
313
|
+
description: 'Exfiltration de donnees via curl POST',
|
|
314
|
+
references: ['https://attack.mitre.org/techniques/T1041/'],
|
|
315
|
+
mitre: 'T1041'
|
|
316
|
+
},
|
|
317
|
+
ssh_access: {
|
|
318
|
+
id: 'MUADDIB-SHELL-009',
|
|
319
|
+
name: 'SSH Key Access',
|
|
320
|
+
severity: 'HIGH',
|
|
321
|
+
confidence: 'high',
|
|
322
|
+
description: 'Acces aux cles SSH detecte',
|
|
323
|
+
references: ['https://attack.mitre.org/techniques/T1552/004/'],
|
|
324
|
+
mitre: 'T1552.004'
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
// AST additional patterns
|
|
328
|
+
possible_obfuscation: {
|
|
329
|
+
id: 'MUADDIB-OBF-002',
|
|
330
|
+
name: 'Possible Code Obfuscation',
|
|
331
|
+
severity: 'MEDIUM',
|
|
332
|
+
confidence: 'low',
|
|
333
|
+
description: 'Fichier potentiellement obfusque (parse echoue, code dense)',
|
|
334
|
+
references: ['https://attack.mitre.org/techniques/T1027/'],
|
|
335
|
+
mitre: 'T1027'
|
|
336
|
+
},
|
|
337
|
+
dangerous_call_function: {
|
|
338
|
+
id: 'MUADDIB-AST-005',
|
|
339
|
+
name: 'new Function() Constructor',
|
|
340
|
+
severity: 'HIGH',
|
|
341
|
+
confidence: 'high',
|
|
342
|
+
description: 'Appel new Function() detecte - equivalent a eval()',
|
|
343
|
+
references: ['https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/Function'],
|
|
344
|
+
mitre: 'T1059.007'
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
// GitHub Actions patterns
|
|
348
|
+
shai_hulud_backdoor: {
|
|
349
|
+
id: 'MUADDIB-GHA-001',
|
|
350
|
+
name: 'Shai-Hulud GitHub Actions Backdoor',
|
|
351
|
+
severity: 'CRITICAL',
|
|
352
|
+
confidence: 'high',
|
|
353
|
+
description: 'Backdoor Shai-Hulud dans GitHub Actions via workflow discussion.yaml sur self-hosted runner',
|
|
354
|
+
references: ['https://www.wiz.io/blog/shai-hulud-npm-supply-chain-attack'],
|
|
355
|
+
mitre: 'T1195.002'
|
|
356
|
+
},
|
|
357
|
+
workflow_injection: {
|
|
358
|
+
id: 'MUADDIB-GHA-002',
|
|
359
|
+
name: 'GitHub Actions Workflow Injection',
|
|
360
|
+
severity: 'HIGH',
|
|
361
|
+
confidence: 'high',
|
|
362
|
+
description: 'Injection potentielle dans GitHub Actions via input non sanitise sur self-hosted runner',
|
|
363
|
+
references: ['https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions'],
|
|
364
|
+
mitre: 'T1195.002'
|
|
365
|
+
},
|
|
196
366
|
};
|
|
197
367
|
|
|
198
368
|
function getRule(type) {
|
package/src/safe-install.js
CHANGED
|
@@ -13,7 +13,7 @@ const NPM_PACKAGE_REGEX = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*
|
|
|
13
13
|
function isValidPackageName(pkgName) {
|
|
14
14
|
// Remove version if present
|
|
15
15
|
const nameOnly = pkgName.split('@').filter((p, i) => i === 0 || !p.match(/^\d/)).join('@');
|
|
16
|
-
return NPM_PACKAGE_REGEX.test(nameOnly)
|
|
16
|
+
return NPM_PACKAGE_REGEX.test(nameOnly);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
// Known safe packages that legitimately use "suspicious" patterns
|
|
@@ -164,8 +164,8 @@ async function scanPackageRecursive(pkg, depth = 0, maxDepth = 3) {
|
|
|
164
164
|
}
|
|
165
165
|
pkgInfo = JSON.parse(result.stdout);
|
|
166
166
|
} catch {
|
|
167
|
-
if (depth === 0) console.log(`[!]
|
|
168
|
-
return { safe:
|
|
167
|
+
if (depth === 0) console.log(`[!] Invalid npm response for ${pkgName}`);
|
|
168
|
+
return { safe: false, package: pkgName, reason: 'invalid_npm_response', source: 'npm-registry', description: 'Invalid or unparseable npm response', depth };
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
// Scan the dependencies
|
package/src/scanner/ast.js
CHANGED
|
@@ -8,7 +8,6 @@ const EXCLUDED_FILES = [
|
|
|
8
8
|
'src/scanner/ast.js',
|
|
9
9
|
'src/scanner/shell.js',
|
|
10
10
|
'src/scanner/package.js',
|
|
11
|
-
'src/ioc/feeds.js',
|
|
12
11
|
'src/response/playbooks.js'
|
|
13
12
|
];
|
|
14
13
|
|
|
@@ -25,6 +24,12 @@ const SENSITIVE_STRINGS = [
|
|
|
25
24
|
'Goldox-T3chs'
|
|
26
25
|
];
|
|
27
26
|
|
|
27
|
+
// Env var keywords to detect sensitive environment access (separate from SENSITIVE_STRINGS)
|
|
28
|
+
const ENV_SENSITIVE_VARS = [
|
|
29
|
+
'TOKEN', 'SECRET', 'KEY', 'PASSWORD', 'CREDENTIAL',
|
|
30
|
+
'AUTH', 'NPM', 'AWS', 'GITHUB', 'SSH', 'NPMRC'
|
|
31
|
+
];
|
|
32
|
+
|
|
28
33
|
// Strings that are NOT suspicious
|
|
29
34
|
const SAFE_STRINGS = [
|
|
30
35
|
'api.github.com',
|
|
@@ -130,7 +135,7 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
130
135
|
node.object?.property?.name === 'env'
|
|
131
136
|
) {
|
|
132
137
|
const envVar = node.property?.name;
|
|
133
|
-
if (envVar &&
|
|
138
|
+
if (envVar && ENV_SENSITIVE_VARS.some(s => envVar.toUpperCase().includes(s))) {
|
|
134
139
|
threats.push({
|
|
135
140
|
type: 'env_access',
|
|
136
141
|
severity: 'HIGH',
|
|
@@ -47,6 +47,17 @@ async function scanDependencies(targetPath) {
|
|
|
47
47
|
|
|
48
48
|
const packages = listPackages(nodeModulesPath);
|
|
49
49
|
|
|
50
|
+
// Pre-compute files and markers lists once (outside the loop)
|
|
51
|
+
const suspiciousFilesRaw = iocs.filesSet || iocs.files || [];
|
|
52
|
+
const filesToCheck = suspiciousFilesRaw instanceof Set
|
|
53
|
+
? Array.from(suspiciousFilesRaw)
|
|
54
|
+
: suspiciousFilesRaw;
|
|
55
|
+
|
|
56
|
+
const markersRaw = iocs.markersSet || iocs.markers || [];
|
|
57
|
+
const markersToCheck = markersRaw instanceof Set
|
|
58
|
+
? Array.from(markersRaw)
|
|
59
|
+
: markersRaw;
|
|
60
|
+
|
|
50
61
|
for (const pkg of packages) {
|
|
51
62
|
// D'abord verifier la whitelist des packages rehabilites
|
|
52
63
|
const rehabStatus = checkRehabilitatedPackage(pkg.name, pkg.version);
|
|
@@ -107,12 +118,6 @@ async function scanDependencies(targetPath) {
|
|
|
107
118
|
if (TRUSTED_PACKAGES.includes(pkg.name)) continue;
|
|
108
119
|
|
|
109
120
|
// Verifie les fichiers suspects (IOCs caches) avec whitelist
|
|
110
|
-
// Utilise Set ou Array selon la structure disponible
|
|
111
|
-
const suspiciousFiles = iocs.filesSet || iocs.files || [];
|
|
112
|
-
const filesToCheck = suspiciousFiles instanceof Set
|
|
113
|
-
? Array.from(suspiciousFiles)
|
|
114
|
-
: suspiciousFiles;
|
|
115
|
-
|
|
116
121
|
for (const suspFile of filesToCheck) {
|
|
117
122
|
// Skip si fichier legitime pour ce package
|
|
118
123
|
if (SAFE_FILES[suspFile] && SAFE_FILES[suspFile].includes(pkg.name)) {
|
|
@@ -137,12 +142,6 @@ async function scanDependencies(targetPath) {
|
|
|
137
142
|
const pkgContent = fs.readFileSync(pkgJsonPath, 'utf8');
|
|
138
143
|
|
|
139
144
|
// Verifie les marqueurs Shai-Hulud
|
|
140
|
-
// Utilise Set ou Array selon la structure disponible
|
|
141
|
-
const markers = iocs.markersSet || iocs.markers || [];
|
|
142
|
-
const markersToCheck = markers instanceof Set
|
|
143
|
-
? Array.from(markers)
|
|
144
|
-
: markers;
|
|
145
|
-
|
|
146
145
|
for (const marker of markersToCheck) {
|
|
147
146
|
if (pkgContent.includes(marker)) {
|
|
148
147
|
threats.push({
|
|
@@ -10,10 +10,23 @@ function scanGitHubActions(targetPath) {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
const files = fs.readdirSync(workflowsPath);
|
|
13
|
-
|
|
13
|
+
|
|
14
14
|
for (const file of files) {
|
|
15
15
|
const filePath = path.join(workflowsPath, file);
|
|
16
|
-
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const stat = fs.statSync(filePath);
|
|
19
|
+
if (!stat.isFile()) continue;
|
|
20
|
+
} catch {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let content;
|
|
25
|
+
try {
|
|
26
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
27
|
+
} catch {
|
|
28
|
+
continue; // Skip unreadable files
|
|
29
|
+
}
|
|
17
30
|
|
|
18
31
|
// Détection du backdoor Shai-Hulud discussion.yaml
|
|
19
32
|
if (file === 'discussion.yaml' || file === 'discussion.yml') {
|
package/src/scanner/hash.js
CHANGED
|
@@ -2,13 +2,11 @@ const fs = require('fs');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const nodeCrypto = require('crypto');
|
|
4
4
|
const { loadCachedIOCs } = require('../ioc/updater.js');
|
|
5
|
+
const { findFiles } = require('../utils.js');
|
|
5
6
|
|
|
6
7
|
// Hash cache: filePath -> { hash, mtime }
|
|
7
8
|
const hashCache = new Map();
|
|
8
9
|
|
|
9
|
-
// Depth limit to avoid infinite recursion
|
|
10
|
-
const MAX_DEPTH = 50;
|
|
11
|
-
|
|
12
10
|
async function scanHashes(targetPath) {
|
|
13
11
|
const threats = [];
|
|
14
12
|
const iocs = loadCachedIOCs();
|
|
@@ -28,10 +26,8 @@ async function scanHashes(targetPath) {
|
|
|
28
26
|
return threats;
|
|
29
27
|
}
|
|
30
28
|
|
|
31
|
-
//
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
const jsFiles = findAllJsFiles(nodeModulesPath, [], visitedInodes, 0);
|
|
29
|
+
// Use shared findFiles utility (with symlink protection and depth limit)
|
|
30
|
+
const jsFiles = findFiles(nodeModulesPath, { extensions: ['.js'], excludedDirs: [], maxDepth: 50 });
|
|
35
31
|
|
|
36
32
|
for (const file of jsFiles) {
|
|
37
33
|
const hash = computeHashCached(file);
|
|
@@ -88,77 +84,6 @@ function computeHash(filePath) {
|
|
|
88
84
|
return nodeCrypto.createHash('sha256').update(content).digest('hex');
|
|
89
85
|
}
|
|
90
86
|
|
|
91
|
-
/**
|
|
92
|
-
* Recursive search for JS files with symlink protection
|
|
93
|
-
* @param {string} dir - Directory to scan
|
|
94
|
-
* @param {string[]} results - Accumulator array
|
|
95
|
-
* @param {Set<number>} visitedInodes - Already visited inodes
|
|
96
|
-
* @param {number} depth - Current depth
|
|
97
|
-
* @returns {string[]} List of .js files
|
|
98
|
-
*/
|
|
99
|
-
function findAllJsFiles(dir, results = [], visitedInodes = new Set(), depth = 0) {
|
|
100
|
-
// Protection against infinite recursion
|
|
101
|
-
if (depth > MAX_DEPTH) {
|
|
102
|
-
return results;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if (!fs.existsSync(dir)) return results;
|
|
106
|
-
|
|
107
|
-
try {
|
|
108
|
-
const items = fs.readdirSync(dir);
|
|
109
|
-
|
|
110
|
-
for (const item of items) {
|
|
111
|
-
const fullPath = path.join(dir, item);
|
|
112
|
-
|
|
113
|
-
try {
|
|
114
|
-
// Use lstatSync to detect symlinks WITHOUT following them
|
|
115
|
-
const lstat = fs.lstatSync(fullPath);
|
|
116
|
-
|
|
117
|
-
// Check if it's a symlink
|
|
118
|
-
if (lstat.isSymbolicLink()) {
|
|
119
|
-
// Resolve the symlink and check the target
|
|
120
|
-
try {
|
|
121
|
-
const realPath = fs.realpathSync(fullPath);
|
|
122
|
-
const realStat = fs.statSync(realPath);
|
|
123
|
-
|
|
124
|
-
// Check if we already visited this inode (avoids loops)
|
|
125
|
-
if (visitedInodes.has(realStat.ino)) {
|
|
126
|
-
continue; // Loop detected, skip
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// If it's a directory, traverse it
|
|
130
|
-
if (realStat.isDirectory()) {
|
|
131
|
-
visitedInodes.add(realStat.ino);
|
|
132
|
-
findAllJsFiles(realPath, results, visitedInodes, depth + 1);
|
|
133
|
-
} else if (item.endsWith('.js')) {
|
|
134
|
-
visitedInodes.add(realStat.ino);
|
|
135
|
-
results.push(realPath);
|
|
136
|
-
}
|
|
137
|
-
} catch {
|
|
138
|
-
// Broken or inaccessible symlink, ignore
|
|
139
|
-
}
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Mark the inode as visited
|
|
144
|
-
visitedInodes.add(lstat.ino);
|
|
145
|
-
|
|
146
|
-
if (lstat.isDirectory()) {
|
|
147
|
-
findAllJsFiles(fullPath, results, visitedInodes, depth + 1);
|
|
148
|
-
} else if (item.endsWith('.js')) {
|
|
149
|
-
results.push(fullPath);
|
|
150
|
-
}
|
|
151
|
-
} catch {
|
|
152
|
-
// Ignore permission errors
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
} catch {
|
|
156
|
-
// Ignore directory read errors
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return results;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
87
|
/**
|
|
163
88
|
* Clears the hash cache (useful for tests)
|
|
164
89
|
*/
|
|
@@ -9,7 +9,12 @@ function detectObfuscation(targetPath) {
|
|
|
9
9
|
const files = findFiles(targetPath, { extensions: ['.js'], excludedDirs: OBF_EXCLUDED_DIRS });
|
|
10
10
|
|
|
11
11
|
for (const file of files) {
|
|
12
|
-
|
|
12
|
+
let content;
|
|
13
|
+
try {
|
|
14
|
+
content = fs.readFileSync(file, 'utf8');
|
|
15
|
+
} catch {
|
|
16
|
+
continue; // Skip unreadable files
|
|
17
|
+
}
|
|
13
18
|
const relativePath = path.relative(targetPath, file);
|
|
14
19
|
|
|
15
20
|
const signals = [];
|
package/src/scanner/package.js
CHANGED
|
@@ -71,14 +71,28 @@ async function scanPackageJson(targetPath) {
|
|
|
71
71
|
};
|
|
72
72
|
|
|
73
73
|
for (const [depName, depVersion] of Object.entries(allDeps)) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
74
|
+
let malicious = null;
|
|
75
|
+
|
|
76
|
+
// Use optimized Map for O(1) lookup if available
|
|
77
|
+
if (iocs.packagesMap) {
|
|
78
|
+
if (iocs.wildcardPackages && iocs.wildcardPackages.has(depName)) {
|
|
79
|
+
const pkgList = iocs.packagesMap.get(depName);
|
|
80
|
+
malicious = pkgList ? pkgList.find(p => p.version === '*') : null;
|
|
81
|
+
} else if (iocs.packagesMap.has(depName)) {
|
|
82
|
+
const pkgList = iocs.packagesMap.get(depName);
|
|
83
|
+
const cleanVersion = depVersion.replace(/^[^0-9]*/, '');
|
|
84
|
+
malicious = pkgList.find(p => p.version === cleanVersion || p.version === depVersion);
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
// Fallback: linear search for compatibility
|
|
88
|
+
malicious = iocs.packages.find(p => {
|
|
89
|
+
if (p.name !== depName) return false;
|
|
90
|
+
if (p.version === '*') return true;
|
|
91
|
+
const cleanVersion = depVersion.replace(/^[^0-9]*/, '');
|
|
92
|
+
if (p.version === cleanVersion || p.version === depVersion) return true;
|
|
93
|
+
return false;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
82
96
|
|
|
83
97
|
if (malicious) {
|
|
84
98
|
threats.push({
|
package/src/scanner/typosquat.js
CHANGED
|
@@ -70,6 +70,9 @@ const WHITELIST = [
|
|
|
70
70
|
];
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
// Pre-computed lowercase versions for performance
|
|
74
|
+
const POPULAR_PACKAGES_LOWER = POPULAR_PACKAGES.map(p => p.toLowerCase());
|
|
75
|
+
|
|
73
76
|
// Seuil minimum de longueur pour eviter faux positifs
|
|
74
77
|
const MIN_PACKAGE_LENGTH = 4;
|
|
75
78
|
|
|
@@ -125,15 +128,18 @@ function findTyposquatMatch(name) {
|
|
|
125
128
|
// Ignore les packages avec suffixes legitimes courants
|
|
126
129
|
if (isLegitimateVariant(nameLower)) return null;
|
|
127
130
|
|
|
128
|
-
for (
|
|
131
|
+
for (let i = 0; i < POPULAR_PACKAGES.length; i++) {
|
|
132
|
+
const popularLower = POPULAR_PACKAGES_LOWER[i];
|
|
133
|
+
const popular = POPULAR_PACKAGES[i];
|
|
134
|
+
|
|
129
135
|
// Ignore si c'est exactement le meme
|
|
130
|
-
if (nameLower ===
|
|
136
|
+
if (nameLower === popularLower) continue;
|
|
131
137
|
|
|
132
138
|
// Ignore si le package populaire est trop court
|
|
133
139
|
if (popular.length < MIN_PACKAGE_LENGTH) continue;
|
|
134
140
|
|
|
135
|
-
const distance = levenshteinDistance(nameLower,
|
|
136
|
-
|
|
141
|
+
const distance = levenshteinDistance(nameLower, popularLower);
|
|
142
|
+
|
|
137
143
|
// Distance de 1 = tres suspect (une seule lettre de difference)
|
|
138
144
|
if (distance === 1) {
|
|
139
145
|
return {
|
package/src/utils.js
CHANGED
|
@@ -49,7 +49,8 @@ function isDevFile(relativePath) {
|
|
|
49
49
|
* @param {string[]} [options.excludedDirs=EXCLUDED_DIRS] - Dirs to skip
|
|
50
50
|
* @param {number} [options.maxDepth=100] - Max recursion depth
|
|
51
51
|
* @param {string[]} [options.results=[]] - Accumulator (internal)
|
|
52
|
-
* @param {Set} [options.visitedInodes=new Set()] - Symlink loop detection
|
|
52
|
+
* @param {Set} [options.visitedInodes=new Set()] - Symlink loop detection (note: inode tracking
|
|
53
|
+
* is unreliable on Windows where stat.ino may be 0; maxDepth serves as fallback protection)
|
|
53
54
|
* @param {number} [options.depth=0] - Current depth (internal)
|
|
54
55
|
* @returns {string[]} List of matching file paths
|
|
55
56
|
*/
|
package/src/watch.js
CHANGED
|
@@ -20,11 +20,16 @@ function watch(targetPath) {
|
|
|
20
20
|
|
|
21
21
|
for (const watchPath of watchPaths) {
|
|
22
22
|
if (fs.existsSync(watchPath)) {
|
|
23
|
+
// Note: recursive option only works on macOS and Windows.
|
|
24
|
+
// On Linux, only top-level changes in watchPath are detected.
|
|
25
|
+
if (process.platform === 'linux' && watchPath.includes('node_modules')) {
|
|
26
|
+
console.log(`[WARN] recursive watch not supported on Linux for ${watchPath}`);
|
|
27
|
+
}
|
|
23
28
|
fs.watch(watchPath, { recursive: true }, (eventType, filename) => {
|
|
24
29
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
25
30
|
|
|
26
31
|
debounceTimer = setTimeout(() => {
|
|
27
|
-
console.log(`\n[CHANGE] ${filename} modifie`);
|
|
32
|
+
console.log(`\n[CHANGE] ${filename || 'unknown file'} modifie`);
|
|
28
33
|
console.log('[MUADDIB] Re-scan...\n');
|
|
29
34
|
run(targetPath, { json: false }).catch(err => console.error('[ERROR]', err.message));
|
|
30
35
|
}, 1000);
|