@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,172 @@
1
+ /**
2
+ * audit-sync.js
3
+ * Sync audit.log history to scd-server.
4
+ *
5
+ * Used by `scd sync --history` to send existing local findings to the server.
6
+ * Safe to re-run — server uses INSERT OR IGNORE (idempotent).
7
+ *
8
+ * What is synced:
9
+ * - findings_batch events reconstructed from FINDING_* events in audit.log
10
+ * - Grouped by session_id so each scan becomes one batch
11
+ * - scan_completed events for any sessions not already on the server
12
+ *
13
+ * What is NOT synced:
14
+ * - SCAN_STARTED, SCAN_PASSED, SCAN_BLOCKED (server already has these via push queue)
15
+ * - Events without a session_id
16
+ */
17
+
18
+ 'use strict';
19
+ const { RESET, YELLOW } = require('./output-constants');
20
+
21
+ const fs = require('fs');
22
+ const store = require('./store');
23
+ const { EVENTS } = require('./audit');
24
+
25
+ const FINDING_EVENTS = new Set([
26
+ EVENTS.FINDING_BLOCKED,
27
+ EVENTS.FINDING_WARNED,
28
+ EVENTS.FINDING_EXCEPTED,
29
+ EVENTS.FINDING_EXCEPTION_EXPIRED,
30
+ ]);
31
+
32
+ /**
33
+ * Read entire audit.log without limit.
34
+ */
35
+ function readFullAuditLog(repoRoot) {
36
+ const p = store.auditPath(repoRoot);
37
+ if (!fs.existsSync(p)) return [];
38
+ return fs.readFileSync(p, 'utf8')
39
+ .split('\n').filter(Boolean)
40
+ .map(line => { try { return JSON.parse(line); } catch { return null; } })
41
+ .filter(Boolean);
42
+ }
43
+
44
+ /**
45
+ * Reconstruct findings_batch events from audit.log.
46
+ * Groups FINDING_* events by session_id.
47
+ * Returns array of findings_batch objects ready for push-queue.
48
+ */
49
+ function buildFindingsBatches(events) {
50
+ const sessions = new Map(); // session_id → { hook, ts, findings[] }
51
+
52
+ for (const e of events) {
53
+ if (!e.session_id) continue;
54
+ if (!FINDING_EVENTS.has(e.event)) continue;
55
+
56
+ if (!sessions.has(e.session_id)) {
57
+ sessions.set(e.session_id, {
58
+ session_id: e.session_id,
59
+ hook: e.hook || 'manual',
60
+ ts: e.timestamp,
61
+ findings: [],
62
+ });
63
+ }
64
+
65
+ sessions.get(e.session_id).findings.push({
66
+ rule_id: e.rule_id,
67
+ rule_name: e.rule_name || null,
68
+ category: e.category || null,
69
+ severity: e.severity,
70
+ file: e.file,
71
+ line: e.line || null,
72
+ snippet: e.snippet || null,
73
+ taint_source: e.taint_source || null,
74
+ excepted: e.excepted || false,
75
+ blocked: e.event === EVENTS.FINDING_BLOCKED,
76
+ exception_id: e.exception_id || null,
77
+ });
78
+ }
79
+
80
+ // Return as findings_batch events, skip sessions with no findings
81
+ return Array.from(sessions.values())
82
+ .filter(s => s.findings.length > 0)
83
+ .map(s => ({
84
+ type: 'findings_batch',
85
+ session_id: s.session_id,
86
+ hook: s.hook,
87
+ ts: s.ts,
88
+ findings: s.findings,
89
+ }));
90
+ }
91
+
92
+ /**
93
+ * Sync full audit.log history to scd-server.
94
+ * Chunks into batches of 50 sessions to avoid huge payloads.
95
+ * Returns { sessions, findings, errors }.
96
+ */
97
+ async function syncHistory(repoRoot) {
98
+ const { getCentralUrl, getCentralToken } = require('./global-config');
99
+ const centralUrl = getCentralUrl();
100
+ if (!centralUrl) {
101
+ return { error: 'No scd-server configured. Run: scd configure --central-url <url>' };
102
+ }
103
+
104
+ const token = getCentralToken();
105
+ if (!token) {
106
+ return { error: 'No API token configured. Run: scd configure --central-url <url>' };
107
+ }
108
+
109
+ const identity = store.getRepoIdentity(repoRoot);
110
+ const repoId = store.getRepoId(repoRoot);
111
+ const meta = store.readMeta(repoRoot) || {};
112
+
113
+ const events = readFullAuditLog(repoRoot);
114
+ if (events.length === 0) {
115
+ return { sessions: 0, findings: 0, errors: 0, message: 'No audit log entries found.' };
116
+ }
117
+
118
+ const batches = buildFindingsBatches(events);
119
+ if (batches.length === 0) {
120
+ return { sessions: 0, findings: 0, errors: 0, message: 'No finding events in audit log.' };
121
+ }
122
+
123
+ // Chunk into groups of 10 sessions to keep payloads manageable
124
+ const CHUNK_SIZE = 10;
125
+ let totalFindings = 0;
126
+ let totalErrors = 0;
127
+
128
+ for (let i = 0; i < batches.length; i += CHUNK_SIZE) {
129
+ const chunk = batches.slice(i, i + CHUNK_SIZE);
130
+ const chunkFindings = chunk.reduce((n, b) => n + b.findings.length, 0);
131
+
132
+ try {
133
+ const res = await fetch(centralUrl + '/api/v1/events/batch', {
134
+ method: 'POST',
135
+ headers: {
136
+ 'Content-Type': 'application/json',
137
+ 'Authorization': 'Bearer ' + token,
138
+ },
139
+ body: JSON.stringify({
140
+ events: chunk,
141
+ meta: {
142
+ repoId: repoId,
143
+ repoName: meta.name || null,
144
+ repoRemote: meta.remote || null,
145
+ installationId: meta.installationId || null,
146
+ hostname: require('os').hostname(),
147
+ platform: process.platform,
148
+ scdVersion: require('../package.json').version,
149
+ },
150
+ }),
151
+ });
152
+
153
+ if (!res.ok) {
154
+ const body = await res.json().catch(() => ({}));
155
+ throw new Error(body.error || `HTTP ${res.status}`);
156
+ }
157
+
158
+ totalFindings += chunkFindings;
159
+ } catch (err) {
160
+ totalErrors++;
161
+ console.error(`${YELLOW} [sync] Chunk ${Math.floor(i/CHUNK_SIZE)+1} failed: ${err.message}${RESET}`);
162
+ }
163
+ }
164
+
165
+ return {
166
+ sessions: batches.length,
167
+ findings: totalFindings,
168
+ errors: totalErrors,
169
+ };
170
+ }
171
+
172
+ module.exports = { syncHistory, buildFindingsBatches, readFullAuditLog };
package/lib/audit.js ADDED
@@ -0,0 +1,356 @@
1
+ /**
2
+ * audit.js
3
+ * Append-only audit trail for all security events.
4
+ *
5
+ * All data lives in ~/.scd/repos/{repoId}/
6
+ * Nothing is written inside the user's git repository.
7
+ *
8
+ * Two files per repo:
9
+ * audit.log – Full detail log (rule IDs, file paths, git user, machine)
10
+ * audit-summary.log – Anonymised statistics only (counts, dates, no paths)
11
+ *
12
+ * Future tiers (Team/Professional):
13
+ * Events pushed to scd-server via push queue (push-queue.js).
14
+ * The repo remains completely untouched.
15
+ */
16
+
17
+ 'use strict';
18
+ const { RESET, DIM } = require('./output-constants');
19
+
20
+ const fs = require('fs');
21
+ const os = require('os');
22
+ const crypto = require('crypto');
23
+ const store = require('./store');
24
+
25
+ // ── Event types ────────────────────────────────────────────────────────────
26
+ const EVENTS = {
27
+ SCAN_STARTED: 'scan_started',
28
+ FINDING_BLOCKED: 'finding_blocked',
29
+ FINDING_WARNED: 'finding_warned',
30
+ FINDING_EXCEPTED: 'finding_excepted',
31
+ FINDING_EXCEPTION_EXPIRED: 'finding_exception_expired',
32
+ SCAN_PASSED: 'scan_passed',
33
+ SCAN_BLOCKED: 'scan_blocked',
34
+ CONFIG_LOADED: 'config_loaded',
35
+ CONFIG_NOT_FOUND: 'config_not_found',
36
+ RISK_ACCEPTED: 'risk_accepted',
37
+ EXPOSURE_RESOLVED: 'exposure_resolved',
38
+ HOOKS_DISABLED: 'hooks_disabled',
39
+ HOOKS_ENABLED: 'hooks_enabled',
40
+ };
41
+
42
+ // ── Git user ───────────────────────────────────────────────────────────────
43
+ function getGitUser() {
44
+ try {
45
+ const { execSync } = require('child_process');
46
+ const name = execSync('git config user.name', { encoding: 'utf8' }).trim();
47
+ const email = execSync('git config user.email', { encoding: 'utf8' }).trim();
48
+ return { name, email };
49
+ } catch {
50
+ return { name: 'unknown', email: 'unknown' };
51
+ }
52
+ }
53
+
54
+ // ── Build base event ───────────────────────────────────────────────────────
55
+ function buildEvent(type, data = {}) {
56
+ const gitUser = getGitUser();
57
+ return {
58
+ timestamp: new Date().toISOString(),
59
+ event: type,
60
+ git_user: gitUser.email,
61
+ git_name: gitUser.name,
62
+ machine: os.hostname(),
63
+ platform: process.platform,
64
+ ...data,
65
+ };
66
+ }
67
+
68
+ // ── Append event to full audit log ────────────────────────────────────────
69
+ function logEvent(repoRoot, type, data = {}) {
70
+ try {
71
+ const event = buildEvent(type, data);
72
+ fs.appendFileSync(store.auditPath(repoRoot), JSON.stringify(event) + '\n');
73
+ return event;
74
+ } catch (err) {
75
+ console.error(`${DIM}[scd] Audit log warning: ${err.message}${RESET}`);
76
+ }
77
+ }
78
+
79
+ // ── Append anonymised entry to summary log ────────────────────────────────
80
+ function logSummaryEntry(repoRoot, summary) {
81
+ try {
82
+ fs.appendFileSync(store.auditSummaryPath(repoRoot), JSON.stringify(summary) + '\n');
83
+ } catch (err) {
84
+ console.error(`${DIM}[scd] Summary log warning: ${err.message}${RESET}`);
85
+ }
86
+ }
87
+
88
+
89
+ // ── Build exclusions summary for audit log ────────────────────────────────
90
+
91
+ /**
92
+ * Build the exclusions block logged to audit.log in SCAN_STARTED.
93
+ * Includes full metadata: pattern/rule, count, source, reason, added_by, added_at.
94
+ */
95
+ function buildAuditExclusions(scopeExclusions, findings) {
96
+ const ruleExclusionCounts = findings?._ruleExclusionCounts || {};
97
+
98
+ return {
99
+ files: (scopeExclusions.file_excludes || []).map(e => ({
100
+ pattern: e.pattern,
101
+ files_excluded: scopeExclusions.files_excluded || 0,
102
+ source: e._source || 'repo',
103
+ reason: e.reason || null,
104
+ added_by: e.added_by || null,
105
+ added_at: e.added_at || null,
106
+ })),
107
+ rules: (scopeExclusions.rule_excludes || []).map(e => ({
108
+ rule: e.rule,
109
+ scoped_to: e.files || null,
110
+ findings_excluded: ruleExclusionCounts[e.rule] || 0,
111
+ source: e._source || 'repo',
112
+ reason: e.reason || null,
113
+ added_by: e.added_by || null,
114
+ added_at: e.added_at || null,
115
+ })),
116
+ };
117
+ }
118
+
119
+ // ── Log a complete scan session ────────────────────────────────────────────
120
+ function logScan(repoRoot, { hookType, files, findings, blocked, exceptions_applied, scanId, noSync, scanMode, repoContext, repoContextChanged, scopeExclusions = null }) {
121
+ // Use the scanId from scan-cache (s-XXXXXXXX format) for full CLI↔server traceability.
122
+ // Falls back to generating a random ID if called without one (e.g. from hooks).
123
+
124
+ const sessionId = scanId || ('s-' + crypto.randomBytes(4).toString('hex'));
125
+
126
+ store.updateMeta(repoRoot, {
127
+ findingCount: findings.length,
128
+ criticalCount: findings.filter(f => f.severity === 'CRITICAL').length,
129
+ });
130
+
131
+ logEvent(repoRoot, EVENTS.SCAN_STARTED, {
132
+ session_id: sessionId,
133
+ hook: hookType,
134
+ scan_mode: scanMode || 'full',
135
+ files_count: files.length,
136
+ files: files.map(f => f.filePath),
137
+ exclusions: scopeExclusions ? buildAuditExclusions(scopeExclusions, findings) : null,
138
+ });
139
+
140
+ for (const f of findings) {
141
+ const eventType = f.excepted
142
+ ? (f.exception_expired ? EVENTS.FINDING_EXCEPTION_EXPIRED : EVENTS.FINDING_EXCEPTED)
143
+ : (f.blocks ? EVENTS.FINDING_BLOCKED : EVENTS.FINDING_WARNED);
144
+
145
+ logEvent(repoRoot, eventType, {
146
+ session_id: sessionId,
147
+ hook: hookType,
148
+ rule_id: f.ruleId,
149
+ rule_name: f.name,
150
+ category: f.category || null,
151
+ severity: f.severity,
152
+ file: f.filePath,
153
+ line: f.line,
154
+ snippet: f.snippet || null,
155
+ taint_source: f.taintSource || null,
156
+ action: f.action,
157
+ excepted: f.excepted || false,
158
+ exception_id: f.exception?.id || null,
159
+ exception_by: f.exception?.approved_by || null,
160
+ });
161
+ }
162
+
163
+ const outcomeType = blocked ? EVENTS.SCAN_BLOCKED : EVENTS.SCAN_PASSED;
164
+ logEvent(repoRoot, outcomeType, {
165
+ session_id: sessionId,
166
+ hook: hookType,
167
+ total_findings: findings.length,
168
+ blocked_findings: findings.filter(f => f.blocks && !f.excepted).length,
169
+ excepted_findings: findings.filter(f => f.excepted).length,
170
+ expired_exceptions: findings.filter(f => f.exception_expired).length,
171
+ });
172
+
173
+ // Anonymised summary – counts only, no paths or identities
174
+ logSummaryEntry(repoRoot, {
175
+ date: new Date().toISOString().slice(0, 10),
176
+ session_id: sessionId,
177
+ hook: hookType,
178
+ scan_mode: scanMode || 'full',
179
+ files_scanned: files.length,
180
+ findings_total: findings.length,
181
+ findings_critical: findings.filter(f => f.severity === 'CRITICAL').length,
182
+ findings_high: findings.filter(f => f.severity === 'HIGH').length,
183
+ findings_exposure: findings.filter(f => f.severity === 'EXPOSURE').length,
184
+ blocked,
185
+ exceptions_applied: findings.filter(f => f.excepted).length,
186
+ });
187
+
188
+ // ── Push queue integration ───────────────────────────────────────────────
189
+ // If a central URL is configured, add a compact scan summary to the queue.
190
+ // Push to scd-server (unless --no-sync or no central URL configured)
191
+ if (!noSync) {
192
+ try {
193
+ const { getCentralUrl } = require('./global-config');
194
+ const centralUrl = getCentralUrl();
195
+ if (centralUrl) {
196
+ const { enqueue } = require('./push-queue');
197
+
198
+ // Build category breakdown: { "Injection (OWASP A03)": { critical, high, medium, exposure } }
199
+ const categories = {};
200
+ for (const f of findings) {
201
+ if (!f.category) continue;
202
+ if (!categories[f.category]) {
203
+ categories[f.category] = { critical: 0, high: 0, medium: 0, exposure: 0 };
204
+ }
205
+ const sev = (f.severity || '').toLowerCase();
206
+ if (categories[f.category][sev] !== undefined) {
207
+ categories[f.category][sev]++;
208
+ }
209
+ }
210
+
211
+ // Build top rules: [{ id, name, severity, count }] sorted by count desc, max 20
212
+ const ruleCounts = {};
213
+ for (const f of findings) {
214
+ if (!f.ruleId) continue;
215
+ if (!ruleCounts[f.ruleId]) {
216
+ ruleCounts[f.ruleId] = { id: f.ruleId, name: f.name || f.ruleId, severity: f.severity, count: 0 };
217
+ }
218
+ ruleCounts[f.ruleId].count++;
219
+ }
220
+ const top_rules = Object.values(ruleCounts)
221
+ .sort((a, b) => b.count - a.count)
222
+ .slice(0, 20);
223
+
224
+ enqueue({
225
+ type: 'scan_completed',
226
+ session_id: sessionId,
227
+ hook: hookType,
228
+ scan_mode: scanMode || 'full',
229
+ files_scanned: files.length,
230
+ findings_total: findings.length,
231
+ findings_critical: findings.filter(f => f.severity === 'CRITICAL').length,
232
+ findings_high: findings.filter(f => f.severity === 'HIGH').length,
233
+ findings_medium: findings.filter(f => f.severity === 'MEDIUM').length,
234
+ findings_exposure: findings.filter(f => f.severity === 'EXPOSURE').length,
235
+ blocked,
236
+ exceptions_applied: findings.filter(f => f.excepted).length,
237
+ categories,
238
+ top_rules,
239
+ ts: new Date().toISOString(),
240
+ });
241
+
242
+ // Enqueue full findings batch for drill-down on server
243
+ if (findings.length > 0) {
244
+ enqueue({
245
+ type: 'findings_batch',
246
+ session_id: sessionId,
247
+ hook: hookType,
248
+ ts: new Date().toISOString(),
249
+ findings: findings.map(f => ({
250
+ rule_id: f.ruleId,
251
+ rule_name: f.name,
252
+ category: f.category || null,
253
+ severity: f.severity,
254
+ file: f.filePath,
255
+ line: f.line,
256
+ snippet: f.snippet || null,
257
+ code_hash: f.codeHash || null,
258
+ finding_id: f.findingId || null,
259
+ taint_source: f.taintSource || null,
260
+ excepted: f.excepted || false,
261
+ blocked: f.blocks || false,
262
+ exception_id: f.exception?.id || null,
263
+ })),
264
+ });
265
+ }
266
+
267
+ // Send repo context if manifests changed since last scan
268
+ if (repoContext && repoContextChanged) {
269
+ enqueue({
270
+ type: 'repo_context',
271
+ session_id: sessionId,
272
+ ts: new Date().toISOString(),
273
+ languages: repoContext.languages || [],
274
+ frameworks: repoContext.frameworks || [],
275
+ manifests: repoContext.manifests || [],
276
+ dependencies: repoContext.dependencies || {},
277
+ });
278
+ }
279
+ }
280
+ } catch {
281
+ // Non-fatal — push queue is best-effort
282
+ }
283
+ }
284
+
285
+ return sessionId;
286
+ }
287
+
288
+ // ── Read audit log ─────────────────────────────────────────────────────────
289
+ function readAuditLog(repoRoot, limit = 100) {
290
+ const p = store.auditPath(repoRoot);
291
+ if (!fs.existsSync(p)) return [];
292
+ return fs.readFileSync(p, 'utf8')
293
+ .split('\n').filter(Boolean).slice(-limit)
294
+ .map(line => { try { return JSON.parse(line); } catch { return null; } })
295
+ .filter(Boolean);
296
+ }
297
+
298
+ // ── Read summary log ───────────────────────────────────────────────────────
299
+ function readSummaryLog(repoRoot, limit = 100) {
300
+ const p = store.auditSummaryPath(repoRoot);
301
+ if (!fs.existsSync(p)) return [];
302
+ return fs.readFileSync(p, 'utf8')
303
+ .split('\n').filter(Boolean).slice(-limit)
304
+ .map(line => { try { return JSON.parse(line); } catch { return null; } })
305
+ .filter(Boolean);
306
+ }
307
+
308
+ // ── Quick summary for terminal ─────────────────────────────────────────────
309
+ function getRecentSummary(repoRoot) {
310
+ const events = readAuditLog(repoRoot, 200);
311
+ if (events.length === 0) return null;
312
+ const scans = events.filter(e => e.event === EVENTS.SCAN_STARTED);
313
+ const blocked = events.filter(e => e.event === EVENTS.FINDING_BLOCKED);
314
+ const excepted = events.filter(e => e.event === EVENTS.FINDING_EXCEPTED);
315
+ const expired = events.filter(e => e.event === EVENTS.FINDING_EXCEPTION_EXPIRED);
316
+ return {
317
+ total_scans: scans.length,
318
+ total_blocked: blocked.length,
319
+ total_excepted: excepted.length,
320
+ expired_exceptions: expired.length,
321
+ last_scan: scans[scans.length - 1]?.timestamp,
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Log a hook disable/enable operation to audit.log and push-queue.
327
+ * Always requires a reason — hook bypasses must be intentional and visible.
328
+ */
329
+ function logHooks(repoRoot, { action, reason, noSync }) {
330
+ const eventType = action === 'disable' ? EVENTS.HOOKS_DISABLED : EVENTS.HOOKS_ENABLED;
331
+
332
+ logEvent(repoRoot, eventType, {
333
+ action,
334
+ reason,
335
+ });
336
+
337
+ // Push to scd-server if configured (premium — visible to team leads)
338
+ if (!noSync) {
339
+ try {
340
+ const { getCentralUrl } = require('./global-config');
341
+ if (getCentralUrl()) {
342
+ const { enqueue } = require('./push-queue');
343
+ enqueue({
344
+ type: 'hooks_' + action + 'd',
345
+ action,
346
+ reason,
347
+ ts: new Date().toISOString(),
348
+ });
349
+ }
350
+ } catch { /* non-fatal */ }
351
+ }
352
+ }
353
+
354
+ module.exports = {
355
+ logEvent, logScan, logHooks, readAuditLog, readSummaryLog, getRecentSummary, EVENTS,
356
+ };
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+ const { RESET, DIM, YELLOW } = require('./output-constants');
3
+ /**
4
+ * cli-helpers.js — Shared CLI utilities used across multiple commands.
5
+ *
6
+ * Extracted from bin/scd.js during Phase 3 refactoring.
7
+ * All functions were previously defined inline at the top of bin/scd.js.
8
+ */
9
+
10
+ /**
11
+ * Print a version warning if the local CLI is below the server's minimum
12
+ * required version. Reads from cached value — no network call.
13
+ * Non-fatal: only shown when a central URL is configured.
14
+ * opts.toStderr — write to stderr instead of stdout (for hook mode)
15
+ */
16
+ function warnIfOutdated(opts = {}) {
17
+ try {
18
+ // Only warn when a server is configured — not in standalone mode
19
+ const globalCfg = require('./global-config');
20
+ const url = globalCfg.getCentralUrl();
21
+ if (!url) return;
22
+
23
+ const { getVersionWarning } = require('./version-check');
24
+ const warn = getVersionWarning();
25
+ if (warn) {
26
+ const out = opts.toStderr ? process.stderr : process.stdout;
27
+ out.write('\n' + YELLOW + warn + RESET + '\n');
28
+ }
29
+ } catch { /* never break a command */ }
30
+ }
31
+
32
+ /**
33
+ * Handles platform differences correctly:
34
+ * macOS → open
35
+ * Linux → xdg-open
36
+ * Windows → start with empty title arg to avoid new terminal window
37
+ */
38
+ function openInBrowser(target) {
39
+ const { spawn } = require('child_process');
40
+ if (process.platform === 'darwin') {
41
+ spawn('open', [target], { detached: true, stdio: 'ignore' }).unref();
42
+ } else if (process.platform === 'win32') {
43
+ // 'start' requires shell:true and empty string as window title
44
+ // to avoid opening a new terminal window instead of the browser
45
+ spawn('cmd', ['/c', 'start', '', target], { detached: true, stdio: 'ignore', shell: false }).unref();
46
+ } else {
47
+ spawn('xdg-open', [target], { detached: true, stdio: 'ignore' }).unref();
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Awaitable push-queue flush — called before process.exit() in scan commands.
53
+ * Non-blocking: resolves immediately if no central URL or empty queue.
54
+ * opts.noSync — skip flush entirely (--no-sync flag)
55
+ */
56
+ async function tryFlush(opts = {}) {
57
+ if (opts.noSync) return; // --no-sync: skip push to scd-server
58
+ try {
59
+ const { getCentralUrl, getCentralToken, getMinCliVersion, setServerVersionInfo } = require('./global-config');
60
+ const centralUrl = getCentralUrl();
61
+ if (!centralUrl) return;
62
+ const { flush, queueSize } = require('./push-queue');
63
+
64
+ if (queueSize() === 0) {
65
+ // Queue empty — no flush needed, but fetch version info if not yet cached.
66
+ // Fire-and-forget: never blocks or throws.
67
+ if (!getMinCliVersion()) {
68
+ const token = getCentralToken();
69
+ const baseUrl = centralUrl.replace(/\/$/, '');
70
+ const http = baseUrl.startsWith('https') ? require('https') : require('http');
71
+ new Promise((resolve) => {
72
+ const req = http.get(
73
+ baseUrl + '/api/v1/health',
74
+ { headers: token ? { 'Authorization': `Bearer ${token}` } : {}, timeout: 4000 },
75
+ (res) => {
76
+ let data = '';
77
+ res.on('data', chunk => { data += chunk; });
78
+ res.on('end', () => {
79
+ try {
80
+ const body = JSON.parse(data);
81
+ if (body.version || body.min_cli_version) {
82
+ setServerVersionInfo(body.version || null, body.min_cli_version || null);
83
+ }
84
+ } catch { /* ignore */ }
85
+ resolve();
86
+ });
87
+ }
88
+ );
89
+ req.on('timeout', () => { req.destroy(); resolve(); });
90
+ req.on('error', () => resolve());
91
+ }).catch(() => {});
92
+ }
93
+ return;
94
+ }
95
+
96
+ const repoRoot = (() => {
97
+ try { return require('./config').getRepoRoot(); } catch { return null; }
98
+ })();
99
+ const status = await flush(centralUrl, { repoRoot });
100
+ if (status === 'license_invalid') {
101
+ console.log(YELLOW + ' ⚠ Server license invalid — scan data queued locally.' + RESET);
102
+ console.log(DIM + ' Data will sync automatically when the license is restored.' + RESET);
103
+ console.log(DIM + ' Contact your scd-server administrator.' + RESET);
104
+ }
105
+ } catch { /* non-fatal */ }
106
+ }
107
+
108
+ module.exports = { warnIfOutdated, openInBrowser, tryFlush };
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+ const { RESET, DIM, RED } = require('../output-constants');
3
+ // lib/commands/accept.js
4
+
5
+ module.exports = { register };
6
+
7
+ function register(program) {
8
+ program
9
+ .command('accept [findingId]')
10
+ .description('Accept a finding as an acceptable risk (requires team-lead approval via scd-server)')
11
+ .option('--reason <text>', 'Reason why this risk is accepted (required)')
12
+ .option('--tag <tag>', 'Optional tag for filtering (e.g. false_positive, out_of_scope, third_party)')
13
+ .action(async (findingId, opts) => {
14
+ const { addExceptionById } = require('../exception-manager');
15
+ const { getRepoRoot } = require('../config');
16
+ const repoRoot = getRepoRoot();
17
+ if (!findingId) {
18
+ console.error(RED + '❌ Finding ID required. Run scd findings to see IDs.' + RESET);
19
+ console.error(DIM + ' Usage: scd accept <finding-id> --reason "..."' + RESET);
20
+ process.exit(1);
21
+ }
22
+ if (!opts.reason) {
23
+ console.error(RED + '❌ --reason is required.' + RESET);
24
+ process.exit(1);
25
+ }
26
+ await addExceptionById(repoRoot, findingId, opts, 'exception');
27
+ });
28
+ }
@@ -0,0 +1,17 @@
1
+ 'use strict';
2
+ // lib/commands/audit.js
3
+
4
+ module.exports = { register };
5
+
6
+ function register(program) {
7
+ program
8
+ .command('audit')
9
+ .description('Show recent audit log')
10
+ .option('--limit <n>', 'Number of events', '50')
11
+ .action(async (opts) => {
12
+ const { showAuditReport } = require('../audit-report');
13
+ const { getRepoRoot } = require('../config');
14
+ const repoRoot = getRepoRoot();
15
+ await showAuditReport(repoRoot, parseInt(opts.limit));
16
+ });
17
+ }