@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,675 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* exception-manager.js
|
|
3
|
+
* Manages exceptions and ignores for scd findings.
|
|
4
|
+
*
|
|
5
|
+
* scd accept <findingId> --reason <text>
|
|
6
|
+
* → Accepted risk: finding is real but justified. Requires team-lead approval via scd-server.
|
|
7
|
+
*
|
|
8
|
+
* scd ignore <findingId> --reason <text>
|
|
9
|
+
* → False positive / ignore: finding not exploitable in this context. Requires approval.
|
|
10
|
+
*
|
|
11
|
+
* Both commands:
|
|
12
|
+
* 1. Resolve findingId (f-{10hex}) from last scan cache
|
|
13
|
+
* 2. Write a pending exception to store config.yml (status: pending)
|
|
14
|
+
* 3. Push exception-request to scd-server via push queue
|
|
15
|
+
*
|
|
16
|
+
* scd sync
|
|
17
|
+
* → Pull approved/rejected exceptions from scd-server, update local config.yml
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
const { RESET, BOLD, DIM, RED, GREEN, YELLOW } = require('./output-constants');
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const crypto = require('crypto');
|
|
26
|
+
const { CONFIG_FILENAME } = require('./config');
|
|
27
|
+
const { logEvent, EVENTS } = require('./audit');
|
|
28
|
+
|
|
29
|
+
// ── stdin prompt helper ───────────────────────────────────────────────────
|
|
30
|
+
function prompt(question) {
|
|
31
|
+
return new Promise(resolve => {
|
|
32
|
+
process.stdout.write(question);
|
|
33
|
+
process.stdin.setEncoding('utf8');
|
|
34
|
+
process.stdin.resume();
|
|
35
|
+
process.stdin.once('data', data => {
|
|
36
|
+
process.stdin.pause();
|
|
37
|
+
resolve(data.toString().trim());
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Add exception by finding ID (new primary API) ────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Add exception or ignore by finding ID (f-{10hex}).
|
|
46
|
+
* Looks up the finding in the last scan cache, then delegates to addException.
|
|
47
|
+
* This is the primary entry point from CLI commands.
|
|
48
|
+
*/
|
|
49
|
+
async function addExceptionById(repoRoot, findingId, opts, type = 'exception') {
|
|
50
|
+
|
|
51
|
+
if (!findingId) {
|
|
52
|
+
const cmd = type === 'ignore' ? 'ignore' : 'accept';
|
|
53
|
+
console.error(`\n${RED}❌ Finding ID required.${RESET}`);
|
|
54
|
+
console.error(`${DIM} Usage: scd ${cmd} <finding-id> --reason "..."${RESET}`);
|
|
55
|
+
console.error(`${DIM} Finding IDs are shown in scd scan --verbose output (e.g. f-20eb992e1f)${RESET}\n`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!findingId.startsWith('f-') || findingId.length !== 12) {
|
|
60
|
+
console.error(`\n${RED}❌ Invalid finding ID: ${findingId}${RESET}`);
|
|
61
|
+
console.error(`${DIM} Finding IDs look like: f-20eb992e1f (shown in scd scan --verbose)${RESET}\n`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!opts.reason || !opts.reason.trim()) {
|
|
66
|
+
console.error(`\n${RED}❌ --reason is required.${RESET}`);
|
|
67
|
+
const cmd = type === 'ignore' ? 'ignore' : 'accept';
|
|
68
|
+
console.error(`${DIM} Example: scd ${cmd} ${findingId} --reason "Not exploitable in this context"${RESET}\n`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Load finding from last scan cache
|
|
73
|
+
const { loadCache } = require('./scan-cache');
|
|
74
|
+
const cache = loadCache(repoRoot);
|
|
75
|
+
const findings = cache?.findings || [];
|
|
76
|
+
|
|
77
|
+
const finding = findings.find(f => f.findingId === findingId);
|
|
78
|
+
|
|
79
|
+
if (!finding) {
|
|
80
|
+
console.error(`\n${RED}❌ Finding ${findingId} not found in last scan.${RESET}`);
|
|
81
|
+
console.error(`${DIM} Run scd scan --verbose to see finding IDs, then re-run this command.${RESET}\n`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Check for duplicate — same finding already has a pending/approved exception
|
|
86
|
+
const { loadConfig } = require('./config');
|
|
87
|
+
const config = loadConfig(repoRoot);
|
|
88
|
+
const exceptions = config.exceptions || [];
|
|
89
|
+
const existing = exceptions.find(e =>
|
|
90
|
+
e.rule === finding.ruleId &&
|
|
91
|
+
e.file === finding.filePath &&
|
|
92
|
+
e.line === finding.line &&
|
|
93
|
+
(e.status === 'pending' || e.status === 'approved')
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (existing) {
|
|
97
|
+
console.log(`\n${YELLOW}⚠ A ${existing.status} exception already exists for this finding.${RESET}`);
|
|
98
|
+
console.log(`${DIM} ID: ${existing.id} Status: ${existing.status} Type: ${existing.type}${RESET}`);
|
|
99
|
+
const answer = await prompt(' Create another exception anyway? [y/N] ');
|
|
100
|
+
if (!answer.trim().toLowerCase().startsWith('y')) {
|
|
101
|
+
console.log(`${DIM} Aborted.${RESET}\n`);
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Delegate to addException with resolved fields including the finding's codeHash
|
|
107
|
+
await addException(repoRoot, {
|
|
108
|
+
rule: finding.ruleId,
|
|
109
|
+
file: finding.filePath,
|
|
110
|
+
line: String(finding.line),
|
|
111
|
+
reason: opts.reason,
|
|
112
|
+
tag: opts.tag,
|
|
113
|
+
codeHash: finding.codeHash || null, // pass through — do not recompute
|
|
114
|
+
}, type);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Add exception or ignore ───────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
async function addException(repoRoot, opts, type = 'exception') {
|
|
120
|
+
const { rule, file, line, reason, tag } = opts;
|
|
121
|
+
|
|
122
|
+
// Validate required fields
|
|
123
|
+
if (!rule || !file || !line) {
|
|
124
|
+
console.error('\n' + RED + 'Usage: scd approve --rule <id> --file <path> --line <n> --reason <text>' + RESET + '\n');
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!reason || !reason.trim()) {
|
|
129
|
+
console.error('\nRED❌ --reason is required.' + RESET);
|
|
130
|
+
console.error(DIM + ' Example: scd approve --rule PY-INJ-001 --file src/db.py --line 68 \\' + RESET);
|
|
131
|
+
console.error(DIM + ' --reason "PRAGMA uses whitelist-validated table names only"' + RESET + '\n');
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Validate tag if provided (fritext, max 40 chars, no whitespace)
|
|
136
|
+
const cleanTag = tag ? String(tag).trim().slice(0, 40).replace(/\s+/g, '_') : null;
|
|
137
|
+
|
|
138
|
+
const lineNum = parseInt(line, 10);
|
|
139
|
+
const filePath = path.resolve(repoRoot, file);
|
|
140
|
+
|
|
141
|
+
// Hash the triggering line for stale-detection
|
|
142
|
+
let lineContent = null;
|
|
143
|
+
let codeHash = opts.codeHash || null; // prefer hash from finding (32-char, matches scanner)
|
|
144
|
+
let codeHashValid = !!codeHash;
|
|
145
|
+
|
|
146
|
+
if (fs.existsSync(filePath)) {
|
|
147
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
|
148
|
+
lineContent = lines[lineNum - 1];
|
|
149
|
+
if (lineContent) {
|
|
150
|
+
if (!codeHash) {
|
|
151
|
+
// Fallback: recompute from file (used when called directly, not via addExceptionById)
|
|
152
|
+
codeHash = crypto.createHash('sha256').update(lineContent).digest('hex').slice(0, 32);
|
|
153
|
+
codeHashValid = true;
|
|
154
|
+
}
|
|
155
|
+
console.log(`\nDIMLine ${lineNum}: ${lineContent.trim()}${RESET}`);
|
|
156
|
+
console.log(`DIMHash: ${codeHash}${RESET}`);
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
console.log(`\nYELLOW⚠ File not found: ${file}${RESET}`);
|
|
160
|
+
console.log(`${DIM} Exception will be created without code hash.${RESET}`);
|
|
161
|
+
console.log(`${DIM} This means it matches ANY occurrence of ${rule} in that file — not just line ${lineNum}.${RESET}\n`);
|
|
162
|
+
|
|
163
|
+
const answer = await prompt(' Continue anyway? [y/N] ');
|
|
164
|
+
if (!answer.trim().toLowerCase().startsWith('y')) {
|
|
165
|
+
console.log(DIM + ' Aborted.' + RESET + '\n');
|
|
166
|
+
process.exit(0);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const { getCentralUrl } = require('./global-config');
|
|
171
|
+
const isStandalone = !getCentralUrl();
|
|
172
|
+
|
|
173
|
+
const excId = `exc-${Date.now().toString(36)}`;
|
|
174
|
+
const created = new Date().toISOString().slice(0, 10);
|
|
175
|
+
|
|
176
|
+
const exception = {
|
|
177
|
+
id: excId,
|
|
178
|
+
type, // 'exception' | 'ignore'
|
|
179
|
+
tag: cleanTag, // optional free-text tag
|
|
180
|
+
rule,
|
|
181
|
+
file: file.replace(/\\/g, '/'),
|
|
182
|
+
line: lineNum,
|
|
183
|
+
code_hash: codeHash,
|
|
184
|
+
code_hash_valid: codeHashValid,
|
|
185
|
+
reason: reason.trim(),
|
|
186
|
+
status: isStandalone ? 'approved' : 'pending',
|
|
187
|
+
created_date: created,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Write to store config
|
|
191
|
+
writeException(repoRoot, exception);
|
|
192
|
+
|
|
193
|
+
// Push to scd-server via push queue
|
|
194
|
+
pushExceptionToServer(repoRoot, exception);
|
|
195
|
+
|
|
196
|
+
// Audit log
|
|
197
|
+
logEvent(repoRoot, 'exception_requested', {
|
|
198
|
+
exception_id: excId,
|
|
199
|
+
type,
|
|
200
|
+
tag: cleanTag,
|
|
201
|
+
rule,
|
|
202
|
+
file,
|
|
203
|
+
line: lineNum,
|
|
204
|
+
code_hash: codeHash,
|
|
205
|
+
reason: reason.trim(),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const typeLabel = type === 'ignore' ? 'Ignore' : 'Exception';
|
|
209
|
+
|
|
210
|
+
console.log(`\n${GREEN}✓ ${typeLabel} ${excId} created${RESET}`);
|
|
211
|
+
if (isStandalone) {
|
|
212
|
+
console.log(`${DIM} Status: approved locally${RESET}`);
|
|
213
|
+
console.log(`${DIM} (No scd-server configured — exception takes effect immediately)${RESET}`);
|
|
214
|
+
} else {
|
|
215
|
+
console.log(`${DIM} Status: pending team-lead approval${RESET}`);
|
|
216
|
+
console.log(`${DIM} → Pushed to scd-server for approval${RESET}`);
|
|
217
|
+
}
|
|
218
|
+
console.log(`${DIM} Rule: ${rule}${RESET}`);
|
|
219
|
+
console.log(`${DIM} File: ${file}:${lineNum}${RESET}`);
|
|
220
|
+
console.log(`${DIM} Reason: ${reason.trim()}${RESET}`);
|
|
221
|
+
if (cleanTag) console.log(`${DIM} Tag: ${cleanTag}${RESET}`);
|
|
222
|
+
console.log();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Push exception to scd-server ─────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
function pushExceptionToServer(repoRoot, exception) {
|
|
228
|
+
try {
|
|
229
|
+
const { getCentralUrl, getCentralToken } = require('./global-config');
|
|
230
|
+
const centralUrl = getCentralUrl();
|
|
231
|
+
if (!centralUrl) return;
|
|
232
|
+
|
|
233
|
+
const token = getCentralToken();
|
|
234
|
+
const meta = require('./push-queue').buildMeta(repoRoot);
|
|
235
|
+
const url = centralUrl.replace(/\/$/, '') + '/api/v1/exceptions/batch';
|
|
236
|
+
const http = url.startsWith('https') ? require('https') : require('http');
|
|
237
|
+
|
|
238
|
+
const body = JSON.stringify({
|
|
239
|
+
exceptions: [{
|
|
240
|
+
rule_id: exception.rule,
|
|
241
|
+
file_path: exception.file,
|
|
242
|
+
line: exception.line,
|
|
243
|
+
code_hash: exception.code_hash,
|
|
244
|
+
type: exception.type,
|
|
245
|
+
tag: exception.tag || null,
|
|
246
|
+
reason: exception.reason,
|
|
247
|
+
}],
|
|
248
|
+
meta,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Fire-and-forget — non-blocking, failure is silent
|
|
252
|
+
const parsed = new (require('url').URL)(url);
|
|
253
|
+
const options = {
|
|
254
|
+
hostname: parsed.hostname,
|
|
255
|
+
port: parsed.port || (url.startsWith('https') ? 443 : 80),
|
|
256
|
+
path: parsed.pathname + parsed.search,
|
|
257
|
+
method: 'POST',
|
|
258
|
+
headers: {
|
|
259
|
+
'Content-Type': 'application/json',
|
|
260
|
+
'Content-Length': Buffer.byteLength(body),
|
|
261
|
+
'Authorization': `Bearer ${token}`,
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const req = http.request(options, (res) => {
|
|
266
|
+
// Consume response to free socket
|
|
267
|
+
res.resume();
|
|
268
|
+
});
|
|
269
|
+
req.on('error', () => {});
|
|
270
|
+
req.setTimeout(8000, () => req.destroy());
|
|
271
|
+
req.write(body);
|
|
272
|
+
req.end();
|
|
273
|
+
|
|
274
|
+
} catch {
|
|
275
|
+
// Non-fatal
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Write exception to local config ──────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
function writeException(repoRoot, exception) {
|
|
282
|
+
const configPath = require('./store').configPath(repoRoot);
|
|
283
|
+
|
|
284
|
+
let content = '';
|
|
285
|
+
if (fs.existsSync(configPath)) {
|
|
286
|
+
content = fs.readFileSync(configPath, 'utf8');
|
|
287
|
+
} else {
|
|
288
|
+
content = '# Secure Code by Design – repo configuration\n\ntrust_level: balanced\n\n';
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const block = [
|
|
292
|
+
` - id: "${exception.id}"`,
|
|
293
|
+
` type: "${exception.type}"`,
|
|
294
|
+
exception.tag ? ` tag: "${exception.tag}"` : null,
|
|
295
|
+
` status: "${exception.status}"`,
|
|
296
|
+
` rule: "${exception.rule}"`,
|
|
297
|
+
` file: "${exception.file}"`,
|
|
298
|
+
` line: ${exception.line}`,
|
|
299
|
+
// line_hash only written when content was actually hashed (not for secrets rules that redact lineRaw)
|
|
300
|
+
exception.code_hash && exception.code_hash_valid ? ` line_hash: "${exception.code_hash}"` : null,
|
|
301
|
+
` reason: "${exception.reason}"`,
|
|
302
|
+
` created_date: "${exception.created_date}"`,
|
|
303
|
+
].filter(Boolean).join('\n');
|
|
304
|
+
|
|
305
|
+
// Check for an active (non-commented) exceptions: section
|
|
306
|
+
if (/^exceptions:\s*$/m.test(content)) {
|
|
307
|
+
content = content.replace(/^exceptions:\s*$/m, `exceptions:\n${block}`);
|
|
308
|
+
} else {
|
|
309
|
+
content += `\nexceptions:\n${block}\n`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
fs.writeFileSync(configPath, content, 'utf8');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Sync approved exceptions from scd-server ─────────────────────────────
|
|
316
|
+
|
|
317
|
+
async function syncExceptions(repoRoot) {
|
|
318
|
+
const { getCentralUrl, getCentralToken } = require('./global-config');
|
|
319
|
+
const centralUrl = getCentralUrl();
|
|
320
|
+
const token = getCentralToken();
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
if (!centralUrl) {
|
|
324
|
+
console.error('\nRED❌ No scd-server configured.' + RESET);
|
|
325
|
+
console.error(DIM + ' Run: scd configure --central-url <url>' + RESET + '\n');
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const store = require('./store');
|
|
330
|
+
const repoId = store.getRepoId(repoRoot);
|
|
331
|
+
const http = centralUrl.startsWith('https') ? require('https') : require('http');
|
|
332
|
+
|
|
333
|
+
console.log('\nCYAN↓ Syncing exceptions from scd-server…' + RESET);
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
// Fetch approved
|
|
337
|
+
const approvedUrl = new URL(`/api/v1/exceptions/approved?repo_id=${encodeURIComponent(repoId)}`, centralUrl);
|
|
338
|
+
const approved = await httpGet(http, approvedUrl.toString(), token);
|
|
339
|
+
const list = approved.exceptions || [];
|
|
340
|
+
|
|
341
|
+
// Also check for rejected so we can notify the developer
|
|
342
|
+
const rejectedUrl = new URL(`/api/v1/exceptions/approved?repo_id=${encodeURIComponent(repoId)}&status=rejected`, centralUrl);
|
|
343
|
+
let rejected = [];
|
|
344
|
+
try {
|
|
345
|
+
const rData = await httpGet(http, rejectedUrl.toString(), token);
|
|
346
|
+
rejected = rData.exceptions || [];
|
|
347
|
+
} catch { /* non-fatal — server may not support status filter */ }
|
|
348
|
+
|
|
349
|
+
if (list.length === 0 && rejected.length === 0) {
|
|
350
|
+
console.log(`${DIM} No approved or rejected exceptions for this repo.${RESET}\n`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Apply approved exceptions locally — also updates pending exceptions that have now been reviewed
|
|
355
|
+
let applied = 0;
|
|
356
|
+
let skipped = 0;
|
|
357
|
+
for (const ex of list) {
|
|
358
|
+
const updated = updateExceptionStatus(repoRoot, ex, 'approved', ex.reviewed_by, ex.review_comment);
|
|
359
|
+
if (updated) applied++;
|
|
360
|
+
else skipped++;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (list.length > 0) {
|
|
364
|
+
console.log(`${GREEN}✓ ${list.length} approved exception(s)${RESET}`);
|
|
365
|
+
if (applied > 0) console.log(`${DIM} ${applied} applied to local config — findings will no longer be flagged${RESET}`);
|
|
366
|
+
if (skipped > 0) console.log(`${DIM} ${skipped} already up to date${RESET}`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Show rejected so developer knows to fix the finding
|
|
370
|
+
if (rejected.length > 0) {
|
|
371
|
+
console.log(`\n${YELLOW}⚠ ${rejected.length} rejected exception(s) — these findings need to be fixed:${RESET}`);
|
|
372
|
+
for (const ex of rejected) {
|
|
373
|
+
console.log(`${DIM} ${ex.rule_id} ${ex.file_path}${ex.line ? ':' + ex.line : ''}${RESET}`);
|
|
374
|
+
if (ex.review_comment) {
|
|
375
|
+
console.log(`${DIM} Reason: ${ex.review_comment}${RESET}`);
|
|
376
|
+
}
|
|
377
|
+
// Mark as rejected locally
|
|
378
|
+
updateExceptionStatus(repoRoot, ex, 'rejected', ex.reviewed_by, ex.review_comment);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Update lastSynced timestamp and store handled IDs in meta.json
|
|
383
|
+
// so getSyncNotice can exclude them even if they were never in local config
|
|
384
|
+
const { updateLastSynced } = require('./store');
|
|
385
|
+
const handledIds = [
|
|
386
|
+
...list.map(e => e.id),
|
|
387
|
+
...rejected.map(e => e.id),
|
|
388
|
+
];
|
|
389
|
+
updateLastSynced(repoRoot, handledIds);
|
|
390
|
+
|
|
391
|
+
console.log('');
|
|
392
|
+
|
|
393
|
+
} catch (err) {
|
|
394
|
+
// Detect server license invalid — show actionable message, not raw JSON
|
|
395
|
+
const msg = err.message || '';
|
|
396
|
+
if (msg.includes('HTTP 503') && msg.includes('License invalid')) {
|
|
397
|
+
console.error('\nYELLOW⚠ Server license invalid — exceptions cannot be synced.' + RESET);
|
|
398
|
+
console.error(DIM + ' Contact your local scd-server administrator to resolve this.' + RESET + '\n');
|
|
399
|
+
} else {
|
|
400
|
+
console.error(`\nRED❌ Sync failed: ${err.message}${RESET}`);
|
|
401
|
+
console.error(DIM + ' Check that scd-server is reachable and token is correctRESET\n');
|
|
402
|
+
}
|
|
403
|
+
process.exit(1);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function updateExceptionStatus(repoRoot, serverEx, status, reviewedBy, comment) {
|
|
408
|
+
const configPath = require('./store').configPath(repoRoot);
|
|
409
|
+
if (!fs.existsSync(configPath)) return false;
|
|
410
|
+
|
|
411
|
+
const lines = fs.readFileSync(configPath, 'utf8').split('\n');
|
|
412
|
+
|
|
413
|
+
// Find the entry by scanning line by line
|
|
414
|
+
// Match by CLI id first, then fall back to rule+file+line
|
|
415
|
+
let firstLine = -1;
|
|
416
|
+
let lastLine = -1;
|
|
417
|
+
|
|
418
|
+
for (let i = 0; i < lines.length; i++) {
|
|
419
|
+
if (!lines[i].startsWith(' - id: "')) continue;
|
|
420
|
+
|
|
421
|
+
// Check if this is our entry
|
|
422
|
+
const excId = serverEx.id || '';
|
|
423
|
+
const isById = excId && lines[i].includes(` - id: "${excId}"`);
|
|
424
|
+
|
|
425
|
+
// Look ahead to collect ALL lines belonging to this entry
|
|
426
|
+
// An entry ends at the next ' - id:' line, a blank line, or end of file
|
|
427
|
+
// BUT we must include reviewer lines that may have been appended
|
|
428
|
+
let j = i + 1;
|
|
429
|
+
const entryLines = [lines[i]];
|
|
430
|
+
while (j < lines.length && !lines[j].startsWith(' - id: "') && lines[j].trim() !== '') {
|
|
431
|
+
entryLines.push(lines[j]);
|
|
432
|
+
j++;
|
|
433
|
+
}
|
|
434
|
+
const entryText = entryLines.join('\n');
|
|
435
|
+
|
|
436
|
+
const isByRuleLine = !isById && serverEx.rule_id && serverEx.file_path
|
|
437
|
+
&& entryText.includes(`rule: "${serverEx.rule_id}"`)
|
|
438
|
+
&& entryText.includes(`file: "${serverEx.file_path}"`)
|
|
439
|
+
&& (!serverEx.line || entryText.includes(`line: ${serverEx.line}`));
|
|
440
|
+
|
|
441
|
+
if (isById || isByRuleLine) {
|
|
442
|
+
firstLine = i;
|
|
443
|
+
lastLine = j - 1;
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (firstLine === -1) {
|
|
449
|
+
// Debug: show why no entry matched
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Update status field within entry
|
|
454
|
+
let statusUpdated = false;
|
|
455
|
+
for (let i = firstLine; i <= lastLine; i++) {
|
|
456
|
+
if (/^\s+status:/.test(lines[i])) {
|
|
457
|
+
lines[i] = lines[i].replace(/status: "[^"]*"/, `status: "${status}"`);
|
|
458
|
+
statusUpdated = true;
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (!statusUpdated) return false;
|
|
463
|
+
|
|
464
|
+
// Add reviewer info + db_id if not already in this entry
|
|
465
|
+
const entryText = lines.slice(firstLine, lastLine + 1).join('\n');
|
|
466
|
+
if (reviewedBy && !entryText.includes('reviewed_by:')) {
|
|
467
|
+
const insertAfter = lastLine;
|
|
468
|
+
const toInsert = [];
|
|
469
|
+
// Store server DB id for resolved notification
|
|
470
|
+
if (serverEx.id && !entryText.includes('db_id:')) {
|
|
471
|
+
toInsert.push(` db_id: ${serverEx.id}`);
|
|
472
|
+
}
|
|
473
|
+
toInsert.push(` reviewed_by: "${reviewedBy}"`);
|
|
474
|
+
if (comment) toInsert.push(` review_comment: "${comment.replace(/"/g, '\\"')}"`);
|
|
475
|
+
lines.splice(insertAfter + 1, 0, ...toInsert);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
fs.writeFileSync(configPath, lines.join('\n'), 'utf8');
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ── HTTP helper ───────────────────────────────────────────────────────────
|
|
483
|
+
|
|
484
|
+
function httpGet(http, url, token) {
|
|
485
|
+
return new Promise((resolve, reject) => {
|
|
486
|
+
const opts = {
|
|
487
|
+
headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json' },
|
|
488
|
+
};
|
|
489
|
+
const req = http.get(url, opts, (res) => {
|
|
490
|
+
let data = '';
|
|
491
|
+
res.on('data', chunk => { data += chunk; });
|
|
492
|
+
res.on('end', () => {
|
|
493
|
+
try {
|
|
494
|
+
if (res.statusCode !== 200) {
|
|
495
|
+
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
|
496
|
+
} else {
|
|
497
|
+
resolve(JSON.parse(data));
|
|
498
|
+
}
|
|
499
|
+
} catch (e) { reject(e); }
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
req.on('error', reject);
|
|
503
|
+
req.setTimeout(10000, () => { req.destroy(); reject(new Error('Request timeout')); });
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ── Sync notice for terminal output ──────────────────────────────────────
|
|
508
|
+
// Returns a notice string if there are pending exceptions, or null.
|
|
509
|
+
// Reads only from local config + meta — zero network cost.
|
|
510
|
+
function getSyncNotice(repoRoot) {
|
|
511
|
+
try {
|
|
512
|
+
const { readMeta } = require('./store');
|
|
513
|
+
const { loadConfig } = require('./config');
|
|
514
|
+
|
|
515
|
+
const config = loadConfig(repoRoot);
|
|
516
|
+
const meta = readMeta(repoRoot);
|
|
517
|
+
|
|
518
|
+
// Exclude exceptions already handled by server (approved or rejected)
|
|
519
|
+
const handled = new Set(Array.isArray(meta.handledExceptionIds) ? meta.handledExceptionIds : []);
|
|
520
|
+
const pending = config.exceptions.filter(e => e.status === 'pending' && !handled.has(e.id));
|
|
521
|
+
|
|
522
|
+
if (pending.length === 0) return null;
|
|
523
|
+
|
|
524
|
+
const lastSynced = meta.lastSynced ? new Date(meta.lastSynced) : null;
|
|
525
|
+
const hoursSince = lastSynced
|
|
526
|
+
? Math.floor((Date.now() - lastSynced.getTime()) / 3_600_000)
|
|
527
|
+
: null;
|
|
528
|
+
|
|
529
|
+
const stale = hoursSince === null || hoursSince >= 24;
|
|
530
|
+
|
|
531
|
+
const icon = stale ? YELLOW + '⚠ ' + RESET : CYAN + 'ℹ ' + RESET;
|
|
532
|
+
const age = hoursSince === null
|
|
533
|
+
? 'never synced'
|
|
534
|
+
: hoursSince < 1 ? 'synced recently'
|
|
535
|
+
: hoursSince < 24 ? `synced ${hoursSince}h ago`
|
|
536
|
+
: `last synced ${Math.floor(hoursSince / 24)}d ago`;
|
|
537
|
+
|
|
538
|
+
return `${icon}${DIM} ${pending.length} exception(s) pending approval – ${age} – run ${RESET}${BOLD}scd sync${RESET}`;
|
|
539
|
+
} catch {
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ── List exceptions from local config ────────────────────────────────────
|
|
545
|
+
|
|
546
|
+
function listExceptions(repoRoot, statusFilter = 'all') {
|
|
547
|
+
const { loadConfig } = require('./config');
|
|
548
|
+
const config = loadConfig(repoRoot);
|
|
549
|
+
|
|
550
|
+
const valid = ['pending', 'approved', 'rejected', 'all'];
|
|
551
|
+
if (!valid.includes(statusFilter)) {
|
|
552
|
+
console.error(`${RED}❌ Invalid status: ${statusFilter}. Use: pending | approved | rejected | all${RESET}`);
|
|
553
|
+
process.exit(1);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const list = statusFilter === 'all'
|
|
557
|
+
? config.exceptions
|
|
558
|
+
: config.exceptions.filter(e => e.status === statusFilter);
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
if (list.length === 0) {
|
|
562
|
+
console.log(`\n${DIM} No ${statusFilter === 'all' ? '' : statusFilter + ' '}exceptions found.${RESET}\n`);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const statusColor = (s) =>
|
|
567
|
+
s === 'approved' ? GREEN :
|
|
568
|
+
s === 'rejected' ? YELLOW :
|
|
569
|
+
DIM;
|
|
570
|
+
|
|
571
|
+
// Build a lookup map from (rule+file+line) → findingId using last scan cache
|
|
572
|
+
const findingIdMap = {};
|
|
573
|
+
try {
|
|
574
|
+
const { loadCache } = require('./scan-cache');
|
|
575
|
+
const cache = loadCache(repoRoot);
|
|
576
|
+
for (const f of (cache?.findings || [])) {
|
|
577
|
+
if (f.findingId) {
|
|
578
|
+
const key = `${f.ruleId}|${f.filePath}|${f.line}`;
|
|
579
|
+
findingIdMap[key] = f.findingId;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
} catch { /* non-fatal */ }
|
|
583
|
+
|
|
584
|
+
console.log(`\n${BOLD}Exceptions${statusFilter !== 'all' ? ' (' + statusFilter + ')' : ''}:${RESET}\n`);
|
|
585
|
+
|
|
586
|
+
for (const ex of list) {
|
|
587
|
+
const sc = statusColor(ex.status);
|
|
588
|
+
const findingId = findingIdMap[`${ex.rule}|${ex.file}|${ex.line}`] || null;
|
|
589
|
+
console.log(` ${BOLD}${ex.id || '—'}${RESET} ${sc}[${ex.status}]${RESET} ${DIM}${ex.type}${RESET}`);
|
|
590
|
+
console.log(` ${DIM}Rule: ${RESET}${ex.rule}`);
|
|
591
|
+
console.log(` ${DIM}File: ${RESET}${ex.file}${ex.line ? ':' + ex.line : ''}${findingId ? ` ${DIM}${findingId}${RESET}` : ''}`);
|
|
592
|
+
console.log(` ${DIM}Reason: ${RESET}${ex.reason}`);
|
|
593
|
+
if (ex.tag) console.log(` ${DIM}Tag: ${RESET}${ex.tag}`);
|
|
594
|
+
if (ex.reviewed_by) console.log(` ${DIM}Reviewed by: ${RESET}${ex.reviewed_by}`);
|
|
595
|
+
if (ex.review_comment) console.log(` ${DIM}Comment: ${RESET}${ex.review_comment}`);
|
|
596
|
+
if (ex.status === 'rejected') {
|
|
597
|
+
console.log(` ${YELLOW}→ scd resolve --rejected ${ex.id}${RESET} ${DIM}(remove from local config)${RESET}`);
|
|
598
|
+
}
|
|
599
|
+
console.log('');
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ── Remove a rejected exception from local config by ID ──────────────────
|
|
604
|
+
|
|
605
|
+
function removeRejected(repoRoot, excId) {
|
|
606
|
+
const configPath = require('./store').configPath(repoRoot);
|
|
607
|
+
|
|
608
|
+
if (!fs.existsSync(configPath)) {
|
|
609
|
+
console.error(`${RED}❌ No config.yml found for this repo.${RESET}`);
|
|
610
|
+
process.exit(1);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
let content = fs.readFileSync(configPath, 'utf8');
|
|
614
|
+
|
|
615
|
+
// Find the entry by id
|
|
616
|
+
const idx = content.indexOf(` - id: "${excId}"`);
|
|
617
|
+
if (idx === -1) {
|
|
618
|
+
console.error(`${RED}❌ Exception ${excId} not found in local config.${RESET}`);
|
|
619
|
+
console.error(`${DIM} Run 'scd exceptions --list rejected' to see available IDs.${RESET}`);
|
|
620
|
+
process.exit(1);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Find the extent of the entry (until next entry or end of exceptions block)
|
|
624
|
+
const nextEntry = content.indexOf(' - id: "', idx + 1);
|
|
625
|
+
const entryEnd = nextEntry !== -1 ? nextEntry : content.length;
|
|
626
|
+
const entry = content.slice(idx, entryEnd);
|
|
627
|
+
|
|
628
|
+
// Verify it's rejected before removing
|
|
629
|
+
if (!entry.includes('status: "rejected"')) {
|
|
630
|
+
console.error(`${RED}❌ Exception ${excId} is not rejected — only rejected exceptions can be removed this way.${RESET}`);
|
|
631
|
+
process.exit(1);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Extract server DB id from entry if present (stored as db_id field)
|
|
635
|
+
// Fall back to notifying server by rule+file+line if no db_id
|
|
636
|
+
const dbIdMatch = entry.match(/db_id:\s*(\d+)/);
|
|
637
|
+
const dbId = dbIdMatch ? parseInt(dbIdMatch[1], 10) : null;
|
|
638
|
+
|
|
639
|
+
content = content.slice(0, idx) + content.slice(entryEnd);
|
|
640
|
+
|
|
641
|
+
// Clean up empty exceptions block
|
|
642
|
+
content = content.replace(/^exceptions:\s*\n(\s*\n)*$/m, '');
|
|
643
|
+
|
|
644
|
+
fs.writeFileSync(configPath, content, 'utf8');
|
|
645
|
+
|
|
646
|
+
// Mark as resolved on server (fire-and-forget)
|
|
647
|
+
// We need the DB id — store it in handledExceptionIds and try to notify server
|
|
648
|
+
const { getCentralUrl, getCentralToken } = require('./global-config');
|
|
649
|
+
const centralUrl = getCentralUrl();
|
|
650
|
+
if (centralUrl && dbId) {
|
|
651
|
+
const token = getCentralToken();
|
|
652
|
+
const url = centralUrl.replace(/\/$/, '') + `/api/v1/exceptions/${dbId}/resolved`;
|
|
653
|
+
const http = url.startsWith('https') ? require('https') : require('http');
|
|
654
|
+
const parsed = new (require('url').URL)(url);
|
|
655
|
+
const opts = {
|
|
656
|
+
hostname: parsed.hostname,
|
|
657
|
+
port: parsed.port || (url.startsWith('https') ? 443 : 80),
|
|
658
|
+
path: parsed.pathname,
|
|
659
|
+
method: 'POST',
|
|
660
|
+
headers: { 'Authorization': `Bearer ${token}`, 'Content-Length': 0 },
|
|
661
|
+
};
|
|
662
|
+
const req = http.request(opts, (res) => { res.resume(); });
|
|
663
|
+
req.on('error', () => {}); // non-fatal
|
|
664
|
+
req.end();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Store handled ID in meta so getSyncNotice doesn't re-show it
|
|
668
|
+
const { updateLastSynced, readMeta } = require('./store');
|
|
669
|
+
updateLastSynced(repoRoot, [excId]);
|
|
670
|
+
|
|
671
|
+
console.log(`\n${GREEN}✓ Rejected exception ${excId} removed from local config.${RESET}`);
|
|
672
|
+
console.log(`${DIM} The finding will be flagged normally in future scans.${RESET}\n`);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
module.exports = { addException, addExceptionById, syncExceptions, getSyncNotice, listExceptions, removeRejected };
|