@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,376 @@
1
+ /**
2
+ * export-findings.js
3
+ * Core export logic for scd export-findings and scd review-rules.
4
+ *
5
+ * Both commands produce a structured JSON file capturing findings and
6
+ * rule metadata from a completed scan — suitable for analysis in a
7
+ * separate session or for sharing with Activemind for rule quality review.
8
+ *
9
+ * scd export-findings : customer-facing, omits rule pattern/antipattern
10
+ * scd review-rules : Activemind-internal, includes pattern/antipattern
11
+ */
12
+
13
+ 'use strict';
14
+ const { RESET, DIM, RED, GREEN } = require('./output-constants');
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ const { loadCache, loadScan } = require('./scan-cache');
20
+ const { storeDir } = require('./store');
21
+ const { queryRules, getRuleById, RULES_VERSION } = require('./rule-registry');
22
+ const pkg = require('../package.json');
23
+
24
+ // ── ANSI helpers ───────────────────────────────────────────────────────────
25
+
26
+ // ── Language mapping ───────────────────────────────────────────────────────
27
+
28
+ const EXT_TO_LANG = {
29
+ js: 'javascript',
30
+ ts: 'typescript',
31
+ jsx: 'javascript',
32
+ tsx: 'typescript',
33
+ py: 'python',
34
+ php: 'php',
35
+ cs: 'csharp',
36
+ aspx: 'aspnet',
37
+ ascx: 'aspnet',
38
+ };
39
+
40
+ // ── Context reader ─────────────────────────────────────────────────────────
41
+
42
+ const CONTEXT_LINES = 5;
43
+
44
+ /**
45
+ * Read lines of source context around a finding.
46
+ * Returns an array of "lineN: content" strings, or null if the file
47
+ * cannot be read (it may have changed or been deleted since the scan).
48
+ */
49
+ function readContext(repoRoot, filePath, lineNum) {
50
+ try {
51
+ const abs = path.resolve(repoRoot, filePath);
52
+ const content = fs.readFileSync(abs, 'utf8').split('\n');
53
+ const start = Math.max(0, lineNum - CONTEXT_LINES - 1);
54
+ const end = Math.min(content.length, lineNum + CONTEXT_LINES);
55
+ return content
56
+ .slice(start, end)
57
+ .map((l, i) => `${start + i + 1}: ${l}`);
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ // ── Raw rule lookup for pattern / antipattern ──────────────────────────────
64
+
65
+ /**
66
+ * Returns { pattern: string|null, antipattern: string|null } for a rule ID.
67
+ * Patterns are already strings in JSON rules (compiled by rule-loader at load time).
68
+ * Only used by scd review-rules (includeRuleInternals = true).
69
+ */
70
+ function getRulePatterns(ruleId) {
71
+ const rule = getRuleById(ruleId);
72
+ if (!rule) return { pattern: null, antipattern: null };
73
+ return {
74
+ pattern: rule.pattern instanceof RegExp ? rule.pattern.source : (rule.pattern || null),
75
+ antipattern: rule.antipattern instanceof RegExp ? rule.antipattern.source : (rule.antipattern || null),
76
+ };
77
+ }
78
+
79
+ // ── Deep result lookup ─────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Find the deep analysis object for a finding from the deep results map.
83
+ * The map key is filePath, which may not exactly match finding.filePath.
84
+ * We do a best-effort match on path suffix equality.
85
+ *
86
+ * @param {Map} deepMap - filePath → [analysisObjects]
87
+ * @param {object} finding
88
+ * @returns {object|null}
89
+ */
90
+ function findDeepResult(deepMap, finding) {
91
+ for (const [fp, analyses] of deepMap) {
92
+ if (!Array.isArray(analyses)) continue;
93
+ // Accept if paths are identical or one is a suffix of the other
94
+ if (fp !== finding.filePath &&
95
+ !fp.endsWith(finding.filePath) &&
96
+ !finding.filePath.endsWith(fp)) continue;
97
+
98
+ const match = analyses.find(a =>
99
+ !a._error &&
100
+ a.ruleId === finding.ruleId &&
101
+ a.line === finding.line
102
+ );
103
+ if (match) return match;
104
+ }
105
+ return null;
106
+ }
107
+
108
+ // ── Main export function ───────────────────────────────────────────────────
109
+
110
+ /**
111
+ * Export findings from a scan to a structured JSON file.
112
+ *
113
+ * @param {object} options
114
+ * @param {string} options.repoRoot Path to the repo being analysed
115
+ * @param {string|null} options.scanId Specific scan ID, or null for latest
116
+ * @param {string|null} options.severity Severity filter, or null for all
117
+ * @param {string|null} options.rule Rule ID filter, or null for all
118
+ * @param {boolean} options.all Include findings without deep analysis
119
+ * @param {string} options.outputPath Full path to write the JSON file
120
+ * @param {boolean} options.includeRuleInternals true for review-rules, false for export-findings
121
+ * @param {string} options.command 'export-findings' or 'review-rules'
122
+ */
123
+ async function exportFindings(options) {
124
+ const {
125
+ repoRoot,
126
+ scanId,
127
+ severity,
128
+ rule: ruleFilter,
129
+ deepOnly: deepOnly,
130
+ outputPath,
131
+ includeRuleInternals,
132
+ command,
133
+ } = options;
134
+
135
+ // ── Load scan ────────────────────────────────────────────────────────────
136
+
137
+ let cache;
138
+ if (scanId) {
139
+ cache = loadScan(repoRoot, scanId);
140
+ if (!cache) {
141
+ console.error('\n' + RED + '✗ Scan not found: ' + scanId + RESET);
142
+ console.error(' Run CYANsc store --scansRESET to list available scans.\n');
143
+ process.exit(1);
144
+ }
145
+ } else {
146
+ cache = loadCache(repoRoot);
147
+ if (!cache) {
148
+ console.error('\n' + RED + '✗ No saved scan found.' + RESET);
149
+ console.error(" Run 'scd scan' first to generate findings to export from.\n");
150
+ process.exit(1);
151
+ }
152
+ }
153
+
154
+ const { findings: allFindings, scanDate, deepResults: deepResultsRaw } = cache;
155
+ const actualScanId = cache.scanId || 'unknown';
156
+
157
+ // ── Rebuild deep results map ─────────────────────────────────────────────
158
+
159
+ // Stored as [[filePath, analysesArray], ...] — restore to Map
160
+ const deepMap = deepResultsRaw instanceof Array
161
+ ? new Map(deepResultsRaw)
162
+ : new Map();
163
+
164
+ // ── Derive languages from all findings (before any filter) ───────────────
165
+
166
+ const langsSet = new Set();
167
+ for (const f of allFindings) {
168
+ const ext = path.extname(f.filePath || '').replace('.', '').toLowerCase();
169
+ if (EXT_TO_LANG[ext]) langsSet.add(EXT_TO_LANG[ext]);
170
+ }
171
+ const languagesScanned = Array.from(langsSet).sort();
172
+
173
+ // ── Apply severity / rule filters ────────────────────────────────────────
174
+
175
+ let filtered = allFindings;
176
+ if (severity) {
177
+ filtered = filtered.filter(f => f.severity === severity.toUpperCase());
178
+ }
179
+ if (ruleFilter) {
180
+ filtered = filtered.filter(f => f.ruleId === ruleFilter);
181
+ }
182
+
183
+ // ── Pair findings with deep results; filter if --deep-only ─────────────────
184
+
185
+ const paired = [];
186
+ for (const f of filtered) {
187
+ const deepAnalysis = findDeepResult(deepMap, f);
188
+ if (deepOnly && !deepAnalysis) continue;
189
+ paired.push({ finding: f, deepAnalysis });
190
+ }
191
+
192
+ // ── Repo name (from meta.json or basename) ───────────────────────────────
193
+
194
+ let repoName = path.basename(path.resolve(repoRoot));
195
+ try {
196
+ const metaPath = path.join(storeDir(repoRoot), 'meta.json');
197
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
198
+ if (meta.name) repoName = meta.name;
199
+ } catch { /* meta may not exist yet */ }
200
+
201
+ // ── Per-rule statistics ──────────────────────────────────────────────────
202
+
203
+ const ruleStats = {}; // ruleId → { name, severity, category, triggered, confirmed, fp, no_verdict }
204
+
205
+ for (const { finding: f, deepAnalysis } of paired) {
206
+ if (!ruleStats[f.ruleId]) {
207
+ ruleStats[f.ruleId] = {
208
+ name: f.name,
209
+ severity: f.severity,
210
+ category: f.category || 'Uncategorised',
211
+ triggered: 0,
212
+ confirmed: 0,
213
+ false_positives: 0,
214
+ no_verdict: 0,
215
+ };
216
+ }
217
+ const rs = ruleStats[f.ruleId];
218
+ rs.triggered++;
219
+ if (deepAnalysis) {
220
+ if (deepAnalysis.confirmed) rs.confirmed++;
221
+ else rs.false_positives++;
222
+ } else {
223
+ rs.no_verdict++;
224
+ }
225
+ }
226
+
227
+ // ── Summary totals ───────────────────────────────────────────────────────
228
+
229
+ let totalConfirmed = 0, totalFP = 0, totalNoVerdict = 0;
230
+ for (const rs of Object.values(ruleStats)) {
231
+ totalConfirmed += rs.confirmed;
232
+ totalFP += rs.false_positives;
233
+ totalNoVerdict += rs.no_verdict;
234
+ }
235
+
236
+ // High FP rules: fp_rate >= 0.5 and sample_size >= 3, sorted descending
237
+ const highFpRules = Object.entries(ruleStats)
238
+ .map(([ruleId, rs]) => {
239
+ const sampleSize = rs.confirmed + rs.false_positives;
240
+ const fpRate = sampleSize > 0 ? rs.false_positives / sampleSize : 0;
241
+ return { rule_id: ruleId, fp_rate: fpRate, sample_size: sampleSize };
242
+ })
243
+ .filter(r => r.fp_rate >= 0.5 && r.sample_size >= 3)
244
+ .sort((a, b) => b.fp_rate - a.fp_rate);
245
+
246
+ // ── Build findings array ─────────────────────────────────────────────────
247
+
248
+ const findingsOutput = paired.map(({ finding: f, deepAnalysis }, i) => {
249
+ const contextLines = readContext(repoRoot, f.filePath, f.line);
250
+ const entry = {
251
+ id: f.findingId || ('f-' + String(i + 1).padStart(3, '0')),
252
+ rule_id: f.ruleId,
253
+ rule_name: f.name,
254
+ severity: f.severity,
255
+ confidence: f.confidence || 'HIGH',
256
+ category: f.category || 'Uncategorised',
257
+ file: f.filePath,
258
+ line: f.line,
259
+ code_line: f.snippet,
260
+ context: contextLines || [f.snippet],
261
+ taint_source: f.taintSource ? {
262
+ variable: f.taintSource.variable,
263
+ line: f.taintSource.line,
264
+ source: f.taintSource.source,
265
+ } : null,
266
+ };
267
+
268
+ if (deepAnalysis) {
269
+ entry.deep = {
270
+ verdict: deepAnalysis.confirmed ? 'confirmed' : 'false_positive',
271
+ confidence: deepAnalysis.confidence || null,
272
+ attack_scenario: deepAnalysis.attack_scenario || null,
273
+ fix: deepAnalysis.fix_code || deepAnalysis.fix_explanation || null,
274
+ analyst_notes: null,
275
+ };
276
+ } else {
277
+ entry.deep = null;
278
+ }
279
+
280
+ return entry;
281
+ });
282
+
283
+ // ── Build rule_analysis map ──────────────────────────────────────────────
284
+
285
+ const ruleAnalysis = {};
286
+
287
+ for (const [ruleId, rs] of Object.entries(ruleStats)) {
288
+ const sampleSize = rs.confirmed + rs.false_positives;
289
+ const fpRate = sampleSize > 0
290
+ ? Math.round((rs.false_positives / sampleSize) * 100) / 100
291
+ : 0;
292
+
293
+ // Rule metadata from the registry (why, fix)
294
+ const regMatches = queryRules({ id: ruleId });
295
+ const regEntry = regMatches[0] || {};
296
+
297
+ const ruleEntry = {
298
+ name: rs.name,
299
+ severity: rs.severity,
300
+ category: rs.category,
301
+ why: regEntry.why || null,
302
+ fix: regEntry.fix || null,
303
+ stats: {
304
+ triggered: rs.triggered,
305
+ confirmed: rs.confirmed,
306
+ false_positives: rs.false_positives,
307
+ no_verdict: rs.no_verdict,
308
+ fp_rate: fpRate,
309
+ },
310
+ };
311
+
312
+ // pattern / antipattern only for review-rules
313
+ if (includeRuleInternals) {
314
+ const patterns = getRulePatterns(ruleId);
315
+ ruleEntry.pattern = patterns.pattern;
316
+ ruleEntry.antipattern = patterns.antipattern;
317
+ }
318
+
319
+ ruleAnalysis[ruleId] = ruleEntry;
320
+ }
321
+
322
+ // ── Assemble output JSON ─────────────────────────────────────────────────
323
+
324
+ const { getMachineFingerprint } = require('./store');
325
+ const os = require('os');
326
+
327
+ const output = {
328
+ meta: {
329
+ sc_version: pkg.version,
330
+ rules_version: RULES_VERSION,
331
+ scan_id: actualScanId,
332
+ scan_date: scanDate,
333
+ installation_id: cache.installation_id || getMachineFingerprint(),
334
+ hostname: cache.hostname || os.hostname(),
335
+ repo_name: repoName,
336
+ languages_scanned: languagesScanned,
337
+ export_type: deepOnly ? 'deep_only' : 'all_findings',
338
+ generated_at: new Date().toISOString(),
339
+ command,
340
+ exclusions: cache.exclusions || null,
341
+ },
342
+ summary: {
343
+ total_findings_in_scan: allFindings.length,
344
+ findings_exported: paired.length,
345
+ confirmed: totalConfirmed,
346
+ false_positives: totalFP,
347
+ no_verdict: totalNoVerdict,
348
+ rules_triggered: Object.keys(ruleStats).length,
349
+ high_fp_rules: highFpRules,
350
+ },
351
+ findings: findingsOutput,
352
+ rule_analysis: ruleAnalysis,
353
+ };
354
+
355
+ // ── Write file (ensure parent dir exists for explicit --output paths) ────
356
+
357
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
358
+ fs.writeFileSync(outputPath, JSON.stringify(output, null, 2), 'utf8');
359
+
360
+ // ── Terminal summary ─────────────────────────────────────────────────────
361
+
362
+ const fpSummary = highFpRules
363
+ .map(r => r.rule_id + ' (' + Math.round(r.fp_rate * 100) + '%)')
364
+ .join(', ');
365
+
366
+ console.log('\n' + GREEN + '✓ Export complete: ' + outputPath + RESET);
367
+ console.log(DIM + ' Findings exported : ' + paired.length + RESET);
368
+ console.log(DIM + ' Confirmed : ' + totalConfirmed + RESET);
369
+ console.log(DIM + ' False positives : ' + totalFP + RESET);
370
+ if (fpSummary) {
371
+ console.log(DIM + ' High FP rules : ' + fpSummary + RESET);
372
+ }
373
+ console.log('');
374
+ }
375
+
376
+ module.exports = { exportFindings };