@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,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 };
|