muaddib-scanner 2.2.19 → 2.2.22
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/.gitattributes +18 -0
- package/README.fr.md +819 -825
- package/README.md +822 -822
- package/bin/muaddib.js +868 -849
- package/docker/Dockerfile +19 -18
- package/docker/sandbox-runner.sh +313 -292
- package/package.json +61 -61
- package/src/commands/evaluate.js +484 -484
- package/src/diff.js +411 -411
- package/src/hooks-init.js +264 -258
- package/src/index.js +439 -437
- package/src/ioc/scraper.js +1293 -1293
- package/src/monitor.js +1817 -1764
- package/src/sandbox.js +631 -620
- package/src/scanner/ast-detectors.js +946 -933
- package/src/scanner/ast.js +96 -96
- package/src/scanner/github-actions.js +96 -96
- package/src/scanner/module-graph.js +2 -2
- package/src/scanner/obfuscation.js +128 -128
- package/src/scanner/shell.js +48 -48
- package/src/shared/download.js +181 -171
- package/src/webhook.js +413 -353
package/src/diff.js
CHANGED
|
@@ -1,411 +1,411 @@
|
|
|
1
|
-
const { execFileSync } = require('child_process');
|
|
2
|
-
const { run } = require('./index.js');
|
|
3
|
-
const path = require('path');
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const os = require('os');
|
|
6
|
-
|
|
7
|
-
// Only allow safe characters in git refs (prevents command injection)
|
|
8
|
-
const SAFE_REF_REGEX = /^[a-zA-Z0-9._\-/~^@{}]+$/;
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Get the list of commits/tags for comparison suggestions
|
|
12
|
-
*/
|
|
13
|
-
function getRecentRefs(targetPath, limit = 10) {
|
|
14
|
-
try {
|
|
15
|
-
const tags = execFileSync('git', ['tag', '--sort=-creatordate'], {
|
|
16
|
-
cwd: targetPath,
|
|
17
|
-
encoding: 'utf8',
|
|
18
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
19
|
-
}).trim().split(
|
|
20
|
-
|
|
21
|
-
const commits = execFileSync('git', ['log', '--oneline', `-${Number(limit) || 10}`], {
|
|
22
|
-
cwd: targetPath,
|
|
23
|
-
encoding: 'utf8',
|
|
24
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
25
|
-
}).trim().split(
|
|
26
|
-
|
|
27
|
-
return { tags, commits };
|
|
28
|
-
} catch {
|
|
29
|
-
return { tags: [], commits: [] };
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Check if we're in a git repository
|
|
35
|
-
*/
|
|
36
|
-
function isGitRepo(targetPath) {
|
|
37
|
-
try {
|
|
38
|
-
execFileSync('git', ['rev-parse', '--git-dir'], {
|
|
39
|
-
cwd: targetPath,
|
|
40
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
41
|
-
});
|
|
42
|
-
return true;
|
|
43
|
-
} catch {
|
|
44
|
-
return false;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Get current commit hash
|
|
50
|
-
*/
|
|
51
|
-
function getCurrentCommit(targetPath) {
|
|
52
|
-
try {
|
|
53
|
-
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
54
|
-
cwd: targetPath,
|
|
55
|
-
encoding: 'utf8',
|
|
56
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
57
|
-
}).trim();
|
|
58
|
-
} catch {
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Resolve a ref (tag, branch, commit) to a commit hash
|
|
65
|
-
*/
|
|
66
|
-
function resolveRef(targetPath, ref) {
|
|
67
|
-
if (!SAFE_REF_REGEX.test(ref)) {
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
try {
|
|
71
|
-
return execFileSync('git', ['rev-parse', ref], {
|
|
72
|
-
cwd: targetPath,
|
|
73
|
-
encoding: 'utf8',
|
|
74
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
75
|
-
}).trim();
|
|
76
|
-
} catch {
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Check if working directory has uncommitted changes
|
|
83
|
-
*/
|
|
84
|
-
function hasUncommittedChanges(targetPath) {
|
|
85
|
-
try {
|
|
86
|
-
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
87
|
-
cwd: targetPath,
|
|
88
|
-
encoding: 'utf8',
|
|
89
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
90
|
-
}).trim();
|
|
91
|
-
return status.length > 0;
|
|
92
|
-
} catch {
|
|
93
|
-
return false;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Create a temporary copy of the repo at a specific commit
|
|
99
|
-
*/
|
|
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
|
-
|
|
106
|
-
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'muaddib-diff-'));
|
|
107
|
-
|
|
108
|
-
try {
|
|
109
|
-
// Clone the repo to temp directory (use execFileSync to prevent injection)
|
|
110
|
-
execFileSync('git', ['clone', '--quiet', '--', targetPath, tempDir], {
|
|
111
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
// Checkout the specific commit
|
|
115
|
-
execFileSync('git', ['checkout', '--quiet', commitHash], {
|
|
116
|
-
cwd: tempDir,
|
|
117
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
// Install dependencies if package.json exists
|
|
121
|
-
// --ignore-scripts prevents execution of malicious preinstall/postinstall
|
|
122
|
-
const packageJsonPath = path.join(tempDir, 'package.json');
|
|
123
|
-
if (fs.existsSync(packageJsonPath)) {
|
|
124
|
-
try {
|
|
125
|
-
execFileSync('npm', ['install', '--quiet', '--no-audit', '--no-fund', '--ignore-scripts'], {
|
|
126
|
-
cwd: tempDir,
|
|
127
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
128
|
-
timeout: 60000
|
|
129
|
-
});
|
|
130
|
-
} catch {
|
|
131
|
-
// Ignore npm install errors
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return tempDir;
|
|
136
|
-
} catch (err) {
|
|
137
|
-
// Clean up on error
|
|
138
|
-
try {
|
|
139
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
140
|
-
} catch {
|
|
141
|
-
// Ignore cleanup errors
|
|
142
|
-
}
|
|
143
|
-
throw err;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Clean up temporary directory
|
|
149
|
-
*/
|
|
150
|
-
function cleanupTempDir(tempDir) {
|
|
151
|
-
try {
|
|
152
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
153
|
-
} catch {
|
|
154
|
-
// Ignore cleanup errors
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Generate a unique threat ID for comparison
|
|
160
|
-
*/
|
|
161
|
-
function getThreatId(threat) {
|
|
162
|
-
// Create a unique ID based on type, file, and message content
|
|
163
|
-
const file = threat.file || '';
|
|
164
|
-
const type = threat.type || '';
|
|
165
|
-
const msgKey = (threat.message || '').replace(/line \d+/gi, '').trim();
|
|
166
|
-
return `${type}:${file}:${msgKey}`;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Compare threats between two scans
|
|
171
|
-
* Returns: { added: [], removed: [], unchanged: [] }
|
|
172
|
-
*/
|
|
173
|
-
function compareThreats(oldThreats, newThreats) {
|
|
174
|
-
const oldIds = new Map();
|
|
175
|
-
const newIds = new Map();
|
|
176
|
-
|
|
177
|
-
oldThreats.forEach(t => oldIds.set(getThreatId(t), t));
|
|
178
|
-
newThreats.forEach(t => newIds.set(getThreatId(t), t));
|
|
179
|
-
|
|
180
|
-
const added = [];
|
|
181
|
-
const removed = [];
|
|
182
|
-
const unchanged = [];
|
|
183
|
-
|
|
184
|
-
// Find added threats (in new but not in old)
|
|
185
|
-
for (const [id, threat] of newIds) {
|
|
186
|
-
if (!oldIds.has(id)) {
|
|
187
|
-
added.push(threat);
|
|
188
|
-
} else {
|
|
189
|
-
unchanged.push(threat);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Find removed threats (in old but not in new)
|
|
194
|
-
for (const [id, threat] of oldIds) {
|
|
195
|
-
if (!newIds.has(id)) {
|
|
196
|
-
removed.push(threat);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
return { added, removed, unchanged };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Run scan and capture results (without console output)
|
|
205
|
-
*/
|
|
206
|
-
async function runSilentScan(targetPath, options = {}) {
|
|
207
|
-
const result = await run(targetPath, { ...options, _capture: true });
|
|
208
|
-
if (result && typeof result === 'object' && result.threats) {
|
|
209
|
-
return result;
|
|
210
|
-
}
|
|
211
|
-
return { threats: [], summary: { total: 0 } };
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Main diff function
|
|
216
|
-
* @param {string} targetPath - Path to the project
|
|
217
|
-
* @param {string} baseRef - Base reference (commit, tag, branch) to compare from
|
|
218
|
-
* @param {object} options - Options (json, explain, etc.)
|
|
219
|
-
*/
|
|
220
|
-
async function diff(targetPath, baseRef, options = {}) {
|
|
221
|
-
const resolvedPath = path.resolve(targetPath);
|
|
222
|
-
|
|
223
|
-
// Verify git repo
|
|
224
|
-
if (!isGitRepo(resolvedPath)) {
|
|
225
|
-
console.error('[ERROR] Not a git repository. The diff command requires git.');
|
|
226
|
-
return 1;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Resolve base reference
|
|
230
|
-
const baseCommit = resolveRef(resolvedPath, baseRef);
|
|
231
|
-
if (!baseCommit) {
|
|
232
|
-
console.error(`[ERROR] Cannot resolve reference: ${baseRef}`);
|
|
233
|
-
console.error('Use a commit hash, tag name, or branch name.');
|
|
234
|
-
const refs = getRecentRefs(resolvedPath);
|
|
235
|
-
if (refs.tags.length > 0) {
|
|
236
|
-
console.error(`\nAvailable tags: ${refs.tags.join(', ')}`);
|
|
237
|
-
}
|
|
238
|
-
if (refs.commits.length > 0) {
|
|
239
|
-
console.error(`\nRecent commits:\n${refs.commits.map(c => ` ${c}`).join('\n')}`);
|
|
240
|
-
}
|
|
241
|
-
return 1;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const currentCommit = getCurrentCommit(resolvedPath);
|
|
245
|
-
const shortBase = baseCommit.substring(0, 7);
|
|
246
|
-
const shortCurrent = currentCommit ? currentCommit.substring(0, 7) : 'working';
|
|
247
|
-
|
|
248
|
-
if (!options.json) {
|
|
249
|
-
console.log(`\n[MUADDIB DIFF] Comparing ${shortBase} -> ${shortCurrent}\n`);
|
|
250
|
-
console.log(`Base: ${baseRef} (${shortBase})`);
|
|
251
|
-
console.log(`Current: ${hasUncommittedChanges(resolvedPath) ? 'working directory (uncommitted changes)' : `HEAD (${shortCurrent})`}\n`);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
let tempDir = null;
|
|
255
|
-
let baseResult, currentResult;
|
|
256
|
-
|
|
257
|
-
try {
|
|
258
|
-
// Scan base commit
|
|
259
|
-
if (!options.json) {
|
|
260
|
-
console.log('[DIFF] Scanning base version...');
|
|
261
|
-
}
|
|
262
|
-
tempDir = createTempCopyAtCommit(resolvedPath, baseCommit);
|
|
263
|
-
baseResult = await runSilentScan(tempDir, { paranoid: options.paranoid });
|
|
264
|
-
cleanupTempDir(tempDir);
|
|
265
|
-
tempDir = null;
|
|
266
|
-
|
|
267
|
-
// Scan current version
|
|
268
|
-
if (!options.json) {
|
|
269
|
-
console.log('[DIFF] Scanning current version...');
|
|
270
|
-
}
|
|
271
|
-
currentResult = await runSilentScan(resolvedPath, { paranoid: options.paranoid });
|
|
272
|
-
|
|
273
|
-
} catch (err) {
|
|
274
|
-
if (tempDir) cleanupTempDir(tempDir);
|
|
275
|
-
console.error(`[ERROR] Diff failed: ${err.message}`);
|
|
276
|
-
return 1;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// Compare threats
|
|
280
|
-
const comparison = compareThreats(
|
|
281
|
-
baseResult.threats || [],
|
|
282
|
-
currentResult.threats || []
|
|
283
|
-
);
|
|
284
|
-
|
|
285
|
-
const result = {
|
|
286
|
-
base: {
|
|
287
|
-
ref: baseRef,
|
|
288
|
-
commit: baseCommit,
|
|
289
|
-
threatsCount: baseResult.threats?.length || 0,
|
|
290
|
-
riskScore: baseResult.summary?.riskScore || 0
|
|
291
|
-
},
|
|
292
|
-
current: {
|
|
293
|
-
commit: currentCommit,
|
|
294
|
-
hasUncommitted: hasUncommittedChanges(resolvedPath),
|
|
295
|
-
threatsCount: currentResult.threats?.length || 0,
|
|
296
|
-
riskScore: currentResult.summary?.riskScore || 0
|
|
297
|
-
},
|
|
298
|
-
diff: {
|
|
299
|
-
added: comparison.added,
|
|
300
|
-
removed: comparison.removed,
|
|
301
|
-
unchanged: comparison.unchanged.length,
|
|
302
|
-
scoreChange: (currentResult.summary?.riskScore || 0) - (baseResult.summary?.riskScore || 0)
|
|
303
|
-
},
|
|
304
|
-
timestamp: new Date().toISOString()
|
|
305
|
-
};
|
|
306
|
-
|
|
307
|
-
// Output
|
|
308
|
-
if (options.json) {
|
|
309
|
-
console.log(JSON.stringify(result, null, 2));
|
|
310
|
-
} else {
|
|
311
|
-
// Summary
|
|
312
|
-
console.log('\n' + '═'.repeat(60));
|
|
313
|
-
console.log(' DIFF SUMMARY');
|
|
314
|
-
console.log('═'.repeat(60));
|
|
315
|
-
|
|
316
|
-
const scoreChange = result.diff.scoreChange;
|
|
317
|
-
const scoreIndicator = scoreChange > 0 ? `+${scoreChange}` : scoreChange.toString();
|
|
318
|
-
const scoreColor = scoreChange > 0 ? 'worse' : scoreChange < 0 ? 'better' : 'same';
|
|
319
|
-
|
|
320
|
-
console.log(`\n Risk Score: ${result.base.riskScore} -> ${result.current.riskScore} (${scoreIndicator} ${scoreColor})`);
|
|
321
|
-
console.log(` Threats: ${result.base.threatsCount} -> ${result.current.threatsCount}`);
|
|
322
|
-
console.log(`\n NEW threats: ${comparison.added.length}`);
|
|
323
|
-
console.log(` REMOVED threats: ${comparison.removed.length}`);
|
|
324
|
-
console.log(` Unchanged: ${comparison.unchanged.length}`);
|
|
325
|
-
|
|
326
|
-
// New threats (the important part!)
|
|
327
|
-
if (comparison.added.length > 0) {
|
|
328
|
-
console.log('\n' + '─'.repeat(60));
|
|
329
|
-
console.log(' NEW THREATS (introduced since ' + baseRef + ')');
|
|
330
|
-
console.log('─'.repeat(60) + '\n');
|
|
331
|
-
|
|
332
|
-
comparison.added.forEach((t, i) => {
|
|
333
|
-
console.log(` ${i + 1}. [${t.severity}] ${t.type}`);
|
|
334
|
-
console.log(` ${t.message}`);
|
|
335
|
-
console.log(` File: ${t.file}`);
|
|
336
|
-
if (t.playbook) {
|
|
337
|
-
console.log(` Action: ${t.playbook}`);
|
|
338
|
-
}
|
|
339
|
-
console.log('');
|
|
340
|
-
});
|
|
341
|
-
} else {
|
|
342
|
-
console.log('\n [OK] No new threats introduced!\n');
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// Removed threats (nice to know)
|
|
346
|
-
if (comparison.removed.length > 0 && options.explain) {
|
|
347
|
-
console.log('─'.repeat(60));
|
|
348
|
-
console.log(' REMOVED THREATS (fixed since ' + baseRef + ')');
|
|
349
|
-
console.log('─'.repeat(60) + '\n');
|
|
350
|
-
|
|
351
|
-
comparison.removed.forEach((t, i) => {
|
|
352
|
-
console.log(` ${i + 1}. [${t.severity}] ${t.type} - ${t.file}`);
|
|
353
|
-
});
|
|
354
|
-
console.log('');
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
console.log('═'.repeat(60) + '\n');
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// Return exit code based on new threats
|
|
361
|
-
const failLevel = options.failLevel || 'high';
|
|
362
|
-
const severityLevels = {
|
|
363
|
-
critical: ['CRITICAL'],
|
|
364
|
-
high: ['CRITICAL', 'HIGH'],
|
|
365
|
-
medium: ['CRITICAL', 'HIGH', 'MEDIUM'],
|
|
366
|
-
low: ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
|
|
367
|
-
};
|
|
368
|
-
|
|
369
|
-
const levelsToCheck = severityLevels[failLevel] || severityLevels.high;
|
|
370
|
-
const newFailingThreats = comparison.added.filter(t => levelsToCheck.includes(t.severity));
|
|
371
|
-
|
|
372
|
-
return newFailingThreats.length;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* Show available refs for comparison
|
|
377
|
-
*/
|
|
378
|
-
function showRefs(targetPath) {
|
|
379
|
-
const resolvedPath = path.resolve(targetPath);
|
|
380
|
-
|
|
381
|
-
if (!isGitRepo(resolvedPath)) {
|
|
382
|
-
console.error('[ERROR] Not a git repository.');
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
const refs = getRecentRefs(resolvedPath, 15);
|
|
387
|
-
|
|
388
|
-
console.log('\n[MUADDIB DIFF] Available references for comparison:\n');
|
|
389
|
-
|
|
390
|
-
if (refs.tags.length > 0) {
|
|
391
|
-
console.log('Tags:');
|
|
392
|
-
refs.tags.forEach(t => console.log(` - ${t}`));
|
|
393
|
-
console.log('');
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
console.log('Recent commits:');
|
|
397
|
-
refs.commits.forEach(c => console.log(` - ${c}`));
|
|
398
|
-
console.log('');
|
|
399
|
-
|
|
400
|
-
console.log('Usage: muaddib diff <ref> [path]');
|
|
401
|
-
console.log('Example: muaddib diff v1.2.0');
|
|
402
|
-
console.log('Example: muaddib diff HEAD~5');
|
|
403
|
-
console.log('Example: muaddib diff abc1234\n');
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
module.exports = {
|
|
407
|
-
diff, showRefs, isGitRepo, getRecentRefs,
|
|
408
|
-
// Exported for testing
|
|
409
|
-
getThreatId, compareThreats, resolveRef, getCurrentCommit,
|
|
410
|
-
hasUncommittedChanges, runSilentScan, SAFE_REF_REGEX
|
|
411
|
-
};
|
|
1
|
+
const { execFileSync } = require('child_process');
|
|
2
|
+
const { run } = require('./index.js');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
// Only allow safe characters in git refs (prevents command injection)
|
|
8
|
+
const SAFE_REF_REGEX = /^[a-zA-Z0-9._\-/~^@{}]+$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get the list of commits/tags for comparison suggestions
|
|
12
|
+
*/
|
|
13
|
+
function getRecentRefs(targetPath, limit = 10) {
|
|
14
|
+
try {
|
|
15
|
+
const tags = execFileSync('git', ['tag', '--sort=-creatordate'], {
|
|
16
|
+
cwd: targetPath,
|
|
17
|
+
encoding: 'utf8',
|
|
18
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
19
|
+
}).trim().split(/\r?\n/).filter(Boolean).slice(0, 5);
|
|
20
|
+
|
|
21
|
+
const commits = execFileSync('git', ['log', '--oneline', `-${Number(limit) || 10}`], {
|
|
22
|
+
cwd: targetPath,
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
25
|
+
}).trim().split(/\r?\n/).filter(Boolean);
|
|
26
|
+
|
|
27
|
+
return { tags, commits };
|
|
28
|
+
} catch {
|
|
29
|
+
return { tags: [], commits: [] };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check if we're in a git repository
|
|
35
|
+
*/
|
|
36
|
+
function isGitRepo(targetPath) {
|
|
37
|
+
try {
|
|
38
|
+
execFileSync('git', ['rev-parse', '--git-dir'], {
|
|
39
|
+
cwd: targetPath,
|
|
40
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
41
|
+
});
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get current commit hash
|
|
50
|
+
*/
|
|
51
|
+
function getCurrentCommit(targetPath) {
|
|
52
|
+
try {
|
|
53
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
54
|
+
cwd: targetPath,
|
|
55
|
+
encoding: 'utf8',
|
|
56
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
57
|
+
}).trim();
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve a ref (tag, branch, commit) to a commit hash
|
|
65
|
+
*/
|
|
66
|
+
function resolveRef(targetPath, ref) {
|
|
67
|
+
if (!SAFE_REF_REGEX.test(ref)) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return execFileSync('git', ['rev-parse', ref], {
|
|
72
|
+
cwd: targetPath,
|
|
73
|
+
encoding: 'utf8',
|
|
74
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
75
|
+
}).trim();
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Check if working directory has uncommitted changes
|
|
83
|
+
*/
|
|
84
|
+
function hasUncommittedChanges(targetPath) {
|
|
85
|
+
try {
|
|
86
|
+
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
87
|
+
cwd: targetPath,
|
|
88
|
+
encoding: 'utf8',
|
|
89
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
90
|
+
}).trim();
|
|
91
|
+
return status.length > 0;
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Create a temporary copy of the repo at a specific commit
|
|
99
|
+
*/
|
|
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
|
+
|
|
106
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'muaddib-diff-'));
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
// Clone the repo to temp directory (use execFileSync to prevent injection)
|
|
110
|
+
execFileSync('git', ['clone', '--quiet', '--', targetPath, tempDir], {
|
|
111
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Checkout the specific commit
|
|
115
|
+
execFileSync('git', ['checkout', '--quiet', commitHash], {
|
|
116
|
+
cwd: tempDir,
|
|
117
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Install dependencies if package.json exists
|
|
121
|
+
// --ignore-scripts prevents execution of malicious preinstall/postinstall
|
|
122
|
+
const packageJsonPath = path.join(tempDir, 'package.json');
|
|
123
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
124
|
+
try {
|
|
125
|
+
execFileSync('npm', ['install', '--quiet', '--no-audit', '--no-fund', '--ignore-scripts'], {
|
|
126
|
+
cwd: tempDir,
|
|
127
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
128
|
+
timeout: 60000
|
|
129
|
+
});
|
|
130
|
+
} catch {
|
|
131
|
+
// Ignore npm install errors
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return tempDir;
|
|
136
|
+
} catch (err) {
|
|
137
|
+
// Clean up on error
|
|
138
|
+
try {
|
|
139
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
140
|
+
} catch {
|
|
141
|
+
// Ignore cleanup errors
|
|
142
|
+
}
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Clean up temporary directory
|
|
149
|
+
*/
|
|
150
|
+
function cleanupTempDir(tempDir) {
|
|
151
|
+
try {
|
|
152
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
153
|
+
} catch {
|
|
154
|
+
// Ignore cleanup errors
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Generate a unique threat ID for comparison
|
|
160
|
+
*/
|
|
161
|
+
function getThreatId(threat) {
|
|
162
|
+
// Create a unique ID based on type, file, and message content
|
|
163
|
+
const file = threat.file || '';
|
|
164
|
+
const type = threat.type || '';
|
|
165
|
+
const msgKey = (threat.message || '').replace(/line \d+/gi, '').trim();
|
|
166
|
+
return `${type}:${file}:${msgKey}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Compare threats between two scans
|
|
171
|
+
* Returns: { added: [], removed: [], unchanged: [] }
|
|
172
|
+
*/
|
|
173
|
+
function compareThreats(oldThreats, newThreats) {
|
|
174
|
+
const oldIds = new Map();
|
|
175
|
+
const newIds = new Map();
|
|
176
|
+
|
|
177
|
+
oldThreats.forEach(t => oldIds.set(getThreatId(t), t));
|
|
178
|
+
newThreats.forEach(t => newIds.set(getThreatId(t), t));
|
|
179
|
+
|
|
180
|
+
const added = [];
|
|
181
|
+
const removed = [];
|
|
182
|
+
const unchanged = [];
|
|
183
|
+
|
|
184
|
+
// Find added threats (in new but not in old)
|
|
185
|
+
for (const [id, threat] of newIds) {
|
|
186
|
+
if (!oldIds.has(id)) {
|
|
187
|
+
added.push(threat);
|
|
188
|
+
} else {
|
|
189
|
+
unchanged.push(threat);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Find removed threats (in old but not in new)
|
|
194
|
+
for (const [id, threat] of oldIds) {
|
|
195
|
+
if (!newIds.has(id)) {
|
|
196
|
+
removed.push(threat);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { added, removed, unchanged };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Run scan and capture results (without console output)
|
|
205
|
+
*/
|
|
206
|
+
async function runSilentScan(targetPath, options = {}) {
|
|
207
|
+
const result = await run(targetPath, { ...options, _capture: true });
|
|
208
|
+
if (result && typeof result === 'object' && result.threats) {
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
return { threats: [], summary: { total: 0 } };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Main diff function
|
|
216
|
+
* @param {string} targetPath - Path to the project
|
|
217
|
+
* @param {string} baseRef - Base reference (commit, tag, branch) to compare from
|
|
218
|
+
* @param {object} options - Options (json, explain, etc.)
|
|
219
|
+
*/
|
|
220
|
+
async function diff(targetPath, baseRef, options = {}) {
|
|
221
|
+
const resolvedPath = path.resolve(targetPath);
|
|
222
|
+
|
|
223
|
+
// Verify git repo
|
|
224
|
+
if (!isGitRepo(resolvedPath)) {
|
|
225
|
+
console.error('[ERROR] Not a git repository. The diff command requires git.');
|
|
226
|
+
return 1;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Resolve base reference
|
|
230
|
+
const baseCommit = resolveRef(resolvedPath, baseRef);
|
|
231
|
+
if (!baseCommit) {
|
|
232
|
+
console.error(`[ERROR] Cannot resolve reference: ${baseRef}`);
|
|
233
|
+
console.error('Use a commit hash, tag name, or branch name.');
|
|
234
|
+
const refs = getRecentRefs(resolvedPath);
|
|
235
|
+
if (refs.tags.length > 0) {
|
|
236
|
+
console.error(`\nAvailable tags: ${refs.tags.join(', ')}`);
|
|
237
|
+
}
|
|
238
|
+
if (refs.commits.length > 0) {
|
|
239
|
+
console.error(`\nRecent commits:\n${refs.commits.map(c => ` ${c}`).join('\n')}`);
|
|
240
|
+
}
|
|
241
|
+
return 1;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const currentCommit = getCurrentCommit(resolvedPath);
|
|
245
|
+
const shortBase = baseCommit.substring(0, 7);
|
|
246
|
+
const shortCurrent = currentCommit ? currentCommit.substring(0, 7) : 'working';
|
|
247
|
+
|
|
248
|
+
if (!options.json) {
|
|
249
|
+
console.log(`\n[MUADDIB DIFF] Comparing ${shortBase} -> ${shortCurrent}\n`);
|
|
250
|
+
console.log(`Base: ${baseRef} (${shortBase})`);
|
|
251
|
+
console.log(`Current: ${hasUncommittedChanges(resolvedPath) ? 'working directory (uncommitted changes)' : `HEAD (${shortCurrent})`}\n`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let tempDir = null;
|
|
255
|
+
let baseResult, currentResult;
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
// Scan base commit
|
|
259
|
+
if (!options.json) {
|
|
260
|
+
console.log('[DIFF] Scanning base version...');
|
|
261
|
+
}
|
|
262
|
+
tempDir = createTempCopyAtCommit(resolvedPath, baseCommit);
|
|
263
|
+
baseResult = await runSilentScan(tempDir, { paranoid: options.paranoid });
|
|
264
|
+
cleanupTempDir(tempDir);
|
|
265
|
+
tempDir = null;
|
|
266
|
+
|
|
267
|
+
// Scan current version
|
|
268
|
+
if (!options.json) {
|
|
269
|
+
console.log('[DIFF] Scanning current version...');
|
|
270
|
+
}
|
|
271
|
+
currentResult = await runSilentScan(resolvedPath, { paranoid: options.paranoid });
|
|
272
|
+
|
|
273
|
+
} catch (err) {
|
|
274
|
+
if (tempDir) cleanupTempDir(tempDir);
|
|
275
|
+
console.error(`[ERROR] Diff failed: ${err.message}`);
|
|
276
|
+
return 1;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Compare threats
|
|
280
|
+
const comparison = compareThreats(
|
|
281
|
+
baseResult.threats || [],
|
|
282
|
+
currentResult.threats || []
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const result = {
|
|
286
|
+
base: {
|
|
287
|
+
ref: baseRef,
|
|
288
|
+
commit: baseCommit,
|
|
289
|
+
threatsCount: baseResult.threats?.length || 0,
|
|
290
|
+
riskScore: baseResult.summary?.riskScore || 0
|
|
291
|
+
},
|
|
292
|
+
current: {
|
|
293
|
+
commit: currentCommit,
|
|
294
|
+
hasUncommitted: hasUncommittedChanges(resolvedPath),
|
|
295
|
+
threatsCount: currentResult.threats?.length || 0,
|
|
296
|
+
riskScore: currentResult.summary?.riskScore || 0
|
|
297
|
+
},
|
|
298
|
+
diff: {
|
|
299
|
+
added: comparison.added,
|
|
300
|
+
removed: comparison.removed,
|
|
301
|
+
unchanged: comparison.unchanged.length,
|
|
302
|
+
scoreChange: (currentResult.summary?.riskScore || 0) - (baseResult.summary?.riskScore || 0)
|
|
303
|
+
},
|
|
304
|
+
timestamp: new Date().toISOString()
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// Output
|
|
308
|
+
if (options.json) {
|
|
309
|
+
console.log(JSON.stringify(result, null, 2));
|
|
310
|
+
} else {
|
|
311
|
+
// Summary
|
|
312
|
+
console.log('\n' + '═'.repeat(60));
|
|
313
|
+
console.log(' DIFF SUMMARY');
|
|
314
|
+
console.log('═'.repeat(60));
|
|
315
|
+
|
|
316
|
+
const scoreChange = result.diff.scoreChange;
|
|
317
|
+
const scoreIndicator = scoreChange > 0 ? `+${scoreChange}` : scoreChange.toString();
|
|
318
|
+
const scoreColor = scoreChange > 0 ? 'worse' : scoreChange < 0 ? 'better' : 'same';
|
|
319
|
+
|
|
320
|
+
console.log(`\n Risk Score: ${result.base.riskScore} -> ${result.current.riskScore} (${scoreIndicator} ${scoreColor})`);
|
|
321
|
+
console.log(` Threats: ${result.base.threatsCount} -> ${result.current.threatsCount}`);
|
|
322
|
+
console.log(`\n NEW threats: ${comparison.added.length}`);
|
|
323
|
+
console.log(` REMOVED threats: ${comparison.removed.length}`);
|
|
324
|
+
console.log(` Unchanged: ${comparison.unchanged.length}`);
|
|
325
|
+
|
|
326
|
+
// New threats (the important part!)
|
|
327
|
+
if (comparison.added.length > 0) {
|
|
328
|
+
console.log('\n' + '─'.repeat(60));
|
|
329
|
+
console.log(' NEW THREATS (introduced since ' + baseRef + ')');
|
|
330
|
+
console.log('─'.repeat(60) + '\n');
|
|
331
|
+
|
|
332
|
+
comparison.added.forEach((t, i) => {
|
|
333
|
+
console.log(` ${i + 1}. [${t.severity}] ${t.type}`);
|
|
334
|
+
console.log(` ${t.message}`);
|
|
335
|
+
console.log(` File: ${t.file}`);
|
|
336
|
+
if (t.playbook) {
|
|
337
|
+
console.log(` Action: ${t.playbook}`);
|
|
338
|
+
}
|
|
339
|
+
console.log('');
|
|
340
|
+
});
|
|
341
|
+
} else {
|
|
342
|
+
console.log('\n [OK] No new threats introduced!\n');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Removed threats (nice to know)
|
|
346
|
+
if (comparison.removed.length > 0 && options.explain) {
|
|
347
|
+
console.log('─'.repeat(60));
|
|
348
|
+
console.log(' REMOVED THREATS (fixed since ' + baseRef + ')');
|
|
349
|
+
console.log('─'.repeat(60) + '\n');
|
|
350
|
+
|
|
351
|
+
comparison.removed.forEach((t, i) => {
|
|
352
|
+
console.log(` ${i + 1}. [${t.severity}] ${t.type} - ${t.file}`);
|
|
353
|
+
});
|
|
354
|
+
console.log('');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
console.log('═'.repeat(60) + '\n');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Return exit code based on new threats
|
|
361
|
+
const failLevel = options.failLevel || 'high';
|
|
362
|
+
const severityLevels = {
|
|
363
|
+
critical: ['CRITICAL'],
|
|
364
|
+
high: ['CRITICAL', 'HIGH'],
|
|
365
|
+
medium: ['CRITICAL', 'HIGH', 'MEDIUM'],
|
|
366
|
+
low: ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const levelsToCheck = severityLevels[failLevel] || severityLevels.high;
|
|
370
|
+
const newFailingThreats = comparison.added.filter(t => levelsToCheck.includes(t.severity));
|
|
371
|
+
|
|
372
|
+
return newFailingThreats.length;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Show available refs for comparison
|
|
377
|
+
*/
|
|
378
|
+
function showRefs(targetPath) {
|
|
379
|
+
const resolvedPath = path.resolve(targetPath);
|
|
380
|
+
|
|
381
|
+
if (!isGitRepo(resolvedPath)) {
|
|
382
|
+
console.error('[ERROR] Not a git repository.');
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const refs = getRecentRefs(resolvedPath, 15);
|
|
387
|
+
|
|
388
|
+
console.log('\n[MUADDIB DIFF] Available references for comparison:\n');
|
|
389
|
+
|
|
390
|
+
if (refs.tags.length > 0) {
|
|
391
|
+
console.log('Tags:');
|
|
392
|
+
refs.tags.forEach(t => console.log(` - ${t}`));
|
|
393
|
+
console.log('');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
console.log('Recent commits:');
|
|
397
|
+
refs.commits.forEach(c => console.log(` - ${c}`));
|
|
398
|
+
console.log('');
|
|
399
|
+
|
|
400
|
+
console.log('Usage: muaddib diff <ref> [path]');
|
|
401
|
+
console.log('Example: muaddib diff v1.2.0');
|
|
402
|
+
console.log('Example: muaddib diff HEAD~5');
|
|
403
|
+
console.log('Example: muaddib diff abc1234\n');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
module.exports = {
|
|
407
|
+
diff, showRefs, isGitRepo, getRecentRefs,
|
|
408
|
+
// Exported for testing
|
|
409
|
+
getThreatId, compareThreats, resolveRef, getCurrentCommit,
|
|
410
|
+
hasUncommittedChanges, runSilentScan, SAFE_REF_REGEX
|
|
411
|
+
};
|