@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,348 @@
1
+ const { RESET, YELLOW } = require('./output-constants');
2
+ /**
3
+ * scanner-manual.js
4
+ * File discovery and orchestration for manual `scd scan` runs.
5
+ *
6
+ * --no-limit flag: bypasses size limit, scans all files with 30s timeout per file.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { buildIgnoreFilter } = require('./file-filter');
12
+ const { loadScope, isFileExcluded, validateScope, summariseScope } = require('./scope');
13
+
14
+ // Simple glob expansion — supports * and ? wildcards in filename segment only
15
+ // e.g. "*.txt", "logs/*.log", "src/**/*.js" (** treated as any depth)
16
+ function expandGlob(pattern, cwd) {
17
+ const resolved = path.resolve(cwd, pattern);
18
+ const dir = path.dirname(resolved);
19
+ const basename = path.basename(resolved);
20
+
21
+ // No wildcards — return as-is (existence checked later)
22
+ if (!basename.includes('*') && !basename.includes('?')) return null;
23
+
24
+ // Convert glob pattern to regex
25
+ const reStr = basename
26
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape regex metacharacters
27
+ .replace(/\*\*/g, '.+') // ** = one or more chars (any depth)
28
+ .replace(/\*/g, '[^/]*') // * = any chars except separator
29
+ .replace(/\?/g, '[^/]'); // ? = single char
30
+ const re = new RegExp('^' + reStr + '$', 'i');
31
+
32
+ // Walk upward to find a real directory to scan
33
+ let scanDir = dir;
34
+ while (!fs.existsSync(scanDir) && scanDir !== path.dirname(scanDir)) {
35
+ scanDir = path.dirname(scanDir);
36
+ }
37
+ if (!fs.existsSync(scanDir)) return [];
38
+
39
+ // Collect matching files (recursive if ** used, flat otherwise)
40
+ const recursive = basename.includes('**');
41
+ return collectMatchingFiles(scanDir, re, recursive);
42
+ }
43
+
44
+ function collectMatchingFiles(dir, re, recursive) {
45
+ const results = [];
46
+ let entries;
47
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
48
+ catch { return results; }
49
+
50
+ for (const entry of entries) {
51
+ if (entry.name.startsWith('.')) continue;
52
+ const fullPath = path.join(dir, entry.name);
53
+ if (entry.isDirectory() && recursive) {
54
+ results.push(...collectMatchingFiles(fullPath, re, recursive));
55
+ } else if (entry.isFile() && re.test(entry.name)) {
56
+ results.push(fullPath);
57
+ }
58
+ }
59
+ return results;
60
+ }
61
+
62
+ const SUPPORTED_EXTENSIONS = new Set([
63
+ 'js', 'mjs', 'cjs', 'ts', 'jsx', 'tsx',
64
+ 'py',
65
+ 'php',
66
+ 'aspx', 'ascx', 'master',
67
+ 'config',
68
+ 'cs',
69
+ 'txt', 'log',
70
+ // Sensitive file types
71
+ 'env',
72
+ 'sql',
73
+ 'yml', 'yaml',
74
+ 'json',
75
+ 'xml',
76
+ 'properties',
77
+ 'ini', 'cfg', 'conf',
78
+ 'sh', 'bash',
79
+ 'ps1',
80
+ 'bat', 'cmd',
81
+ 'bak', 'old', 'orig',
82
+ 'pem', 'key', 'pfx', 'p12',
83
+ 'sqlite', 'sqlite3', 'db',
84
+ ]);
85
+
86
+ const LANG_TO_EXTENSIONS = {
87
+ js: ['js', 'mjs', 'cjs'],
88
+ javascript: ['js', 'mjs', 'cjs'],
89
+ ts: ['ts', 'tsx'],
90
+ typescript: ['ts', 'tsx'],
91
+ react: ['jsx', 'tsx'],
92
+ py: ['py'],
93
+ python: ['py'],
94
+ php: ['php'],
95
+ aspx: ['aspx', 'ascx', 'master'],
96
+ aspnet: ['aspx', 'ascx', 'master', 'config', 'cs'],
97
+ dotnet: ['aspx', 'ascx', 'master', 'config', 'cs'],
98
+ config: ['config'],
99
+ cs: ['cs'],
100
+ csharp: ['cs'],
101
+ // Sensitive file types
102
+ env: ['env'],
103
+ sql: ['sql'],
104
+ yaml: ['yml', 'yaml'],
105
+ yml: ['yml', 'yaml'],
106
+ json: ['json'],
107
+ xml: ['xml'],
108
+ properties: ['properties'],
109
+ ini: ['ini', 'cfg', 'conf'],
110
+ shell: ['sh', 'bash'],
111
+ sh: ['sh', 'bash'],
112
+ powershell: ['ps1'],
113
+ ps1: ['ps1'],
114
+ batch: ['bat', 'cmd'],
115
+ bat: ['bat', 'cmd'],
116
+ secrets: ['env', 'pem', 'key', 'pfx', 'p12', 'sqlite', 'sqlite3', 'db', 'bak'],
117
+ txt: ['txt'],
118
+ log: ['log'],
119
+ logs: ['log', 'txt'],
120
+ };
121
+
122
+ const IGNORED_DIRS = new Set([
123
+ 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt',
124
+ 'coverage', '.nyc_output', '__pycache__', '.cache',
125
+ 'target', 'out',
126
+ ]);
127
+
128
+ // ── Vendor path patterns ───────────────────────────────────────────────────
129
+ // Matches paths that are vendor/dependency code, not project source.
130
+ // Used by isVendorPath() to filter files during discovery.
131
+ // Vendor path pattern — matches dependency/library code that is not project source.
132
+ // Covers: JS (node_modules), PHP (vendor), Python (site-packages, venv, __pycache__),
133
+ // .NET (packages, bin/Release, obj/Debug), minified assets.
134
+ const VENDOR_PATH_RE = /(?:^|[/\\])(?:node_modules|vendor|site-packages|bower_components|packages)[/\\]|[/\\]lib[/\\]python[0-9]|(?:^|[/\\])__pycache__[/\\]|\.pyc$|(?:^|[/\\])(?:\.venv|venv|env)[/\\]|(?:^|[/\\])bin[/\\](?:Debug|Release)[/\\]|(?:^|[/\\])obj[/\\](?:Debug|Release)[/\\]|\.min\.(?:js|css)$/i;
135
+
136
+ /**
137
+ * Returns true if a file path looks like vendor/dependency code.
138
+ * Used to implement --include-vendor / --vendor-only behaviour.
139
+ */
140
+ function isVendorPath(filePath) {
141
+ return VENDOR_PATH_RE.test(filePath);
142
+ }
143
+
144
+ // Default max file size: 2MB. Overridden by scan.max_file_size_kb in yml.
145
+ const DEFAULT_MAX_FILE_SIZE = 2 * 1024 * 1024;
146
+
147
+ // Timeout per file when --no-limit is active (ms)
148
+ const NO_LIMIT_TIMEOUT_MS = 30_000;
149
+
150
+ // ── File discovery ─────────────────────────────────────────────────────────
151
+
152
+ function discoverFiles(target, opts = {}) {
153
+ const { lang, noLimit = false, includeVendor = false, vendorOnly = false, includeIgnored = false, repoRoot = null } = opts;
154
+
155
+ const allowedExts = lang
156
+ ? new Set(LANG_TO_EXTENSIONS[lang.toLowerCase()] || [])
157
+ : SUPPORTED_EXTENSIONS;
158
+
159
+ if (lang && allowedExts.size === 0) {
160
+ throw new Error(
161
+ `Unknown language: "${lang}". Available: ${Object.keys(LANG_TO_EXTENSIONS).join(', ')}`
162
+ );
163
+ }
164
+
165
+ const resolved = path.resolve(process.cwd(), target || '.');
166
+
167
+ // ── Glob pattern (contains * or ?) ──────────────────────────────────────
168
+ if (target && (target.includes('*') || target.includes('?'))) {
169
+ const matches = expandGlob(target, process.cwd());
170
+ if (!matches || matches.length === 0) {
171
+ throw new Error(`Hittade inga filer som matchar: ${target}`);
172
+ }
173
+ // Filter by allowed extensions if --lang was specified
174
+ const filtered = allowedExts.size > 0
175
+ ? matches.filter(f => allowedExts.has(path.extname(f).slice(1).toLowerCase()))
176
+ : matches.filter(f => SUPPORTED_EXTENSIONS.has(path.extname(f).slice(1).toLowerCase()));
177
+ if (filtered.length === 0) {
178
+ throw new Error(`No files with supported extensions matched: ${target}`);
179
+ }
180
+ const r = readFiles(filtered, allowedExts, opts.config, noLimit);
181
+ return { ...r, scopeExclusions: null, scope: null };
182
+ }
183
+
184
+ if (!fs.existsSync(resolved)) {
185
+ throw new Error(`Hittade inte: ${target}`);
186
+ }
187
+
188
+ const stat = fs.statSync(resolved);
189
+
190
+ if (stat.isFile()) {
191
+ const r = readFiles([resolved], allowedExts, opts.config, noLimit);
192
+ return { ...r, scopeExclusions: null, scope: null };
193
+ }
194
+
195
+ if (stat.isDirectory()) {
196
+ // Build .gitignore filter — respects git ls-files or manual .gitignore parsing
197
+ const ignoreRoot = repoRoot || resolved;
198
+
199
+ // Load scope.yml exclusions (global + repo + server-managed)
200
+ const scope = repoRoot ? loadScope(repoRoot) : { file_excludes: [], rule_excludes: [] };
201
+
202
+ // Warn on incomplete scope entries (missing reason/added_by/added_at)
203
+ const scopeWarnings = validateScope(scope);
204
+ for (const w of scopeWarnings) {
205
+ process.stderr.write(`${YELLOW} ⚠ scope.yml: entry "${w.identifier}" is missing required fields: ${w.missing.join(', ')}${RESET}\n`);
206
+ }
207
+
208
+ // Build combined filter: .gitignore + scope file_excludes
209
+ const shouldIgnoreGit = includeIgnored ? () => false : buildIgnoreFilter(ignoreRoot);
210
+ // Count files excluded by scope (not by .gitignore) for audit/output
211
+ let scopeExcludedCount = 0;
212
+ const shouldIgnoreWithCount = (filePath) => {
213
+ if (shouldIgnoreGit(filePath)) return true;
214
+ if (scope.file_excludes.length === 0) return false;
215
+ const result = isFileExcluded(scope, filePath, ignoreRoot);
216
+ if (result.excluded) { scopeExcludedCount++; return true; }
217
+ return false;
218
+ };
219
+
220
+ const found = walkDir(resolved, allowedExts, { includeVendor, vendorOnly, shouldIgnore: shouldIgnoreWithCount });
221
+ const { files, skipped } = readFiles(found, allowedExts, opts.config, noLimit);
222
+
223
+ // Build scope exclusion summary for audit log and scan output
224
+ const scopeSummary = summariseScope(scope);
225
+ const scopeExclusions = scopeSummary.hasExclusions ? {
226
+ files_excluded: scopeExcludedCount,
227
+ file_excludes: scope.file_excludes,
228
+ rule_excludes: scope.rule_excludes,
229
+ _summary: scopeSummary,
230
+ _warnings: scopeWarnings,
231
+ } : null;
232
+
233
+ return { files, skipped, scopeExclusions, scope };
234
+ }
235
+
236
+ throw new Error(`Kan inte scanna: ${target} (varken fil eller katalog)`);
237
+ }
238
+
239
+ function walkDir(dir, allowedExts, opts = {}) {
240
+ const { includeVendor = false, vendorOnly = false, shouldIgnore = () => false } = opts;
241
+ const results = [];
242
+
243
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
244
+ if (entry.name.startsWith('.') && entry.name !== '.env') continue;
245
+ if (IGNORED_DIRS.has(entry.name)) continue;
246
+
247
+ const fullPath = path.join(dir, entry.name);
248
+
249
+ if (entry.isDirectory()) {
250
+ // Skip dirs ignored by .gitignore
251
+ if (shouldIgnore(fullPath + path.sep)) continue;
252
+ // Default mode: skip known vendor dir names
253
+ const isDirVendor = isVendorPath(fullPath + '/');
254
+ if (isDirVendor && !includeVendor && !vendorOnly) continue;
255
+ results.push(...walkDir(fullPath, allowedExts, opts));
256
+ } else if (entry.isFile()) {
257
+ const ext = entry.name.split('.').pop().toLowerCase();
258
+ if (!allowedExts.has(ext)) continue;
259
+
260
+ // Skip files ignored by .gitignore
261
+ if (shouldIgnore(fullPath)) continue;
262
+
263
+ const vendor = isVendorPath(fullPath);
264
+ // Default: skip vendor files
265
+ if (vendor && !includeVendor && !vendorOnly) continue;
266
+ // --vendor-only: skip non-vendor files
267
+ if (!vendor && vendorOnly) continue;
268
+
269
+ results.push(fullPath);
270
+ }
271
+ }
272
+
273
+ return results;
274
+ }
275
+
276
+ /**
277
+ * Read files, enforce size limit (unless noLimit=true).
278
+ * noLimit mode: reads all files but enforces a 30s scan timeout per file
279
+ * by marking them so scanFull can bail out if scanning stalls.
280
+ */
281
+ function readFiles(filePaths, allowedExts, config = null, noLimit = false) {
282
+ const files = [];
283
+ const skipped = [];
284
+
285
+ const maxBytes = noLimit
286
+ ? Infinity
287
+ : (config?.scan?.max_file_size_kb
288
+ ? config.scan.max_file_size_kb * 1024
289
+ : DEFAULT_MAX_FILE_SIZE);
290
+
291
+ for (const filePath of filePaths) {
292
+ const ext = filePath.split('.').pop().toLowerCase();
293
+ if (!allowedExts.has(ext)) continue;
294
+
295
+ const relativePath = path.relative(process.cwd(), filePath);
296
+
297
+ try {
298
+ const content = fs.readFileSync(filePath, 'utf8');
299
+ const kb = Math.round(content.length / 1024);
300
+
301
+ if (content.length > maxBytes) {
302
+ skipped.push({
303
+ filePath: relativePath,
304
+ reason: 'too_large',
305
+ sizeKb: kb,
306
+ limitKb: Math.round(maxBytes / 1024),
307
+ });
308
+ continue;
309
+ }
310
+
311
+ // Flag large files so scanner can apply per-file timeout
312
+ const isLarge = kb > 512; // > 512KB flaggas som stor
313
+ files.push({ filePath: relativePath, content, sizeKb: kb, isLarge, noLimit });
314
+ } catch (err) {
315
+ skipped.push({ filePath: relativePath, reason: 'unreadable', error: err.message });
316
+ }
317
+ }
318
+
319
+ return { files, skipped };
320
+ }
321
+
322
+ /**
323
+ * Filter findings based on CLI options.
324
+ */
325
+ function filterFindings(findings, opts = {}) {
326
+ let result = findings;
327
+
328
+ if (opts.severity) {
329
+ const sev = opts.severity.toUpperCase();
330
+ result = result.filter(f => f.severity === sev);
331
+ }
332
+
333
+ if (opts.rule) {
334
+ const ruleId = opts.rule.toUpperCase();
335
+ result = result.filter(f => f.ruleId.toUpperCase() === ruleId);
336
+ }
337
+
338
+ return result;
339
+ }
340
+
341
+ module.exports = {
342
+ discoverFiles,
343
+ filterFindings,
344
+ isVendorPath,
345
+ SUPPORTED_EXTENSIONS,
346
+ LANG_TO_EXTENSIONS,
347
+ NO_LIMIT_TIMEOUT_MS,
348
+ };
@@ -0,0 +1,83 @@
1
+ /**
2
+ * scanner-secrets.js
3
+ * Fast secrets detection for pre-commit hook.
4
+ * Now config-aware and audit-logged.
5
+ *
6
+ * Rules are loaded from rules/rules-secrets.json via rule-loader.js,
7
+ * consistent with all other rule packs.
8
+ */
9
+
10
+ const crypto = require('crypto');
11
+ const { isExcepted, getRuleAction } = require('./config');
12
+ const { loadPack } = require('../rules/rule-loader');
13
+
14
+ // Load and compile rules from JSON — patterns and antipatterns are RegExp objects
15
+ const _secretsPack = require('../rules/rules-secrets.json');
16
+ const RULES = loadPack(_secretsPack);
17
+
18
+ async function scanSecrets(files, config = null) {
19
+ const findings = [];
20
+
21
+ for (const { filePath, content } of files) {
22
+ const lines = content.split('\n');
23
+
24
+ for (const rule of RULES) {
25
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
26
+ const line = lines[lineIndex];
27
+ const matches = [...line.matchAll(new RegExp(rule.pattern.source, rule.pattern.flags))];
28
+
29
+ for (const match of matches) {
30
+ // Antipattern-check: if the rule has an antipattern, check match line + lookahead
31
+ if (rule.antipattern) {
32
+ const lookahead = rule.lookahead || 80;
33
+ const windowEnd = Math.min(lines.length, lineIndex + Math.ceil(lookahead / 80) + 1);
34
+ const window = lines.slice(lineIndex, windowEnd).join('\n');
35
+ if (rule.antipattern.test(window)) continue; // likely false positive — skip
36
+ }
37
+
38
+ const lineNum = lineIndex + 1;
39
+ // Secrets rules use position-based deterministic ID
40
+ const findingId = 'f-' + crypto.createHash('sha256')
41
+ .update((rule.id || '') + '|' + filePath + '|' + String(lineNum))
42
+ .digest('hex').slice(0, 10);
43
+
44
+ const finding = {
45
+ ruleId: rule.id,
46
+ name: rule.name,
47
+ severity: rule.severity,
48
+ category: rule.category,
49
+ filePath,
50
+ line: lineNum,
51
+ snippet: line.trim(),
52
+ codeHash: null, // secrets scanner does not compute codeHash
53
+ findingId,
54
+ why: rule.why,
55
+ scenario: rule.scenario,
56
+ fix: rule.fix,
57
+ hook: 'pre-commit',
58
+ };
59
+
60
+ if (config) {
61
+ const excResult = isExcepted(config, finding, line);
62
+ finding.excepted = excResult.excepted;
63
+ finding.exception_expired = excResult.expired;
64
+ finding.exception = excResult.exception;
65
+ const action = getRuleAction(config, rule.id, rule.severity);
66
+ finding.action = action;
67
+ finding.blocks = !excResult.excepted && action === 'block';
68
+ } else {
69
+ finding.excepted = false;
70
+ finding.action = rule.severity === 'CRITICAL' ? 'block' : 'warn';
71
+ finding.blocks = rule.severity === 'CRITICAL';
72
+ }
73
+
74
+ findings.push(finding);
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ return findings;
81
+ }
82
+
83
+ module.exports = { scanSecrets, RULES };