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/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('\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('\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
- };
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
+ };