@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,200 @@
1
+ 'use strict';
2
+ const { RESET, BOLD, DIM, RED, GREEN, CYAN } = require('../output-constants');
3
+ // lib/commands/configure.js
4
+
5
+ module.exports = { register };
6
+
7
+ function register(program) {
8
+ program
9
+ .command('configure')
10
+ .description('Manage global configuration (API key etc.)')
11
+ .option('--show', 'Show current global configuration')
12
+ .option('--central-url <url>', 'Set scd-server URL (enables push queue)')
13
+ .option('--clear-central-url', 'Remove scd-server URL (disables push queue)')
14
+ .option('--token <token>', 'Set scd-server API token')
15
+ .option('--clear-token', 'Remove scd-server API token')
16
+ .option('--server-timeout <value>', 'Set server API timeout (e.g. 15s, 30s). Default: 30s')
17
+ .option('--deep-timeout <value>', 'Set deep analysis timeout (e.g. 10m, 20m). Default: 20m')
18
+ .option('--trust-level <value>', 'Set global default trust level (maximum_privacy|balanced|maximum_analysis)')
19
+ .option('--scan-mode <value>', 'Set global default scan mode (full|fast)')
20
+ .option('--block-on-high <value>', 'Set global default block-on-high (true|false)')
21
+ .option('--block-on-critical <value>', 'Set global default block-on-critical (true|false)')
22
+ .action((opts) => {
23
+ const { getCentralUrl, setCentralUrl, removeCentralUrl, getCentralToken, setCentralToken, removeCentralToken,
24
+ getServerTimeout, setServerTimeout, getDeepTimeout, setDeepTimeout, parseTimeoutArg,
25
+ GLOBAL_CONFIG } =
26
+ require('../global-config');
27
+
28
+
29
+ // ── --central-url <url> ───────────────────────────────────────────────
30
+ if (opts.centralUrl) {
31
+ const url = opts.centralUrl.trim();
32
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
33
+ console.error(`\n${RED}✗ Invalid URL – must start with http:// or https://${RESET}\n`);
34
+ process.exit(1);
35
+ }
36
+ setCentralUrl(url);
37
+ const savedUrl = getCentralUrl();
38
+ console.log(`\n${GREEN}✓ Central URL saved${RESET} → ${DIM}${savedUrl}${RESET}`);
39
+ if (savedUrl !== url) {
40
+ console.log(` ${DIM}(normalized from ${url})${RESET}`);
41
+ }
42
+ console.log(` ${DIM}Push queue enabled – events will sync on each scd command.${RESET}\n`);
43
+ process.exit(0);
44
+ }
45
+
46
+ // ── --clear-central-url ───────────────────────────────────────────────
47
+ if (opts.clearCentralUrl) {
48
+ const removed = removeCentralUrl();
49
+ if (removed) {
50
+ console.log(`\n${GREEN}✓ Central URL removed${RESET} – push queue disabled.\n`);
51
+ } else {
52
+ console.log(`\n${DIM}No central URL configured.${RESET}\n`);
53
+ }
54
+ process.exit(0);
55
+ }
56
+
57
+ // ── --token <token> ───────────────────────────────────────────────────
58
+ if (opts.token) {
59
+ const token = opts.token.trim();
60
+ if (!token.startsWith('scd-')) {
61
+ console.error(`\n${RED}✗ Invalid token format – scd-server tokens start with scd-${RESET}\n`);
62
+ process.exit(1);
63
+ }
64
+ setCentralToken(token);
65
+ console.log(`\n${GREEN}✓ Token saved${RESET} → ${DIM}${GLOBAL_CONFIG}${RESET}`);
66
+ console.log(` ${DIM}${token.slice(0, 12)}...${RESET}\n`);
67
+ process.exit(0);
68
+ }
69
+
70
+ // ── --clear-token ─────────────────────────────────────────────────────
71
+ if (opts.clearToken) {
72
+ const removed = removeCentralToken();
73
+ if (removed) {
74
+ console.log(`\n${GREEN}✓ Token removed${RESET} from ${DIM}${GLOBAL_CONFIG}${RESET}\n`);
75
+ } else {
76
+ console.log(`\n${DIM}No token to remove.${RESET}\n`);
77
+ }
78
+ process.exit(0);
79
+ }
80
+
81
+ // ── --server-timeout <value> ─────────────────────────────────────────
82
+ if (opts.serverTimeout !== undefined) {
83
+ try {
84
+ const ms = parseTimeoutArg(opts.serverTimeout);
85
+ setServerTimeout(ms);
86
+ const fmt = ms >= 60000 ? `${Math.round(ms/60000)}m` : `${Math.round(ms/1000)}s`;
87
+ console.log(`\n${GREEN}✓ Server timeout set to ${fmt} (${ms}ms)${RESET}\n`);
88
+ } catch (err) {
89
+ console.error(`\n${RED}❌ ${err.message}${RESET}\n`);
90
+ process.exit(1);
91
+ }
92
+ process.exit(0);
93
+ }
94
+
95
+ // ── --deep-timeout <value> ────────────────────────────────────────────
96
+ if (opts.deepTimeout !== undefined) {
97
+ try {
98
+ const ms = parseTimeoutArg(opts.deepTimeout);
99
+ setDeepTimeout(ms);
100
+ const fmt = ms >= 60000 ? `${Math.round(ms/60000)}m` : `${Math.round(ms/1000)}s`;
101
+ console.log(`\n${GREEN}✓ Deep analysis timeout set to ${fmt} (${ms}ms)${RESET}\n`);
102
+ } catch (err) {
103
+ console.error(`\n${RED}❌ ${err.message}${RESET}\n`);
104
+ process.exit(1);
105
+ }
106
+ process.exit(0);
107
+ }
108
+
109
+ // ── global repo defaults ─────────────────────────────────────────────
110
+ const VALID_TRUST = ['maximum_privacy', 'balanced', 'maximum_analysis'];
111
+ const VALID_MODES = ['full', 'fast'];
112
+
113
+ if (opts.trustLevel !== undefined) {
114
+ if (!VALID_TRUST.includes(opts.trustLevel)) {
115
+ console.error(`\n${RED}✗ Invalid trust level. Use: ${VALID_TRUST.join(' | ')}${RESET}\n`);
116
+ process.exit(1);
117
+ }
118
+ require('../global-config').set('REPO_TRUST_LEVEL', opts.trustLevel);
119
+ console.log(`\n${GREEN}✓ Global default trust_level set to ${opts.trustLevel}${RESET}\n`);
120
+ process.exit(0);
121
+ }
122
+
123
+ if (opts.scanMode !== undefined) {
124
+ if (!VALID_MODES.includes(opts.scanMode)) {
125
+ console.error(`\n${RED}✗ Invalid scan mode. Use: full | fast${RESET}\n`);
126
+ process.exit(1);
127
+ }
128
+ require('../global-config').set('REPO_SCAN_MODE', opts.scanMode);
129
+ console.log(`\n${GREEN}✓ Global default scan_mode set to ${opts.scanMode}${RESET}\n`);
130
+ process.exit(0);
131
+ }
132
+
133
+ if (opts.blockOnHigh !== undefined) {
134
+ const val = opts.blockOnHigh.toLowerCase();
135
+ if (val !== 'true' && val !== 'false') {
136
+ console.error(`\n${RED}✗ Invalid value. Use: true | false${RESET}\n`);
137
+ process.exit(1);
138
+ }
139
+ require('../global-config').set('REPO_BLOCK_ON_HIGH', val);
140
+ console.log(`\n${GREEN}✓ Global default block_on_high set to ${val}${RESET}\n`);
141
+ process.exit(0);
142
+ }
143
+
144
+ if (opts.blockOnCritical !== undefined) {
145
+ const val = opts.blockOnCritical.toLowerCase();
146
+ if (val !== 'true' && val !== 'false') {
147
+ console.error(`\n${RED}✗ Invalid value. Use: true | false${RESET}\n`);
148
+ process.exit(1);
149
+ }
150
+ require('../global-config').set('REPO_BLOCK_ON_CRITICAL', val);
151
+ console.log(`\n${GREEN}✓ Global default block_on_critical set to ${val}${RESET}\n`);
152
+ process.exit(0);
153
+ }
154
+
155
+ // ── --show (default if no flags) ──────────────────────────────────────
156
+ const centralUrl = getCentralUrl();
157
+ const gc = require('../global-config');
158
+
159
+ console.log(`\n${CYAN}${BOLD}Secure Code by Design – Global configuration${RESET}\n`);
160
+ console.log(` Central URL: ${centralUrl ? GREEN + centralUrl : DIM + '(not set – push queue disabled)'}${RESET}`);
161
+ console.log('');
162
+ if (centralUrl) {
163
+ const token = getCentralToken();
164
+ const { queueSize, staleCount } = require('../push-queue');
165
+ const pending = queueSize();
166
+ const stale = staleCount();
167
+ console.log(` Token: ${token ? DIM + token.slice(0, 12) + '...' + RESET : RED + '(not set)' + RESET}`);
168
+ console.log(` Queue: ${DIM}${pending} pending event(s)${stale > 0 ? ' ' + RED + stale + ' stale' + RESET : ''}${RESET}`);
169
+ const fmtMs = ms => ms >= 60000 ? `${Math.round(ms/60000)}m` : `${Math.round(ms/1000)}s`;
170
+ console.log(` Server timeout: ${DIM}${fmtMs(getServerTimeout())}${RESET} Deep timeout: ${DIM}${fmtMs(getDeepTimeout())}${RESET}`);
171
+ console.log('');
172
+ console.log(` ${DIM}Clear URL: scd configure --clear-central-url${RESET}`);
173
+ if (token) {
174
+ console.log(` ${DIM}Clear token: scd configure --clear-token${RESET}`);
175
+ } else {
176
+ console.log(` ${DIM}Set token: scd configure --token <token>${RESET}`);
177
+ }
178
+ } else {
179
+ console.log(` ${DIM}Set server URL: scd configure --central-url https://your-server:3000${RESET}`);
180
+ console.log(` ${DIM}Then set token: scd configure --token <token>${RESET}`);
181
+ }
182
+
183
+ // Show global repo defaults
184
+ const REPO_KEYS = ['trust_level','scan_mode','block_on_critical','block_on_high'];
185
+ const CODE_DEFAULTS = { trust_level: 'balanced', scan_mode: 'full', block_on_critical: true, block_on_high: true };
186
+ const hasAny = REPO_KEYS.some(k => gc.get('REPO_' + k.toUpperCase()) !== undefined);
187
+ console.log(` ${BOLD}Global repo defaults${RESET} ${DIM}(fallback for all repos unless overridden in config.yml)${RESET}`);
188
+ for (const key of REPO_KEYS) {
189
+ const raw = gc.get('REPO_' + key.toUpperCase());
190
+ const val = raw !== undefined ? raw : String(CODE_DEFAULTS[key]);
191
+ const src = raw !== undefined ? GREEN + val + RESET : DIM + val + ' (code default)' + RESET;
192
+ console.log(` ${DIM}${key.padEnd(20)}${RESET}${src}`);
193
+ }
194
+ console.log('');
195
+ console.log(` ${DIM}Change global repo defaults with: scd configure --trust-level <value>${RESET}`);
196
+ console.log(` ${DIM} scd configure --scan-mode <fast|full>${RESET}`);
197
+ console.log(` ${DIM} scd configure --block-on-high <true|false>${RESET}`);
198
+ console.log('');
199
+ });
200
+ }
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+ // lib/commands/doctor.js
3
+
4
+ module.exports = { register };
5
+
6
+ function register(program) {
7
+ program
8
+ .command('doctor')
9
+ .description('Check installation health')
10
+ .action(async () => {
11
+ const { doctor } = require('../doctor');
12
+ await doctor();
13
+ });
14
+ }
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+ // lib/commands/exceptions.js
3
+
4
+ module.exports = { register };
5
+
6
+ function register(program) {
7
+ program
8
+ .command('exceptions')
9
+ .description('List exceptions and ignores in the local store')
10
+ .option('--list <status>', 'Filter by status: pending | approved | rejected | all (default: all)')
11
+ .action(async (opts) => {
12
+ const { listExceptions } = require('../exception-manager');
13
+ const { getRepoRoot } = require('../config');
14
+ const { warnIfOutdated } = require('../cli-helpers');
15
+ const repoRoot = getRepoRoot();
16
+ listExceptions(repoRoot, opts.list || 'all');
17
+ warnIfOutdated();
18
+ });
19
+ }
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+ // lib/commands/export-findings.js
3
+
4
+ module.exports = { register };
5
+
6
+ function register(program) {
7
+ program
8
+ .command('export-findings')
9
+ .description('Export findings from a scan for external review')
10
+ .option('--scan <id>', 'Scan ID to export (default: latest scan)')
11
+ .option('--severity <level>', 'Filter by severity: critical, high, medium, exposure')
12
+ .option('--rule <id>', 'Filter to a specific rule ID')
13
+ .option('--deep-only', 'Export only findings that have a deep analysis result')
14
+ .option('--output <path>', 'Output file path (default: ~/.scd/repos/{id}/exports/scd-findings-{scanId}.json)')
15
+ .action(async (opts) => {
16
+ const path = require('path');
17
+ const store = require('../store');
18
+ const { exportFindings } = require('../export-findings');
19
+ const { loadCache } = require('../scan-cache');
20
+ const { getRepoRoot } = require('../config');
21
+ const repoRoot = getRepoRoot();
22
+
23
+ // Resolve scan ID first so we can use it in the default filename
24
+ let resolvedScanId = opts.scan || null;
25
+ if (!resolvedScanId) {
26
+ const latest = loadCache(repoRoot);
27
+ if (latest) resolvedScanId = latest.scanId;
28
+ }
29
+
30
+ const defaultName = 'scd-findings-' + (resolvedScanId || 'scan') + '.json';
31
+ const outputPath = opts.output
32
+ ? path.resolve(process.cwd(), opts.output)
33
+ : store.exportPath(repoRoot, defaultName);
34
+
35
+ await exportFindings({
36
+ repoRoot,
37
+ scanId: opts.scan || null,
38
+ severity: opts.severity || null,
39
+ rule: opts.rule || null,
40
+ deepOnly: !!opts.deepOnly,
41
+ outputPath,
42
+ includeRuleInternals: false,
43
+ command: 'export-findings',
44
+ });
45
+ });
46
+ }
@@ -0,0 +1,306 @@
1
+ 'use strict';
2
+ const { RESET, BOLD, DIM, RED, GREEN, YELLOW, BLUE, CYAN } = require('../output-constants');
3
+ // lib/commands/findings.js
4
+
5
+ module.exports = { register, findingsAction };
6
+
7
+ async function findingsAction(findingId, opts) {
8
+ const { loadCache, loadScan, cacheAge } = require('../scan-cache');
9
+ const { getRepoRoot } = require('../config');
10
+ const { warnIfOutdated } = require('../cli-helpers');
11
+ const repoRoot = getRepoRoot();
12
+
13
+
14
+ const SEV_ICON = { CRITICAL: '🔴', HIGH: '🟠', MEDIUM: '🟡', EXPOSURE: '🔷', INFO: '⬜' };
15
+ const SEV_COLOR = { CRITICAL: RED, HIGH: YELLOW, MEDIUM: YELLOW, EXPOSURE: BLUE, INFO: DIM };
16
+
17
+ // Load scan data
18
+ let scan = null;
19
+ let isHistoric = false;
20
+ if (opts.scan) {
21
+ scan = loadScan(repoRoot, opts.scan);
22
+ isHistoric = true;
23
+ if (!scan) {
24
+ console.error(`\nRED❌ Scan not found: ${opts.scan}${RESET}`);
25
+ console.error(`${DIM} Run scd repo scans to list available scans${RESET}\n`);
26
+ process.exit(1);
27
+ }
28
+ } else {
29
+ scan = loadCache(repoRoot);
30
+ if (!scan) {
31
+ console.error(`\nRED❌ No scan found for this repo.${RESET}`);
32
+ console.error(`${DIM} Run scd scan first${RESET}\n`);
33
+ process.exit(1);
34
+ }
35
+ }
36
+
37
+ // ── --show-suppressed mode ─────────────────────────────────────────────
38
+ // Entirely separate display path — suppressed findings are a different data
39
+ // set with different fields (base_severity, context_modifiers, suppress_reason).
40
+ if (opts.showSuppressed) {
41
+ const suppressed = scan.suppressed_findings || [];
42
+ const scanAge = scan.scanDate ? cacheAge(scan.scanDate) : 'unknown';
43
+ const scanLabel = opts.scan ? `Scan ${scan.scanId}` : 'Last scan';
44
+
45
+ console.log(`\n${BOLD}Suppressed Findings${RESET} ${DIM}${scanLabel} · ${scanAge} · suppressed by file context${RESET}`);
46
+ console.log(`${DIM} These findings were detected but suppressed — their effective severity fell below threshold.${RESET}`);
47
+ console.log(`${DIM}${'─'.repeat(64)}${RESET}\n`);
48
+
49
+ if (suppressed.length === 0) {
50
+ console.log(`${DIM} No suppressed findings in this scan.${RESET}\n`);
51
+ return;
52
+ }
53
+
54
+ // Apply --rule filter if given
55
+ let toShow = suppressed;
56
+ if (opts.rule) {
57
+ toShow = toShow.filter(f => f.ruleId === opts.rule);
58
+ }
59
+
60
+ // Group by file
61
+ const byFile = {};
62
+ for (const f of toShow) {
63
+ if (!byFile[f.filePath]) byFile[f.filePath] = [];
64
+ byFile[f.filePath].push(f);
65
+ }
66
+
67
+ for (const [filePath, fileFindings] of Object.entries(byFile).sort()) {
68
+ console.log(` ${BOLD}${filePath}${RESET}`);
69
+ for (const f of fileFindings) {
70
+ const baseSev = f.base_severity || '?';
71
+ const basIcon = SEV_ICON[baseSev] || '⬜';
72
+ const basColor = SEV_COLOR[baseSev] || DIM;
73
+ const fid = f.findingId ? ` ${DIM}${f.findingId}${RESET}` : '';
74
+ const line = f.line ? `:${f.line}` : '';
75
+ // Show base severity (what the rule said) → suppressed
76
+ console.log(` ${basIcon} ${basColor}${f.name}${RESET} ${DIM}${f.ruleId}${line}${RESET}${fid} ${DIM}[suppressed]${RESET}`);
77
+ if (f.snippet && f.snippet !== '[REDACTED]') {
78
+ const snip = f.snippet.trim().slice(0, 80);
79
+ console.log(` ${DIM}${snip}${snip.length === 80 ? '…' : ''}${RESET}`);
80
+ }
81
+ // File context that drove suppression
82
+ if (f.file_context) {
83
+ const fc = f.file_context;
84
+ const fcParts = [fc.file_type];
85
+ if (fc.test_framework) fcParts.push(fc.test_framework);
86
+ if (fc.language) fcParts.push(fc.language);
87
+ console.log(` ${DIM}context: ${fcParts.join(' · ')}${RESET}`);
88
+ }
89
+ // Show each modifier that contributed
90
+ if (f.context_modifiers && f.context_modifiers.length > 0) {
91
+ for (const m of f.context_modifiers) {
92
+ console.log(` ${DIM}modifier: ${m.signal} (${m.modifier})${RESET}`);
93
+ }
94
+ }
95
+ // Suppress reason
96
+ if (f.suppress_reason) {
97
+ console.log(` ${DIM}reason: ${f.suppress_reason}${RESET}`);
98
+ }
99
+ console.log('');
100
+ }
101
+ console.log('');
102
+ }
103
+
104
+ console.log(`${DIM} ${toShow.length} suppressed finding(s)${RESET}`);
105
+ console.log(`${DIM} Base severity shown — effective score fell to ≤ 0 after context modifiers.${RESET}\n`);
106
+ return;
107
+ }
108
+
109
+ // ── Normal findings mode ───────────────────────────────────────────────
110
+
111
+ let findings = scan.findings || [];
112
+ const showAll = opts.all || opts.excepted || !!findingId; // single finding searches all
113
+ const showExcepted = opts.excepted;
114
+ const showVerbose = opts.verbose || !!findingId; // single finding always verbose
115
+ const scanAge = scan.scanDate ? cacheAge(scan.scanDate) : 'unknown';
116
+
117
+ // Re-evaluate exception status against current config.yml — a finding may have been
118
+ // accepted/ignored since the last scan without re-running the scan.
119
+ if (!isHistoric && repoRoot) {
120
+ try {
121
+ const { loadConfig, isExcepted } = require('../config');
122
+ const cfg = loadConfig(repoRoot);
123
+ findings = findings.map(f => {
124
+ if (f.excepted) return f; // already marked excepted in cache
125
+ const lineContent = f.snippet && f.snippet !== '[REDACTED]' ? f.snippet : null;
126
+ const result = isExcepted(cfg, f, lineContent);
127
+ if (result.excepted) return { ...f, excepted: true };
128
+ if (result.rejected) return { ...f, rejected: true };
129
+ return f;
130
+ });
131
+ } catch { /* non-fatal — fall back to cached values */ }
132
+ }
133
+
134
+ // If a specific findingId was given, filter to that one finding and show verbose
135
+ if (findingId) {
136
+ findings = findings.filter(f => f.findingId === findingId);
137
+ if (findings.length === 0) {
138
+ console.error(`\nRED❌ Finding ${findingId} not found in this scan.${RESET}`);
139
+ console.error(`${DIM} Run scd findings to see all finding IDs${RESET}\n`);
140
+ process.exit(1);
141
+ }
142
+ }
143
+
144
+ // Apply --open filter (default) — exclude excepted and resolved
145
+ if (!showAll) {
146
+ findings = findings.filter(f => !f.excepted && !f.resolved);
147
+ }
148
+
149
+ // Apply --excepted filter
150
+ if (showExcepted) {
151
+ findings = findings.filter(f => f.excepted);
152
+ }
153
+
154
+ // Apply --severity filter
155
+ if (opts.severity) {
156
+ const sev = opts.severity.toUpperCase();
157
+ findings = findings.filter(f => f.severity === sev);
158
+ }
159
+
160
+ // Apply --rule filter
161
+ if (opts.rule) {
162
+ findings = findings.filter(f => f.ruleId === opts.rule);
163
+ }
164
+
165
+ // Header
166
+ const scanLabel = opts.scan ? `Scan ${scan.scanId}` : `Last scan`;
167
+ const modeLabel = findingId
168
+ ? `finding ${findingId}`
169
+ : showExcepted ? 'excepted findings' : showAll ? 'all findings' : 'open findings only';
170
+ console.log(`\n${BOLD}Findings${RESET} ${DIM}${scanLabel} · ${scanAge} · ${modeLabel}${RESET}`);
171
+ if (!showAll) {
172
+ console.log(`${DIM} Showing unhandled findings. Use --all to include excepted and resolved.${RESET}`);
173
+ }
174
+ if (isHistoric && opts.open) {
175
+ console.log(`${YELLOW} Note: --open on a historic scan reflects exception status at scan time.${RESET}`);
176
+ }
177
+ // Hint about suppressed findings if any exist in this scan
178
+ const suppressedCount = (scan.suppressed_findings || []).length;
179
+ if (suppressedCount > 0) {
180
+ console.log(`${DIM} ${suppressedCount} finding(s) suppressed by file context · scd findings --show-suppressed${RESET}`);
181
+ }
182
+ console.log(`${DIM}${'─'.repeat(64)}${RESET}\n`);
183
+
184
+ if (findings.length === 0) {
185
+ if (showExcepted) {
186
+ console.log(`${DIM} No excepted findings in this scan.${RESET}\n`);
187
+ } else if (!showAll) {
188
+ console.log(`${GREEN} ✅ No open findings.${RESET}${opts.severity || opts.rule ? '' : ' All findings are excepted or resolved.'}\n`);
189
+ } else {
190
+ console.log(`${DIM} No findings match the current filters.${RESET}\n`);
191
+ }
192
+ return;
193
+ }
194
+
195
+ // Group by file
196
+ const byFile = {};
197
+ for (const f of findings) {
198
+ if (!byFile[f.filePath]) byFile[f.filePath] = [];
199
+ byFile[f.filePath].push(f);
200
+ }
201
+
202
+ const SEV_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, EXPOSURE: 3, INFO: 4 };
203
+
204
+ for (const [filePath, filefindings] of Object.entries(byFile).sort()) {
205
+ console.log(` ${BOLD}${filePath}${RESET}`);
206
+ const sorted = [...filefindings].sort((a, b) =>
207
+ (SEV_ORDER[a.severity] ?? 9) - (SEV_ORDER[b.severity] ?? 9)
208
+ );
209
+ for (const f of sorted) {
210
+ const icon = SEV_ICON[f.severity] || '⬜';
211
+ const color = SEV_COLOR[f.severity] || DIM;
212
+ const fid = f.findingId ? ` ${DIM}${f.findingId}${RESET}` : '';
213
+ const exc = f.excepted ? ` ${DIM}[excepted]${RESET}` : '';
214
+ const res = f.resolved ? ` ${DIM}[resolved]${RESET}` : '';
215
+ const line = f.line ? `:${f.line}` : '';
216
+ // Show severity downgrade hint when context modifiers reduced severity
217
+ const sevDowngrade = (f.base_severity && f.base_severity !== f.severity)
218
+ ? ` ${DIM}↓ ${f.base_severity} → ${f.severity}${RESET}`
219
+ : '';
220
+ console.log(` ${icon} ${color}${f.name}${RESET} ${DIM}${f.ruleId}${line}${RESET}${fid}${exc}${res}${sevDowngrade}`);
221
+ if (f.snippet && f.snippet !== '[REDACTED]') {
222
+ const snip = f.snippet.trim().slice(0, 80);
223
+ console.log(` ${DIM}${snip}${snip.length === 80 ? '…' : ''}${RESET}`);
224
+ }
225
+ if (showVerbose) {
226
+ if (f.why) {
227
+ console.log(`\n ${BOLD}Problem${RESET}`);
228
+ const whyWords = f.why.split(' ');
229
+ let whyLine = ' ';
230
+ for (const word of whyWords) {
231
+ if (whyLine.length + word.length > 79) { console.log(whyLine); whyLine = ' ' + word + ' '; }
232
+ else whyLine += word + ' ';
233
+ }
234
+ if (whyLine.trim()) console.log(whyLine);
235
+ }
236
+ if (f.scenario) {
237
+ console.log(`\n ${BOLD}Scenario${RESET}`);
238
+ // Word-wrap at 72 chars
239
+ const words = f.scenario.split(' ');
240
+ let line2 = ' ';
241
+ for (const word of words) {
242
+ if (line2.length + word.length > 79) {
243
+ console.log(line2);
244
+ line2 = ' ' + word + ' ';
245
+ } else {
246
+ line2 += word + ' ';
247
+ }
248
+ }
249
+ if (line2.trim()) console.log(line2);
250
+ }
251
+ if (f.fix) {
252
+ console.log(`\n ${BOLD}Fix${RESET}`);
253
+ const words = f.fix.split(' ');
254
+ let line2 = ' ';
255
+ for (const word of words) {
256
+ if (line2.length + word.length > 79) {
257
+ console.log(line2);
258
+ line2 = ' ' + word + ' ';
259
+ } else {
260
+ line2 += word + ' ';
261
+ }
262
+ }
263
+ if (line2.trim()) console.log(line2);
264
+ }
265
+ // Show context modifiers in verbose mode when severity was adjusted
266
+ if (f.context_modifiers && f.context_modifiers.length > 0) {
267
+ console.log(`\n ${BOLD}File context${RESET}`);
268
+ for (const m of f.context_modifiers) {
269
+ console.log(` ${DIM}${m.signal} (${m.modifier})${RESET}`);
270
+ }
271
+ }
272
+ console.log('');
273
+ }
274
+ }
275
+ console.log('');
276
+ }
277
+
278
+ // Summary + hints
279
+ const counts = {};
280
+ for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
281
+ const parts = ['CRITICAL','HIGH','MEDIUM','EXPOSURE']
282
+ .filter(s => counts[s])
283
+ .map(s => `${SEV_ICON[s]} ${counts[s]} ${s.toLowerCase()}`);
284
+ console.log(`${DIM} ${findings.length} finding(s)${parts.length ? ': ' + parts.join(' ') : ''}${RESET}`);
285
+ if (!showAll && !showExcepted && findings.length > 0) {
286
+ console.log(`${DIM} scd accept <finding-id> --reason "..." or scd ignore <finding-id> --reason "..."${RESET}`);
287
+ }
288
+ console.log('');
289
+ warnIfOutdated();
290
+ }
291
+
292
+ function register(program) {
293
+ program
294
+ .command('findings [findingId]')
295
+ .description('List findings from the last scan (default: open/unhandled only)')
296
+ .option('--all', 'Show all findings including excepted and resolved')
297
+ .option('--severity <level>', 'Filter by severity: critical, high, medium, exposure')
298
+ .option('--rule <id>', 'Filter by rule ID (e.g. JS-ERR-002)')
299
+ .option('--scan <id>', 'Load a specific scan by ID instead of last scan')
300
+ .option('--excepted', 'Show only excepted findings')
301
+ .option('--show-suppressed', 'Show findings suppressed by file context (test files, vendor code, etc.)')
302
+ .option('--verbose', 'Show problem description, attack scenario, and fix for each finding')
303
+ .action(async (findingId, opts) => {
304
+ await findingsAction(findingId, opts);
305
+ });
306
+ }
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+ const { RESET, DIM, RED } = require('../output-constants');
3
+ // lib/commands/ignore.js
4
+
5
+ module.exports = { register };
6
+
7
+ function register(program) {
8
+ program
9
+ .command('ignore [findingId]')
10
+ .description('Ignore a finding (requires team-lead approval via scd-server)')
11
+ .option('--reason <text>', 'Reason for ignoring this finding (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 ignore <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, 'ignore');
27
+ });
28
+ }
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+ // lib/commands/init.js
3
+
4
+ module.exports = { register };
5
+
6
+ function register(program) {
7
+ program
8
+ .command('init')
9
+ .description('Initialise Secure Code by Design in this repo and install git hooks')
10
+ .action(async () => {
11
+ const { initRepo } = require('../init-repo');
12
+ const { getRepoRoot } = require('../config');
13
+ const repoRoot = getRepoRoot();
14
+ await initRepo(repoRoot);
15
+ });
16
+ }
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+ const { RESET } = require('../output-constants');
3
+ // lib/commands/insights.js
4
+
5
+ module.exports = { register };
6
+
7
+ function register(program) {
8
+ program
9
+ .command('insights')
10
+ .description('Analyze behavioral patterns and knowledge gaps from the audit log')
11
+ .option('--days <n>', 'Analyze the last N days (default: 90)', '90')
12
+ .action(async (opts) => {
13
+ const { analyzeInsights } = require('../insights-analyzer');
14
+ const { renderInsights } = require('../insights-output');
15
+ const { getRepoRoot } = require('../config');
16
+ const repoRoot = getRepoRoot();
17
+ const days = Math.max(1, parseInt(opts.days) || 90);
18
+
19
+ console.log(`\nDIM↺ Analyzing audit log (last ${days} days)…${RESET}`);
20
+
21
+ const analysis = await analyzeInsights(repoRoot, { days });
22
+ renderInsights(analysis);
23
+ });
24
+ }
@@ -0,0 +1,15 @@
1
+ 'use strict';
2
+ // lib/commands/install.js
3
+
4
+ module.exports = { register };
5
+
6
+ function register(program) {
7
+ const { Command } = require('commander');
8
+ const cmd = new Command('install')
9
+ .description('Install global git hooks on this machine')
10
+ .action(async () => {
11
+ const { install } = require('../installer');
12
+ await install();
13
+ });
14
+ program.addCommand(cmd);
15
+ }