muaddib-scanner 1.3.1 → 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.3.1",
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
@@ -22,22 +22,30 @@ async function startDaemon(options = {}) {
22
22
 
23
23
  // Surveille le dossier courant
24
24
  const cwd = process.cwd();
25
- watchDirectory(cwd);
25
+ const watchers = watchDirectory(cwd);
26
26
 
27
- // Garde le processus actif
28
- process.on('SIGINT', () => {
29
- console.log('\n[DAEMON] Arret...');
30
- isRunning = false;
31
- process.exit(0);
27
+ // Cleanup function to close all watchers
28
+ function cleanup() {
29
+ for (const w of watchers) {
30
+ try { w.close(); } catch { /* ignore */ }
31
+ }
32
+ }
33
+
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
+ });
32
42
  });
33
43
 
34
- // Boucle infinie
35
- while (isRunning) {
36
- await sleep(1000);
37
- }
44
+ process.exit(0);
38
45
  }
39
46
 
40
47
  function watchDirectory(dir) {
48
+ const watchers = [];
41
49
  const nodeModulesPath = path.join(dir, 'node_modules');
42
50
  const packageLockPath = path.join(dir, 'package-lock.json');
43
51
  const yarnLockPath = path.join(dir, 'yarn.lock');
@@ -46,21 +54,23 @@ function watchDirectory(dir) {
46
54
 
47
55
  // Surveille package-lock.json
48
56
  if (fs.existsSync(packageLockPath)) {
49
- watchFile(packageLockPath, dir);
57
+ const w = watchFile(packageLockPath, dir);
58
+ if (w) watchers.push(w);
50
59
  }
51
60
 
52
61
  // Surveille yarn.lock
53
62
  if (fs.existsSync(yarnLockPath)) {
54
- watchFile(yarnLockPath, dir);
63
+ const w = watchFile(yarnLockPath, dir);
64
+ if (w) watchers.push(w);
55
65
  }
56
66
 
57
67
  // Surveille node_modules
58
68
  if (fs.existsSync(nodeModulesPath)) {
59
- watchNodeModules(nodeModulesPath, dir);
69
+ watchers.push(watchNodeModules(nodeModulesPath, dir));
60
70
  }
61
71
 
62
72
  // Surveille la creation de node_modules
63
- fs.watch(dir, (eventType, filename) => {
73
+ const dirWatcher = fs.watch(dir, (eventType, filename) => {
64
74
  if (filename === 'node_modules' && eventType === 'rename') {
65
75
  const nmPath = path.join(dir, 'node_modules');
66
76
  if (fs.existsSync(nmPath)) {
@@ -73,25 +83,37 @@ function watchDirectory(dir) {
73
83
  triggerScan(dir);
74
84
  }
75
85
  });
86
+ watchers.push(dirWatcher);
87
+
88
+ return watchers;
76
89
  }
77
90
 
78
91
  function watchFile(filePath, projectDir) {
79
- 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
+ }
80
98
 
81
- fs.watch(filePath, (eventType) => {
99
+ return fs.watch(filePath, (eventType) => {
82
100
  if (eventType === 'change') {
83
- const currentMtime = fs.statSync(filePath).mtime.getTime();
84
- if (currentMtime !== lastMtime) {
85
- lastMtime = currentMtime;
86
- console.log(`[DAEMON] ${path.basename(filePath)} modifie`);
87
- triggerScan(projectDir);
101
+ try {
102
+ const currentMtime = fs.statSync(filePath).mtime.getTime();
103
+ if (currentMtime !== lastMtime) {
104
+ lastMtime = currentMtime;
105
+ console.log(`[DAEMON] ${path.basename(filePath)} modifie`);
106
+ triggerScan(projectDir);
107
+ }
108
+ } catch {
109
+ // File may have been deleted between watch trigger and stat
88
110
  }
89
111
  }
90
112
  });
91
113
  }
92
114
 
93
115
  function watchNodeModules(nodeModulesPath, projectDir) {
94
- fs.watch(nodeModulesPath, { recursive: true }, (eventType, filename) => {
116
+ return fs.watch(nodeModulesPath, { recursive: true }, (eventType, filename) => {
95
117
  if (filename && filename.includes('package.json')) {
96
118
  console.log(`[DAEMON] Nouveau package detecte: ${filename}`);
97
119
  triggerScan(projectDir);
@@ -133,8 +155,4 @@ function triggerScan(dir) {
133
155
  }, 3000);
134
156
  }
135
157
 
136
- function sleep(ms) {
137
- return new Promise(resolve => setTimeout(resolve, ms));
138
- }
139
-
140
158
  module.exports = { startDaemon };
package/src/diff.js CHANGED
@@ -4,6 +4,9 @@ const path = require('path');
4
4
  const fs = require('fs');
5
5
  const os = require('os');
6
6
 
7
+ // Only allow safe characters in git refs (prevents command injection)
8
+ const SAFE_REF_REGEX = /^[a-zA-Z0-9._\-/~^@{}:]+$/;
9
+
7
10
  /**
8
11
  * Get the list of commits/tags for comparison suggestions
9
12
  */
@@ -61,6 +64,9 @@ function getCurrentCommit(targetPath) {
61
64
  * Resolve a ref (tag, branch, commit) to a commit hash
62
65
  */
63
66
  function resolveRef(targetPath, ref) {
67
+ if (!SAFE_REF_REGEX.test(ref)) {
68
+ return null;
69
+ }
64
70
  try {
65
71
  return execSync(`git rev-parse ${ref}`, {
66
72
  cwd: targetPath,
@@ -92,11 +98,16 @@ function hasUncommittedChanges(targetPath) {
92
98
  * Create a temporary copy of the repo at a specific commit
93
99
  */
94
100
  function createTempCopyAtCommit(targetPath, commitHash) {
101
+ // Sanitize commitHash (should be a hex hash from resolveRef)
102
+ if (!SAFE_REF_REGEX.test(commitHash)) {
103
+ throw new Error('Invalid commit hash');
104
+ }
105
+
95
106
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'muaddib-diff-'));
96
107
 
97
108
  try {
98
- // Clone the repo to temp directory
99
- execSync(`git clone --quiet "${targetPath}" "${tempDir}"`, {
109
+ // Clone the repo to temp directory (use -- to separate paths from options)
110
+ execSync(`git clone --quiet -- "${targetPath}" "${tempDir}"`, {
100
111
  stdio: ['pipe', 'pipe', 'pipe']
101
112
  });
102
113
 
@@ -107,10 +118,11 @@ function createTempCopyAtCommit(targetPath, commitHash) {
107
118
  });
108
119
 
109
120
  // Install dependencies if package.json exists
121
+ // --ignore-scripts prevents execution of malicious preinstall/postinstall
110
122
  const packageJsonPath = path.join(tempDir, 'package.json');
111
123
  if (fs.existsSync(packageJsonPath)) {
112
124
  try {
113
- execSync('npm install --quiet --no-audit --no-fund', {
125
+ execSync('npm install --quiet --no-audit --no-fund --ignore-scripts', {
114
126
  cwd: tempDir,
115
127
  stdio: ['pipe', 'pipe', 'pipe'],
116
128
  timeout: 60000
@@ -192,25 +204,20 @@ function compareThreats(oldThreats, newThreats) {
192
204
  * Run scan and capture results (without console output)
193
205
  */
194
206
  async function runSilentScan(targetPath, options = {}) {
195
- // Capture console.log output
196
207
  const originalLog = console.log;
197
208
  const logs = [];
198
- console.log = (...args) => logs.push(args.join(' '));
199
-
200
209
  try {
210
+ console.log = (...args) => logs.push(args.join(' '));
201
211
  await run(targetPath, { ...options, json: true });
212
+ } finally {
202
213
  console.log = originalLog;
214
+ }
203
215
 
204
- // Parse the JSON output
205
- const jsonOutput = logs.join('\n');
206
- try {
207
- return JSON.parse(jsonOutput);
208
- } catch {
209
- return { threats: [], summary: { total: 0 } };
210
- }
211
- } catch (err) {
212
- console.log = originalLog;
213
- throw err;
216
+ const jsonOutput = logs.join('\n');
217
+ try {
218
+ return JSON.parse(jsonOutput);
219
+ } catch {
220
+ return { threats: [], summary: { total: 0 } };
214
221
  }
215
222
  }
216
223
 
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
@@ -32,7 +32,10 @@ const SEVERITY_WEIGHTS = {
32
32
 
33
33
  // MEDIUM: Potential threats (suspicious patterns, light obfuscation)
34
34
  // Moderate impact, requires investigation but not necessarily malicious
35
- MEDIUM: 3
35
+ MEDIUM: 3,
36
+
37
+ // LOW: Informational findings, minimal impact on risk score
38
+ LOW: 1
36
39
  };
37
40
 
38
41
  // Thresholds for determining the overall risk level
@@ -52,12 +55,12 @@ function scanParanoid(targetPath) {
52
55
  const threats = [];
53
56
 
54
57
  function scanFile(filePath) {
55
- try {
56
- const content = fs.readFileSync(filePath, 'utf8');
58
+ try {
59
+ const content = fs.readFileSync(filePath, 'utf8');
57
60
 
58
61
  // Ignore URLs (they often contain patterns like .git)
59
62
  const contentWithoutUrls = content.replace(/https?:\/\/[^\s"']+/g, '');
60
-
63
+
61
64
  for (const [, rule] of Object.entries(PARANOID_RULES)) {
62
65
  for (const pattern of rule.patterns) {
63
66
  if (contentWithoutUrls.includes(pattern)) {
@@ -75,15 +78,18 @@ function scanParanoid(targetPath) {
75
78
  // Ignore read errors
76
79
  }
77
80
  }
78
-
81
+
79
82
  function walkDir(dir) {
80
83
  const excluded = ['node_modules', '.git', 'test', 'tests', 'src', 'vscode-extension', '.muaddib-cache', 'data', 'iocs'];
81
84
  try {
82
85
  const files = fs.readdirSync(dir);
83
86
  for (const file of files) {
84
87
  const fullPath = path.join(dir, file);
85
- const stat = fs.statSync(fullPath);
86
-
88
+ // Use lstatSync to avoid following symlinks
89
+ const stat = fs.lstatSync(fullPath);
90
+
91
+ if (stat.isSymbolicLink()) continue;
92
+
87
93
  if (stat.isDirectory()) {
88
94
  if (!excluded.includes(file)) {
89
95
  walkDir(fullPath);
@@ -96,7 +102,7 @@ function scanParanoid(targetPath) {
96
102
  // Ignore walk errors
97
103
  }
98
104
  }
99
-
105
+
100
106
  walkDir(targetPath);
101
107
  return threats;
102
108
  }
@@ -162,11 +168,13 @@ async function run(targetPath, options = {}) {
162
168
  const criticalCount = threats.filter(t => t.severity === 'CRITICAL').length;
163
169
  const highCount = threats.filter(t => t.severity === 'HIGH').length;
164
170
  const mediumCount = threats.filter(t => t.severity === 'MEDIUM').length;
171
+ const lowCount = threats.filter(t => t.severity === 'LOW').length;
165
172
 
166
173
  let riskScore = 0;
167
174
  riskScore += criticalCount * SEVERITY_WEIGHTS.CRITICAL;
168
175
  riskScore += highCount * SEVERITY_WEIGHTS.HIGH;
169
176
  riskScore += mediumCount * SEVERITY_WEIGHTS.MEDIUM;
177
+ riskScore += lowCount * SEVERITY_WEIGHTS.LOW;
170
178
  riskScore = Math.min(MAX_RISK_SCORE, riskScore);
171
179
 
172
180
  const riskLevel = riskScore >= RISK_THRESHOLDS.CRITICAL ? 'CRITICAL'
@@ -184,6 +192,7 @@ async function run(targetPath, options = {}) {
184
192
  critical: criticalCount,
185
193
  high: highCount,
186
194
  medium: mediumCount,
195
+ low: lowCount,
187
196
  riskScore: riskScore,
188
197
  riskLevel: riskLevel
189
198
  }