@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,519 @@
1
+ 'use strict';
2
+ const { RESET, DIM, RED, YELLOW, CYAN } = require('../output-constants');
3
+ // lib/commands/scan.js
4
+
5
+ module.exports = { register };
6
+
7
+ function register(program) {
8
+ const pkg = require('../../package.json');
9
+
10
+ program
11
+ .command('scan [targets...]')
12
+ .description('Run security scan – hook mode (automatic) or manual')
13
+ .option('--hook <type>', 'Hook mode: pre-commit or pre-push (run by git hooks)')
14
+ .option('--lang <lang>', 'Limit to language: js, ts, py, php ...')
15
+ .option('--severity <level>', 'Show only: CRITICAL, HIGH, EXPOSURE ...')
16
+ .option('--rule <id>', 'Show only specific rule: INJ-001, JWT-001 ...')
17
+ .option('--format <fmt>', 'Output format: terminal (default), html, json', 'terminal')
18
+ .option('--output <file>', 'Save report to file (used with --format html/json)')
19
+ .option('--no-limit', 'Scan files above size limit (30s timeout/file – may be slow)')
20
+ .option('--deep', 'Enable Claude API deep analysis of CRITICAL/HIGH findings')
21
+ .option('--deep-delay <ms>', 'Delay in ms between --deep API calls (overrides config deep_delay_ms)')
22
+ .option('--no-audit', 'Skip audit logging for this scan')
23
+ .option('--no-sync', 'Skip push to scd-server for this scan (audit log kept locally)')
24
+ .option('--include-vendor', 'Include vendor/dependency code in scan (node_modules, site-packages, vendor/ etc.)')
25
+ .option('--vendor-only', 'Scan only vendor/dependency code (supply chain audit)')
26
+ .option('--include-ignored', 'Scan files ignored by .gitignore (default: respect .gitignore)')
27
+ .option('--exclude <pattern>', 'Exclude a file/directory for this scan only (repeatable, not saved)', (v, a) => [...a, v], [])
28
+ .option('--exclude-rule <ruleId>', 'Exclude a rule for this scan only (repeatable, not saved)', (v, a) => [...a, v], [])
29
+ .option('--log-to <mode>', 'Logging mode for non-interactive use: none, current, target (default: prompt if TTY, none if not)')
30
+ .option('--verbose', 'Show full file-grouped and rule-grouped output (default: compact summary)')
31
+ .action(async (targets, opts) => {
32
+ const { scanSecrets } = require('../scanner-secrets');
33
+ const { scanFull } = require('../scanner-full');
34
+ const { formatTerminal } = require('../output-terminal');
35
+ const { getChangedFiles } = require('../git-utils');
36
+ const { loadConfig, getRepoRoot } = require('../config');
37
+ const { resolveTargetContext, checkRepoOverlap } = require('../scan-context');
38
+ const { logScan } = require('../audit');
39
+ const { saveCache, loadCache, cacheAge, makeScanId } = require('../scan-cache');
40
+ const { warnIfOutdated, tryFlush } = require('../cli-helpers');
41
+
42
+ const cwdRepoRoot = getRepoRoot(); // provisional — may be overridden by target context
43
+ let repoRoot = cwdRepoRoot;
44
+ let skipLogging = false;
45
+ const config = loadConfig(repoRoot);
46
+
47
+ // Validate vendor flags
48
+ if (opts.includeVendor && opts.vendorOnly) {
49
+ console.error(RED + '❌ --include-vendor and --vendor-only cannot be used together' + RESET);
50
+ process.exit(1);
51
+ }
52
+
53
+ // ── Hook mode (called by git pre-commit/pre-push) ──────────────────
54
+ if (opts.hook) {
55
+ // Check for overlapping repos — warn but don't block (hooks are non-interactive)
56
+ if (repoRoot) await checkRepoOverlap(repoRoot, { interactive: false });
57
+
58
+ // Hook mode: git-tracked changed files only — vendor code never appears here
59
+ const files = await getChangedFiles(opts.hook);
60
+ if (files.length === 0) {
61
+ console.log(DIM + '[scd] No files to scan.' + RESET);
62
+ process.exit(0);
63
+ }
64
+
65
+ console.log(`\n${CYAN}╔══════════════════════════════════════════╗${RESET}`);
66
+ const _vt = 'Secure Code by Design v' + pkg.version;
67
+ const _pl = Math.floor((42 - _vt.length) / 2);
68
+ const _pr = 42 - _vt.length - _pl;
69
+ console.log(`${CYAN}║${ ' '.repeat(_pl)}${_vt}${ ' '.repeat(_pr)}║${RESET}`);
70
+ console.log(`${CYAN}╚══════════════════════════════════════════╝${RESET}`);
71
+ console.log(`${DIM} Scanning ${files.length} file(s) – hook: ${opts.hook}${RESET}\n`);
72
+
73
+ const scopeExclusions = null; // Hook mode: scope not applied (changed files only)
74
+ const findings = opts.hook === 'pre-commit'
75
+ ? await scanSecrets(files, config)
76
+ : await scanFull(files, config, null);
77
+
78
+ const suppressedFindings = findings._suppressedFindings || [];
79
+
80
+ const blocked = findings.some(f => f.blocks && !f.excepted);
81
+ const scanId = makeScanId();
82
+
83
+ logScan(repoRoot, {
84
+ hookType: opts.hook, files, findings, blocked,
85
+ exceptions_applied: findings.filter(f => f.excepted).length,
86
+ scanId,
87
+ noSync: opts.sync === false,
88
+ scanMode: config.scan_mode || 'full',
89
+ });
90
+
91
+ const { output, exitCode } = formatTerminal(findings, opts.hook, config, { verbose: opts.verbose, scopeExclusions });
92
+ console.log(output);
93
+
94
+ // Suppressed findings summary (hook mode — shown before exit)
95
+ if (suppressedFindings.length > 0) {
96
+ console.log(`${DIM} ${suppressedFindings.length} finding(s) suppressed by file context · scd findings --show-suppressed${RESET}\n`);
97
+ }
98
+
99
+ if (opts.sync !== false) await tryFlush(opts);
100
+ warnIfOutdated({ toStderr: true });
101
+ process.exit(exitCode);
102
+ }
103
+
104
+ // ── Manual mode ───────────────────────────────────────────────────
105
+ const { discoverFiles, filterFindings } = require('../scanner-manual');
106
+
107
+ // Commander with variadic [targets...] is unreliable across versions —
108
+ // it sometimes drops the last argument. Read process.argv directly instead.
109
+ //
110
+ // Strategy: find the last occurrence of 'scan' in argv (avoids matching
111
+ // 'scan' in the script path), then collect everything after it that is
112
+ // not an option flag (-x / --x) and not the VALUE of a known option flag.
113
+ const knownOptionFlags = new Set([
114
+ '--hook', '--lang', '--severity', '--rule', '--format', '--output', '--exclude', '--exclude-rule', '--log-to'
115
+ ]);
116
+ const allArgv = process.argv;
117
+ let scanIdx = -1;
118
+ for (let i = allArgv.length - 1; i >= 0; i--) {
119
+ if (allArgv[i] === 'scan') { scanIdx = i; break; }
120
+ }
121
+ const rawTargets = [];
122
+ if (scanIdx !== -1) {
123
+ let skipNext = false;
124
+ for (let i = scanIdx + 1; i < allArgv.length; i++) {
125
+ const a = allArgv[i];
126
+ if (skipNext) { skipNext = false; continue; }
127
+ if (knownOptionFlags.has(a)) { skipNext = true; continue; } // skip flag + its value
128
+ if (a.startsWith('-')) { continue; } // boolean flags
129
+ rawTargets.push(a);
130
+ }
131
+ }
132
+ const targetList = rawTargets.length > 0 ? rawTargets : ['.'];
133
+ const scanTarget = targetList.length === 1 ? targetList[0] : targetList.join(', ');
134
+
135
+ // ── Resolve repo context from target, not just CWD ──────────────────
136
+ // Prevents contaminating the wrong repo when scanning files outside CWD.
137
+ // --no-audit and --no-sync bypass this check (user explicitly opted out of logging).
138
+ if (!opts.noAudit && !opts.noSync) {
139
+ const ctx = await resolveTargetContext(targetList, cwdRepoRoot, { logTo: opts.logTo });
140
+ if (ctx.cancelled) {
141
+ console.log(DIM + ' Scan cancelled.' + RESET + '\n');
142
+ process.exit(0);
143
+ }
144
+ repoRoot = ctx.repoRoot || cwdRepoRoot;
145
+ skipLogging = ctx.skipLogging;
146
+ }
147
+ // ── Check for overlapping scd repos ────────────────────────────────
148
+ if (!skipLogging && repoRoot) {
149
+ const overlap = await checkRepoOverlap(repoRoot, { interactive: true });
150
+ if (overlap.cancelled) {
151
+ console.log(DIM + ' Scan cancelled.' + RESET + '\n');
152
+ process.exit(0);
153
+ }
154
+ if (overlap.skipLogging) skipLogging = true;
155
+ }
156
+ // ────────────────────────────────────────────────────────────────────
157
+
158
+ // Propagate skipLogging into opts so tryFlush is also suppressed
159
+ if (skipLogging) opts = { ...opts, noSync: true, noAudit: true };
160
+ // ─────────────────────────────────────────────────────────────────────
161
+
162
+ // Show discovering status — discoverFiles reads all files from disk which can take a moment
163
+ process.stderr.write('\r' + DIM + ' Discovering files…' + RESET);
164
+
165
+ let files = [], skipped = [], scopeExclusions = null, scope = null;
166
+ try {
167
+ if (targetList.length === 1) {
168
+ ({ files, skipped, scopeExclusions, scope } = discoverFiles(targetList[0], { lang: opts.lang, config, noLimit: opts.noLimit || false, includeVendor: !!opts.includeVendor, vendorOnly: !!opts.vendorOnly, includeIgnored: !!opts.includeIgnored, repoRoot }));
169
+ } else {
170
+ // Multiple targets (shell glob expansion): merge results, deduplicate by filePath
171
+ const seen = new Set();
172
+ for (const t of targetList) {
173
+ try {
174
+ const result = discoverFiles(t, { lang: opts.lang, config, noLimit: opts.noLimit || false, includeVendor: !!opts.includeVendor, vendorOnly: !!opts.vendorOnly, includeIgnored: !!opts.includeIgnored, repoRoot });
175
+ for (const f of result.files) { if (!seen.has(f.filePath)) { seen.add(f.filePath); files.push(f); } }
176
+ for (const s of result.skipped) { skipped.push(s); }
177
+ if (!scopeExclusions && result.scopeExclusions) { scopeExclusions = result.scopeExclusions; scope = result.scope; }
178
+ } catch { /* skip targets that don't resolve */ }
179
+ }
180
+ if (files.length === 0) throw new Error(`Hittade inga filer att scanna: ${scanTarget}`);
181
+ }
182
+ } catch (err) {
183
+ console.error(`\nRED❌ ${err.message}${RESET}\n`);
184
+ process.exit(1);
185
+ }
186
+
187
+ // Header
188
+ if (opts.noLimit) {
189
+ console.log(`\nYELLOW⚠️ --no-limit active – size limit disabled.${RESET}`);
190
+ console.log(`${DIM} Large files (>512KB) scanned with 30s timeout per file. May be slow.${RESET}`);
191
+ }
192
+ process.stderr.write('\r\x1b[K'); // clear discovering status
193
+ const langLabel = opts.lang ? ` [${opts.lang}]` : '';
194
+ const vendorLabel = opts.vendorOnly ? ' ' + YELLOW + '[vendor-only]' + RESET : opts.includeVendor ? ' ' + YELLOW + '[+vendor]' + RESET : '';
195
+ const ignoredLabel = opts.includeIgnored ? ' ' + YELLOW + '[+ignored]' + RESET : '';
196
+ console.log(`\n${CYAN}╔══════════════════════════════════════════╗${RESET}`);
197
+ const _vt2 = 'Secure Code by Design v' + pkg.version;
198
+ const _pl2 = Math.floor((42 - _vt2.length) / 2);
199
+ const _pr2 = 42 - _vt2.length - _pl2;
200
+ console.log(`${CYAN}║${ ' '.repeat(_pl2)}${_vt2}${ ' '.repeat(_pr2)}║${RESET}`);
201
+ console.log(`${CYAN}╚══════════════════════════════════════════╝${RESET}`);
202
+ console.log(`${DIM} Manual scan${langLabel}${vendorLabel}${ignoredLabel}: ${scanTarget}${RESET}`);
203
+ // For single-file and glob scans, scope is not loaded by discoverFiles.
204
+ // Load it here and filter out any files excluded by scope.
205
+ if (!scope && repoRoot) {
206
+ try {
207
+ const { loadScope, isFileExcluded, validateScope, summariseScope } = require('../scope');
208
+ const path = require('path');
209
+ scope = loadScope(repoRoot);
210
+ const scopeWarnings = validateScope(scope);
211
+ if (scope.file_excludes.length > 0) {
212
+ let scopeExcludedCount = 0;
213
+ files = files.filter(f => {
214
+ const abs = path.resolve(repoRoot, f.filePath);
215
+ const result = isFileExcluded(scope, abs, repoRoot);
216
+ if (result.excluded) { scopeExcludedCount++; return false; }
217
+ return true;
218
+ });
219
+ if (scopeExcludedCount > 0) {
220
+ const summary = summariseScope(scope);
221
+ scopeExclusions = {
222
+ files_excluded: scopeExcludedCount,
223
+ file_excludes: scope.file_excludes,
224
+ rule_excludes: scope.rule_excludes,
225
+ _summary: summary,
226
+ _warnings: scopeWarnings,
227
+ };
228
+ }
229
+ }
230
+ } catch { /* non-fatal — scope unavailable */ }
231
+ }
232
+
233
+ const _scopeFileCount = scopeExclusions?.files_excluded || 0;
234
+ const _scopeFileLabel = _scopeFileCount > 0 ? ` · ${_scopeFileCount} excluded (scope.yml)` : '';
235
+ console.log(`${DIM} ${files.length} file(s) found${skipped.length > 0 ? ` · ${skipped.length} skipped` : ''}${_scopeFileLabel}${RESET}`);
236
+
237
+ // ── --exclude / --exclude-rule one-off handling ───────────────────────
238
+ // Build one-off scope entries — same format as scope.yml but source: 'one-off'.
239
+ // Mergea into existing scope (never written to disk).
240
+ const oneOffExcludes = opts.exclude || [];
241
+ const oneOffRuleExcludes = opts.excludeRule || [];
242
+
243
+ if (oneOffExcludes.length > 0 || oneOffRuleExcludes.length > 0) {
244
+ const path = require('path');
245
+ const { isFileExcluded, isRuleExcluded } = require('../scope');
246
+
247
+ const { getMachineFingerprint } = require('../store');
248
+ const installId = getMachineFingerprint() || 'unknown';
249
+ const addedAt = new Date().toLocaleString('sv-SE', {
250
+ timeZone: 'Europe/Stockholm', year: 'numeric', month: '2-digit',
251
+ day: '2-digit', hour: '2-digit', minute: '2-digit',
252
+ }).replace(',', '');
253
+
254
+ // Build one-off scope object
255
+ const oneOffScope = {
256
+ file_excludes: oneOffExcludes.map(p => ({
257
+ pattern: p,
258
+ reason: '(one-off)',
259
+ added_by: installId,
260
+ added_at: addedAt,
261
+ source: 'one-off',
262
+ })),
263
+ rule_excludes: oneOffRuleExcludes.map(r => ({
264
+ rule: r,
265
+ files: null,
266
+ reason: '(one-off)',
267
+ added_by: installId,
268
+ added_at: addedAt,
269
+ source: 'one-off',
270
+ })),
271
+ };
272
+
273
+ // Filter files by one-off file excludes
274
+ let oneOffFileExcludedCount = 0;
275
+ if (oneOffScope.file_excludes.length > 0) {
276
+ const ignoreRoot = repoRoot || process.cwd();
277
+ files = files.filter(f => {
278
+ const abs = path.resolve(ignoreRoot, f.filePath);
279
+ const result = isFileExcluded(oneOffScope, abs, ignoreRoot);
280
+ if (result.excluded) { oneOffFileExcludedCount++; return false; }
281
+ return true;
282
+ });
283
+ }
284
+
285
+ // Merge into scope (for rule exclusion in scanFull)
286
+ if (!scope) scope = { file_excludes: [], rule_excludes: [] };
287
+ scope = {
288
+ file_excludes: [...(scope.file_excludes || []), ...oneOffScope.file_excludes],
289
+ rule_excludes: [...(scope.rule_excludes || []), ...oneOffScope.rule_excludes],
290
+ };
291
+
292
+ // Build display lines for one-off exclusions
293
+ const oneOffFileLines = oneOffExcludes.map(p => `${p} ${DIM}(one-off)${RESET}`);
294
+ const oneOffRuleLines = oneOffRuleExcludes.map(r => `${r} ${DIM}(one-off)${RESET}`);
295
+
296
+ // Merge into scopeExclusions for display
297
+ if (!scopeExclusions) {
298
+ scopeExclusions = { files_excluded: 0, file_excludes: [], rule_excludes: [], _summary: { hasExclusions: false, fileLines: [], ruleLines: [] }, _warnings: [] };
299
+ }
300
+ scopeExclusions.files_excluded = (scopeExclusions.files_excluded || 0) + oneOffFileExcludedCount;
301
+ scopeExclusions.file_excludes = [...(scopeExclusions.file_excludes || []), ...oneOffScope.file_excludes];
302
+ scopeExclusions.rule_excludes = [...(scopeExclusions.rule_excludes || []), ...oneOffScope.rule_excludes];
303
+ scopeExclusions._summary = scopeExclusions._summary || { hasExclusions: false, fileLines: [], ruleLines: [] };
304
+ scopeExclusions._summary.hasExclusions = true;
305
+ scopeExclusions._summary.fileLines = [...(scopeExclusions._summary.fileLines || []), ...oneOffFileLines];
306
+ scopeExclusions._summary.ruleLines = [...(scopeExclusions._summary.ruleLines || []), ...oneOffRuleLines];
307
+
308
+ if (oneOffFileExcludedCount > 0) {
309
+ console.log(`${DIM} ${oneOffFileExcludedCount} additional file(s) excluded (--exclude)${RESET}`);
310
+ }
311
+ }
312
+
313
+ // ── fast mode warning — shown early, before scan begins ───────────────
314
+ if ((config?.scan_mode || 'full') === 'fast') {
315
+ console.log(`\n ${YELLOW}⚠ fast mode — taint analysis disabled. Some CRITICAL findings may be missed.${RESET}`);
316
+ console.log(` ${DIM} Set scan_mode: full in config.yml to enable full scanning.${RESET}`);
317
+ }
318
+
319
+ // Show active scope exclusions warning
320
+ if (scopeExclusions?._summary?.hasExclusions) {
321
+ const s = scopeExclusions._summary;
322
+ console.log(`${YELLOW} ⚠ Active scope exclusions:${RESET}`);
323
+ for (const line of s.fileLines) console.log(`${YELLOW} Files : ${line.trim()}${RESET}`);
324
+ for (const line of s.ruleLines) console.log(`${YELLOW} Rules : ${line.trim()}${RESET}`);
325
+ }
326
+ // Show scope.yml validation warnings if any
327
+ if (scopeExclusions?._warnings?.length) {
328
+ for (const w of scopeExclusions._warnings) {
329
+ console.log(`${YELLOW} ⚠ scope.yml: "${w.identifier}" missing: ${w.missing.join(', ')} — run: scd repo scope --annotate${RESET}`);
330
+ }
331
+ }
332
+ if (skipLogging) {
333
+ const logToNote = opts.logTo === 'none' ? ' (--log-to none)' : ' — target is outside any known repository';
334
+ console.log(`${YELLOW} ↷ Scanning without logging${logToNote}${RESET}`);
335
+ }
336
+ console.log();
337
+
338
+ if (files.length === 0) {
339
+ console.log(YELLOW + ' No supported files found.' + RESET);
340
+ console.log(`${DIM} Supported extensions: .js .ts .jsx .tsx .mjs .py .php${RESET}\n`);
341
+ process.exit(0);
342
+ }
343
+
344
+ // Scan – always full OWASP + secrets in manual mode
345
+ let findings = await scanFull(files, config, scope);
346
+
347
+ // Extract suppressed findings before applying CLI filters.
348
+ // CLI filters (--severity, --rule) apply to active findings only —
349
+ // suppressed findings are always kept as a complete audit set.
350
+ const suppressedFindings = findings._suppressedFindings || [];
351
+
352
+ // Apply CLI filters (--severity, --rule)
353
+ findings = filterFindings(findings, { severity: opts.severity, rule: opts.rule });
354
+
355
+ // Show saving status during audit + cache write
356
+ if (process.stderr.isTTY) process.stderr.write('\r' + DIM + ' Analyzing and saving results…' + RESET);
357
+
358
+ // Parse repo context from manifest files (package.json, requirements.txt, etc.)
359
+ const { parseRepoContext, saveRepoContext } = require('../repo-context');
360
+ const repoContext = parseRepoContext(repoRoot);
361
+ let repoContextChanged = false;
362
+
363
+ // Create one scanId — shared by audit log and scan file for full traceability
364
+ const scanId = makeScanId();
365
+
366
+ // Notify if --no-sync is active
367
+ if (opts.sync === false) {
368
+ process.stderr.write('\r\x1b[K');
369
+ console.log(DIM + ' ↷ --no-sync: results saved locally, not pushed to scd-server' + RESET);
370
+ }
371
+
372
+ // Save repo context if changed
373
+ if (!skipLogging && repoContext) {
374
+ repoContextChanged = saveRepoContext(repoRoot, repoContext);
375
+ }
376
+
377
+ // Audit (unless --no-audit)
378
+ if (opts.audit !== false) {
379
+ if (!skipLogging) logScan(repoRoot, {
380
+ hookType: 'manual', files, findings,
381
+ blocked: false, // manual scan never blocks
382
+ exceptions_applied: findings.filter(f => f.excepted).length,
383
+ scanId,
384
+ noSync: opts.sync === false,
385
+ scanMode: config.scan_mode || 'full',
386
+ repoContext: repoContext || null,
387
+ repoContextChanged: repoContextChanged || false,
388
+ });
389
+ }
390
+
391
+ if (process.stderr.isTTY) process.stderr.write('\r\x1b[K'); // clear saving status
392
+
393
+ // Output
394
+ if (opts.format === 'json') {
395
+ const jsonOut = JSON.stringify({ scan: 'manual', target: scanTarget, findings, suppressed_findings: suppressedFindings }, null, 2);
396
+ if (opts.output) {
397
+ const fs = require('fs');
398
+ const path = require('path');
399
+ const outPath = path.resolve(process.cwd(), opts.output);
400
+ fs.writeFileSync(outPath, jsonOut, 'utf8');
401
+ console.log(`\nGREEN✓ JSON-rapport sparad: ${outPath}${RESET}\n`);
402
+ } else {
403
+ console.log(jsonOut);
404
+ }
405
+ await tryFlush(opts);
406
+ process.exit(0);
407
+ }
408
+
409
+ if (opts.format === 'html') {
410
+ const { generateReport, writeReport } = require('../report-html');
411
+ const path = require('path');
412
+
413
+ const defaultName = `security-report-${new Date().toISOString().split('T')[0]}.html`;
414
+ const outPath = path.resolve(process.cwd(), opts.output || defaultName);
415
+
416
+ const html = generateReport(findings, {
417
+ target: scanTarget,
418
+ scanDate: new Date(),
419
+ totalFiles: files.length,
420
+ skipped,
421
+ repoRoot: repoRoot || process.cwd(),
422
+ });
423
+
424
+ writeReport(html, outPath);
425
+
426
+ // Terminal summary first
427
+ const timedOut2 = findings._timedOut || [];
428
+ const { output: termOut } = formatTerminal(findings, 'manual', config, { skipped, timedOut: timedOut2, verbose: opts.verbose, scopeExclusions });
429
+ console.log(termOut);
430
+
431
+ // Suppressed findings summary
432
+ if (suppressedFindings.length > 0) {
433
+ console.log(`${DIM} ${suppressedFindings.length} finding(s) suppressed by file context · scd findings --show-suppressed${RESET}\n`);
434
+ }
435
+
436
+ // OSC 8 clickable link to report (same terminal detection as output-terminal.js)
437
+ const term = process.env.TERM_PROGRAM || '';
438
+ const supportsOsc8 = ['iTerm.app', 'vscode', 'WarpTerminal', 'ghostty', 'JetBrains'].some(t => term.includes(t));
439
+ const fileUri = `file://${outPath}`;
440
+ const linkText = outPath;
441
+
442
+ if (supportsOsc8) {
443
+ const osc8Link = `\x1b]8;;${fileUri}\x07${linkText}\x1b]8;;\x07`;
444
+ console.log(`\n${GREEN}✓ HTML report:${RESET} ${CYAN}${osc8Link}${RESET}`);
445
+ } else {
446
+ console.log(`\n${GREEN}✓ HTML report:${RESET} ${CYAN}${linkText}${RESET}`);
447
+ }
448
+ console.log(`${DIM} open ${outPath}${RESET}\n`);
449
+ await tryFlush(opts);
450
+ process.exit(0);
451
+ }
452
+
453
+ const timedOut = findings._timedOut || [];
454
+ const { output } = formatTerminal(findings, 'manual', config, { skipped, timedOut, verbose: opts.verbose, scopeExclusions });
455
+ console.log(output);
456
+
457
+ // ── Suppressed findings summary ──────────────────────────────────────
458
+ // Shown after the findings list when suppressions exist.
459
+ // Design rule: never silent — always tell the user something was suppressed.
460
+ if (suppressedFindings.length > 0) {
461
+ console.log(`${DIM} ${suppressedFindings.length} finding(s) suppressed by file context · scd findings --show-suppressed${RESET}\n`);
462
+ }
463
+
464
+ // ── Sync notice ──────────────────────────────────────────────────────
465
+ try {
466
+ const { getSyncNotice } = require('../exception-manager');
467
+ const notice = getSyncNotice(repoRoot);
468
+ if (notice) console.log(' ' + notice + '\n');
469
+ } catch { /* non-fatal */ }
470
+
471
+ // ── Deep analysis (--deep) ───────────────────────────────────────────
472
+ let deepResults = null;
473
+ if (opts.deep) {
474
+ // maximum_privacy blocks all external calls — deep analysis not permitted
475
+ if (config.trust_level === 'maximum_privacy') {
476
+ console.log('\nYELLOW⚠️ --deep is disabled when trust_level is maximum_privacy.' + RESET);
477
+ console.log(DIM + ' Set trust_level: balanced in ~/.scd/repos/{repoId}/config.yml to enable.' + RESET + '\n');
478
+ } else {
479
+ const { deepAnalyze, formatDeepSection } = require('../deep-analyzer');
480
+ const { getCentralUrl, getCentralToken } = require('../global-config');
481
+
482
+ const centralUrl = getCentralUrl();
483
+ const token = getCentralToken();
484
+ const repoId = require('../store').getRepoId(repoRoot);
485
+
486
+ deepResults = await deepAnalyze(findings, {
487
+ centralUrl,
488
+ token,
489
+ repoId,
490
+ scanId,
491
+ trustLevel: config.trust_level || 'balanced',
492
+ verbose: true,
493
+ });
494
+
495
+ const deepOutput = formatDeepSection(findings, deepResults);
496
+ if (deepOutput) console.log(deepOutput);
497
+ }
498
+ }
499
+
500
+ // Cache findings (including suppressed findings for audit trail and --show-suppressed)
501
+ if (!skipLogging) saveCache(repoRoot, {
502
+ findings,
503
+ suppressed_findings: suppressedFindings,
504
+ target: scanTarget,
505
+ totalFiles: files.length,
506
+ skipped,
507
+ scanDate: new Date(),
508
+ deepResults: deepResults ? Array.from(deepResults.entries()) : null,
509
+ repoRoot: repoRoot || process.cwd(),
510
+ scanMode: config.scan_mode || 'full',
511
+ scopeExclusions,
512
+ }, scanId);
513
+
514
+ // Manual scan: always exit 0 (informational, never blocks workflow)
515
+ await tryFlush(opts);
516
+ warnIfOutdated();
517
+ process.exit(0);
518
+ });
519
+ }