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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Supply-chain threat detection & response for npm",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/daemon.js CHANGED
@@ -31,18 +31,17 @@ async function startDaemon(options = {}) {
31
31
  }
32
32
  }
33
33
 
34
- // Garde le processus actif
35
- process.on('SIGINT', () => {
36
- console.log('\n[DAEMON] Arret...');
37
- isRunning = false;
38
- cleanup();
39
- process.exit(0);
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
- // Boucle infinie
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
- watchers.push(watchFile(packageLockPath, dir));
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
- watchers.push(watchFile(yarnLockPath, dir));
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 = fs.statSync(filePath).mtime.getTime();
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: v1.2.7
143
+ rev: ${PKG_VERSION}
135
144
  hooks:
136
145
  - id: ${hookId}
137
146
  `;
package/src/index.js CHANGED
@@ -192,6 +192,7 @@ async function run(targetPath, options = {}) {
192
192
  critical: criticalCount,
193
193
  high: highCount,
194
194
  medium: mediumCount,
195
+ low: lowCount,
195
196
  riskScore: riskScore,
196
197
  riskLevel: riskLevel
197
198
  }
@@ -17564,7 +17564,7 @@
17564
17564
  "pigS3cr3ts.json"
17565
17565
  ],
17566
17566
  "files": [],
17567
- "updated": "2026-02-09T21:29:10.442Z",
17567
+ "updated": "2026-02-09T22:02:04.174Z",
17568
17568
  "sources": [
17569
17569
  "shai-hulud-detector",
17570
17570
  "datadog-consolidated",
@@ -111,7 +111,9 @@ function loadStaticIOCs() {
111
111
  return { socket: [], phylum: [], npmRemoved: [] };
112
112
  }
113
113
 
114
- function fetchJSON(url, options = {}) {
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
 
@@ -72,58 +72,93 @@ async function updateIOCs() {
72
72
  }
73
73
 
74
74
  /**
75
- * Merge source IOCs into target without duplicates
76
- * Returns number of packages added
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 exists = target.packages.find(function(p) {
84
- return p.name === pkg.name && p.version === pkg.version;
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.hashes.includes(hash)) {
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.markers.includes(marker)) {
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.files.includes(file)) {
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
- fetchUrl(res.headers.location, redirectCount + 1).then(resolve).catch(reject);
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) {
@@ -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
- if (!iocs.packages.find(x => x.name === p.name && x.version === p.version)) {
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
- if (!iocs.packages.find(x => x.name === p.name && x.version === p.version)) {
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 (!iocs.files.find(x => x.name === fileName)) {
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 (!iocs.hashes.find(x => x.sha256 === hash)) {
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 (!iocs.markers.find(x => x.pattern === pattern)) {
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 (!iocs.hashes.find(x => x.sha256 === h.sha256)) {
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 (!iocs.markers.find(x => x.pattern === m.pattern)) {
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 (!iocs.files.find(x => x.name === f.name)) {
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
 
@@ -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) {
@@ -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) || (nameOnly.startsWith('@') && 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(`[!] Package ${pkgName} not found on npm`);
168
- return { safe: true };
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
@@ -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 && SENSITIVE_STRINGS.some(s => envVar.includes(s.replace('.', '')))) {
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
- const content = fs.readFileSync(filePath, 'utf8');
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') {
@@ -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
- // Set to track visited inodes (avoids symlink loops)
32
- const visitedInodes = new Set();
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
- const content = fs.readFileSync(file, 'utf8');
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 = [];
@@ -71,14 +71,28 @@ async function scanPackageJson(targetPath) {
71
71
  };
72
72
 
73
73
  for (const [depName, depVersion] of Object.entries(allDeps)) {
74
- const malicious = iocs.packages.find(p => {
75
- if (p.name !== depName) return false;
76
- if (p.version === '*') return true;
77
- // Exact version match only (strip semver range prefixes ^~>=)
78
- const cleanVersion = depVersion.replace(/^[^0-9]*/, '');
79
- if (p.version === cleanVersion || p.version === depVersion) return true;
80
- return false;
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({
@@ -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 (const popular of POPULAR_PACKAGES) {
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 === popular.toLowerCase()) continue;
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, popular.toLowerCase());
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);