@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/scope.js ADDED
@@ -0,0 +1,331 @@
1
+ /**
2
+ * lib/scope.js
3
+ * Loads and applies scan scope exclusions from scope.yml files.
4
+ *
5
+ * Scope decisions are explicit, documented choices about what scd scans.
6
+ * They are distinct from operational config (config.yml) and are treated
7
+ * as security decisions — every entry requires reason, added_by, added_at.
8
+ *
9
+ * Three sources, merged in priority order (last wins on conflict):
10
+ * 1. ~/.scd/scope.yml — global, user-owned
11
+ * 2. ~/.scd/repos/{id}/scope.yml — repo, user-owned
12
+ * 3. ~/.scd/repos/{id}/scope-server.yml — server-owned, read-only
13
+ *
14
+ * Source 3 only applied when a server URL is configured (getCentralUrl()).
15
+ * Standalone-first: missing scope files are not errors.
16
+ */
17
+
18
+ 'use strict';
19
+ const { RESET, DIM } = require('./output-constants');
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const store = require('./store');
24
+
25
+ // ── YAML parser (hand-rolled, consistent with config.js) ─────────────────
26
+
27
+ /**
28
+ * Minimal YAML parser for scope.yml structure.
29
+ * Handles: top-level keys, list items (- key: value), nested keys under lists.
30
+ * Does not support anchors, aliases, or multi-document streams.
31
+ */
32
+ function parseScope(yamlText) {
33
+ const lines = yamlText.split('\n');
34
+ const result = { file_excludes: [], rule_excludes: [] };
35
+
36
+ let section = null; // 'file_excludes' | 'rule_excludes'
37
+ let current = null; // current list entry being built
38
+
39
+ function finalise() {
40
+ if (!current) return;
41
+ if (section === 'file_excludes' && current.pattern) {
42
+ result.file_excludes.push(current);
43
+ } else if (section === 'rule_excludes' && current.rule) {
44
+ result.rule_excludes.push(current);
45
+ }
46
+ current = null;
47
+ }
48
+
49
+ for (const rawLine of lines) {
50
+ const line = rawLine.trimEnd();
51
+ if (!line || line.trimStart().startsWith('#')) continue;
52
+
53
+ // Top-level section headers
54
+ if (/^file_excludes\s*:/.test(line)) { finalise(); section = 'file_excludes'; continue; }
55
+ if (/^rule_excludes\s*:/.test(line)) { finalise(); section = 'rule_excludes'; continue; }
56
+
57
+ // List item start: " - key: value"
58
+ const listItemMatch = line.match(/^(\s+)-\s+(\w+)\s*:\s*(.*)/);
59
+ if (listItemMatch) {
60
+ finalise();
61
+ current = {};
62
+ const [, , key, val] = listItemMatch;
63
+ current[key] = unquote(val.trim());
64
+ if (section === 'rule_excludes' && key === 'rule' && !current.files) {
65
+ current.files = null;
66
+ }
67
+ continue;
68
+ }
69
+
70
+ // Nested key under current list item: " key: value"
71
+ if (current) {
72
+ const nestedKeyMatch = line.match(/^(\s+)(\w+)\s*:\s*(.*)/);
73
+ if (nestedKeyMatch) {
74
+ const [, , key, val] = nestedKeyMatch;
75
+ const trimmedVal = val.trim();
76
+
77
+ // files: is a list — initialise it if we see the key with no value
78
+ if (key === 'files') {
79
+ current.files = trimmedVal ? [unquote(trimmedVal)] : [];
80
+ continue;
81
+ }
82
+
83
+ current[key] = unquote(trimmedVal);
84
+ continue;
85
+ }
86
+
87
+ // List item under files: " - pattern"
88
+ const filesItemMatch = line.match(/^(\s+)-\s+(.*)/);
89
+ if (filesItemMatch && Array.isArray(current.files)) {
90
+ current.files.push(unquote(filesItemMatch[2].trim()));
91
+ continue;
92
+ }
93
+ }
94
+ }
95
+
96
+ finalise();
97
+ return result;
98
+ }
99
+
100
+ function unquote(str) {
101
+ if (!str) return str;
102
+ if ((str.startsWith('"') && str.endsWith('"')) ||
103
+ (str.startsWith("'") && str.endsWith("'"))) {
104
+ return str.slice(1, -1);
105
+ }
106
+ return str;
107
+ }
108
+
109
+ // ── Load a single scope file ──────────────────────────────────────────────
110
+
111
+ function loadScopeFile(filePath, source) {
112
+ if (!fs.existsSync(filePath)) return null;
113
+ try {
114
+ const text = fs.readFileSync(filePath, 'utf8');
115
+ const parsed = parseScope(text);
116
+ // Tag each entry with its source
117
+ for (const e of parsed.file_excludes) e._source = source;
118
+ for (const e of parsed.rule_excludes) e._source = source;
119
+ return parsed;
120
+ } catch (err) {
121
+ console.error(`${DIM}[scd] scope.yml warning (${source}): ${err.message}${RESET}`);
122
+ return null;
123
+ }
124
+ }
125
+
126
+ // ── Merge two scope objects (later wins on conflict) ──────────────────────
127
+
128
+ function mergeScope(base, override) {
129
+ if (!override) return base;
130
+ return {
131
+ file_excludes: [...base.file_excludes, ...override.file_excludes],
132
+ rule_excludes: [...base.rule_excludes, ...override.rule_excludes],
133
+ };
134
+ }
135
+
136
+ // ── Load merged scope for a repo ──────────────────────────────────────────
137
+
138
+ /**
139
+ * Load and merge scope from all three sources for a given repo root.
140
+ * Returns a merged scope object: { file_excludes, rule_excludes }
141
+ *
142
+ * Source priority (last wins): global → repo → server
143
+ *
144
+ * @param {string} repoRoot Absolute path to repo root
145
+ * @returns {{ file_excludes: Array, rule_excludes: Array }}
146
+ */
147
+ function loadScope(repoRoot) {
148
+ let scope = { file_excludes: [], rule_excludes: [] };
149
+
150
+ // 1. Global scope (~/.scd/scope.yml)
151
+ const global = loadScopeFile(store.globalScopePath(), 'global');
152
+ if (global) scope = mergeScope(scope, global);
153
+
154
+ // 2. Repo scope (~/.scd/repos/{id}/scope.yml)
155
+ const repo = loadScopeFile(store.scopePath(repoRoot), 'repo');
156
+ if (repo) scope = mergeScope(scope, repo);
157
+
158
+ // 3. Server scope (~/.scd/repos/{id}/scope-server.yml)
159
+ // Only applied when a central URL is configured
160
+ try {
161
+ const { getCentralUrl } = require('./global-config');
162
+ if (getCentralUrl()) {
163
+ const server = loadScopeFile(store.serverScopePath(repoRoot), 'server');
164
+ if (server) scope = mergeScope(scope, server);
165
+ }
166
+ } catch { /* global-config unavailable — standalone mode */ }
167
+
168
+ return scope;
169
+ }
170
+
171
+ // ── Pattern matching ───────────────────────────────────────────────────────
172
+
173
+ /**
174
+ * Convert a scope pattern to a match function.
175
+ * Supports:
176
+ * - Directory: "tests/fixtures/" or "tests/fixtures" → prefix match
177
+ * - Glob: "**\/*.test.js" → glob → regex
178
+ * - Exact: "bin/scd.js" → equality
179
+ */
180
+ function makePatternMatcher(pattern) {
181
+ const norm = pattern.replace(/\\/g, '/').replace(/\/$/, '');
182
+
183
+ // Directory pattern (trailing slash in original, or contains no extension and no glob chars)
184
+ const isDir = pattern.endsWith('/');
185
+ if (isDir) {
186
+ return (rel) => rel === norm || rel.startsWith(norm + '/');
187
+ }
188
+
189
+ // Glob pattern
190
+ if (pattern.includes('*') || pattern.includes('?')) {
191
+ const regexStr = norm
192
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
193
+ .replace(/\\\*/g, '__STAR__')
194
+ .replace(/\*\*/g, '__DOUBLESTAR__')
195
+ .replace(/__STAR__/g, '[^/]*')
196
+ .replace(/__DOUBLESTAR__/g, '.*')
197
+ .replace(/\?/g, '[^/]');
198
+ const re = new RegExp('^' + regexStr + '$');
199
+ return (rel) => re.test(rel);
200
+ }
201
+
202
+ // Exact match (also matches as directory prefix)
203
+ return (rel) => rel === norm || rel.startsWith(norm + '/');
204
+ }
205
+
206
+ // ── Public filter API ─────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Check if a file should be excluded by scope.file_excludes.
210
+ *
211
+ * @param {{ file_excludes: Array }} scope Merged scope object
212
+ * @param {string} absPath Absolute file path
213
+ * @param {string} repoRoot Repo root for relative path calculation
214
+ * @returns {{ excluded: boolean, entry: object|null }}
215
+ */
216
+ function isFileExcluded(scope, absPath, repoRoot) {
217
+ if (!scope.file_excludes.length) return { excluded: false, entry: null };
218
+
219
+ // Normalise to forward-slash relative path for matching
220
+ const rel = path.relative(repoRoot, absPath).replace(/\\/g, '/');
221
+
222
+ for (const entry of scope.file_excludes) {
223
+ if (!entry.pattern) continue;
224
+ const matches = makePatternMatcher(entry.pattern);
225
+ if (matches(rel)) return { excluded: true, entry };
226
+ }
227
+
228
+ return { excluded: false, entry: null };
229
+ }
230
+
231
+ /**
232
+ * Check if a finding should be excluded by scope.rule_excludes.
233
+ *
234
+ * @param {{ rule_excludes: Array }} scope Merged scope object
235
+ * @param {string} ruleId Finding rule ID
236
+ * @param {string} filePath Finding file path (relative)
237
+ * @returns {{ excluded: boolean, entry: object|null }}
238
+ */
239
+ function isRuleExcluded(scope, ruleId, filePath) {
240
+ if (!scope.rule_excludes.length) return { excluded: false, entry: null };
241
+
242
+ for (const entry of scope.rule_excludes) {
243
+ if (!entry.rule || entry.rule !== ruleId) continue;
244
+
245
+ // No files list → exclude for all files
246
+ if (!entry.files || !entry.files.length) {
247
+ return { excluded: true, entry };
248
+ }
249
+
250
+ // Files list → check if filePath matches any pattern
251
+ const normFile = (filePath || '').replace(/\\/g, '/');
252
+ for (const pattern of entry.files) {
253
+ const matches = makePatternMatcher(pattern);
254
+ if (matches(normFile)) return { excluded: true, entry };
255
+ }
256
+ }
257
+
258
+ return { excluded: false, entry: null };
259
+ }
260
+
261
+ // ── Validation ────────────────────────────────────────────────────────────
262
+
263
+ /**
264
+ * Validate a parsed scope object. Returns an array of warning objects
265
+ * for entries missing required fields (reason, added_by, added_at).
266
+ * Never throws — validation is advisory only.
267
+ *
268
+ * @param {{ file_excludes: Array, rule_excludes: Array }} scope
269
+ * @returns {Array<{ type: string, identifier: string, missing: string[] }>}
270
+ */
271
+ function validateScope(scope) {
272
+ const warnings = [];
273
+ const REQUIRED = ['reason', 'added_by', 'added_at'];
274
+
275
+ for (const entry of scope.file_excludes) {
276
+ const missing = REQUIRED.filter(f => !entry[f]);
277
+ if (missing.length) {
278
+ warnings.push({ type: 'file_exclude', identifier: entry.pattern || '(unknown)', missing });
279
+ }
280
+ }
281
+
282
+ for (const entry of scope.rule_excludes) {
283
+ const missing = REQUIRED.filter(f => !entry[f]);
284
+ if (missing.length) {
285
+ warnings.push({ type: 'rule_exclude', identifier: entry.rule || '(unknown)', missing });
286
+ }
287
+ }
288
+
289
+ return warnings;
290
+ }
291
+
292
+ // ── Scope summary for output ──────────────────────────────────────────────
293
+
294
+ /**
295
+ * Build a human-readable summary of active scope exclusions.
296
+ * Used in terminal output and audit log.
297
+ *
298
+ * @param {{ file_excludes: Array, rule_excludes: Array }} scope
299
+ * @returns {{ hasExclusions: boolean, fileLines: string[], ruleLines: string[] }}
300
+ */
301
+ function summariseScope(scope) {
302
+ const fileLines = scope.file_excludes.map(e => {
303
+ const reason = e.reason ? ` — "${e.reason}"` : ' — (no reason given)';
304
+ const meta = [e.added_by, e.added_at].filter(Boolean).join(', ');
305
+ const src = e._source ? ` [${e._source}]` : '';
306
+ return ` ${e.pattern}${reason} (${meta})${src}`;
307
+ });
308
+
309
+ const ruleLines = scope.rule_excludes.map(e => {
310
+ const scope_ = e.files && e.files.length ? e.files.join(', ') : 'all files';
311
+ const reason = e.reason ? ` — "${e.reason}"` : ' — (no reason given)';
312
+ const meta = [e.added_by, e.added_at].filter(Boolean).join(', ');
313
+ const src = e._source ? ` [${e._source}]` : '';
314
+ return ` ${e.rule} (${scope_})${reason} (${meta})${src}`;
315
+ });
316
+
317
+ return {
318
+ hasExclusions: fileLines.length > 0 || ruleLines.length > 0,
319
+ fileLines,
320
+ ruleLines,
321
+ };
322
+ }
323
+
324
+ module.exports = {
325
+ loadScope,
326
+ isFileExcluded,
327
+ isRuleExcluded,
328
+ validateScope,
329
+ summariseScope,
330
+ parseScope, // exported for testing
331
+ };