@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.
- package/LICENSE.md +35 -0
- package/README.md +417 -0
- package/bin/scd.js +140 -0
- package/lib/audit-report.js +93 -0
- package/lib/audit-sync.js +172 -0
- package/lib/audit.js +356 -0
- package/lib/cli-helpers.js +108 -0
- package/lib/commands/accept.js +28 -0
- package/lib/commands/audit.js +17 -0
- package/lib/commands/configure.js +200 -0
- package/lib/commands/doctor.js +14 -0
- package/lib/commands/exceptions.js +19 -0
- package/lib/commands/export-findings.js +46 -0
- package/lib/commands/findings.js +306 -0
- package/lib/commands/ignore.js +28 -0
- package/lib/commands/init.js +16 -0
- package/lib/commands/insights.js +24 -0
- package/lib/commands/install.js +15 -0
- package/lib/commands/list.js +109 -0
- package/lib/commands/remove.js +16 -0
- package/lib/commands/repo.js +862 -0
- package/lib/commands/report.js +234 -0
- package/lib/commands/resolve.js +25 -0
- package/lib/commands/rules.js +185 -0
- package/lib/commands/scan.js +519 -0
- package/lib/commands/scope.js +341 -0
- package/lib/commands/sync.js +40 -0
- package/lib/commands/uninstall.js +15 -0
- package/lib/commands/version.js +33 -0
- package/lib/comment-map.js +388 -0
- package/lib/config.js +325 -0
- package/lib/context-modifiers.js +211 -0
- package/lib/deep-analyzer.js +225 -0
- package/lib/doctor.js +236 -0
- package/lib/exception-manager.js +675 -0
- package/lib/export-findings.js +376 -0
- package/lib/file-context.js +380 -0
- package/lib/file-filter.js +204 -0
- package/lib/file-manifest.js +145 -0
- package/lib/git-utils.js +102 -0
- package/lib/global-config.js +239 -0
- package/lib/hooks-manager.js +130 -0
- package/lib/init-repo.js +147 -0
- package/lib/insights-analyzer.js +416 -0
- package/lib/insights-output.js +160 -0
- package/lib/installer.js +128 -0
- package/lib/output-constants.js +32 -0
- package/lib/output-terminal.js +407 -0
- package/lib/push-queue.js +322 -0
- package/lib/remove-repo.js +108 -0
- package/lib/repo-context.js +187 -0
- package/lib/report-html.js +1154 -0
- package/lib/report-index.js +157 -0
- package/lib/report-json.js +136 -0
- package/lib/report-markdown.js +250 -0
- package/lib/resolve-manager.js +148 -0
- package/lib/rule-registry.js +205 -0
- package/lib/scan-cache.js +171 -0
- package/lib/scan-context.js +312 -0
- package/lib/scan-schema.js +67 -0
- package/lib/scanner-full.js +681 -0
- package/lib/scanner-manual.js +348 -0
- package/lib/scanner-secrets.js +83 -0
- package/lib/scope.js +331 -0
- package/lib/store-verify.js +395 -0
- package/lib/store.js +310 -0
- package/lib/taint-register.js +196 -0
- package/lib/version-check.js +46 -0
- package/package.json +37 -0
- package/rules/rule-loader.js +324 -0
- package/rules/rules-aspx-cs.json +399 -0
- package/rules/rules-aspx.json +222 -0
- package/rules/rules-infra-leakage.json +434 -0
- package/rules/rules-js.json +664 -0
- package/rules/rules-php.json +521 -0
- package/rules/rules-python.json +466 -0
- package/rules/rules-secrets.json +99 -0
- package/rules/rules-sensitive-files.json +475 -0
- 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 };
|