@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,681 @@
|
|
|
1
|
+
const { RESET } = require('./output-constants');
|
|
2
|
+
/**
|
|
3
|
+
* scanner-full.js
|
|
4
|
+
* Full security scan for pre-push hook.
|
|
5
|
+
* Language-aware: selects rules based on file extension.
|
|
6
|
+
* Antipattern-aware: skips findings where a safe pattern appears nearby.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const { isRuleExcluded } = require('./scope');
|
|
12
|
+
const { buildTaintRegister, extToLanguage } = require('./taint-register');
|
|
13
|
+
const { scanSecrets } = require('./scanner-secrets');
|
|
14
|
+
const { isExcepted, getRuleAction, isResolved } = require('./config');
|
|
15
|
+
const { loadRule } = require('../rules/rule-loader');
|
|
16
|
+
const { buildFileContext } = require('./file-context');
|
|
17
|
+
const { applyContextModifiers } = require('./context-modifiers');
|
|
18
|
+
const { buildCommentMap } = require('./comment-map');
|
|
19
|
+
const { buildFileManifest, formatManifestSummary } = require('./file-manifest');
|
|
20
|
+
const { getScanTrace } = require('./global-config');
|
|
21
|
+
const _jsPack = require('../rules/rules-js.json');
|
|
22
|
+
const JS_RULES = _jsPack.rules.filter(r => r.severity !== 'EXPOSURE').map(r => loadRule(r, 'builtin'));
|
|
23
|
+
const JS_EXPOSURE = _jsPack.rules.filter(r => r.severity === 'EXPOSURE').map(r => loadRule(r, 'builtin'));
|
|
24
|
+
const _pyPack = require('../rules/rules-python.json');
|
|
25
|
+
const _phpPack = require('../rules/rules-php.json');
|
|
26
|
+
const PY_RULES = _pyPack.rules.map(r => loadRule(r, 'builtin'));
|
|
27
|
+
const PHP_RULES = _phpPack.rules.map(r => loadRule(r, 'builtin'));
|
|
28
|
+
const PY_EXPOSURE = PY_RULES.filter(r => r.severity === 'EXPOSURE');
|
|
29
|
+
const PHP_EXPOSURE = PHP_RULES.filter(r => r.severity === 'EXPOSURE');
|
|
30
|
+
|
|
31
|
+
// Merge all EXPOSURE rules into one set — applied to all supported source files
|
|
32
|
+
const ALL_EXPOSURE_RULES = [
|
|
33
|
+
...(JS_EXPOSURE || []),
|
|
34
|
+
...(PY_EXPOSURE || []),
|
|
35
|
+
...(PHP_EXPOSURE || []),
|
|
36
|
+
];
|
|
37
|
+
const _tsPack = require('../rules/rules-ts.json');
|
|
38
|
+
const TS_RULES = _tsPack.rules.map(r => loadRule(r, 'builtin'));
|
|
39
|
+
const _aspxPack = require('../rules/rules-aspx.json');
|
|
40
|
+
const _aspxCsPack = require('../rules/rules-aspx-cs.json');
|
|
41
|
+
const ASPX_RULES = _aspxPack.rules.map(r => loadRule(r, 'builtin'));
|
|
42
|
+
const ASPX_CS_RULES = _aspxCsPack.rules.map(r => loadRule(r, 'builtin'));
|
|
43
|
+
const _sensitivePack = require('../rules/rules-sensitive-files.json');
|
|
44
|
+
const _sensitiveRules = _sensitivePack.rules.map(r => loadRule(r, 'builtin'));
|
|
45
|
+
const SENSITIVE_CONTENT_RULES = _sensitiveRules.filter(r => r.matchMode !== 'filename');
|
|
46
|
+
const SENSITIVE_FILENAME_RULES = _sensitiveRules.filter(r => r.matchMode === 'filename');
|
|
47
|
+
const _infraPack = require('../rules/rules-infra-leakage.json');
|
|
48
|
+
const INFRA_RULES = _infraPack.rules.map(r => loadRule(r, 'builtin'));
|
|
49
|
+
|
|
50
|
+
// Separate filename-match rules (e.g. log files in web root)
|
|
51
|
+
const ASPX_FILENAME_RULES = ASPX_RULES.filter(r => r.matchMode === 'filename');
|
|
52
|
+
const ASPX_CONTENT_RULES = ASPX_RULES.filter(r => r.matchMode !== 'filename');
|
|
53
|
+
|
|
54
|
+
// ── File extension → rule sets ─────────────────────────────────────────────
|
|
55
|
+
const RULES_BY_EXT = {
|
|
56
|
+
js: JS_RULES, mjs: JS_RULES, cjs: JS_RULES,
|
|
57
|
+
ts: [...JS_RULES, ...TS_RULES], // TypeScript: JS rules + TS-specific rules
|
|
58
|
+
jsx: JS_RULES,
|
|
59
|
+
tsx: [...JS_RULES, ...TS_RULES], // TSX same as TS
|
|
60
|
+
py: PY_RULES,
|
|
61
|
+
php: PHP_RULES,
|
|
62
|
+
aspx: ASPX_CONTENT_RULES,
|
|
63
|
+
ascx: ASPX_CONTENT_RULES,
|
|
64
|
+
master: ASPX_CONTENT_RULES,
|
|
65
|
+
config: ASPX_CONTENT_RULES,
|
|
66
|
+
cs: ASPX_CS_RULES, // C# code-behind and class files
|
|
67
|
+
txt: SENSITIVE_CONTENT_RULES.filter(r => r.fileTypes.includes('txt')),
|
|
68
|
+
log: SENSITIVE_CONTENT_RULES.filter(r => r.fileTypes.includes('log')),
|
|
69
|
+
// Sensitive file types — content rules
|
|
70
|
+
env: SENSITIVE_CONTENT_RULES.filter(r => r.fileTypes.includes('env')),
|
|
71
|
+
sql: SENSITIVE_CONTENT_RULES.filter(r => r.fileTypes.includes('sql')),
|
|
72
|
+
yml: SENSITIVE_CONTENT_RULES.filter(r => r.fileTypes.includes('yml')),
|
|
73
|
+
yaml: SENSITIVE_CONTENT_RULES.filter(r => r.fileTypes.includes('yaml')),
|
|
74
|
+
json: [...SENSITIVE_CONTENT_RULES.filter(r => r.fileTypes.includes('json')),
|
|
75
|
+
...TS_RULES.filter(r => r.id === 'TS-SUPPRESS-002')], // tsconfig.json strict checks
|
|
76
|
+
xml: SENSITIVE_CONTENT_RULES.filter(r => r.fileTypes.includes('xml')),
|
|
77
|
+
properties: SENSITIVE_CONTENT_RULES.filter(r => r.fileTypes.includes('properties')),
|
|
78
|
+
ini: SENSITIVE_CONTENT_RULES.filter(r => r.fileTypes.includes('ini')),
|
|
79
|
+
cfg: SENSITIVE_CONTENT_RULES.filter(r => r.fileTypes.includes('cfg')),
|
|
80
|
+
conf: SENSITIVE_CONTENT_RULES.filter(r => r.fileTypes.includes('conf')),
|
|
81
|
+
sh: SENSITIVE_CONTENT_RULES.filter(r => r.fileTypes.includes('sh')),
|
|
82
|
+
bash: SENSITIVE_CONTENT_RULES.filter(r => r.fileTypes.includes('bash')),
|
|
83
|
+
ps1: SENSITIVE_CONTENT_RULES.filter(r => r.fileTypes.includes('ps1')),
|
|
84
|
+
bat: SENSITIVE_CONTENT_RULES.filter(r => r.fileTypes.includes('bat')),
|
|
85
|
+
cmd: SENSITIVE_CONTENT_RULES.filter(r => r.fileTypes.includes('cmd')),
|
|
86
|
+
bak: [], // filename-only rule, no content scan
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const EXPOSURE_EXTS = new Set(['js', 'ts', 'mjs', 'jsx', 'tsx', 'html', 'php', 'py']);
|
|
90
|
+
|
|
91
|
+
// Extensions where infra-leakage rules are meaningful (i.e. files that get deployed)
|
|
92
|
+
// Excludes binary, lock-files, and pure asset files.
|
|
93
|
+
const INFRA_SKIP_EXTS = new Set([
|
|
94
|
+
'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'woff', 'woff2', 'ttf', 'eot',
|
|
95
|
+
'mp4', 'mp3', 'pdf', 'zip', 'gz', 'tar', 'lock', 'sum',
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
// Filename patterns that indicate test/example context — skip infra rules
|
|
99
|
+
const INFRA_TEST_FILE_RE = /(?:\.(?:test|spec|mock)\.|__tests__|__mocks__|\.env\.(?:example|template)|README|CHANGELOG|fixtures|examples|webpack\.config|vite\.config|rollup\.config|babel\.config|jest\.config|karma\.conf|postcss\.config|tailwind\.config|next\.config|nuxt\.config|vitest\.config)/i;
|
|
100
|
+
|
|
101
|
+
// ── Antipattern check ──────────────────────────────────────────────────────
|
|
102
|
+
function isAntipatternPresent(rule, content, matchIndex) {
|
|
103
|
+
if (!rule.antipattern) return false;
|
|
104
|
+
const lookahead = rule.lookahead ?? 300;
|
|
105
|
+
const lookbehind = rule.lookbehind ?? 120; // check same line backwards too
|
|
106
|
+
const start = Math.max(0, matchIndex - lookbehind);
|
|
107
|
+
const window = content.slice(start, matchIndex + lookahead);
|
|
108
|
+
return rule.antipattern.test(window);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Build a finding object ─────────────────────────────────────────────────
|
|
112
|
+
function buildFinding(rule, filePath, lineNumber, snippet, lineRaw, config, taintInfo = null, matchObj = null) {
|
|
113
|
+
// ── Confidence ──────────────────────────────────────────────────────────
|
|
114
|
+
// Rules may declare confidence as:
|
|
115
|
+
// 'HIGH' | 'MEDIUM' | 'LOW' (static string)
|
|
116
|
+
// function(matchObj, lineRaw, filePath) → 'HIGH' | 'MEDIUM' | 'LOW' (dynamic)
|
|
117
|
+
// Default is 'HIGH' for backward compatibility.
|
|
118
|
+
let confidence = 'HIGH';
|
|
119
|
+
if (typeof rule.confidence === 'function') {
|
|
120
|
+
try { confidence = rule.confidence(matchObj, lineRaw, filePath) || 'HIGH'; }
|
|
121
|
+
catch { confidence = 'HIGH'; }
|
|
122
|
+
} else if (rule.confidence) {
|
|
123
|
+
confidence = rule.confidence;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const finding = {
|
|
127
|
+
ruleId: rule.id,
|
|
128
|
+
name: rule.name,
|
|
129
|
+
severity: rule.severity,
|
|
130
|
+
confidence,
|
|
131
|
+
category: rule.category,
|
|
132
|
+
filePath,
|
|
133
|
+
line: lineNumber,
|
|
134
|
+
snippet,
|
|
135
|
+
codeHash: lineRaw ? crypto.createHash('sha256').update(lineRaw).digest('hex').slice(0, 32) : null,
|
|
136
|
+
// findingId: ruleId+filePath+content (or line position for redacted rules).
|
|
137
|
+
// filePath is repo-relative — keeps IDs portable across machines and users.
|
|
138
|
+
// codeHash hashes only lineRaw (unchanged) so exception matching is unaffected.
|
|
139
|
+
findingId: lineRaw
|
|
140
|
+
? 'f-' + crypto.createHash('sha256').update((rule.id || '') + '|' + filePath + '|' + lineRaw).digest('hex').slice(0, 10)
|
|
141
|
+
: 'f-' + crypto.createHash('sha256').update((rule.id || '') + '|' + filePath + '|' + String(lineNumber)).digest('hex').slice(0, 10),
|
|
142
|
+
why: rule.why,
|
|
143
|
+
scenario: rule.scenario,
|
|
144
|
+
fix: rule.fix || null,
|
|
145
|
+
checklist: rule.checklist || null,
|
|
146
|
+
service: rule.service || null,
|
|
147
|
+
resolve_hint: rule.resolve_hint || null,
|
|
148
|
+
hook: 'pre-push',
|
|
149
|
+
deepAnalysis: null,
|
|
150
|
+
taintSource: taintInfo || null,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (config) {
|
|
154
|
+
const excResult = isExcepted(config, finding, lineRaw);
|
|
155
|
+
finding.excepted = excResult.excepted;
|
|
156
|
+
finding.exception_expired = excResult.expired;
|
|
157
|
+
finding.exception_rejected = excResult.rejected;
|
|
158
|
+
finding.exception = excResult.exception;
|
|
159
|
+
|
|
160
|
+
if (finding.severity === 'EXPOSURE') {
|
|
161
|
+
const res = isResolved(config, finding);
|
|
162
|
+
finding.resolved = res.resolved;
|
|
163
|
+
finding.resolve_record = res.record || null;
|
|
164
|
+
} else {
|
|
165
|
+
finding.resolved = false;
|
|
166
|
+
finding.resolve_record = null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const action = getRuleAction(config, rule.id, rule.severity);
|
|
170
|
+
finding.action = action;
|
|
171
|
+
finding.blocks = !excResult.excepted && !finding.resolved && action === 'block';
|
|
172
|
+
} else {
|
|
173
|
+
finding.excepted = false;
|
|
174
|
+
finding.resolved = false;
|
|
175
|
+
finding.resolve_record = null;
|
|
176
|
+
finding.action = rule.severity === 'CRITICAL' ? 'block'
|
|
177
|
+
: rule.severity === 'EXPOSURE' ? 'educate'
|
|
178
|
+
: 'warn';
|
|
179
|
+
finding.blocks = rule.severity === 'CRITICAL';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return finding;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Scan a single file with a set of rules ─────────────────────────────────
|
|
186
|
+
function scanFileWithRules(filePath, content, rules, config, taintReg = null, commentMap = null) {
|
|
187
|
+
const findings = [];
|
|
188
|
+
const lines = content.split('\n');
|
|
189
|
+
const fileExt = filePath.split('.').pop().toLowerCase();
|
|
190
|
+
|
|
191
|
+
// Pre-build line start offsets once per file — avoids repeated substring+split
|
|
192
|
+
// for every match when computing line numbers.
|
|
193
|
+
const lineOffsets = [0];
|
|
194
|
+
for (let i = 0; i < content.length; i++) {
|
|
195
|
+
if (content[i] === '\n') lineOffsets.push(i + 1);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Filter out rules that explicitly exclude this file type
|
|
199
|
+
const applicableRules = rules.filter(rule =>
|
|
200
|
+
!rule.excludeFileTypes || !rule.excludeFileTypes.includes(fileExt)
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Binary search: find 1-based line number for a character offset
|
|
204
|
+
function offsetToLine(offset) {
|
|
205
|
+
let lo = 0, hi = lineOffsets.length - 1;
|
|
206
|
+
while (lo < hi) {
|
|
207
|
+
const mid = (lo + hi + 1) >> 1;
|
|
208
|
+
if (lineOffsets[mid] <= offset) lo = mid;
|
|
209
|
+
else hi = mid - 1;
|
|
210
|
+
}
|
|
211
|
+
return lo + 1; // 1-based
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Whether the taint register has any entries — used for early-exit below
|
|
215
|
+
const hasTaint = taintReg && !taintReg.isEmpty();
|
|
216
|
+
|
|
217
|
+
for (const rule of applicableRules) {
|
|
218
|
+
// Early exit: taintAware rules are only useful if the file has tainted variables.
|
|
219
|
+
// Skip matchAll entirely if the register is empty — avoids expensive regex scan.
|
|
220
|
+
if (rule.taintAware && !hasTaint) continue;
|
|
221
|
+
|
|
222
|
+
const re = new RegExp(rule.pattern.source, rule.pattern.flags);
|
|
223
|
+
const matches = [...content.matchAll(re)];
|
|
224
|
+
|
|
225
|
+
for (const match of matches) {
|
|
226
|
+
const matchIndex = match.index;
|
|
227
|
+
const lineNumber = offsetToLine(matchIndex);
|
|
228
|
+
const lineRaw = lines[lineNumber - 1] || '';
|
|
229
|
+
const snippet = lineRaw.trim();
|
|
230
|
+
|
|
231
|
+
if (isAntipatternPresent(rule, content, matchIndex)) continue;
|
|
232
|
+
|
|
233
|
+
// ── Comment line check ─────────────────────────────────────────────
|
|
234
|
+
// Skip findings on lines that are entirely comments — comment lines
|
|
235
|
+
// cannot produce runtime behaviour regardless of their content.
|
|
236
|
+
// Rules that intentionally target comment content can set
|
|
237
|
+
// `scanComments: true` to opt out of this filter.
|
|
238
|
+
// MIXED lines (code + inline comment) are always scanned — the match
|
|
239
|
+
// may be in the code portion. Inline-comment suppression for MIXED
|
|
240
|
+
// lines is a separate optional feature (config: scan.comment_scanning).
|
|
241
|
+
if (commentMap && !rule.scanComments && commentMap.isComment(lineNumber)) continue;
|
|
242
|
+
|
|
243
|
+
// ── Taint-aware check ──────────────────────────────────────────────
|
|
244
|
+
// If rule is taintAware, extract the variable name from the match line
|
|
245
|
+
// using the appropriate extraction strategy (concat, interpolation, or
|
|
246
|
+
// func_concat). Check if that variable was assigned from external input
|
|
247
|
+
// earlier in the file. If not found in register, skip the finding.
|
|
248
|
+
let taintInfo = null;
|
|
249
|
+
if (rule.taintAware && taintReg) {
|
|
250
|
+
const strategy = rule.taintExtract || 'concat';
|
|
251
|
+
let varName = null;
|
|
252
|
+
|
|
253
|
+
// PHP vars have $ prefix; Python/JS/TS use plain identifiers
|
|
254
|
+
const phpVarRe = /\$(?!_GET|_POST|_REQUEST|_COOKIE|_SESSION|stmt|pdo|conn|db)([a-zA-Z_]\w*)/;
|
|
255
|
+
const jsKeywords = /^(?:function|return|const|let|var|if|else|for|while|true|false|null|undefined|new|this|class|import|export|require|async|await|typeof|instanceof)$/;
|
|
256
|
+
|
|
257
|
+
if (strategy === 'interpolation') {
|
|
258
|
+
// PHP: extract $var from inside the SQL string
|
|
259
|
+
const strContent = lineRaw.match(/"([^"]+)"/);
|
|
260
|
+
const m = strContent
|
|
261
|
+
? strContent[1].match(phpVarRe)
|
|
262
|
+
: lineRaw.match(phpVarRe);
|
|
263
|
+
varName = m ? m[1] : null;
|
|
264
|
+
|
|
265
|
+
} else if (strategy === 'func_concat') {
|
|
266
|
+
// PHP: ($var or . $var
|
|
267
|
+
const mPhp1 = lineRaw.match(/\(\s*\$(?!_GET|_POST|_REQUEST|_COOKIE|_SESSION|stmt|pdo|conn|db)([a-zA-Z_]\w*)/);
|
|
268
|
+
const mPhp2 = lineRaw.match(/\.\s*\$(?!_GET|_POST|_REQUEST|_COOKIE|_SESSION|stmt|pdo|conn|db)([a-zA-Z_]\w*)\s*[;)"'.\\]]/);
|
|
269
|
+
if (mPhp1 || mPhp2) {
|
|
270
|
+
varName = mPhp1 ? mPhp1[1] : mPhp2[1];
|
|
271
|
+
} else {
|
|
272
|
+
// Python/JS: extract tainted variable from concatenation or first arg.
|
|
273
|
+
// mPlus: picks up + varname followed by anything (string, +, ,, ))
|
|
274
|
+
// mPlain: picks up first arg of function call, but only when it's a plain
|
|
275
|
+
// identifier not immediately followed by + (avoid matching base in base+doc)
|
|
276
|
+
const mPlus = lineRaw.match(/[+]\s*([a-zA-Z_]\w{1,40})\s*[+,);'"` + '`' + r`]/);
|
|
277
|
+
const mPlain = lineRaw.match(/\(\s*([a-zA-Z_]\w{1,40})\s*[,)]/);
|
|
278
|
+
// Also catch template literals: ${varname}
|
|
279
|
+
const mTmplF = lineRaw.match(/\$\{([a-zA-Z_]\w{1,40})\}/);
|
|
280
|
+
const cand = mTmplF ? mTmplF[1] : (mPlus ? mPlus[1] : (mPlain ? mPlain[1] : null));
|
|
281
|
+
if (cand && !jsKeywords.test(cand)) varName = cand;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
} else {
|
|
285
|
+
// default: concat
|
|
286
|
+
const mPhp = lineRaw.match(/\.\s*\$(?!_GET|_POST|_REQUEST|_COOKIE|_SESSION|stmt|pdo|conn|db)([a-zA-Z_]\w*)\s*[;,"'\)\]]/);
|
|
287
|
+
if (mPhp) {
|
|
288
|
+
varName = mPhp[1];
|
|
289
|
+
} else {
|
|
290
|
+
// JS/Python: template literal ${var}, or + var at end of expression
|
|
291
|
+
const mTmpl = lineRaw.match(/\$\{([a-zA-Z_]\w{1,40})\}/);
|
|
292
|
+
const mPlus = lineRaw.match(/[+]\s*([a-zA-Z_]\w{1,40})\s*[+,;)"'\]` + '`' + r`]/);
|
|
293
|
+
const cand = mTmpl ? mTmpl[1] : (mPlus ? mPlus[1] : null);
|
|
294
|
+
if (cand && !jsKeywords.test(cand)) varName = cand;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (varName) {
|
|
299
|
+
if (taintReg.has(varName)) {
|
|
300
|
+
taintInfo = {
|
|
301
|
+
variable: varName,
|
|
302
|
+
line: taintReg.getLine(varName),
|
|
303
|
+
source: taintReg.getSource(varName),
|
|
304
|
+
};
|
|
305
|
+
} else if (taintReg.has('*')) {
|
|
306
|
+
// extract($_GET) used — all vars in scope potentially tainted
|
|
307
|
+
taintInfo = { variable: varName, line: taintReg.getLine('*'), source: 'extract()' };
|
|
308
|
+
} else {
|
|
309
|
+
// Variable not found in taint register — skip finding
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// If no variable could be extracted from a taintAware rule, skip the finding.
|
|
314
|
+
// taintAware rules are only meaningful when a specific tainted variable can be
|
|
315
|
+
// identified — without one we cannot confirm the taint path.
|
|
316
|
+
if (!varName) continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
findings.push(buildFinding(rule, filePath, lineNumber, snippet, lineRaw, config, taintInfo, match));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return findings;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Full scan ──────────────────────────────────────────────────────────────
|
|
327
|
+
const { NO_LIMIT_TIMEOUT_MS } = require('./scanner-manual');
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Scan a single file with timeout guard (used for large files in --no-limit mode).
|
|
331
|
+
* Returns { findings, timedOut }.
|
|
332
|
+
*/
|
|
333
|
+
function scanFileWithTimeout(filePath, content, rules, exposureRules, config, timeoutMs, commentMap = null) {
|
|
334
|
+
return new Promise((resolve) => {
|
|
335
|
+
const timer = setTimeout(() => {
|
|
336
|
+
resolve({ findings: [], timedOut: true });
|
|
337
|
+
}, timeoutMs);
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
const findings = [];
|
|
341
|
+
if (rules) findings.push(...scanFileWithRules(filePath, content, rules, config, null, commentMap));
|
|
342
|
+
if (exposureRules) findings.push(...scanFileWithRules(filePath, content, exposureRules, config, null, commentMap));
|
|
343
|
+
clearTimeout(timer);
|
|
344
|
+
resolve({ findings, timedOut: false });
|
|
345
|
+
} catch (err) {
|
|
346
|
+
clearTimeout(timer);
|
|
347
|
+
resolve({ findings: [], timedOut: false }); // scan error – skip silently
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Route a finding through file context modifiers and push to the correct bucket.
|
|
354
|
+
*
|
|
355
|
+
* @param {Object} finding - Raw finding from buildFinding().
|
|
356
|
+
* @param {Object} fileContext - Context from buildFileContext() for this file.
|
|
357
|
+
* @param {Object[]} findings - Active findings array (mutated in place).
|
|
358
|
+
* @param {Object[]} suppressedFindings - Suppressed findings array (mutated in place).
|
|
359
|
+
* @param {Object} [traceOpts] - Optional trace injection.
|
|
360
|
+
* @param {boolean} [traceOpts.enabled] - Whether to keep _trace on findings.
|
|
361
|
+
* @param {string} [traceOpts.manifestContext] - Scan context from file manifest.
|
|
362
|
+
* @param {string} [traceOpts.commentLineType] - Line type from comment-map (CODE/COMMENT/MIXED).
|
|
363
|
+
*/
|
|
364
|
+
function routeFinding(finding, fileContext, findings, suppressedFindings, traceOpts = {}) {
|
|
365
|
+
const modified = applyContextModifiers(finding, fileContext);
|
|
366
|
+
|
|
367
|
+
// ── Inject pipeline context into _trace ──────────────────────────────────
|
|
368
|
+
// manifest_context and comment_line_type are only known at call site, not
|
|
369
|
+
// inside applyContextModifiers(), so they are injected here.
|
|
370
|
+
if (modified._trace) {
|
|
371
|
+
modified._trace.manifest_context = traceOpts.manifestContext ?? 'source';
|
|
372
|
+
modified._trace.comment_line_type = traceOpts.commentLineType ?? null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Strip _trace unless scan.trace is enabled in config.
|
|
376
|
+
// Keeps scan-JSON files lean in normal operation.
|
|
377
|
+
if (!traceOpts.enabled) {
|
|
378
|
+
delete modified._trace;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (modified.suppressed) {
|
|
382
|
+
suppressedFindings.push(modified);
|
|
383
|
+
} else {
|
|
384
|
+
findings.push(modified);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function scanFull(files, config = null, scope = null) {
|
|
389
|
+
const rawSecrets = await scanSecrets(files, config);
|
|
390
|
+
const timedOut = []; // files that exceeded timeout
|
|
391
|
+
|
|
392
|
+
// ── Findings buckets ───────────────────────────────────────────────────
|
|
393
|
+
// Active findings and suppressed findings are kept separate throughout the
|
|
394
|
+
// scan. Deduplication runs on active findings only — suppressed findings
|
|
395
|
+
// are already context-adjusted and need no further dedup.
|
|
396
|
+
const findings = [];
|
|
397
|
+
const suppressedFindings = [];
|
|
398
|
+
|
|
399
|
+
// ── Pre-scan: build file manifest ─────────────────────────────────────
|
|
400
|
+
// Classifies every file into a scan context BEFORE any rules run.
|
|
401
|
+
// This is the correct architectural layer for this decision — routing
|
|
402
|
+
// happens here, not as post-scan severity compensation.
|
|
403
|
+
//
|
|
404
|
+
// source → scanned with full rule set (production code + config + docs)
|
|
405
|
+
// test → scanned with test rule set (currently empty — defined later)
|
|
406
|
+
// excluded → vendor/generated, not scanned, documented in output
|
|
407
|
+
//
|
|
408
|
+
// Classification uses buildFileContext() internally: two-layer detection
|
|
409
|
+
// (path/filename signal + content confirmation). Tentative-only signals
|
|
410
|
+
// (path without content confirmation) fall back to source — conservative.
|
|
411
|
+
const manifest = buildFileManifest(files);
|
|
412
|
+
|
|
413
|
+
// ── Trace config ─────────────────────────────────────────────────────────
|
|
414
|
+
// When SCAN_TRACE=true in ~/.scd/config, _trace is preserved on every finding
|
|
415
|
+
// (active and suppressed) in all scan-JSON files. Never shown in terminal.
|
|
416
|
+
// Set manually — not exposed via scd configure. Internal debug tool only.
|
|
417
|
+
const traceEnabled = getScanTrace();
|
|
418
|
+
|
|
419
|
+
// Show manifest summary early — before scanning begins.
|
|
420
|
+
// Design rule: important information must be shown as early as possible.
|
|
421
|
+
if (process.stderr.isTTY && files.length > 0) {
|
|
422
|
+
process.stderr.write(` ${formatManifestSummary(manifest.summary)}\n`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Apply file context to secrets findings.
|
|
426
|
+
// Secrets scanner returns findings with filePath already set — use manifest
|
|
427
|
+
// context map to determine if the file was classified as test/excluded.
|
|
428
|
+
// Config/env files are almost always source context, but manifest is
|
|
429
|
+
// authoritative.
|
|
430
|
+
for (const f of rawSecrets) {
|
|
431
|
+
const scanCtx = manifest.contexts.get(f.filePath) ?? 'source';
|
|
432
|
+
if (scanCtx === 'excluded') continue; // vendor/generated — skip
|
|
433
|
+
if (scanCtx === 'test') continue; // test context — not scanned with source rules
|
|
434
|
+
|
|
435
|
+
// Reuse the manifest fileContext — never call buildFileContext() without
|
|
436
|
+
// content here. buildFileContext(filePath) with no content commits a
|
|
437
|
+
// tentative classification as-is, which causes test files to be reported
|
|
438
|
+
// as file_type:test while manifest_context remains source — a mismatch
|
|
439
|
+
// that produces incorrect _trace data and incorrect severity modifiers.
|
|
440
|
+
const fileContext = manifest.fileContexts.get(f.filePath)
|
|
441
|
+
?? buildFileContext(f.filePath);
|
|
442
|
+
|
|
443
|
+
routeFinding(f, fileContext, findings, suppressedFindings, {
|
|
444
|
+
enabled: traceEnabled,
|
|
445
|
+
manifestContext: scanCtx,
|
|
446
|
+
commentLineType: null, // secrets scanner has no line-level comment context
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const total = manifest.source.length; // progress tracks source files only
|
|
451
|
+
const showProg = total > 20 && process.stderr.isTTY;
|
|
452
|
+
let scanned = 0;
|
|
453
|
+
const progInterval = Math.max(1, Math.floor(total / 40)); // update ~40 times
|
|
454
|
+
|
|
455
|
+
// Count by extension for progress display
|
|
456
|
+
const extCounts = {};
|
|
457
|
+
|
|
458
|
+
// ── Scan source context — full rule set ───────────────────────────────
|
|
459
|
+
// manifest.source already has fileContext attached from buildFileManifest().
|
|
460
|
+
// We reuse it directly — no second buildFileContext() call per file.
|
|
461
|
+
for (const file of manifest.source) {
|
|
462
|
+
const { filePath, content, isLarge, noLimit, fileContext } = file;
|
|
463
|
+
const ext = path.extname(filePath).replace('.', '').toLowerCase();
|
|
464
|
+
const rules = RULES_BY_EXT[ext];
|
|
465
|
+
const exposureRules = EXPOSURE_EXTS.has(ext) ? ALL_EXPOSURE_RULES : null;
|
|
466
|
+
|
|
467
|
+
// ── Build comment map once per file ───────────────────────────────────
|
|
468
|
+
// Classifies each line as CODE, COMMENT, or MIXED before the rule loop.
|
|
469
|
+
// COMMENT lines are skipped in scanFileWithRules — comment content cannot
|
|
470
|
+
// produce runtime behaviour. inline option reads from config (default: off).
|
|
471
|
+
const commentInline = config?.scan?.comment_scanning === 'inline';
|
|
472
|
+
const commentMap = buildCommentMap(content, ext, { inline: commentInline });
|
|
473
|
+
|
|
474
|
+
// ── Filename-based rules (e.g. log/debug files in web root) ────────────
|
|
475
|
+
const basename = path.basename(filePath);
|
|
476
|
+
for (const rule of ASPX_FILENAME_RULES) {
|
|
477
|
+
rule.pattern.lastIndex = 0;
|
|
478
|
+
if (rule.pattern.test(basename)) {
|
|
479
|
+
const f = buildFinding(rule, filePath, 0, basename, basename, config);
|
|
480
|
+
routeFinding(f, fileContext, findings, suppressedFindings, {
|
|
481
|
+
enabled: traceEnabled, manifestContext: 'source', commentLineType: null,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
for (const rule of SENSITIVE_FILENAME_RULES) {
|
|
486
|
+
rule.pattern.lastIndex = 0;
|
|
487
|
+
if (rule.pattern.test(basename)) {
|
|
488
|
+
const f = buildFinding(rule, filePath, 0, basename, basename, config);
|
|
489
|
+
routeFinding(f, fileContext, findings, suppressedFindings, {
|
|
490
|
+
enabled: traceEnabled, manifestContext: 'source', commentLineType: null,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ── Build taint register for this file ─────────────────────────────
|
|
496
|
+
// Pre-scan pass: identify variables assigned from user-controlled sources
|
|
497
|
+
// (e.g. $id = $_GET['id']). Passed to taintAware rules so they can detect
|
|
498
|
+
// when a tainted variable reaches a dangerous sink on a different line.
|
|
499
|
+
// Skipped in 'fast' scan_mode for performance on large codebases.
|
|
500
|
+
const taintLang = extToLanguage(ext);
|
|
501
|
+
const taintReg = (taintLang && config?.scan_mode !== 'fast')
|
|
502
|
+
? buildTaintRegister(content, taintLang)
|
|
503
|
+
: null;
|
|
504
|
+
|
|
505
|
+
if (isLarge && noLimit) {
|
|
506
|
+
// Large file in --no-limit mode: wrap in timeout
|
|
507
|
+
const { findings: f, timedOut: didTimeout } = await scanFileWithTimeout(
|
|
508
|
+
filePath, content, rules, exposureRules, config, NO_LIMIT_TIMEOUT_MS, commentMap
|
|
509
|
+
);
|
|
510
|
+
if (didTimeout) {
|
|
511
|
+
timedOut.push({ filePath, sizeKb: file.sizeKb });
|
|
512
|
+
} else {
|
|
513
|
+
for (const finding of f) {
|
|
514
|
+
routeFinding(finding, fileContext, findings, suppressedFindings, {
|
|
515
|
+
enabled: traceEnabled,
|
|
516
|
+
manifestContext: 'source',
|
|
517
|
+
commentLineType: commentMap?.lineType?.(finding.line) ?? null,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
} else {
|
|
522
|
+
// Normal path – synchronous
|
|
523
|
+
if (rules) {
|
|
524
|
+
for (const f of scanFileWithRules(filePath, content, rules, config, taintReg, commentMap)) {
|
|
525
|
+
routeFinding(f, fileContext, findings, suppressedFindings, {
|
|
526
|
+
enabled: traceEnabled,
|
|
527
|
+
manifestContext: 'source',
|
|
528
|
+
commentLineType: commentMap?.lineType?.(f.line) ?? null,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (exposureRules) {
|
|
533
|
+
for (const f of scanFileWithRules(filePath, content, exposureRules, config, taintReg, commentMap)) {
|
|
534
|
+
routeFinding(f, fileContext, findings, suppressedFindings, {
|
|
535
|
+
enabled: traceEnabled,
|
|
536
|
+
manifestContext: 'source',
|
|
537
|
+
commentLineType: commentMap?.lineType?.(f.line) ?? null,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ── Infra leakage rules — source context only ─────────────────────────
|
|
544
|
+
// Runs on all source-context file types, skips minified and vendor files.
|
|
545
|
+
// Test files are now excluded at the manifest level — INFRA_TEST_FILE_RE
|
|
546
|
+
// is kept as a secondary guard for edge cases not caught by file-manifest.
|
|
547
|
+
const isMinified = content.length > 500 && (content.indexOf('\n') === -1 || content.length / (content.split('\n').length || 1) > 500);
|
|
548
|
+
const isVendor = /(?:[/\\]vendor[/\\]|[/\\]node_modules[/\\]|[/\\]bower_components[/\\]|\.min\.(?:js|css)$|flot_|jquery[-.]|bootstrap[-.])/i.test(filePath);
|
|
549
|
+
if (!INFRA_SKIP_EXTS.has(ext) && !INFRA_TEST_FILE_RE.test(filePath) && !isMinified && !isVendor) {
|
|
550
|
+
// Filter INFRA rules per file: extension allowlist and fileType skip list.
|
|
551
|
+
// Rules with `extensions` only fire on listed extensions.
|
|
552
|
+
// Rules with `skipForFileTypes` skip files whose fileContext.fileType matches.
|
|
553
|
+
const applicableInfraRules = INFRA_RULES.filter(rule => {
|
|
554
|
+
if (rule.extensions && !rule.extensions.includes(ext)) return false;
|
|
555
|
+
if (rule.skipForFileTypes?.includes(fileContext?.fileType)) return false;
|
|
556
|
+
return true;
|
|
557
|
+
});
|
|
558
|
+
if (applicableInfraRules.length > 0) {
|
|
559
|
+
for (const f of scanFileWithRules(filePath, content, applicableInfraRules, config, null, commentMap)) {
|
|
560
|
+
routeFinding(f, fileContext, findings, suppressedFindings, {
|
|
561
|
+
enabled: traceEnabled,
|
|
562
|
+
manifestContext: 'source',
|
|
563
|
+
commentLineType: commentMap?.lineType?.(f.line) ?? null,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ── Progress indicator ──────────────────────────────────────────────────
|
|
570
|
+
scanned++;
|
|
571
|
+
if (showProg && (scanned % progInterval === 0 || scanned === total)) {
|
|
572
|
+
extCounts[ext] = (extCounts[ext] ?? 0) + 1;
|
|
573
|
+
const pct = Math.round((scanned / total) * 100);
|
|
574
|
+
const bar = '█'.repeat(Math.floor(pct / 5)) + '░'.repeat(20 - Math.floor(pct / 5));
|
|
575
|
+
const topExts = Object.entries(extCounts)
|
|
576
|
+
.sort(([, a], [, b]) => b - a)
|
|
577
|
+
.slice(0, 3)
|
|
578
|
+
.map(([e, n]) => `.${e}:${n}`)
|
|
579
|
+
.join(' ');
|
|
580
|
+
process.stderr.write(`\rDIM Scanning ${bar} ${String(scanned).padStart(String(total).length)}/${total} ${topExts}${RESET}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Clear progress line
|
|
585
|
+
if (showProg) process.stderr.write('\r\x1b[K');
|
|
586
|
+
|
|
587
|
+
// ── Scan test context — test rule set (stub) ───────────────────────────
|
|
588
|
+
// Test files are classified and routed here but the test rule set is not
|
|
589
|
+
// yet defined. This stub documents the intended architecture and ensures
|
|
590
|
+
// the flow is correct for when test rules are added.
|
|
591
|
+
//
|
|
592
|
+
// Future: load rules/rules-test.json and scanFileWithRules() here.
|
|
593
|
+
// The test rule set will focus on:
|
|
594
|
+
// - Credentials committed in test files (CI/CD leak risk)
|
|
595
|
+
// - Test bypasses that could reach production (skipAuth, verifySSL=False)
|
|
596
|
+
// - Test files importing production secrets
|
|
597
|
+
//
|
|
598
|
+
// for (const file of manifest.test) { ... }
|
|
599
|
+
|
|
600
|
+
// _timedOut attached to final result below (after rule_excludes filter)
|
|
601
|
+
|
|
602
|
+
// Deduplicate active findings only.
|
|
603
|
+
//
|
|
604
|
+
// Pass 1: same ruleId + file + line — prefer taintAware (with taintSource).
|
|
605
|
+
// Pass 2: same file + line across different rules — prefer higher severity.
|
|
606
|
+
// This prevents e.g. PY-INJ-006 (HIGH) duplicating PY-INJ-001 (CRITICAL)
|
|
607
|
+
// on the same line when both patterns match the same construct.
|
|
608
|
+
//
|
|
609
|
+
// Note: dedup uses effective severity (post-modifier) so the best surviving
|
|
610
|
+
// finding already reflects file context. Suppressed findings skip dedup —
|
|
611
|
+
// they are context-adjusted and kept as full audit records.
|
|
612
|
+
const SEV_RANK = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, EXPOSURE: 3, INFO: 4 };
|
|
613
|
+
|
|
614
|
+
const seen = new Set();
|
|
615
|
+
const locationBest = new Map(); // 'file:line' → best severity rank so far
|
|
616
|
+
|
|
617
|
+
// Sort: taintSource first, then by effective severity
|
|
618
|
+
const sorted = findings.sort((a, b) => {
|
|
619
|
+
const taintDiff = (b.taintSource ? 1 : 0) - (a.taintSource ? 1 : 0);
|
|
620
|
+
if (taintDiff !== 0) return taintDiff;
|
|
621
|
+
return (SEV_RANK[a.severity] ?? 9) - (SEV_RANK[b.severity] ?? 9);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
const deduped = sorted.filter(f => {
|
|
625
|
+
// Pass 1: exact ruleId + file + line dedup
|
|
626
|
+
const exactKey = `${f.ruleId}:${f.filePath}:${f.line}`;
|
|
627
|
+
if (seen.has(exactKey)) return false;
|
|
628
|
+
seen.add(exactKey);
|
|
629
|
+
|
|
630
|
+
// Pass 2: if a higher-severity finding already occupies this file:line, drop this one
|
|
631
|
+
const locKey = `${f.filePath}:${f.line}`;
|
|
632
|
+
const myRank = SEV_RANK[f.severity] ?? 9;
|
|
633
|
+
const bestRank = locationBest.get(locKey) ?? 99;
|
|
634
|
+
if (myRank > bestRank) return false; // lower priority — already covered
|
|
635
|
+
locationBest.set(locKey, Math.min(myRank, bestRank));
|
|
636
|
+
return true;
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// ── Deduplicate suppressed findings ──────────────────────────────────────
|
|
640
|
+
// Same EXPOSURE rules can fire from two separate scanFileWithRules() calls
|
|
641
|
+
// on the same file (once via rules[], once via exposureRules[]). Dedup on
|
|
642
|
+
// ruleId + filePath + line — first occurrence wins, no severity comparison
|
|
643
|
+
// needed (all suppressed findings have the same effective score ≤ 0).
|
|
644
|
+
const suppressedSeen = new Set();
|
|
645
|
+
const dedupedSuppressed = suppressedFindings.filter(f => {
|
|
646
|
+
const key = `${f.ruleId}:${f.filePath}:${f.line}`;
|
|
647
|
+
if (suppressedSeen.has(key)) return false;
|
|
648
|
+
suppressedSeen.add(key);
|
|
649
|
+
return true;
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
// ── Apply rule_excludes from scope ────────────────────────────────────────
|
|
653
|
+
// Post-dedup filter — scope exclusions are applied last so dedup counts
|
|
654
|
+
// are not affected by exclusions. Excluded findings are tracked for audit.
|
|
655
|
+
// Suppressed findings are not subject to scope rule_excludes — they are
|
|
656
|
+
// already below the severity threshold and excluded from active analysis.
|
|
657
|
+
if (!scope || !scope.rule_excludes || scope.rule_excludes.length === 0) {
|
|
658
|
+
deduped._timedOut = timedOut;
|
|
659
|
+
deduped._suppressedFindings = dedupedSuppressed;
|
|
660
|
+
deduped._manifest = manifest;
|
|
661
|
+
return deduped;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const ruleExclusionCounts = {}; // ruleId → count of excluded findings
|
|
665
|
+
const result = deduped.filter(f => {
|
|
666
|
+
const { excluded } = isRuleExcluded(scope, f.ruleId, f.filePath);
|
|
667
|
+
if (!excluded) return true;
|
|
668
|
+
// Track count per rule for audit metadata
|
|
669
|
+
ruleExclusionCounts[f.ruleId] = (ruleExclusionCounts[f.ruleId] || 0) + 1;
|
|
670
|
+
return false;
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
// Attach metadata for audit.js and scan-cache.js
|
|
674
|
+
result._timedOut = timedOut;
|
|
675
|
+
result._ruleExclusionCounts = ruleExclusionCounts;
|
|
676
|
+
result._suppressedFindings = dedupedSuppressed;
|
|
677
|
+
result._manifest = manifest;
|
|
678
|
+
return result;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
module.exports = { scanFull, EXPOSURE_RULES: ALL_EXPOSURE_RULES };
|