@activemind/scd 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.
Files changed (79) hide show
  1. package/LICENSE.md +35 -0
  2. package/README.md +417 -0
  3. package/bin/scd.js +140 -0
  4. package/lib/audit-report.js +93 -0
  5. package/lib/audit-sync.js +172 -0
  6. package/lib/audit.js +356 -0
  7. package/lib/cli-helpers.js +108 -0
  8. package/lib/commands/accept.js +28 -0
  9. package/lib/commands/audit.js +17 -0
  10. package/lib/commands/configure.js +200 -0
  11. package/lib/commands/doctor.js +14 -0
  12. package/lib/commands/exceptions.js +19 -0
  13. package/lib/commands/export-findings.js +46 -0
  14. package/lib/commands/findings.js +306 -0
  15. package/lib/commands/ignore.js +28 -0
  16. package/lib/commands/init.js +16 -0
  17. package/lib/commands/insights.js +24 -0
  18. package/lib/commands/install.js +15 -0
  19. package/lib/commands/list.js +109 -0
  20. package/lib/commands/remove.js +16 -0
  21. package/lib/commands/repo.js +862 -0
  22. package/lib/commands/report.js +234 -0
  23. package/lib/commands/resolve.js +25 -0
  24. package/lib/commands/rules.js +185 -0
  25. package/lib/commands/scan.js +519 -0
  26. package/lib/commands/scope.js +341 -0
  27. package/lib/commands/sync.js +40 -0
  28. package/lib/commands/uninstall.js +15 -0
  29. package/lib/commands/version.js +33 -0
  30. package/lib/comment-map.js +388 -0
  31. package/lib/config.js +325 -0
  32. package/lib/context-modifiers.js +211 -0
  33. package/lib/deep-analyzer.js +225 -0
  34. package/lib/doctor.js +236 -0
  35. package/lib/exception-manager.js +675 -0
  36. package/lib/export-findings.js +376 -0
  37. package/lib/file-context.js +380 -0
  38. package/lib/file-filter.js +204 -0
  39. package/lib/file-manifest.js +145 -0
  40. package/lib/git-utils.js +102 -0
  41. package/lib/global-config.js +239 -0
  42. package/lib/hooks-manager.js +130 -0
  43. package/lib/init-repo.js +147 -0
  44. package/lib/insights-analyzer.js +416 -0
  45. package/lib/insights-output.js +160 -0
  46. package/lib/installer.js +128 -0
  47. package/lib/output-constants.js +32 -0
  48. package/lib/output-terminal.js +407 -0
  49. package/lib/push-queue.js +322 -0
  50. package/lib/remove-repo.js +108 -0
  51. package/lib/repo-context.js +187 -0
  52. package/lib/report-html.js +1154 -0
  53. package/lib/report-index.js +157 -0
  54. package/lib/report-json.js +136 -0
  55. package/lib/report-markdown.js +250 -0
  56. package/lib/resolve-manager.js +148 -0
  57. package/lib/rule-registry.js +205 -0
  58. package/lib/scan-cache.js +171 -0
  59. package/lib/scan-context.js +312 -0
  60. package/lib/scan-schema.js +67 -0
  61. package/lib/scanner-full.js +681 -0
  62. package/lib/scanner-manual.js +348 -0
  63. package/lib/scanner-secrets.js +83 -0
  64. package/lib/scope.js +331 -0
  65. package/lib/store-verify.js +395 -0
  66. package/lib/store.js +310 -0
  67. package/lib/taint-register.js +196 -0
  68. package/lib/version-check.js +46 -0
  69. package/package.json +37 -0
  70. package/rules/rule-loader.js +324 -0
  71. package/rules/rules-aspx-cs.json +399 -0
  72. package/rules/rules-aspx.json +222 -0
  73. package/rules/rules-infra-leakage.json +434 -0
  74. package/rules/rules-js.json +664 -0
  75. package/rules/rules-php.json +521 -0
  76. package/rules/rules-python.json +466 -0
  77. package/rules/rules-secrets.json +99 -0
  78. package/rules/rules-sensitive-files.json +475 -0
  79. package/rules/rules-ts.json +76 -0
@@ -0,0 +1,675 @@
1
+ /**
2
+ * exception-manager.js
3
+ * Manages exceptions and ignores for scd findings.
4
+ *
5
+ * scd accept <findingId> --reason <text>
6
+ * → Accepted risk: finding is real but justified. Requires team-lead approval via scd-server.
7
+ *
8
+ * scd ignore <findingId> --reason <text>
9
+ * → False positive / ignore: finding not exploitable in this context. Requires approval.
10
+ *
11
+ * Both commands:
12
+ * 1. Resolve findingId (f-{10hex}) from last scan cache
13
+ * 2. Write a pending exception to store config.yml (status: pending)
14
+ * 3. Push exception-request to scd-server via push queue
15
+ *
16
+ * scd sync
17
+ * → Pull approved/rejected exceptions from scd-server, update local config.yml
18
+ */
19
+
20
+ 'use strict';
21
+ const { RESET, BOLD, DIM, RED, GREEN, YELLOW } = require('./output-constants');
22
+
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+ const crypto = require('crypto');
26
+ const { CONFIG_FILENAME } = require('./config');
27
+ const { logEvent, EVENTS } = require('./audit');
28
+
29
+ // ── stdin prompt helper ───────────────────────────────────────────────────
30
+ function prompt(question) {
31
+ return new Promise(resolve => {
32
+ process.stdout.write(question);
33
+ process.stdin.setEncoding('utf8');
34
+ process.stdin.resume();
35
+ process.stdin.once('data', data => {
36
+ process.stdin.pause();
37
+ resolve(data.toString().trim());
38
+ });
39
+ });
40
+ }
41
+
42
+ // ── Add exception by finding ID (new primary API) ────────────────────────
43
+
44
+ /**
45
+ * Add exception or ignore by finding ID (f-{10hex}).
46
+ * Looks up the finding in the last scan cache, then delegates to addException.
47
+ * This is the primary entry point from CLI commands.
48
+ */
49
+ async function addExceptionById(repoRoot, findingId, opts, type = 'exception') {
50
+
51
+ if (!findingId) {
52
+ const cmd = type === 'ignore' ? 'ignore' : 'accept';
53
+ console.error(`\n${RED}❌ Finding ID required.${RESET}`);
54
+ console.error(`${DIM} Usage: scd ${cmd} <finding-id> --reason "..."${RESET}`);
55
+ console.error(`${DIM} Finding IDs are shown in scd scan --verbose output (e.g. f-20eb992e1f)${RESET}\n`);
56
+ process.exit(1);
57
+ }
58
+
59
+ if (!findingId.startsWith('f-') || findingId.length !== 12) {
60
+ console.error(`\n${RED}❌ Invalid finding ID: ${findingId}${RESET}`);
61
+ console.error(`${DIM} Finding IDs look like: f-20eb992e1f (shown in scd scan --verbose)${RESET}\n`);
62
+ process.exit(1);
63
+ }
64
+
65
+ if (!opts.reason || !opts.reason.trim()) {
66
+ console.error(`\n${RED}❌ --reason is required.${RESET}`);
67
+ const cmd = type === 'ignore' ? 'ignore' : 'accept';
68
+ console.error(`${DIM} Example: scd ${cmd} ${findingId} --reason "Not exploitable in this context"${RESET}\n`);
69
+ process.exit(1);
70
+ }
71
+
72
+ // Load finding from last scan cache
73
+ const { loadCache } = require('./scan-cache');
74
+ const cache = loadCache(repoRoot);
75
+ const findings = cache?.findings || [];
76
+
77
+ const finding = findings.find(f => f.findingId === findingId);
78
+
79
+ if (!finding) {
80
+ console.error(`\n${RED}❌ Finding ${findingId} not found in last scan.${RESET}`);
81
+ console.error(`${DIM} Run scd scan --verbose to see finding IDs, then re-run this command.${RESET}\n`);
82
+ process.exit(1);
83
+ }
84
+
85
+ // Check for duplicate — same finding already has a pending/approved exception
86
+ const { loadConfig } = require('./config');
87
+ const config = loadConfig(repoRoot);
88
+ const exceptions = config.exceptions || [];
89
+ const existing = exceptions.find(e =>
90
+ e.rule === finding.ruleId &&
91
+ e.file === finding.filePath &&
92
+ e.line === finding.line &&
93
+ (e.status === 'pending' || e.status === 'approved')
94
+ );
95
+
96
+ if (existing) {
97
+ console.log(`\n${YELLOW}⚠ A ${existing.status} exception already exists for this finding.${RESET}`);
98
+ console.log(`${DIM} ID: ${existing.id} Status: ${existing.status} Type: ${existing.type}${RESET}`);
99
+ const answer = await prompt(' Create another exception anyway? [y/N] ');
100
+ if (!answer.trim().toLowerCase().startsWith('y')) {
101
+ console.log(`${DIM} Aborted.${RESET}\n`);
102
+ process.exit(0);
103
+ }
104
+ }
105
+
106
+ // Delegate to addException with resolved fields including the finding's codeHash
107
+ await addException(repoRoot, {
108
+ rule: finding.ruleId,
109
+ file: finding.filePath,
110
+ line: String(finding.line),
111
+ reason: opts.reason,
112
+ tag: opts.tag,
113
+ codeHash: finding.codeHash || null, // pass through — do not recompute
114
+ }, type);
115
+ }
116
+
117
+ // ── Add exception or ignore ───────────────────────────────────────────────
118
+
119
+ async function addException(repoRoot, opts, type = 'exception') {
120
+ const { rule, file, line, reason, tag } = opts;
121
+
122
+ // Validate required fields
123
+ if (!rule || !file || !line) {
124
+ console.error('\n' + RED + 'Usage: scd approve --rule <id> --file <path> --line <n> --reason <text>' + RESET + '\n');
125
+ process.exit(1);
126
+ }
127
+
128
+ if (!reason || !reason.trim()) {
129
+ console.error('\nRED❌ --reason is required.' + RESET);
130
+ console.error(DIM + ' Example: scd approve --rule PY-INJ-001 --file src/db.py --line 68 \\' + RESET);
131
+ console.error(DIM + ' --reason "PRAGMA uses whitelist-validated table names only"' + RESET + '\n');
132
+ process.exit(1);
133
+ }
134
+
135
+ // Validate tag if provided (fritext, max 40 chars, no whitespace)
136
+ const cleanTag = tag ? String(tag).trim().slice(0, 40).replace(/\s+/g, '_') : null;
137
+
138
+ const lineNum = parseInt(line, 10);
139
+ const filePath = path.resolve(repoRoot, file);
140
+
141
+ // Hash the triggering line for stale-detection
142
+ let lineContent = null;
143
+ let codeHash = opts.codeHash || null; // prefer hash from finding (32-char, matches scanner)
144
+ let codeHashValid = !!codeHash;
145
+
146
+ if (fs.existsSync(filePath)) {
147
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n');
148
+ lineContent = lines[lineNum - 1];
149
+ if (lineContent) {
150
+ if (!codeHash) {
151
+ // Fallback: recompute from file (used when called directly, not via addExceptionById)
152
+ codeHash = crypto.createHash('sha256').update(lineContent).digest('hex').slice(0, 32);
153
+ codeHashValid = true;
154
+ }
155
+ console.log(`\nDIMLine ${lineNum}: ${lineContent.trim()}${RESET}`);
156
+ console.log(`DIMHash: ${codeHash}${RESET}`);
157
+ }
158
+ } else {
159
+ console.log(`\nYELLOW⚠ File not found: ${file}${RESET}`);
160
+ console.log(`${DIM} Exception will be created without code hash.${RESET}`);
161
+ console.log(`${DIM} This means it matches ANY occurrence of ${rule} in that file — not just line ${lineNum}.${RESET}\n`);
162
+
163
+ const answer = await prompt(' Continue anyway? [y/N] ');
164
+ if (!answer.trim().toLowerCase().startsWith('y')) {
165
+ console.log(DIM + ' Aborted.' + RESET + '\n');
166
+ process.exit(0);
167
+ }
168
+ }
169
+
170
+ const { getCentralUrl } = require('./global-config');
171
+ const isStandalone = !getCentralUrl();
172
+
173
+ const excId = `exc-${Date.now().toString(36)}`;
174
+ const created = new Date().toISOString().slice(0, 10);
175
+
176
+ const exception = {
177
+ id: excId,
178
+ type, // 'exception' | 'ignore'
179
+ tag: cleanTag, // optional free-text tag
180
+ rule,
181
+ file: file.replace(/\\/g, '/'),
182
+ line: lineNum,
183
+ code_hash: codeHash,
184
+ code_hash_valid: codeHashValid,
185
+ reason: reason.trim(),
186
+ status: isStandalone ? 'approved' : 'pending',
187
+ created_date: created,
188
+ };
189
+
190
+ // Write to store config
191
+ writeException(repoRoot, exception);
192
+
193
+ // Push to scd-server via push queue
194
+ pushExceptionToServer(repoRoot, exception);
195
+
196
+ // Audit log
197
+ logEvent(repoRoot, 'exception_requested', {
198
+ exception_id: excId,
199
+ type,
200
+ tag: cleanTag,
201
+ rule,
202
+ file,
203
+ line: lineNum,
204
+ code_hash: codeHash,
205
+ reason: reason.trim(),
206
+ });
207
+
208
+ const typeLabel = type === 'ignore' ? 'Ignore' : 'Exception';
209
+
210
+ console.log(`\n${GREEN}✓ ${typeLabel} ${excId} created${RESET}`);
211
+ if (isStandalone) {
212
+ console.log(`${DIM} Status: approved locally${RESET}`);
213
+ console.log(`${DIM} (No scd-server configured — exception takes effect immediately)${RESET}`);
214
+ } else {
215
+ console.log(`${DIM} Status: pending team-lead approval${RESET}`);
216
+ console.log(`${DIM} → Pushed to scd-server for approval${RESET}`);
217
+ }
218
+ console.log(`${DIM} Rule: ${rule}${RESET}`);
219
+ console.log(`${DIM} File: ${file}:${lineNum}${RESET}`);
220
+ console.log(`${DIM} Reason: ${reason.trim()}${RESET}`);
221
+ if (cleanTag) console.log(`${DIM} Tag: ${cleanTag}${RESET}`);
222
+ console.log();
223
+ }
224
+
225
+ // ── Push exception to scd-server ─────────────────────────────────────────
226
+
227
+ function pushExceptionToServer(repoRoot, exception) {
228
+ try {
229
+ const { getCentralUrl, getCentralToken } = require('./global-config');
230
+ const centralUrl = getCentralUrl();
231
+ if (!centralUrl) return;
232
+
233
+ const token = getCentralToken();
234
+ const meta = require('./push-queue').buildMeta(repoRoot);
235
+ const url = centralUrl.replace(/\/$/, '') + '/api/v1/exceptions/batch';
236
+ const http = url.startsWith('https') ? require('https') : require('http');
237
+
238
+ const body = JSON.stringify({
239
+ exceptions: [{
240
+ rule_id: exception.rule,
241
+ file_path: exception.file,
242
+ line: exception.line,
243
+ code_hash: exception.code_hash,
244
+ type: exception.type,
245
+ tag: exception.tag || null,
246
+ reason: exception.reason,
247
+ }],
248
+ meta,
249
+ });
250
+
251
+ // Fire-and-forget — non-blocking, failure is silent
252
+ const parsed = new (require('url').URL)(url);
253
+ const options = {
254
+ hostname: parsed.hostname,
255
+ port: parsed.port || (url.startsWith('https') ? 443 : 80),
256
+ path: parsed.pathname + parsed.search,
257
+ method: 'POST',
258
+ headers: {
259
+ 'Content-Type': 'application/json',
260
+ 'Content-Length': Buffer.byteLength(body),
261
+ 'Authorization': `Bearer ${token}`,
262
+ },
263
+ };
264
+
265
+ const req = http.request(options, (res) => {
266
+ // Consume response to free socket
267
+ res.resume();
268
+ });
269
+ req.on('error', () => {});
270
+ req.setTimeout(8000, () => req.destroy());
271
+ req.write(body);
272
+ req.end();
273
+
274
+ } catch {
275
+ // Non-fatal
276
+ }
277
+ }
278
+
279
+ // ── Write exception to local config ──────────────────────────────────────
280
+
281
+ function writeException(repoRoot, exception) {
282
+ const configPath = require('./store').configPath(repoRoot);
283
+
284
+ let content = '';
285
+ if (fs.existsSync(configPath)) {
286
+ content = fs.readFileSync(configPath, 'utf8');
287
+ } else {
288
+ content = '# Secure Code by Design – repo configuration\n\ntrust_level: balanced\n\n';
289
+ }
290
+
291
+ const block = [
292
+ ` - id: "${exception.id}"`,
293
+ ` type: "${exception.type}"`,
294
+ exception.tag ? ` tag: "${exception.tag}"` : null,
295
+ ` status: "${exception.status}"`,
296
+ ` rule: "${exception.rule}"`,
297
+ ` file: "${exception.file}"`,
298
+ ` line: ${exception.line}`,
299
+ // line_hash only written when content was actually hashed (not for secrets rules that redact lineRaw)
300
+ exception.code_hash && exception.code_hash_valid ? ` line_hash: "${exception.code_hash}"` : null,
301
+ ` reason: "${exception.reason}"`,
302
+ ` created_date: "${exception.created_date}"`,
303
+ ].filter(Boolean).join('\n');
304
+
305
+ // Check for an active (non-commented) exceptions: section
306
+ if (/^exceptions:\s*$/m.test(content)) {
307
+ content = content.replace(/^exceptions:\s*$/m, `exceptions:\n${block}`);
308
+ } else {
309
+ content += `\nexceptions:\n${block}\n`;
310
+ }
311
+
312
+ fs.writeFileSync(configPath, content, 'utf8');
313
+ }
314
+
315
+ // ── Sync approved exceptions from scd-server ─────────────────────────────
316
+
317
+ async function syncExceptions(repoRoot) {
318
+ const { getCentralUrl, getCentralToken } = require('./global-config');
319
+ const centralUrl = getCentralUrl();
320
+ const token = getCentralToken();
321
+
322
+
323
+ if (!centralUrl) {
324
+ console.error('\nRED❌ No scd-server configured.' + RESET);
325
+ console.error(DIM + ' Run: scd configure --central-url <url>' + RESET + '\n');
326
+ process.exit(1);
327
+ }
328
+
329
+ const store = require('./store');
330
+ const repoId = store.getRepoId(repoRoot);
331
+ const http = centralUrl.startsWith('https') ? require('https') : require('http');
332
+
333
+ console.log('\nCYAN↓ Syncing exceptions from scd-server…' + RESET);
334
+
335
+ try {
336
+ // Fetch approved
337
+ const approvedUrl = new URL(`/api/v1/exceptions/approved?repo_id=${encodeURIComponent(repoId)}`, centralUrl);
338
+ const approved = await httpGet(http, approvedUrl.toString(), token);
339
+ const list = approved.exceptions || [];
340
+
341
+ // Also check for rejected so we can notify the developer
342
+ const rejectedUrl = new URL(`/api/v1/exceptions/approved?repo_id=${encodeURIComponent(repoId)}&status=rejected`, centralUrl);
343
+ let rejected = [];
344
+ try {
345
+ const rData = await httpGet(http, rejectedUrl.toString(), token);
346
+ rejected = rData.exceptions || [];
347
+ } catch { /* non-fatal — server may not support status filter */ }
348
+
349
+ if (list.length === 0 && rejected.length === 0) {
350
+ console.log(`${DIM} No approved or rejected exceptions for this repo.${RESET}\n`);
351
+ return;
352
+ }
353
+
354
+ // Apply approved exceptions locally — also updates pending exceptions that have now been reviewed
355
+ let applied = 0;
356
+ let skipped = 0;
357
+ for (const ex of list) {
358
+ const updated = updateExceptionStatus(repoRoot, ex, 'approved', ex.reviewed_by, ex.review_comment);
359
+ if (updated) applied++;
360
+ else skipped++;
361
+ }
362
+
363
+ if (list.length > 0) {
364
+ console.log(`${GREEN}✓ ${list.length} approved exception(s)${RESET}`);
365
+ if (applied > 0) console.log(`${DIM} ${applied} applied to local config — findings will no longer be flagged${RESET}`);
366
+ if (skipped > 0) console.log(`${DIM} ${skipped} already up to date${RESET}`);
367
+ }
368
+
369
+ // Show rejected so developer knows to fix the finding
370
+ if (rejected.length > 0) {
371
+ console.log(`\n${YELLOW}⚠ ${rejected.length} rejected exception(s) — these findings need to be fixed:${RESET}`);
372
+ for (const ex of rejected) {
373
+ console.log(`${DIM} ${ex.rule_id} ${ex.file_path}${ex.line ? ':' + ex.line : ''}${RESET}`);
374
+ if (ex.review_comment) {
375
+ console.log(`${DIM} Reason: ${ex.review_comment}${RESET}`);
376
+ }
377
+ // Mark as rejected locally
378
+ updateExceptionStatus(repoRoot, ex, 'rejected', ex.reviewed_by, ex.review_comment);
379
+ }
380
+ }
381
+
382
+ // Update lastSynced timestamp and store handled IDs in meta.json
383
+ // so getSyncNotice can exclude them even if they were never in local config
384
+ const { updateLastSynced } = require('./store');
385
+ const handledIds = [
386
+ ...list.map(e => e.id),
387
+ ...rejected.map(e => e.id),
388
+ ];
389
+ updateLastSynced(repoRoot, handledIds);
390
+
391
+ console.log('');
392
+
393
+ } catch (err) {
394
+ // Detect server license invalid — show actionable message, not raw JSON
395
+ const msg = err.message || '';
396
+ if (msg.includes('HTTP 503') && msg.includes('License invalid')) {
397
+ console.error('\nYELLOW⚠ Server license invalid — exceptions cannot be synced.' + RESET);
398
+ console.error(DIM + ' Contact your local scd-server administrator to resolve this.' + RESET + '\n');
399
+ } else {
400
+ console.error(`\nRED❌ Sync failed: ${err.message}${RESET}`);
401
+ console.error(DIM + ' Check that scd-server is reachable and token is correctRESET\n');
402
+ }
403
+ process.exit(1);
404
+ }
405
+ }
406
+
407
+ function updateExceptionStatus(repoRoot, serverEx, status, reviewedBy, comment) {
408
+ const configPath = require('./store').configPath(repoRoot);
409
+ if (!fs.existsSync(configPath)) return false;
410
+
411
+ const lines = fs.readFileSync(configPath, 'utf8').split('\n');
412
+
413
+ // Find the entry by scanning line by line
414
+ // Match by CLI id first, then fall back to rule+file+line
415
+ let firstLine = -1;
416
+ let lastLine = -1;
417
+
418
+ for (let i = 0; i < lines.length; i++) {
419
+ if (!lines[i].startsWith(' - id: "')) continue;
420
+
421
+ // Check if this is our entry
422
+ const excId = serverEx.id || '';
423
+ const isById = excId && lines[i].includes(` - id: "${excId}"`);
424
+
425
+ // Look ahead to collect ALL lines belonging to this entry
426
+ // An entry ends at the next ' - id:' line, a blank line, or end of file
427
+ // BUT we must include reviewer lines that may have been appended
428
+ let j = i + 1;
429
+ const entryLines = [lines[i]];
430
+ while (j < lines.length && !lines[j].startsWith(' - id: "') && lines[j].trim() !== '') {
431
+ entryLines.push(lines[j]);
432
+ j++;
433
+ }
434
+ const entryText = entryLines.join('\n');
435
+
436
+ const isByRuleLine = !isById && serverEx.rule_id && serverEx.file_path
437
+ && entryText.includes(`rule: "${serverEx.rule_id}"`)
438
+ && entryText.includes(`file: "${serverEx.file_path}"`)
439
+ && (!serverEx.line || entryText.includes(`line: ${serverEx.line}`));
440
+
441
+ if (isById || isByRuleLine) {
442
+ firstLine = i;
443
+ lastLine = j - 1;
444
+ break;
445
+ }
446
+ }
447
+
448
+ if (firstLine === -1) {
449
+ // Debug: show why no entry matched
450
+ return false;
451
+ }
452
+
453
+ // Update status field within entry
454
+ let statusUpdated = false;
455
+ for (let i = firstLine; i <= lastLine; i++) {
456
+ if (/^\s+status:/.test(lines[i])) {
457
+ lines[i] = lines[i].replace(/status: "[^"]*"/, `status: "${status}"`);
458
+ statusUpdated = true;
459
+ break;
460
+ }
461
+ }
462
+ if (!statusUpdated) return false;
463
+
464
+ // Add reviewer info + db_id if not already in this entry
465
+ const entryText = lines.slice(firstLine, lastLine + 1).join('\n');
466
+ if (reviewedBy && !entryText.includes('reviewed_by:')) {
467
+ const insertAfter = lastLine;
468
+ const toInsert = [];
469
+ // Store server DB id for resolved notification
470
+ if (serverEx.id && !entryText.includes('db_id:')) {
471
+ toInsert.push(` db_id: ${serverEx.id}`);
472
+ }
473
+ toInsert.push(` reviewed_by: "${reviewedBy}"`);
474
+ if (comment) toInsert.push(` review_comment: "${comment.replace(/"/g, '\\"')}"`);
475
+ lines.splice(insertAfter + 1, 0, ...toInsert);
476
+ }
477
+
478
+ fs.writeFileSync(configPath, lines.join('\n'), 'utf8');
479
+ return true;
480
+ }
481
+
482
+ // ── HTTP helper ───────────────────────────────────────────────────────────
483
+
484
+ function httpGet(http, url, token) {
485
+ return new Promise((resolve, reject) => {
486
+ const opts = {
487
+ headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json' },
488
+ };
489
+ const req = http.get(url, opts, (res) => {
490
+ let data = '';
491
+ res.on('data', chunk => { data += chunk; });
492
+ res.on('end', () => {
493
+ try {
494
+ if (res.statusCode !== 200) {
495
+ reject(new Error(`HTTP ${res.statusCode}: ${data}`));
496
+ } else {
497
+ resolve(JSON.parse(data));
498
+ }
499
+ } catch (e) { reject(e); }
500
+ });
501
+ });
502
+ req.on('error', reject);
503
+ req.setTimeout(10000, () => { req.destroy(); reject(new Error('Request timeout')); });
504
+ });
505
+ }
506
+
507
+ // ── Sync notice for terminal output ──────────────────────────────────────
508
+ // Returns a notice string if there are pending exceptions, or null.
509
+ // Reads only from local config + meta — zero network cost.
510
+ function getSyncNotice(repoRoot) {
511
+ try {
512
+ const { readMeta } = require('./store');
513
+ const { loadConfig } = require('./config');
514
+
515
+ const config = loadConfig(repoRoot);
516
+ const meta = readMeta(repoRoot);
517
+
518
+ // Exclude exceptions already handled by server (approved or rejected)
519
+ const handled = new Set(Array.isArray(meta.handledExceptionIds) ? meta.handledExceptionIds : []);
520
+ const pending = config.exceptions.filter(e => e.status === 'pending' && !handled.has(e.id));
521
+
522
+ if (pending.length === 0) return null;
523
+
524
+ const lastSynced = meta.lastSynced ? new Date(meta.lastSynced) : null;
525
+ const hoursSince = lastSynced
526
+ ? Math.floor((Date.now() - lastSynced.getTime()) / 3_600_000)
527
+ : null;
528
+
529
+ const stale = hoursSince === null || hoursSince >= 24;
530
+
531
+ const icon = stale ? YELLOW + '⚠ ' + RESET : CYAN + 'ℹ ' + RESET;
532
+ const age = hoursSince === null
533
+ ? 'never synced'
534
+ : hoursSince < 1 ? 'synced recently'
535
+ : hoursSince < 24 ? `synced ${hoursSince}h ago`
536
+ : `last synced ${Math.floor(hoursSince / 24)}d ago`;
537
+
538
+ return `${icon}${DIM} ${pending.length} exception(s) pending approval – ${age} – run ${RESET}${BOLD}scd sync${RESET}`;
539
+ } catch {
540
+ return null;
541
+ }
542
+ }
543
+
544
+ // ── List exceptions from local config ────────────────────────────────────
545
+
546
+ function listExceptions(repoRoot, statusFilter = 'all') {
547
+ const { loadConfig } = require('./config');
548
+ const config = loadConfig(repoRoot);
549
+
550
+ const valid = ['pending', 'approved', 'rejected', 'all'];
551
+ if (!valid.includes(statusFilter)) {
552
+ console.error(`${RED}❌ Invalid status: ${statusFilter}. Use: pending | approved | rejected | all${RESET}`);
553
+ process.exit(1);
554
+ }
555
+
556
+ const list = statusFilter === 'all'
557
+ ? config.exceptions
558
+ : config.exceptions.filter(e => e.status === statusFilter);
559
+
560
+
561
+ if (list.length === 0) {
562
+ console.log(`\n${DIM} No ${statusFilter === 'all' ? '' : statusFilter + ' '}exceptions found.${RESET}\n`);
563
+ return;
564
+ }
565
+
566
+ const statusColor = (s) =>
567
+ s === 'approved' ? GREEN :
568
+ s === 'rejected' ? YELLOW :
569
+ DIM;
570
+
571
+ // Build a lookup map from (rule+file+line) → findingId using last scan cache
572
+ const findingIdMap = {};
573
+ try {
574
+ const { loadCache } = require('./scan-cache');
575
+ const cache = loadCache(repoRoot);
576
+ for (const f of (cache?.findings || [])) {
577
+ if (f.findingId) {
578
+ const key = `${f.ruleId}|${f.filePath}|${f.line}`;
579
+ findingIdMap[key] = f.findingId;
580
+ }
581
+ }
582
+ } catch { /* non-fatal */ }
583
+
584
+ console.log(`\n${BOLD}Exceptions${statusFilter !== 'all' ? ' (' + statusFilter + ')' : ''}:${RESET}\n`);
585
+
586
+ for (const ex of list) {
587
+ const sc = statusColor(ex.status);
588
+ const findingId = findingIdMap[`${ex.rule}|${ex.file}|${ex.line}`] || null;
589
+ console.log(` ${BOLD}${ex.id || '—'}${RESET} ${sc}[${ex.status}]${RESET} ${DIM}${ex.type}${RESET}`);
590
+ console.log(` ${DIM}Rule: ${RESET}${ex.rule}`);
591
+ console.log(` ${DIM}File: ${RESET}${ex.file}${ex.line ? ':' + ex.line : ''}${findingId ? ` ${DIM}${findingId}${RESET}` : ''}`);
592
+ console.log(` ${DIM}Reason: ${RESET}${ex.reason}`);
593
+ if (ex.tag) console.log(` ${DIM}Tag: ${RESET}${ex.tag}`);
594
+ if (ex.reviewed_by) console.log(` ${DIM}Reviewed by: ${RESET}${ex.reviewed_by}`);
595
+ if (ex.review_comment) console.log(` ${DIM}Comment: ${RESET}${ex.review_comment}`);
596
+ if (ex.status === 'rejected') {
597
+ console.log(` ${YELLOW}→ scd resolve --rejected ${ex.id}${RESET} ${DIM}(remove from local config)${RESET}`);
598
+ }
599
+ console.log('');
600
+ }
601
+ }
602
+
603
+ // ── Remove a rejected exception from local config by ID ──────────────────
604
+
605
+ function removeRejected(repoRoot, excId) {
606
+ const configPath = require('./store').configPath(repoRoot);
607
+
608
+ if (!fs.existsSync(configPath)) {
609
+ console.error(`${RED}❌ No config.yml found for this repo.${RESET}`);
610
+ process.exit(1);
611
+ }
612
+
613
+ let content = fs.readFileSync(configPath, 'utf8');
614
+
615
+ // Find the entry by id
616
+ const idx = content.indexOf(` - id: "${excId}"`);
617
+ if (idx === -1) {
618
+ console.error(`${RED}❌ Exception ${excId} not found in local config.${RESET}`);
619
+ console.error(`${DIM} Run 'scd exceptions --list rejected' to see available IDs.${RESET}`);
620
+ process.exit(1);
621
+ }
622
+
623
+ // Find the extent of the entry (until next entry or end of exceptions block)
624
+ const nextEntry = content.indexOf(' - id: "', idx + 1);
625
+ const entryEnd = nextEntry !== -1 ? nextEntry : content.length;
626
+ const entry = content.slice(idx, entryEnd);
627
+
628
+ // Verify it's rejected before removing
629
+ if (!entry.includes('status: "rejected"')) {
630
+ console.error(`${RED}❌ Exception ${excId} is not rejected — only rejected exceptions can be removed this way.${RESET}`);
631
+ process.exit(1);
632
+ }
633
+
634
+ // Extract server DB id from entry if present (stored as db_id field)
635
+ // Fall back to notifying server by rule+file+line if no db_id
636
+ const dbIdMatch = entry.match(/db_id:\s*(\d+)/);
637
+ const dbId = dbIdMatch ? parseInt(dbIdMatch[1], 10) : null;
638
+
639
+ content = content.slice(0, idx) + content.slice(entryEnd);
640
+
641
+ // Clean up empty exceptions block
642
+ content = content.replace(/^exceptions:\s*\n(\s*\n)*$/m, '');
643
+
644
+ fs.writeFileSync(configPath, content, 'utf8');
645
+
646
+ // Mark as resolved on server (fire-and-forget)
647
+ // We need the DB id — store it in handledExceptionIds and try to notify server
648
+ const { getCentralUrl, getCentralToken } = require('./global-config');
649
+ const centralUrl = getCentralUrl();
650
+ if (centralUrl && dbId) {
651
+ const token = getCentralToken();
652
+ const url = centralUrl.replace(/\/$/, '') + `/api/v1/exceptions/${dbId}/resolved`;
653
+ const http = url.startsWith('https') ? require('https') : require('http');
654
+ const parsed = new (require('url').URL)(url);
655
+ const opts = {
656
+ hostname: parsed.hostname,
657
+ port: parsed.port || (url.startsWith('https') ? 443 : 80),
658
+ path: parsed.pathname,
659
+ method: 'POST',
660
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Length': 0 },
661
+ };
662
+ const req = http.request(opts, (res) => { res.resume(); });
663
+ req.on('error', () => {}); // non-fatal
664
+ req.end();
665
+ }
666
+
667
+ // Store handled ID in meta so getSyncNotice doesn't re-show it
668
+ const { updateLastSynced, readMeta } = require('./store');
669
+ updateLastSynced(repoRoot, [excId]);
670
+
671
+ console.log(`\n${GREEN}✓ Rejected exception ${excId} removed from local config.${RESET}`);
672
+ console.log(`${DIM} The finding will be flagged normally in future scans.${RESET}\n`);
673
+ }
674
+
675
+ module.exports = { addException, addExceptionById, syncExceptions, getSyncNotice, listExceptions, removeRejected };