@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
package/lib/config.js ADDED
@@ -0,0 +1,325 @@
1
+ const { RESET, YELLOW } = require('./output-constants');
2
+ /**
3
+ * config.js
4
+ * Loads per-repo configuration from ~/.scd/repos/{repoId}/config.yml
5
+ * Never reads from the customer repository — zero repo footprint.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const crypto = require('crypto');
11
+
12
+ const CONFIG_FILENAME = 'config.yml'; // lives in global store, not in repo
13
+
14
+ const DEFAULTS = {
15
+ trust_level: 'balanced', // maximum_privacy | balanced | maximum_analysis
16
+ block_on_critical: true, // always blocks (cannot be disabled by rule_overrides)
17
+ block_on_high: true, // set to false to warn only
18
+ scan_mode: 'full', // 'full' (default) | 'fast' (skips taint analysis)
19
+ locked_rules: [
20
+ 'SECRET-001', 'SECRET-002', 'SECRET-003',
21
+ 'SECRET-006', 'SECRET-007', 'JWT-001',
22
+ ],
23
+ deep_delay_ms: 0, // ms pause between API calls in scd scan --deep (0 = no delay)
24
+ };
25
+
26
+ // ── YAML parser ────────────────────────────────────────────────────────────
27
+ // Hand-rolled subset parser – avoids external deps.
28
+ // Supports: top-level scalars, top-level sections (objects), lists of objects,
29
+ // nested object properties (rule_overrides: { RULE: { action, reason } })
30
+ function parseSimpleYaml(content) {
31
+ const result = {};
32
+ const lines = content.split('\n');
33
+
34
+ let currentSection = null; // top-level key
35
+ let currentItem = null; // current list item object
36
+ let currentSubKey = null; // e.g. "SECRET-005" inside rule_overrides
37
+ let inList = false;
38
+
39
+ for (const raw of lines) {
40
+ const line = raw.replace(/\r$/, '');
41
+ const trimmed = line.trim();
42
+ if (!trimmed || trimmed.startsWith('#')) continue;
43
+
44
+ const indent = line.match(/^(\s*)/)[1].length;
45
+
46
+ // ── Top-level key: value (indent 0, has colon, not ending with ':')
47
+ if (indent === 0 && trimmed.includes(':') && !trimmed.endsWith(':')) {
48
+ const [k, ...vParts] = trimmed.split(':');
49
+ result[k.trim()] = parseValue(vParts.join(':').trim());
50
+ currentSection = null; inList = false; currentItem = null; currentSubKey = null;
51
+ continue;
52
+ }
53
+
54
+ // ── Top-level section header (indent 0, ends with ':')
55
+ if (indent === 0 && trimmed.endsWith(':')) {
56
+ currentSection = trimmed.slice(0, -1);
57
+ result[currentSection] = {};
58
+ inList = false; currentItem = null; currentSubKey = null;
59
+ continue;
60
+ }
61
+
62
+ if (!currentSection) continue;
63
+
64
+ // ── List item start (indent 2, starts with '- ')
65
+ if (indent === 2 && trimmed.startsWith('- ')) {
66
+ if (!inList) {
67
+ result[currentSection] = [];
68
+ inList = true;
69
+ }
70
+ currentItem = {};
71
+ currentSubKey = null;
72
+ const rest = trimmed.slice(2).trim();
73
+ if (rest.includes(':')) {
74
+ const [k, ...vParts] = rest.split(':');
75
+ currentItem[k.trim()] = parseValue(vParts.join(':').trim());
76
+ }
77
+ result[currentSection].push(currentItem);
78
+ continue;
79
+ }
80
+
81
+ // ── List item property (indent 4, inside a list item)
82
+ if (indent === 4 && inList && currentItem && trimmed.includes(':')) {
83
+ const [k, ...vParts] = trimmed.split(':');
84
+ const key = k.trim();
85
+ const val = vParts.join(':').trim();
86
+ currentItem[key] = parseLineRangeOrValue(key, val);
87
+ continue;
88
+ }
89
+
90
+ // ── Sub-key in a section object (e.g. rule_overrides: SECRET-005:)
91
+ if (indent === 2 && !inList && trimmed.endsWith(':')) {
92
+ currentSubKey = trimmed.slice(0, -1);
93
+ result[currentSection][currentSubKey] = {};
94
+ continue;
95
+ }
96
+
97
+ // ── Property of sub-key (indent 4, inside rule_overrides.SECRET-005)
98
+ if (indent === 4 && !inList && currentSubKey && trimmed.includes(':')) {
99
+ const [k, ...vParts] = trimmed.split(':');
100
+ result[currentSection][currentSubKey][k.trim()] = parseValue(vParts.join(':').trim());
101
+ continue;
102
+ }
103
+
104
+ // ── Section scalar (indent 2, section is plain object, no subkey)
105
+ if (indent === 2 && !inList && !trimmed.endsWith(':') && trimmed.includes(':')) {
106
+ const [k, ...vParts] = trimmed.split(':');
107
+ if (typeof result[currentSection] === 'object' && !Array.isArray(result[currentSection])) {
108
+ result[currentSection][k.trim()] = parseValue(vParts.join(':').trim());
109
+ }
110
+ continue;
111
+ }
112
+ }
113
+
114
+ return result;
115
+ }
116
+
117
+ function parseValue(v) {
118
+ if (v === 'true') return true;
119
+ if (v === 'false') return false;
120
+ if (v === '' || v === null || v === undefined) return null;
121
+ if (!isNaN(v) && v !== '') return Number(v);
122
+ return v.replace(/^['"]|['"]$/g, '');
123
+ }
124
+
125
+ // Parse line_range: [3, 3] as an actual array
126
+ function parseLineRangeOrValue(key, val) {
127
+ if (key === 'line_range') {
128
+ const m = val.match(/\[\s*(\d+)\s*,\s*(\d+)\s*\]/);
129
+ if (m) return [parseInt(m[1]), parseInt(m[2])];
130
+ }
131
+ return parseValue(val);
132
+ }
133
+
134
+ // ── Hash a line for exception matching ────────────────────────────────────
135
+ function hashLine(rawLine) {
136
+ const normalized = rawLine
137
+ .trim()
138
+ .replace(/\s+/g, ' ')
139
+ .replace(/"/g, "'");
140
+ return 'sha256:' + crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 16);
141
+ }
142
+
143
+ // ── Global config keys that can serve as per-repo fallbacks ──────────────
144
+ // These keys in ~/.scd/config (KEY=VALUE format) override code defaults
145
+ // but are overridden by per-repo config.yml values.
146
+ const GLOBAL_FALLBACK_KEYS = [
147
+ 'trust_level', 'block_on_critical', 'block_on_high', 'scan_mode', 'deep_delay_ms',
148
+ ];
149
+
150
+ function parseGlobalBool(val) {
151
+ if (val === 'true') return true;
152
+ if (val === 'false') return false;
153
+ return undefined;
154
+ }
155
+
156
+ // ── Load config ────────────────────────────────────────────────────────────
157
+ function loadConfig(repoRoot) {
158
+ const store = require('./store');
159
+ const configPath = store.configPath(repoRoot);
160
+ let parsed = {};
161
+
162
+ if (fs.existsSync(configPath)) {
163
+ try {
164
+ const content = fs.readFileSync(configPath, 'utf8');
165
+ parsed = parseSimpleYaml(content);
166
+ } catch (err) {
167
+ console.error(`${YELLOW}[scd] Could not read config: ${err.message}${RESET}`);
168
+ }
169
+ }
170
+
171
+ // Read global fallback settings from ~/.scd/config
172
+ // Priority: repo config.yml > global config > code defaults
173
+ const globalFallback = {};
174
+ try {
175
+ const gc = require('./global-config');
176
+ for (const key of GLOBAL_FALLBACK_KEYS) {
177
+ const raw = gc.get('REPO_' + key.toUpperCase());
178
+ if (raw === undefined) continue;
179
+ if (key === 'block_on_critical' || key === 'block_on_high') {
180
+ const v = parseGlobalBool(raw);
181
+ if (v !== undefined) globalFallback[key] = v;
182
+ } else if (key === 'deep_delay_ms') {
183
+ const n = parseInt(raw, 10);
184
+ if (!isNaN(n)) globalFallback[key] = n;
185
+ } else {
186
+ globalFallback[key] = raw;
187
+ }
188
+ }
189
+ } catch { /* global-config not available — skip */ }
190
+
191
+ return {
192
+ ...DEFAULTS,
193
+ ...globalFallback,
194
+ ...parsed,
195
+ locked_rules: [
196
+ ...DEFAULTS.locked_rules,
197
+ ...(parsed.locked_rules || []),
198
+ ],
199
+ exceptions: Array.isArray(parsed.exceptions) ? parsed.exceptions : [],
200
+ resolutions: Array.isArray(parsed.resolutions) ? parsed.resolutions : [],
201
+ rule_overrides: parsed.rule_overrides && typeof parsed.rule_overrides === 'object'
202
+ ? parsed.rule_overrides : {},
203
+ };
204
+ }
205
+
206
+ // ── Check if a finding is excepted ────────────────────────────────────────
207
+ function isExcepted(config, finding, lineContent) {
208
+ const lineHash = lineContent ? hashLine(lineContent) : null;
209
+
210
+ for (const exc of config.exceptions) {
211
+ if (exc.rule !== finding.ruleId) continue;
212
+
213
+ // Check expiry first
214
+ if (exc.expires) {
215
+ const expiry = new Date(exc.expires);
216
+ if (expiry < new Date()) {
217
+ return { excepted: false, expired: true, rejected: false, exception: exc };
218
+ }
219
+ }
220
+
221
+ // Normalise file paths for comparison
222
+ const ne = exc.file ? exc.file.replace(/\\/g, '/').replace(/^\.\//, '') : null;
223
+ const nf = finding.filePath ? finding.filePath.replace(/\\/g, '/').replace(/^\.\//, '') : null;
224
+ const fileMatches = ne && nf && (nf === ne || nf.endsWith('/' + ne));
225
+
226
+ // Hash match — three formats supported:
227
+ // 1. codeHash (32-char hex): stored by addExceptionById — exact match against finding.codeHash
228
+ // 2. Legacy 16-char hex: old addException computed sha256.slice(0,16) from file content.
229
+ // These are a prefix of finding.codeHash (which is sha256.slice(0,32) of the same content).
230
+ // 3. hashLine() format "sha256:{16hex}": stored by legacy addException path
231
+ const codeHashMatches = exc.line_hash && finding.codeHash && (
232
+ exc.line_hash === finding.codeHash || // format 1: exact 32-char
233
+ (exc.line_hash.length === 16 && finding.codeHash.startsWith(exc.line_hash)) // format 2: legacy 16-char prefix
234
+ );
235
+ const lineHashMatches = exc.line_hash && lineHash &&
236
+ exc.line_hash === lineHash;
237
+
238
+ if ((codeHashMatches || lineHashMatches) && fileMatches) {
239
+ if (exc.status === 'rejected') {
240
+ return { excepted: false, expired: false, rejected: true, exception: exc };
241
+ }
242
+ return { excepted: true, expired: false, rejected: false, exception: exc };
243
+ }
244
+
245
+ // Fallback: line_hash exists in config but lineContent was empty (e.g. secrets rules
246
+ // that redact lineRaw). Match on rule + file + line instead — the hash cannot be verified
247
+ // but the finding is specific enough to match safely.
248
+ if (exc.line_hash && !lineHash && fileMatches && exc.line != null && finding.line === exc.line) {
249
+ if (exc.status === 'rejected') {
250
+ return { excepted: false, expired: false, rejected: true, exception: exc };
251
+ }
252
+ return { excepted: true, expired: false, rejected: false, exception: exc };
253
+ }
254
+
255
+ // File + line_range match (no hash)
256
+ if (!exc.line_hash && fileMatches) {
257
+ if (Array.isArray(exc.line_range) && finding.line != null) {
258
+ const [from, to] = exc.line_range;
259
+ if (finding.line >= from && finding.line <= to) {
260
+ if (exc.status === 'rejected') {
261
+ return { excepted: false, expired: false, rejected: true, exception: exc };
262
+ }
263
+ return { excepted: true, expired: false, rejected: false, exception: exc };
264
+ }
265
+ continue;
266
+ }
267
+ if (exc.status === 'rejected') {
268
+ return { excepted: false, expired: false, rejected: true, exception: exc };
269
+ }
270
+ return { excepted: true, expired: false, rejected: false, exception: exc };
271
+ }
272
+ }
273
+
274
+ return { excepted: false, expired: false, rejected: false, exception: null };
275
+ }
276
+
277
+ // ── Get effective action for a rule ───────────────────────────────────────
278
+ function getRuleAction(config, ruleId, defaultSeverity) {
279
+ if (config.locked_rules.includes(ruleId)) return 'block';
280
+
281
+ const override = config.rule_overrides?.[ruleId];
282
+ if (override && override.action) return override.action;
283
+
284
+ if (defaultSeverity === 'CRITICAL') return config.block_on_critical ? 'block' : 'warn';
285
+ if (defaultSeverity === 'HIGH') return config.block_on_high ? 'block' : 'warn';
286
+ return 'warn';
287
+ }
288
+
289
+ // ── Get repo root ──────────────────────────────────────────────────────────
290
+ function getRepoRoot() {
291
+ try {
292
+ const { execSync } = require('child_process');
293
+ return execSync('git rev-parse --show-toplevel', {
294
+ encoding: 'utf8',
295
+ stdio: ['pipe', 'pipe', 'pipe'], // suppress stderr – avoids "fatal: not a git repo" leaking to terminal
296
+ }).trim();
297
+ } catch {
298
+ return process.cwd();
299
+ }
300
+ }
301
+
302
+
303
+ // ── Check if an EXPOSURE finding has been resolved ────────────────────────
304
+ function isResolved(config, finding) {
305
+ const resolutions = config.resolutions || [];
306
+ for (const res of resolutions) {
307
+ if (res.rule !== finding.ruleId) continue;
308
+ if (res.file) {
309
+ const ne = res.file.replace(/\\/g, '/').replace(/^\.\//, '');
310
+ const nf = finding.filePath ? finding.filePath.replace(/\\/g, '/').replace(/^\.\//, '') : null;
311
+ if (nf !== ne && !nf?.endsWith('/' + ne)) continue;
312
+ }
313
+ // Check review date
314
+ if (res.review_date) {
315
+ const review = new Date(res.review_date);
316
+ if (review < new Date()) {
317
+ return { resolved: false, expired: true, record: res };
318
+ }
319
+ }
320
+ return { resolved: true, expired: false, record: res };
321
+ }
322
+ return { resolved: false, expired: false, record: null };
323
+ }
324
+
325
+ module.exports = { loadConfig, isExcepted, isResolved, getRuleAction, hashLine, getRepoRoot, CONFIG_FILENAME };
@@ -0,0 +1,211 @@
1
+ /**
2
+ * context-modifiers.js
3
+ * Applies file context modifiers to a finding after a rule match.
4
+ *
5
+ * Modifiers are cumulative and additive — multiple signals stack.
6
+ * The effective severity is derived from: base_score + total_modifier.
7
+ * If effective_score <= SUPPRESS_THRESHOLD, the finding is flagged suppressed.
8
+ *
9
+ * This module never touches rule logic. Rules produce findings exactly as before.
10
+ * File context confirms or adjusts — never replaces rule output.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ // ── Severity score mapping ─────────────────────────────────────────────────
16
+ // Higher score = higher severity. Scores are integers for clean arithmetic.
17
+ const SEVERITY_TO_SCORE = {
18
+ CRITICAL: 4,
19
+ HIGH: 3,
20
+ MEDIUM: 2,
21
+ EXPOSURE: 1,
22
+ INFO: 0,
23
+ };
24
+
25
+ const SCORE_TO_SEVERITY = {
26
+ 4: 'CRITICAL',
27
+ 3: 'HIGH',
28
+ 2: 'MEDIUM',
29
+ 1: 'EXPOSURE',
30
+ 0: 'INFO',
31
+ };
32
+
33
+ // ── Suppress threshold ─────────────────────────────────────────────────────
34
+ // Fixed at 0 for v1.0.0. Configurable in v1.1.0 via config.yml.
35
+ const SUPPRESS_THRESHOLD = 0;
36
+
37
+ // ── Modifier table ─────────────────────────────────────────────────────────
38
+ // Values are additive. A file matching multiple signals accumulates all modifiers.
39
+ // Validated against scd-research data after Phase 3 — values may be tuned.
40
+ //
41
+ // Specificity rationale:
42
+ // fixture -2 : data intended to trigger rule patterns by design
43
+ // vendor -3 : third-party code — never the customer's responsibility
44
+ // generated -2 : machine-generated — findings are not actionable
45
+ // test -1 : test code — lower risk but still customer-owned
46
+ // docs -1 : documentation — informational, not deployed
47
+ // config 0 : config files are production risk — no modifier (may boost post-release)
48
+ //
49
+ // Path segment modifiers stack with fileType modifiers for cumulative effect.
50
+ // Example: Jest test in /fixtures/ → fileType(-2) + path(-1) + framework(-1) = -4
51
+
52
+ const MODIFIERS = {
53
+ // ── File type modifiers ──────────────────────────────────────────────────
54
+ fileType: {
55
+ fixture: -2,
56
+ vendor: -3,
57
+ generated: -2,
58
+ test: -1,
59
+ docs: -1,
60
+ config: 0,
61
+ source: 0,
62
+ },
63
+
64
+ // ── Path segment modifiers ───────────────────────────────────────────────
65
+ // Applied independently of fileType — stacks on top.
66
+ // Only matched against the normalised filePath (lowercase, forward slashes).
67
+ pathSegment: [
68
+ { segment: '/test/', modifier: -1 },
69
+ { segment: '/tests/', modifier: -1 },
70
+ { segment: '/spec/', modifier: -1 },
71
+ { segment: '/specs/', modifier: -1 },
72
+ { segment: '/__tests__/', modifier: -1 },
73
+ { segment: '/fixtures/', modifier: -1 },
74
+ { segment: '/fixture/', modifier: -1 },
75
+ { segment: '/__fixtures__/',modifier: -1 },
76
+ { segment: '/mocks/', modifier: -1 },
77
+ { segment: '/__mocks__/', modifier: -1 },
78
+ { segment: '/vendor/', modifier: -2 },
79
+ { segment: '/node_modules/',modifier: -2 },
80
+ ],
81
+
82
+ // ── Test framework modifiers ─────────────────────────────────────────────
83
+ // Applied when a specific test framework is detected.
84
+ // Rationale: confirmed test framework = high confidence it is test code.
85
+ testFramework: {
86
+ jest: -1,
87
+ vitest: -1,
88
+ mocha: -1,
89
+ pytest: -1,
90
+ phpunit: -1,
91
+ rspec: -1,
92
+ },
93
+ };
94
+
95
+ // ── Helpers ────────────────────────────────────────────────────────────────
96
+
97
+ function normalisePath(filePath) {
98
+ const n = filePath.replace(/\\/g, '/').toLowerCase();
99
+ return n.startsWith('/') ? n : '/' + n;
100
+ }
101
+
102
+ /**
103
+ * Derive effective severity string from a numeric score.
104
+ * Score is clamped to [0, 4] — never goes negative or above CRITICAL.
105
+ */
106
+ function scoreToSeverity(score) {
107
+ const clamped = Math.max(0, Math.min(4, score));
108
+ return SCORE_TO_SEVERITY[clamped];
109
+ }
110
+
111
+ // ── Main export ────────────────────────────────────────────────────────────
112
+
113
+ /**
114
+ * Apply file context modifiers to a finding.
115
+ *
116
+ * Mutates a shallow copy of the finding — never the original object.
117
+ * Adds: base_severity, context_modifiers[], file_context, suppressed, suppress_reason.
118
+ * Updates: severity (becomes effective_severity when modifiers apply).
119
+ *
120
+ * @param {Object} finding - Finding object from buildFinding().
121
+ * @param {FileContext} fileContext - Context object from buildFileContext().
122
+ * @returns {Object} Modified finding (new object).
123
+ */
124
+ function applyContextModifiers(finding, fileContext) {
125
+ const baseSeverity = finding.severity;
126
+ const baseScore = SEVERITY_TO_SCORE[baseSeverity] ?? 0;
127
+
128
+ const appliedModifiers = []; // { signal, modifier }
129
+ let totalModifier = 0;
130
+
131
+ // ── 1. File type modifier ────────────────────────────────────────────────
132
+ const ftMod = MODIFIERS.fileType[fileContext.fileType] ?? 0;
133
+ if (ftMod !== 0) {
134
+ appliedModifiers.push({ signal: `fileType: ${fileContext.fileType}`, modifier: ftMod });
135
+ totalModifier += ftMod;
136
+ }
137
+
138
+ // ── 2. Path segment modifiers ────────────────────────────────────────────
139
+ const normPath = normalisePath(fileContext.filePath);
140
+ for (const { segment, modifier } of MODIFIERS.pathSegment) {
141
+ if (normPath.includes(segment)) {
142
+ appliedModifiers.push({ signal: `path: ${segment}`, modifier });
143
+ totalModifier += modifier;
144
+ }
145
+ }
146
+
147
+ // ── 3. Test framework modifier ───────────────────────────────────────────
148
+ if (fileContext.testFramework) {
149
+ const fwMod = MODIFIERS.testFramework[fileContext.testFramework] ?? 0;
150
+ if (fwMod !== 0) {
151
+ appliedModifiers.push({ signal: `framework: ${fileContext.testFramework}`, modifier: fwMod });
152
+ totalModifier += fwMod;
153
+ }
154
+ }
155
+
156
+ // ── Compute effective score and severity ─────────────────────────────────
157
+ const effectiveScore = baseScore + totalModifier;
158
+ const effectiveSeverity = scoreToSeverity(effectiveScore);
159
+ const suppressed = effectiveScore <= SUPPRESS_THRESHOLD;
160
+
161
+ // ── Build modified finding ───────────────────────────────────────────────
162
+ const modified = {
163
+ ...finding,
164
+ // Preserve original severity as base_severity for audit trail
165
+ base_severity: baseSeverity,
166
+ // severity reflects effective severity (may be unchanged if no modifiers)
167
+ severity: effectiveSeverity,
168
+ // context_modifiers is always present — empty array when nothing applied
169
+ context_modifiers: appliedModifiers,
170
+ // file_context always present — consumers can use it for display/filtering
171
+ file_context: {
172
+ file_type: fileContext.fileType,
173
+ test_framework: fileContext.testFramework,
174
+ language: fileContext.language,
175
+ },
176
+ suppressed: suppressed,
177
+ suppress_reason: suppressed
178
+ ? 'Effective score below threshold after context modifiers'
179
+ : null,
180
+ // ── Internal trace (written when scan.trace: true in config.yml) ────────
181
+ // Never shown in terminal output or reports. Available in scan-JSON files
182
+ // and scd-research snapshots for FP analysis and rule refinement.
183
+ // Structure mirrors the scan pipeline steps in order:
184
+ // manifest → file-context → modifiers → suppress-check
185
+ // comment_line_type is injected by routeFinding() after comment-map lookup.
186
+ _trace: {
187
+ manifest_context: null, // injected by routeFinding()
188
+ file_type: fileContext.fileType,
189
+ file_signals: fileContext.signals ?? [],
190
+ tentative: fileContext.tentative ?? false,
191
+ test_framework: fileContext.testFramework ?? null,
192
+ comment_line_type: null, // injected by routeFinding()
193
+ base_severity: baseSeverity,
194
+ base_score: baseScore,
195
+ modifiers: appliedModifiers.map(m => ({
196
+ step: m.signal,
197
+ delta: m.modifier,
198
+ reason: m.signal,
199
+ })),
200
+ total_modifier: totalModifier,
201
+ effective_score: effectiveScore,
202
+ final_severity: effectiveSeverity,
203
+ suppressed,
204
+ suppress_reason: suppressed ? 'effective_score <= suppress_threshold' : null,
205
+ },
206
+ };
207
+
208
+ return modified;
209
+ }
210
+
211
+ module.exports = { applyContextModifiers, MODIFIERS, SEVERITY_TO_SCORE, SUPPRESS_THRESHOLD };