@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,341 @@
1
+ 'use strict';
2
+ const { RESET, BOLD, DIM, RED, GREEN, YELLOW, CYAN } = require('../output-constants');
3
+ // lib/commands/scope.js
4
+ // scd scope — manage global scan scope exclusions (~/.scd/scope.yml)
5
+ // For repo-level scope, use: scd repo scope
6
+
7
+ module.exports = { register, appendToScope, buildFileEntry, buildRuleEntry, removeFromScope };
8
+
9
+ function register(program) {
10
+ const { Command } = require('commander');
11
+
12
+ const scopeCmd = new Command('scope')
13
+ .description('Manage global scan scope exclusions (~/.scd/scope.yml)')
14
+ .addHelpText('after', `
15
+ Examples:
16
+ scd scope --show
17
+ scd scope --add-file "tests/fixtures/" --reason "Test fixtures with intentional vulns"
18
+ scd scope --add-rule INFRA-001 --reason "Cloud-managed infrastructure"
19
+ scd scope --add-rule JS-ERR-002 --files "lib/rules/,**/*.test.js" --reason "Rule definition files"
20
+
21
+ For repo-level scope: scd repo scope --show`)
22
+ .option('--show', 'Show active global scope exclusions')
23
+ .option('--add-file <pattern>','Add a file/directory exclusion pattern')
24
+ .option('--add-rule <ruleId>', 'Add a rule exclusion')
25
+ .option('--files <globs>', 'Comma-separated file globs to scope a rule exclusion (use with --add-rule)')
26
+ .option('--reason <text>', 'Reason for the exclusion (required with --add-file and --add-rule)')
27
+ .option('--remove-file <pattern>','Remove a file exclusion by pattern')
28
+ .option('--remove-rule <ruleId>', 'Remove a rule exclusion by rule ID')
29
+ .action((opts) => {
30
+ const fs = require('fs');
31
+ const os = require('os');
32
+ const path = require('path');
33
+ const store = require('../store');
34
+ const { loadScope, validateScope, summariseScope } = require('../scope');
35
+
36
+
37
+ const scopeFile = store.globalScopePath();
38
+
39
+ // ── --show ──────────────────────────────────────────────────────────────
40
+ if (opts.show || (!opts.addFile && !opts.addRule && !opts.removeFile && !opts.removeRule)) {
41
+ if (!fs.existsSync(scopeFile)) {
42
+ console.log(`\n${DIM} No global scope.yml found.${RESET}`);
43
+ console.log(`${DIM} Use ${RESET}${CYAN}scd scope --add-file${RESET}${DIM} or ${RESET}${CYAN}scd scope --add-rule${RESET}${DIM} to create one.${RESET}\n`);
44
+ return;
45
+ }
46
+
47
+ const scope = loadScope(null); // global only — no repoRoot
48
+ const warnings = validateScope(scope);
49
+ const summary = summariseScope(scope);
50
+
51
+ console.log(`\n${BOLD}Global scope exclusions${RESET} ${DIM}(~/.scd/scope.yml)${RESET}\n`);
52
+
53
+ if (!summary.hasExclusions) {
54
+ console.log(`${DIM} No active exclusions.${RESET}\n`);
55
+ return;
56
+ }
57
+
58
+ if (summary.fileLines.length > 0) {
59
+ console.log(`${BOLD} File exclusions:${RESET}`);
60
+ for (const line of summary.fileLines) console.log(` ${line.trim()}`);
61
+ console.log();
62
+ }
63
+
64
+ if (summary.ruleLines.length > 0) {
65
+ console.log(`${BOLD} Rule exclusions:${RESET}`);
66
+ for (const line of summary.ruleLines) console.log(` ${line.trim()}`);
67
+ console.log();
68
+ }
69
+
70
+ if (warnings.length > 0) {
71
+ console.log(`${YELLOW} ⚠ Incomplete entries (missing required fields):${RESET}`);
72
+ for (const w of warnings) {
73
+ console.log(`${YELLOW} ${w.identifier}: missing ${w.missing.join(', ')}${RESET}`);
74
+ }
75
+ console.log();
76
+ }
77
+ return;
78
+ }
79
+
80
+ // ── --add-file / --add-rule: require --reason ────────────────────────────
81
+ if (!opts.reason) {
82
+ console.error(`\n${RED}✗ --reason is required.${RESET}`);
83
+ console.error(` Every scope exclusion must have a documented reason.\n`);
84
+ process.exit(1);
85
+ }
86
+
87
+ // ── Build entry ──────────────────────────────────────────────────────────
88
+ const { getMachineFingerprint } = require('../store');
89
+ const installId = getMachineFingerprint() || 'unknown';
90
+ const addedAt = new Date().toLocaleString('sv-SE', {
91
+ timeZone: 'Europe/Stockholm', year: 'numeric', month: '2-digit',
92
+ day: '2-digit', hour: '2-digit', minute: '2-digit',
93
+ }).replace(',', '');
94
+
95
+ // ── --add-file ───────────────────────────────────────────────────────────
96
+ if (opts.addFile) {
97
+ const entry = buildFileEntry(opts.addFile, opts.reason, installId, addedAt);
98
+ appendToScope(scopeFile, 'file_excludes', entry);
99
+ console.log(`\n${GREEN}✓ File exclusion added to global scope.yml${RESET}`);
100
+ console.log(` ${DIM}Pattern : ${opts.addFile}${RESET}`);
101
+ console.log(` ${DIM}Reason : ${opts.reason}${RESET}`);
102
+ console.log(` ${DIM}Added by: ${installId}${RESET}\n`);
103
+ console.log(`${YELLOW} ⚠ Active file exclusions are visible in every scan output.${RESET}\n`);
104
+ return;
105
+ }
106
+
107
+ // ── --add-rule ───────────────────────────────────────────────────────────
108
+ if (opts.addRule) {
109
+ const files = opts.files
110
+ ? opts.files.split(',').map(s => s.trim()).filter(Boolean)
111
+ : null;
112
+ const entry = buildRuleEntry(opts.addRule, files, opts.reason, installId, addedAt);
113
+ appendToScope(scopeFile, 'rule_excludes', entry);
114
+ const scopeDesc = files ? files.join(', ') : 'all files';
115
+ console.log(`\n${GREEN}✓ Rule exclusion added to global scope.yml${RESET}`);
116
+ console.log(` ${DIM}Rule : ${opts.addRule}${RESET}`);
117
+ console.log(` ${DIM}Scope : ${scopeDesc}${RESET}`);
118
+ console.log(` ${DIM}Reason : ${opts.reason}${RESET}`);
119
+ console.log(` ${DIM}Added by: ${installId}${RESET}\n`);
120
+ console.log(`${YELLOW} ⚠ Active rule exclusions are visible in every scan output.${RESET}\n`);
121
+ return;
122
+ }
123
+
124
+ // ── --remove-file ─────────────────────────────────────────────────────────
125
+ if (opts.removeFile) {
126
+ const removed = removeFromScope(scopeFile, 'file_excludes', 'pattern', opts.removeFile);
127
+ if (removed.length === 0) {
128
+ console.log(`\n${YELLOW} No file exclusion found matching: ${opts.removeFile}${RESET}\n`);
129
+ } else {
130
+ console.log(`\n${GREEN}✓ Removed ${removed.length} file exclusion(s) from global scope.yml${RESET}`);
131
+ for (const r of removed) {
132
+ console.log(` ${DIM}Pattern : ${r.pattern}${RESET}`);
133
+ console.log(` ${DIM}Reason : ${r.reason || '(none)'}${RESET}`);
134
+ console.log(` ${DIM}Added by: ${r.added_by || '(unknown)'} ${r.added_at || ''}${RESET}`);
135
+ }
136
+ console.log();
137
+ }
138
+ return;
139
+ }
140
+
141
+ // ── --remove-rule ─────────────────────────────────────────────────────────
142
+ if (opts.removeRule) {
143
+ const removed = removeFromScope(scopeFile, 'rule_excludes', 'rule', opts.removeRule);
144
+ if (removed.length === 0) {
145
+ console.log(`\n${YELLOW} No rule exclusion found matching: ${opts.removeRule}${RESET}\n`);
146
+ } else {
147
+ console.log(`\n${GREEN}✓ Removed ${removed.length} rule exclusion(s) from global scope.yml${RESET}`);
148
+ for (const r of removed) {
149
+ const scopeDesc = r.files && r.files.length ? r.files.join(', ') : 'all files';
150
+ console.log(` ${DIM}Rule : ${r.rule} (${scopeDesc})${RESET}`);
151
+ console.log(` ${DIM}Reason : ${r.reason || '(none)'}${RESET}`);
152
+ console.log(` ${DIM}Added by: ${r.added_by || '(unknown)'} ${r.added_at || ''}${RESET}`);
153
+ }
154
+ console.log();
155
+ }
156
+ return;
157
+ }
158
+ });
159
+
160
+ program.addCommand(scopeCmd);
161
+ }
162
+
163
+ // ── Helpers ───────────────────────────────────────────────────────────────
164
+
165
+ function buildFileEntry(pattern, reason, addedBy, addedAt) {
166
+ return [
167
+ ` - pattern: ${pattern}`,
168
+ ` reason: "${reason}"`,
169
+ ` added_by: ${addedBy}`,
170
+ ` added_at: "${addedAt}"`,
171
+ ].join('\n');
172
+ }
173
+
174
+ function buildRuleEntry(rule, files, reason, addedBy, addedAt) {
175
+ const lines = [
176
+ ` - rule: ${rule}`,
177
+ ];
178
+ if (files && files.length) {
179
+ lines.push(` files:`);
180
+ for (const f of files) lines.push(` - ${f}`);
181
+ }
182
+ lines.push(` reason: "${reason}"`);
183
+ lines.push(` added_by: ${addedBy}`);
184
+ lines.push(` added_at: "${addedAt}"`);
185
+ return lines.join('\n');
186
+ }
187
+
188
+
189
+ /**
190
+ * Remove all entries from a section in scope.yml that match a key/value.
191
+ * Parses the YAML as text — uses the scope parser to identify entries,
192
+ * then removes their line ranges from the raw file.
193
+ *
194
+ * Returns an array of removed entries (for display).
195
+ */
196
+ function removeFromScope(scopeFile, section, matchKey, matchValue) {
197
+ const fs = require('fs');
198
+ const { parseScope } = require('../scope');
199
+
200
+ if (!fs.existsSync(scopeFile)) return [];
201
+
202
+ const content = fs.readFileSync(scopeFile, 'utf8');
203
+ const parsed = parseScope(content);
204
+ const entries = parsed[section] || [];
205
+
206
+ // Find entries that match
207
+ const toRemove = entries.filter(e => e[matchKey] === matchValue);
208
+ if (toRemove.length === 0) return [];
209
+
210
+ // Remove by rebuilding the section without matched entries
211
+ const remaining = entries.filter(e => e[matchKey] !== matchValue);
212
+
213
+ // Rebuild file: replace section content
214
+ const lines = content.split('\n');
215
+ const newLines = [];
216
+ let inSection = false;
217
+ let inEntry = false;
218
+ let skipEntry = false;
219
+ let entryLines = [];
220
+
221
+ // Two-pass: collect entry line ranges, then rebuild
222
+ // Simpler approach: regenerate the section from remaining entries
223
+ const otherSection = section === 'file_excludes' ? 'rule_excludes' : 'file_excludes';
224
+ const otherEntries = parsed[otherSection] || [];
225
+
226
+ let result = rebuildScopeFile(content, section, remaining, otherSection, otherEntries);
227
+ fs.writeFileSync(scopeFile, result, { encoding: 'utf8', mode: 0o600 });
228
+
229
+ return toRemove;
230
+ }
231
+
232
+ /**
233
+ * Rebuild scope.yml content preserving header comments and both sections.
234
+ * Regenerates section entries from parsed data.
235
+ */
236
+ function rebuildScopeFile(original, changedSection, changedEntries, otherSection, otherEntries) {
237
+ const lines = original.split('\n');
238
+ const result = [];
239
+ let inSection = null;
240
+ let skipUntilNextSection = false;
241
+
242
+ for (const line of lines) {
243
+ // Detect top-level section headers
244
+ if (/^file_excludes\s*:/.test(line)) {
245
+ inSection = 'file_excludes';
246
+ skipUntilNextSection = true;
247
+ result.push(line);
248
+ // Inject entries for this section
249
+ const entries = inSection === changedSection ? changedEntries : otherEntries;
250
+ for (const e of (changedSection === 'file_excludes' ? changedEntries : otherEntries)) {
251
+ result.push(buildFileEntry(e.pattern, e.reason || '', e.added_by || '', e.added_at || ''));
252
+ }
253
+ continue;
254
+ }
255
+ if (/^rule_excludes\s*:/.test(line)) {
256
+ inSection = 'rule_excludes';
257
+ skipUntilNextSection = true;
258
+ result.push(line);
259
+ for (const e of (changedSection === 'rule_excludes' ? changedEntries : otherEntries)) {
260
+ result.push(buildRuleEntry(e.rule, e.files || null, e.reason || '', e.added_by || '', e.added_at || ''));
261
+ }
262
+ continue;
263
+ }
264
+
265
+ // Skip existing entry lines inside a section
266
+ if (skipUntilNextSection && inSection) {
267
+ // A line that is not indented and not empty and not a comment = new top-level key
268
+ if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('\t') && !line.startsWith('#')) {
269
+ skipUntilNextSection = false;
270
+ inSection = null;
271
+ result.push(line);
272
+ }
273
+ // Otherwise skip — entries were already injected above
274
+ continue;
275
+ }
276
+
277
+ result.push(line);
278
+ }
279
+
280
+ return result.join('\n');
281
+ }
282
+
283
+ /**
284
+ * Append a new entry to the correct section in scope.yml.
285
+ * Creates the file with header if it does not exist.
286
+ * Appends to existing section if present, adds section header if not.
287
+ */
288
+ function appendToScope(scopeFile, section, entryYaml) {
289
+ const fs = require('fs');
290
+ const path = require('path');
291
+ const os = require('os');
292
+
293
+ // Ensure ~/.scd/ exists
294
+ const dir = path.dirname(scopeFile);
295
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
296
+
297
+ if (!fs.existsSync(scopeFile)) {
298
+ // Create file with header and both section stubs
299
+ const header = [
300
+ `# scope.yml — global scan scope exclusions`,
301
+ `# Managed by: scd scope`,
302
+ `# Every entry requires reason, added_by, and added_at.`,
303
+ `# Missing fields produce a warning in scan output and audit log.`,
304
+ `#`,
305
+ `# Documentation: https://docs.securecodebydesign.com/scope`,
306
+ ``,
307
+ `file_excludes:`,
308
+ section === 'file_excludes' ? entryYaml : '',
309
+ ``,
310
+ `rule_excludes:`,
311
+ section === 'rule_excludes' ? entryYaml : '',
312
+ ``,
313
+ ].join('\n');
314
+ fs.writeFileSync(scopeFile, header, { encoding: 'utf8', mode: 0o600 });
315
+ return;
316
+ }
317
+
318
+ let content = fs.readFileSync(scopeFile, 'utf8');
319
+
320
+ // Section already exists — append after the section header
321
+ const sectionRe = new RegExp(`^(${section}\\s*:[ \\t]*)$`, 'm');
322
+ if (sectionRe.test(content)) {
323
+ // Find the section and append entry before the next top-level key or EOF
324
+ const lines = content.split('\n');
325
+ const sectionIdx = lines.findIndex(l => new RegExp(`^${section}\\s*:`).test(l));
326
+ // Find end of section: next line that is a top-level key (no leading space) and not a comment
327
+ let insertIdx = sectionIdx + 1;
328
+ while (insertIdx < lines.length) {
329
+ const line = lines[insertIdx];
330
+ if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('\t') && !line.startsWith('#')) break;
331
+ insertIdx++;
332
+ }
333
+ lines.splice(insertIdx, 0, entryYaml);
334
+ content = lines.join('\n');
335
+ } else {
336
+ // Section does not exist — append to end of file
337
+ content = content.trimEnd() + `\n\n${section}:\n${entryYaml}\n`;
338
+ }
339
+
340
+ fs.writeFileSync(scopeFile, content, { encoding: 'utf8', mode: 0o600 });
341
+ }
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+ const { RESET, DIM, GREEN, CYAN } = require('../output-constants');
3
+ // lib/commands/sync.js
4
+
5
+ module.exports = { register };
6
+
7
+ function register(program) {
8
+ program
9
+ .command('sync')
10
+ .description('Pull approved exceptions from scd-server and update local config')
11
+ .option('--history', 'Sync full audit.log history to scd-server (one-time, idempotent)')
12
+ .action(async (opts) => {
13
+ const { getRepoRoot } = require('../config');
14
+ const { warnIfOutdated } = require('../cli-helpers');
15
+ const repoRoot = getRepoRoot();
16
+
17
+ if (opts.history) {
18
+ const { syncHistory } = require('../audit-sync');
19
+ console.log('\n' + DIM + ' Syncing audit history to scd-server…' + RESET);
20
+ const result = await syncHistory(repoRoot);
21
+ if (result.error) {
22
+ console.log('\nRED Error: ' + result.error + RESET + '\n');
23
+ process.exit(1);
24
+ }
25
+ if (result.message) {
26
+ console.log('\n' + DIM + ' ' + result.message + RESET + '\n');
27
+ return;
28
+ }
29
+ console.log('\n' + GREEN + ' ✓ History sync complete' + RESET);
30
+ console.log(DIM + ' Sessions: ' + result.sessions + ' · Findings: ' + result.findings +
31
+ (result.errors > 0 ? ' · ' + YELLOW + 'Errors: ' + result.errors + RESET : '') + '\n' + RESET);
32
+ console.log(DIM + ' Safe to re-run — server ignores duplicates.\n' + RESET);
33
+ return;
34
+ }
35
+
36
+ const { syncExceptions } = require('../exception-manager');
37
+ await syncExceptions(repoRoot);
38
+ warnIfOutdated();
39
+ });
40
+ }
@@ -0,0 +1,15 @@
1
+ 'use strict';
2
+ // lib/commands/uninstall.js
3
+
4
+ module.exports = { register };
5
+
6
+ function register(program) {
7
+ const { Command } = require('commander');
8
+ const cmd = new Command('uninstall')
9
+ .description('Remove global git hooks from this machine')
10
+ .action(async () => {
11
+ const { uninstall } = require('../installer');
12
+ await uninstall();
13
+ });
14
+ program.addCommand(cmd);
15
+ }
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+ const { RESET, BOLD, DIM } = require('../output-constants');
3
+ // lib/commands/version.js
4
+
5
+ module.exports = { register, showVersion };
6
+
7
+ function showVersion() {
8
+ const os = require('os');
9
+ const pkg = require('../../package.json');
10
+ const { RULES_VERSION, getRegistry } = require('../rule-registry');
11
+ const rules = getRegistry();
12
+ const sevCount = (sev) => rules.filter(r => r.severity === sev).length;
13
+
14
+ console.log('\n' + BOLD + 'Secure Code by Design' + RESET);
15
+ console.log(DIM + '─'.repeat(40) + RESET);
16
+ console.log(' CLI: ' + BOLD + pkg.version + RESET);
17
+ console.log(' Rules: ' + BOLD + RULES_VERSION + RESET +
18
+ DIM + ' (' + rules.length + ' rules' +
19
+ ' · CRITICAL: ' + sevCount('CRITICAL') +
20
+ ' HIGH: ' + sevCount('HIGH') +
21
+ ' MEDIUM: ' + sevCount('MEDIUM') +
22
+ ' EXPOSURE: ' + sevCount('EXPOSURE') + ')' + RESET);
23
+ console.log(' Node: ' + DIM + process.version + RESET);
24
+ console.log(' OS: ' + DIM + os.platform() + ' ' + os.arch() + RESET);
25
+ console.log();
26
+ }
27
+
28
+ function register(program) {
29
+ program
30
+ .command('version')
31
+ .description('Show detailed version information')
32
+ .action(showVersion);
33
+ }