muaddib-scanner 1.3.1 → 1.4.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
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,12 +22,20 @@ 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
+
27
+ // Cleanup function to close all watchers
28
+ function cleanup() {
29
+ for (const w of watchers) {
30
+ try { w.close(); } catch { /* ignore */ }
31
+ }
32
+ }
26
33
 
27
34
  // Garde le processus actif
28
35
  process.on('SIGINT', () => {
29
36
  console.log('\n[DAEMON] Arret...');
30
37
  isRunning = false;
38
+ cleanup();
31
39
  process.exit(0);
32
40
  });
33
41
 
@@ -38,6 +46,7 @@ async function startDaemon(options = {}) {
38
46
  }
39
47
 
40
48
  function watchDirectory(dir) {
49
+ const watchers = [];
41
50
  const nodeModulesPath = path.join(dir, 'node_modules');
42
51
  const packageLockPath = path.join(dir, 'package-lock.json');
43
52
  const yarnLockPath = path.join(dir, 'yarn.lock');
@@ -46,21 +55,21 @@ function watchDirectory(dir) {
46
55
 
47
56
  // Surveille package-lock.json
48
57
  if (fs.existsSync(packageLockPath)) {
49
- watchFile(packageLockPath, dir);
58
+ watchers.push(watchFile(packageLockPath, dir));
50
59
  }
51
60
 
52
61
  // Surveille yarn.lock
53
62
  if (fs.existsSync(yarnLockPath)) {
54
- watchFile(yarnLockPath, dir);
63
+ watchers.push(watchFile(yarnLockPath, dir));
55
64
  }
56
65
 
57
66
  // Surveille node_modules
58
67
  if (fs.existsSync(nodeModulesPath)) {
59
- watchNodeModules(nodeModulesPath, dir);
68
+ watchers.push(watchNodeModules(nodeModulesPath, dir));
60
69
  }
61
70
 
62
71
  // Surveille la creation de node_modules
63
- fs.watch(dir, (eventType, filename) => {
72
+ const dirWatcher = fs.watch(dir, (eventType, filename) => {
64
73
  if (filename === 'node_modules' && eventType === 'rename') {
65
74
  const nmPath = path.join(dir, 'node_modules');
66
75
  if (fs.existsSync(nmPath)) {
@@ -73,25 +82,32 @@ function watchDirectory(dir) {
73
82
  triggerScan(dir);
74
83
  }
75
84
  });
85
+ watchers.push(dirWatcher);
86
+
87
+ return watchers;
76
88
  }
77
89
 
78
90
  function watchFile(filePath, projectDir) {
79
91
  let lastMtime = fs.statSync(filePath).mtime.getTime();
80
92
 
81
- fs.watch(filePath, (eventType) => {
93
+ return fs.watch(filePath, (eventType) => {
82
94
  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);
95
+ try {
96
+ const currentMtime = fs.statSync(filePath).mtime.getTime();
97
+ if (currentMtime !== lastMtime) {
98
+ lastMtime = currentMtime;
99
+ console.log(`[DAEMON] ${path.basename(filePath)} modifie`);
100
+ triggerScan(projectDir);
101
+ }
102
+ } catch {
103
+ // File may have been deleted between watch trigger and stat
88
104
  }
89
105
  }
90
106
  });
91
107
  }
92
108
 
93
109
  function watchNodeModules(nodeModulesPath, projectDir) {
94
- fs.watch(nodeModulesPath, { recursive: true }, (eventType, filename) => {
110
+ return fs.watch(nodeModulesPath, { recursive: true }, (eventType, filename) => {
95
111
  if (filename && filename.includes('package.json')) {
96
112
  console.log(`[DAEMON] Nouveau package detecte: ${filename}`);
97
113
  triggerScan(projectDir);
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/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'