correctover-scan 1.5.0 → 1.6.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 +21 -0
- package/README.md +112 -27
- package/core/bundle-scanner.js +775 -0
- package/core/license.js +40 -75
- package/core/scanner.js +0 -0
- package/index.js +302 -29
- package/package.json +26 -8
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Correctover CCS Security Scanner — Bundle / published-package code scanner
|
|
3
|
+
* v1.4.0
|
|
4
|
+
*
|
|
5
|
+
* Signal-based static review of minified/bundled JavaScript as shipped in npm
|
|
6
|
+
* packages. Minifiers (esbuild/terser/webpack) rename local variables but keep
|
|
7
|
+
* property names, string literals, URLs, env var names and error messages —
|
|
8
|
+
* the attack-surface signals the 14 CCS/AISVS checks rely on survive bundling.
|
|
9
|
+
*
|
|
10
|
+
* This module does NOT attempt to "understand code intent". Every finding is
|
|
11
|
+
* grounded at file + line with a snippet; context heuristics suppress known
|
|
12
|
+
* benign patterns (e.g. AWS SDK IMDS credential providers) and report them as
|
|
13
|
+
* info. Conclusions on semi-automatic checks remain a manual-review job.
|
|
14
|
+
*
|
|
15
|
+
* Zero runtime dependencies (same constraint as the config scanner).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
const JS_EXT = new Set(['.js', '.mjs', '.cjs', '.ts']);
|
|
24
|
+
const MAX_SNIPPET = 220;
|
|
25
|
+
const MAX_FINDINGS_PER_CHECK = 40;
|
|
26
|
+
const WINDOW = 6; // lines of context around a hit for semantic classification
|
|
27
|
+
|
|
28
|
+
/* ------------------------------------------------------------------ */
|
|
29
|
+
/* Lightweight beautifier (no js-beautify dependency) */
|
|
30
|
+
/* ------------------------------------------------------------------ */
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Best-effort reformatting of minified JS: inserts newlines after ; { } and
|
|
34
|
+
* before } so that line numbers become useful. Not a parser — the scanner
|
|
35
|
+
* always works on raw text for findings; beautified text is only used to
|
|
36
|
+
* widen semantic context windows.
|
|
37
|
+
*/
|
|
38
|
+
function beautifySource(src) {
|
|
39
|
+
let out = '';
|
|
40
|
+
let inS = null; // quote char: ' " `
|
|
41
|
+
let inLineComment = false;
|
|
42
|
+
let inBlockComment = false;
|
|
43
|
+
for (let i = 0; i < src.length; i++) {
|
|
44
|
+
const ch = src[i];
|
|
45
|
+
const nxt = src[i + 1];
|
|
46
|
+
if (inLineComment) {
|
|
47
|
+
out += ch;
|
|
48
|
+
if (ch === '\n') inLineComment = false;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (inBlockComment) {
|
|
52
|
+
out += ch;
|
|
53
|
+
if (ch === '*' && nxt === '/') { out += '/'; i++; inBlockComment = false; }
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (inS) {
|
|
57
|
+
out += ch;
|
|
58
|
+
if (ch === '\\') { out += nxt; i++; continue; }
|
|
59
|
+
if (ch === inS) inS = null;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (ch === '/' && nxt === '/') { inLineComment = true; out += ch; continue; }
|
|
63
|
+
if (ch === '/' && nxt === '*') { inBlockComment = true; out += ch; continue; }
|
|
64
|
+
if (ch === '"' || ch === "'" || ch === '`') { inS = ch; out += ch; continue; }
|
|
65
|
+
if (ch === ';' || ch === '{' || ch === '}') {
|
|
66
|
+
out += ch + '\n';
|
|
67
|
+
if (ch === '}') out += '\n';
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
out += ch;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* ------------------------------------------------------------------ */
|
|
76
|
+
/* File discovery */
|
|
77
|
+
/* ------------------------------------------------------------------ */
|
|
78
|
+
|
|
79
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'vendor', 'dist-node-modules']);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build the list of JS files to audit for a bundle target.
|
|
83
|
+
* - .js/.mjs/.cjs file -> [that file]
|
|
84
|
+
* - directory -> package.json entry (main/module/bin) + all JS
|
|
85
|
+
* files in the tree (node_modules skipped), capped.
|
|
86
|
+
*/
|
|
87
|
+
function discoverBundleFiles(target, opts = {}) {
|
|
88
|
+
const stat = fs.statSync(target);
|
|
89
|
+
if (stat.isFile()) return [path.resolve(target)];
|
|
90
|
+
|
|
91
|
+
const files = [];
|
|
92
|
+
const entries = [];
|
|
93
|
+
let pkg = null;
|
|
94
|
+
const pkgPath = path.join(target, 'package.json');
|
|
95
|
+
if (fs.existsSync(pkgPath)) {
|
|
96
|
+
try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); } catch (e) { pkg = null; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function walk(dir, depth) {
|
|
100
|
+
if (depth > (opts.maxDepth ?? 8)) return;
|
|
101
|
+
let names;
|
|
102
|
+
try { names = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
|
|
103
|
+
for (const ent of names) {
|
|
104
|
+
const full = path.join(dir, ent.name);
|
|
105
|
+
if (ent.isDirectory()) {
|
|
106
|
+
if (SKIP_DIRS.has(ent.name) || ent.name.startsWith('.')) continue;
|
|
107
|
+
walk(full, depth + 1);
|
|
108
|
+
} else if (ent.isFile() && JS_EXT.has(path.extname(ent.name))) {
|
|
109
|
+
entries.push(full);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
walk(target, 0);
|
|
114
|
+
|
|
115
|
+
if (pkg) {
|
|
116
|
+
const candidates = [];
|
|
117
|
+
if (pkg.main) candidates.push(pkg.main);
|
|
118
|
+
if (pkg.module) candidates.push(pkg.module);
|
|
119
|
+
if (typeof pkg.bin === 'string') candidates.push(pkg.bin);
|
|
120
|
+
else if (pkg.bin && typeof pkg.bin === 'object') {
|
|
121
|
+
for (const k of Object.keys(pkg.bin)) candidates.push(pkg.bin[k]);
|
|
122
|
+
}
|
|
123
|
+
for (const c of candidates) {
|
|
124
|
+
const full = path.resolve(target, c);
|
|
125
|
+
if (fs.existsSync(full) && fs.statSync(full).isFile()) files.push(full);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const f of entries) {
|
|
129
|
+
if (!files.includes(f)) files.push(f);
|
|
130
|
+
}
|
|
131
|
+
return files;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* ------------------------------------------------------------------ */
|
|
135
|
+
/* Helpers */
|
|
136
|
+
/* ------------------------------------------------------------------ */
|
|
137
|
+
|
|
138
|
+
function snippet(line) {
|
|
139
|
+
const t = line.trim();
|
|
140
|
+
return t.length > MAX_SNIPPET ? t.slice(0, MAX_SNIPPET) + ' …' : t;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function lineWindow(rawLines, idx, radius = WINDOW) {
|
|
144
|
+
const lo = Math.max(0, idx - radius);
|
|
145
|
+
const hi = Math.min(rawLines.length, idx + radius + 1);
|
|
146
|
+
return rawLines.slice(lo, hi).join('\n');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function countMatches(re, text) {
|
|
150
|
+
const m = text.match(re);
|
|
151
|
+
return m ? m.length : 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Distinct values of a regex across a text (global regex), with counts. */
|
|
155
|
+
function tally(re, text, limit = 60) {
|
|
156
|
+
const map = new Map();
|
|
157
|
+
let m;
|
|
158
|
+
let guard = 0;
|
|
159
|
+
while ((m = re.exec(text)) !== null) {
|
|
160
|
+
const v = m[1] || m[0];
|
|
161
|
+
map.set(v, (map.get(v) || 0) + 1);
|
|
162
|
+
if (++guard > 200000) break;
|
|
163
|
+
}
|
|
164
|
+
return [...map.entries()]
|
|
165
|
+
.sort((a, b) => b[1] - a[1])
|
|
166
|
+
.slice(0, limit)
|
|
167
|
+
.map(([value, n]) => ({ value, count: n }));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* ------------------------------------------------------------------ */
|
|
171
|
+
/* Check definitions — ids/names/aisvs continue the config scanner's */
|
|
172
|
+
/* 14-check scheme so text/json/sarif outputs stay aligned. */
|
|
173
|
+
/* ------------------------------------------------------------------ */
|
|
174
|
+
|
|
175
|
+
const SECRET_PATTERNS = [
|
|
176
|
+
{ name: 'OpenAI-style key sk-', re: /\bsk-[A-Za-z0-9_-]{20,}/ },
|
|
177
|
+
{ name: 'GitHub token ghp_', re: /\bghp_[A-Za-z0-9]{36}/ },
|
|
178
|
+
{ name: 'AWS access key AKIA', re: /\bAKIA[0-9A-Z]{16}/ },
|
|
179
|
+
{ name: 'Google API key AIza', re: /\bAIza[0-9A-Za-z_-]{35}/ },
|
|
180
|
+
{ name: 'GitHub gho_/github_pat_', re: /\b(?:gho_|github_pat_)[A-Za-z0-9_]{20,}/ },
|
|
181
|
+
{ name: 'Slack xox token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/ },
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
// Strings that look like secrets in examples/docs/tests — not real leaks.
|
|
185
|
+
const PLACEHOLDER_CTX = /(sk-?your|your-?sk|example|placeholder|xxxx+|<[^>]*>|REDACTED|dummy|fake|sample|test[_-]?key|sk-box-|sk-again-)/i;
|
|
186
|
+
|
|
187
|
+
// Hosts that routinely appear over http:// but are not plaintext outbound
|
|
188
|
+
// traffic (spec namespaces, localhost, placeholders).
|
|
189
|
+
const DOC_HOSTS = /^(www\.)?w3\.org$|^(www\.)?json-schema\.org$|^(www\.)?ibm\.com$|^(www\.)?apple\.com$|^schema\./i;
|
|
190
|
+
const LOCAL_HOSTS = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|::1)(:\d+)?$/i;
|
|
191
|
+
const PLACEHOLDER_HOSTS = /^(www\.)?example\.(com|org|net)$|^dogs\.are\.great$|^x$|^\.$|^foo\.|^bar\./i;
|
|
192
|
+
// Cloud metadata endpoints (IPs, IPv6 link-local, GCP hostname) — assessed
|
|
193
|
+
// with context by the ssrf check instead of the plain-TLS check.
|
|
194
|
+
const METADATA_HOSTS = /metadata\.google(?:\.internal)?|169\.254\.|fd00:ec2/i;
|
|
195
|
+
|
|
196
|
+
// Context that proves a 169.254/private address is a cloud SDK credential
|
|
197
|
+
// provider (normal) rather than business-code SSRF (finding).
|
|
198
|
+
const SDK_CTX = /(aws|amazon|ec2|ecs|eks|imds|metadata\s*(service|endpoint|host)|credentialprovider|container\s*credentials|ecr|sts\.)/i;
|
|
199
|
+
// Context that proves the address sits inside an SSRF guard / blocklist.
|
|
200
|
+
// Actual guard constructs (a bare "ssrf" word in a comment is not a guard).
|
|
201
|
+
const GUARD_CTX = /(no_?proxy|blocklist|block_list|denylist|deny_list|allowlist|preflight|isPrivate|private[_-]?ip|loopback|metadata.{0,30}(block|deny|filter|redirect))/i;
|
|
202
|
+
|
|
203
|
+
const CHECKS = [
|
|
204
|
+
{
|
|
205
|
+
id: 'cred-exposure', category: 'C5 访问控制', name: '硬编码凭证 (code)', severity: 'critical', aisvs: 'C5.1',
|
|
206
|
+
fix: '移除发布包中的硬编码密钥,改用环境变量/密钥管理服务;确认为示例字符串则忽略',
|
|
207
|
+
run: checkHardcodedSecrets,
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: 'mcp-tls', category: 'C10 MCP安全', name: '明文HTTP出站端点 (code)', severity: 'high', aisvs: 'C10.1',
|
|
211
|
+
fix: '出站端点应使用 https://;http:// 仅允许 localhost/文档命名空间等非传输场景',
|
|
212
|
+
run: checkPlaintextHttp,
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
id: 'ssrf-protection', category: 'C10 MCP安全', name: '云元数据/内网地址 (code)', severity: 'critical', aisvs: 'C10.3',
|
|
216
|
+
fix: '业务代码不得直接访问云元数据(169.254.169.254等)或内网地址;需有SSRF预检/白名单',
|
|
217
|
+
run: checkMetadataIntranet,
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
id: 'command-exec', category: 'C4 基础设施', name: '子进程执行 shell:true/exec (code)', severity: 'high', aisvs: 'C4.1',
|
|
221
|
+
fix: 'spawn 优先 shell:false + 参数数组;shell:true/exec 路径必须有命令白名单/权限门与转义',
|
|
222
|
+
run: checkCommandExec,
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
id: 'dynamic-eval', category: 'C4 基础设施', name: '动态代码执行 eval/Function/vm (code)', severity: 'high', aisvs: 'C4.1',
|
|
226
|
+
fix: '避免 eval/new Function/vm 执行动态字符串;第三方库内的已知用法需人工确认参数不可控',
|
|
227
|
+
run: checkDynamicEval,
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: 'env-secrets', category: 'C5 访问控制', name: '环境变量与凭证流 (code)', severity: 'medium', aisvs: 'C5.1',
|
|
231
|
+
fix: '凭证类环境变量应仅被读取、不得回退到硬编码默认值或被记录到日志',
|
|
232
|
+
run: checkEnvVars,
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
id: 'allowed-tools', category: 'C9 Agent安全', name: '权限模式与工具门 (code)', severity: 'high', aisvs: 'C9.3',
|
|
236
|
+
fix: '保留 allowedTools/disallowedTools 白名单;dangerously-skip-permissions 需有 root/沙箱护栏',
|
|
237
|
+
run: checkPermissionModes,
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
id: 'mcp-auth', category: 'C10 MCP安全', name: 'MCP传输鉴权 (code)', severity: 'high', aisvs: 'C10.2',
|
|
241
|
+
fix: '远程 MCP 传输(sse/http/ws)必须注入 Authorization/Bearer 等鉴权头;stdio 本地传输不适用',
|
|
242
|
+
run: checkMcpTransportAuth,
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
id: 'mcp-timeout', category: 'C9 Agent安全', name: '超时与中断信号 (code)', severity: 'medium', aisvs: 'C9.1',
|
|
246
|
+
fix: '出站请求/子进程应配置超时(AbortSignal.timeout / timeout 选项)',
|
|
247
|
+
run: checkTimeout,
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
id: 'kill-switch', category: 'C9 Agent安全', name: '紧急终止 AbortController (code)', severity: 'high', aisvs: 'C9.5',
|
|
251
|
+
fix: 'fetch/spawn 应贯穿 AbortController/AbortSignal,支持异常时终止',
|
|
252
|
+
run: checkKillSwitch,
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
id: 'sandbox', category: 'C4 基础设施', name: '沙箱隔离机制 (code)', severity: 'medium', aisvs: 'C4.1',
|
|
256
|
+
fix: '命令执行应置于沙箱(bwrap/容器)中,文件系统默认只读、网络经代理',
|
|
257
|
+
run: checkSandbox,
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
id: 'input-validation', category: 'C2 输入验证', name: '输入校验/Schema (code)', severity: 'high', aisvs: 'C2.1',
|
|
261
|
+
fix: '对外部输入(工具参数/MCP消息/URL)应有 schema 校验或规范化逻辑',
|
|
262
|
+
run: checkInputValidation,
|
|
263
|
+
},
|
|
264
|
+
// --- 半自动检查:信号枚举,结论留给人工深审 ---
|
|
265
|
+
{
|
|
266
|
+
id: 'budget-limit', category: 'C9 Agent安全', name: 'Token预算 (code, 半自动)', severity: 'high', aisvs: 'C9.1',
|
|
267
|
+
fix: '确认 MAX_*_TOKENS/budget 常量被实际强制执行(信号可见,强制语义需人工确认)',
|
|
268
|
+
run: (f) => semiAuto(f, /MAX_[A-Z_]*TOKENS?|token[_-]?budget|cost[_-]?limit|maxOutputTokens/i,
|
|
269
|
+
'token 预算/上限常量', 'budget-limit'),
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
id: 'logging', category: 'C12 监控', name: '审计日志/遥测 (code, 半自动)', severity: 'medium', aisvs: 'C12.1',
|
|
273
|
+
fix: '确认日志/遥测端点不携带凭证且敏感字段已脱敏(信号可见,脱敏语义需人工确认)',
|
|
274
|
+
run: (f) => semiAuto(f, /\/metrics|telemetry|audit[_-]?log|claude_cli_feedback|OTEL_|opentelemetry/i,
|
|
275
|
+
'日志/遥测信号', 'logging'),
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
id: 'version-pin', category: 'C6 供应链', name: '供应链/Vendor SBOM (code, 半自动)', severity: 'medium', aisvs: 'C6.1',
|
|
279
|
+
fix: '零依赖 bundle 下审计面转为 vendor 件版本(.node/.jar/wasm);建议人工核对 SBOM',
|
|
280
|
+
run: checkSupplyChain,
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
id: 'error-handling', category: 'C12 监控', name: '错误处理/重试 (code, 半自动)', severity: 'medium', aisvs: 'C12.2',
|
|
284
|
+
fix: '确认 retry/fallback 不掩盖安全错误(信号可见,降级语义需人工确认)',
|
|
285
|
+
run: (f) => semiAuto(f, /fallback[_-]?model|retry[A-Z]|backoff|maxRetries/i,
|
|
286
|
+
'retry/fallback 信号', 'error-handling'),
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
id: 'output-validation', category: 'C7 输出控制', name: '输出截断/过滤 (code, 半自动)', severity: 'medium', aisvs: 'C7.1',
|
|
290
|
+
fix: '确认输出截断/HTML转换不会丢弃安全相关内容(信号可见,过滤语义需人工确认)',
|
|
291
|
+
run: (f) => semiAuto(f, /content truncated|truncat|turndown|sanitize/i,
|
|
292
|
+
'输出截断/过滤信号', 'output-validation'),
|
|
293
|
+
},
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
function semiAuto(file, re, label, checkId) {
|
|
297
|
+
const findings = [];
|
|
298
|
+
const lineRe = new RegExp(re.source, 'i');
|
|
299
|
+
let firstLine = 0;
|
|
300
|
+
file.lines.forEach((line, i) => { if (!firstLine && lineRe.test(line)) firstLine = i + 1; });
|
|
301
|
+
const hits = tally(new RegExp(re.source, 'gi'), file.text, 12);
|
|
302
|
+
for (const h of hits) {
|
|
303
|
+
findings.push(mkFinding(file, firstLine, 'info', `${label}: "${h.value}" ×${h.count}(半自动:信号在,判定需人工)`, { suppressed: true, checkId }));
|
|
304
|
+
}
|
|
305
|
+
return findings;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function mkFinding(file, line, severity, message, extra = {}) {
|
|
309
|
+
return {
|
|
310
|
+
file: file.rel,
|
|
311
|
+
line,
|
|
312
|
+
severity, // fail | warn | info
|
|
313
|
+
suppressed: !!extra.suppressed,
|
|
314
|
+
message,
|
|
315
|
+
snippet: extra.snippet || '',
|
|
316
|
+
checkId: extra.checkId,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* ---- check 1: hardcoded secrets ---- */
|
|
321
|
+
function checkHardcodedSecrets(file) {
|
|
322
|
+
const findings = [];
|
|
323
|
+
file.lines.forEach((line, i) => {
|
|
324
|
+
for (const p of SECRET_PATTERNS) {
|
|
325
|
+
const m = line.match(p.re);
|
|
326
|
+
if (!m) continue;
|
|
327
|
+
if (PLACEHOLDER_CTX.test(line)) {
|
|
328
|
+
findings.push(mkFinding(file, i + 1, 'info',
|
|
329
|
+
`${p.name} 模式命中但位于示例/占位上下文(已降噪): ${m[0].slice(0, 18)}…`,
|
|
330
|
+
{ suppressed: true, snippet: snippet(line), checkId: 'cred-exposure' }));
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
findings.push(mkFinding(file, i + 1, 'fail',
|
|
334
|
+
`${p.name} 疑似硬编码密钥: ${m[0].slice(0, 10)}…(${m[0].length} 字符)`,
|
|
335
|
+
{ snippet: snippet(line), checkId: 'cred-exposure' }));
|
|
336
|
+
}
|
|
337
|
+
if (findings.filter(f => !f.suppressed).length >= MAX_FINDINGS_PER_CHECK) return;
|
|
338
|
+
});
|
|
339
|
+
return findings;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/* ---- check 2: plaintext http:// endpoints ---- */
|
|
343
|
+
function checkPlaintextHttp(file) {
|
|
344
|
+
const findings = [];
|
|
345
|
+
const seen = new Set();
|
|
346
|
+
const re = /\bhttp:\/\/([a-zA-Z0-9.\-]+|\[[^\]]+\])(?::\d+)?(?:\/[^\s"'`)<>]*)?/g;
|
|
347
|
+
file.lines.forEach((line, i) => {
|
|
348
|
+
let m;
|
|
349
|
+
while ((m = re.exec(line)) !== null) {
|
|
350
|
+
const host = m[1];
|
|
351
|
+
const url = m[0];
|
|
352
|
+
const key = host + '|' + (i + 1);
|
|
353
|
+
if (seen.has(key)) continue;
|
|
354
|
+
seen.add(key);
|
|
355
|
+
let sev, msg, suppressed = false;
|
|
356
|
+
if (LOCAL_HOSTS.test(host)) {
|
|
357
|
+
sev = 'info'; suppressed = true;
|
|
358
|
+
msg = `http://${host} 本地回环地址(非出站明文流量,已降噪)`;
|
|
359
|
+
} else if (DOC_HOSTS.test(host)) {
|
|
360
|
+
sev = 'info'; suppressed = true;
|
|
361
|
+
msg = `http://${host} 规范/文档命名空间 URL(非业务端点,已降噪)`;
|
|
362
|
+
} else if (PLACEHOLDER_HOSTS.test(host)) {
|
|
363
|
+
sev = 'info'; suppressed = true;
|
|
364
|
+
msg = `http://${host} 占位/示例域名(已降噪)`;
|
|
365
|
+
} else if (METADATA_HOSTS.test(host)) {
|
|
366
|
+
// metadata endpoints are assessed with context by ssrf-protection
|
|
367
|
+
// (covers both IPs and hostnames like metadata.google.internal)
|
|
368
|
+
continue;
|
|
369
|
+
} else {
|
|
370
|
+
sev = 'warn';
|
|
371
|
+
msg = `明文 HTTP 出站端点: ${url.length > 80 ? url.slice(0, 80) + '…' : url}`;
|
|
372
|
+
}
|
|
373
|
+
findings.push(mkFinding(file, i + 1, sev, msg, { suppressed, snippet: snippet(line), checkId: 'mcp-tls' }));
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
return findings.slice(0, MAX_FINDINGS_PER_CHECK);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/* ---- check 3: cloud metadata / intranet addresses ---- */
|
|
380
|
+
function checkMetadataIntranet(file) {
|
|
381
|
+
const findings = [];
|
|
382
|
+
const seen = new Set();
|
|
383
|
+
// 169.254.x.x (cloud link-local/metadata), RFC1918 private literals,
|
|
384
|
+
// GCP metadata hostname, or IPv6 cloud link-local (fd00:ec2::*).
|
|
385
|
+
const re = /\b(169\.254\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|metadata\.google(?:\.internal)?\.?|\[?fd00:ec2[0-9a-f:]*\]?)\b/gi;
|
|
386
|
+
file.lines.forEach((line, i) => {
|
|
387
|
+
let m;
|
|
388
|
+
while ((m = re.exec(line)) !== null) {
|
|
389
|
+
const target = m[1];
|
|
390
|
+
if (seen.has(target + ':' + i)) continue;
|
|
391
|
+
seen.add(target + ':' + i);
|
|
392
|
+
const ctx = lineWindow(file.lines, i);
|
|
393
|
+
const isMetadata = /^169\.254\./.test(target) || /metadata\.google/i.test(target) || /fd00:ec2/i.test(target);
|
|
394
|
+
const inSdk = SDK_CTX.test(ctx) || /SECONDARY_HOST_ADDRESS|gcp|google/i.test(ctx);
|
|
395
|
+
const inGuard = GUARD_CTX.test(ctx);
|
|
396
|
+
let sev, msg, suppressed = false;
|
|
397
|
+
if (inSdk) {
|
|
398
|
+
sev = 'info'; suppressed = true;
|
|
399
|
+
msg = `${target} 位于云 SDK 凭证提供者上下文(AWS IMDS / GCP metadata 正常用法,非业务 SSRF,已降噪)`;
|
|
400
|
+
} else if (inGuard) {
|
|
401
|
+
sev = 'info'; suppressed = true;
|
|
402
|
+
msg = `${target} 位于 SSRF 防护/代理黑名单上下文(NO_PROXY/blocklist 等防护信号,已降噪)`;
|
|
403
|
+
} else if (isMetadata) {
|
|
404
|
+
sev = 'fail';
|
|
405
|
+
msg = `云元数据地址 ${target} 出现在业务代码中且无 SDK/防护上下文 — 疑似 SSRF(需人工确认可达性)`;
|
|
406
|
+
} else {
|
|
407
|
+
sev = 'warn';
|
|
408
|
+
msg = `内网地址 ${target} 硬编码于代码中且无防护上下文 — 检查是否可被外部输入触达(SSRF)`;
|
|
409
|
+
}
|
|
410
|
+
findings.push(mkFinding(file, i + 1, sev, msg, { suppressed, snippet: snippet(line), checkId: 'ssrf-protection' }));
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
return findings.slice(0, MAX_FINDINGS_PER_CHECK);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/* ---- check 4: child_process shell:true / exec ---- */
|
|
417
|
+
function checkCommandExec(file) {
|
|
418
|
+
const findings = [];
|
|
419
|
+
const shellTrue = /shell\s*:\s*(?:!0|true)/;
|
|
420
|
+
const execCall = /(^|[^.\w$])(?:exec|execSync)\s*\(/;
|
|
421
|
+
const gatedRe = /(permission[A-Z]|permission[ _-]?(?:check|mode|behavior|behaviour|prompt|decision|system|gate)|allowedTools|disallowedTools|shell-quote|shellQuote|\.quote\(|PreToolUse|PostToolUse|hookEventName|"Bash"|Bash tool|isCommandAllowed|canUseBash|checkPermissions|permissionRule|toolPermission)/i;
|
|
422
|
+
const buildRe = /(node-gyp|npm install|install script|spawnSync\(\s*["'`])(?:[a-z\-]+ )?[a-z\-]+(?:\s|["'`])/i;
|
|
423
|
+
// call site where the command arg is a fixed literal ("cmd ...")
|
|
424
|
+
const staticCallRe = /(?:spawn|spawnSync|exec|execSync)(?:\s*\.\w+)?\s*\(\s*[A-Za-z_$][\w$]*\s*,?[^)]{0,40}shell\s*:\s*(?:!0|true)|(?:spawnSync|execSync)\s*\(\s*["'`][a-zA-Z0-9_\-./ ]+["'`]/;
|
|
425
|
+
// build/install tooling or fixed-command env probes in the vicinity
|
|
426
|
+
const buildWideRe = /(node-gyp|gyp rebuild|npm[ _-]?install|prebuild-install|sharp:|Installation error|spawnSync\(\s*["'`][a-zA-Z0-9_\-. ]+["'`])/i;
|
|
427
|
+
const dynamicCmd = /\$\{|`[^`]*\$\{|\+\s*[A-Za-z_$][\w$]*\s*\)/;
|
|
428
|
+
const seen = new Set();
|
|
429
|
+
|
|
430
|
+
// Wide context (±250 lines) catches permission gates/hook dispatchers
|
|
431
|
+
// defined far above the spawn call site in bundled tool implementations.
|
|
432
|
+
function isGated(i) {
|
|
433
|
+
const lo = Math.max(0, i - 250);
|
|
434
|
+
const hi = Math.min(file.lines.length, i + 15);
|
|
435
|
+
return gatedRe.test(file.lines.slice(lo, hi).join('\n'));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
file.lines.forEach((line, i) => {
|
|
439
|
+
if (shellTrue.test(line)) {
|
|
440
|
+
if (seen.has('shell:' + i)) return;
|
|
441
|
+
seen.add('shell:' + i);
|
|
442
|
+
const gated = isGated(i);
|
|
443
|
+
const buildCtx = buildWideRe.test(lineWindow(file.lines, i, 120));
|
|
444
|
+
const staticCmd = staticCallRe.test(line) || buildCtx;
|
|
445
|
+
const sev = gated || staticCmd ? 'info' : 'warn';
|
|
446
|
+
const why = gated
|
|
447
|
+
? '邻近上下文可见权限门/hook 分发/转义(permission/quote/PreToolUse),降级 info'
|
|
448
|
+
: staticCmd
|
|
449
|
+
? '命令为固定字面量(构建/环境探测脚本),外部输入不可控,降级 info'
|
|
450
|
+
: '未见邻近权限门且命令可能动态构造 — warn';
|
|
451
|
+
findings.push(mkFinding(file, i + 1, sev,
|
|
452
|
+
`child_process spawn/spawnSync 使用 shell:true — 命令注入面存在;${why};仍建议人工确认参数来源`,
|
|
453
|
+
{ suppressed: sev === 'info', snippet: snippet(line), checkId: 'command-exec' }));
|
|
454
|
+
}
|
|
455
|
+
if (execCall.test(line)) {
|
|
456
|
+
if (seen.has('exec:' + i)) return;
|
|
457
|
+
seen.add('exec:' + i);
|
|
458
|
+
const ctx = lineWindow(file.lines, i);
|
|
459
|
+
const looksDynamic = dynamicCmd.test(line) || /\$\{/.test(ctx);
|
|
460
|
+
findings.push(mkFinding(file, i + 1, looksDynamic ? 'warn' : 'info',
|
|
461
|
+
`child_process exec/execSync 调用(始终经 shell)${looksDynamic ? ',参数疑似动态拼接 — 检查注入面' : ',参数为静态/库内字符串'}`,
|
|
462
|
+
{ suppressed: !looksDynamic, snippet: snippet(line), checkId: 'command-exec' }));
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
return findings.slice(0, MAX_FINDINGS_PER_CHECK);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/* ---- check 5: eval / new Function / vm ---- */
|
|
469
|
+
function checkDynamicEval(file) {
|
|
470
|
+
const findings = [];
|
|
471
|
+
const knownBenign = [
|
|
472
|
+
{ re: /new Function\([^)]*RULES|ajv|ValidationError/i, why: 'ajv/JSON-schema 库编译校验函数(第三方库常见模式,参数为库内数据)' },
|
|
473
|
+
{ re: /eval\(\s*["']quire["']|quire["']\s*\.replace|lazy.*require/i, why: '惰性 require shim(字符串拆分拼接 require,try/catch 包裹、失败返回 null)' },
|
|
474
|
+
{ re: /eval\s+\$\{|shell-quote|\.quote\(/i, why: '沙箱脚本内 eval 经 shell-quote 转义的参数(bwrap/socat 包装)' },
|
|
475
|
+
{ re: /hardenVMIntrinsics|harden[a-zA-Z]*[Ii]ntrinsics|createContext\s*\(|repl-tool-code|REPL (?:code|replay|execution)|runInContext|new [A-Za-z_$][\w$]*\.Script\(/, why: 'VM 沙箱自身实现(createContext/冻结 intrinsics/隔离 REPL 与插件代码)—— 属于安全控制原语而非风险,参数为库内模板' },
|
|
476
|
+
];
|
|
477
|
+
const patterns = [
|
|
478
|
+
{ re: /(^|[^.\w$])eval\s*\(/g, label: 'eval(' },
|
|
479
|
+
{ re: /new\s+Function\s*\(/g, label: 'new Function(' },
|
|
480
|
+
{ re: /\b(?:runInNewContext|runInThisContext|runInContext)\s*\(/g, label: 'vm.runIn*Context' },
|
|
481
|
+
{ re: /new\s+vm\.\w+\s*\(|require\(\s*["']node:vm["']\s*\)|from\s*["']node:vm["']/g, label: 'node:vm 原语' },
|
|
482
|
+
];
|
|
483
|
+
const seen = new Set();
|
|
484
|
+
for (const p of patterns) {
|
|
485
|
+
// non-stateful test regex (global flags would carry lastIndex across lines)
|
|
486
|
+
const testRe = new RegExp(p.re.source, p.re.flags.replace('g', ''));
|
|
487
|
+
file.lines.forEach((line, i) => {
|
|
488
|
+
if (!testRe.test(line)) return;
|
|
489
|
+
// skip method definitions like "eval(r) {" and member calls ".eval("
|
|
490
|
+
if (/\beval\s*\([^)]*\)\s*\{/.test(line) && p.label === 'eval(') return;
|
|
491
|
+
const key = p.label + ':' + i;
|
|
492
|
+
if (seen.has(key)) return;
|
|
493
|
+
seen.add(key);
|
|
494
|
+
// wide window: vm.runInContext template bodies and hardening calls
|
|
495
|
+
// span many beautified lines
|
|
496
|
+
const ctx = lineWindow(file.lines, i, 30) + '\n' + lineWindow(file.lines, i, 6);
|
|
497
|
+
const benign = knownBenign.find(b => b.re.test(ctx) || b.re.test(line));
|
|
498
|
+
if (benign) {
|
|
499
|
+
findings.push(mkFinding(file, i + 1, 'info',
|
|
500
|
+
`${p.label} 动态代码执行点 — ${benign.why}(判据:上下文模式匹配,已降噪为 info;建议人工复核参数可控性)`,
|
|
501
|
+
{ suppressed: true, snippet: snippet(line), checkId: 'dynamic-eval' }));
|
|
502
|
+
} else {
|
|
503
|
+
findings.push(mkFinding(file, i + 1, 'warn',
|
|
504
|
+
`${p.label} 动态代码执行原语 — 未见已知良性上下文,需人工确认参数字符串是否外部可控`,
|
|
505
|
+
{ snippet: snippet(line), checkId: 'dynamic-eval' }));
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
return findings.slice(0, MAX_FINDINGS_PER_CHECK);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/* ---- check 6: env vars / credential flow ---- */
|
|
513
|
+
function checkEnvVars(file) {
|
|
514
|
+
const findings = [];
|
|
515
|
+
const envRe = /process\.env\.([A-Z][A-Z0-9_]{2,})/g;
|
|
516
|
+
const credRe = /(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)/;
|
|
517
|
+
const tallyMap = new Map();
|
|
518
|
+
const credLines = new Map();
|
|
519
|
+
let m;
|
|
520
|
+
while ((m = envRe.exec(file.text)) !== null) {
|
|
521
|
+
const name = m[1];
|
|
522
|
+
tallyMap.set(name, (tallyMap.get(name) || 0) + 1);
|
|
523
|
+
if (credRe.test(name) && !credLines.has(name)) {
|
|
524
|
+
const idx = file.text.slice(0, m.index).split('\n').length - 1;
|
|
525
|
+
credLines.set(name, idx);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// Hardcoded fallback defaults for credential env vars: process.env.X || "literal"
|
|
529
|
+
const hardcodeRe = /process\.env\.([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Z0-9_]*)\s*(?:\|\||\?\?)\s*["'`]([A-Za-z0-9_\-./+]{8,})["'`]/g;
|
|
530
|
+
while ((m = hardcodeRe.exec(file.text)) !== null) {
|
|
531
|
+
const idx = file.text.slice(0, m.index).split('\n').length - 1;
|
|
532
|
+
findings.push(mkFinding(file, idx + 1, 'fail',
|
|
533
|
+
`凭证环境变量 ${m[1]} 带有硬编码回退默认值 "${m[2].slice(0, 6)}…" — 疑似后门/泄漏`,
|
|
534
|
+
{ snippet: snippet(file.lines[idx] || ''), checkId: 'env-secrets' }));
|
|
535
|
+
}
|
|
536
|
+
const credNames = [...tallyMap.entries()].filter(([n]) => credRe.test(n)).sort((a, b) => b[1] - a[1]);
|
|
537
|
+
for (const [name, n] of credNames.slice(0, 20)) {
|
|
538
|
+
const idx = credLines.get(name) ?? 0;
|
|
539
|
+
findings.push(mkFinding(file, idx + 1, 'info',
|
|
540
|
+
`凭证类环境变量 process.env.${name}(读取 ${n} 处)— 追踪其流向:不得入日志/不得硬编码回退`,
|
|
541
|
+
{ suppressed: true, snippet: snippet(file.lines[idx] || ''), checkId: 'env-secrets' }));
|
|
542
|
+
}
|
|
543
|
+
findings.unshift(mkFinding(file, 0, 'info',
|
|
544
|
+
`共枚举到 ${tallyMap.size} 个不同环境变量名,其中凭证类 ${credNames.length} 个(信号全保留,可用于凭证流向审计)`,
|
|
545
|
+
{ suppressed: true, checkId: 'env-secrets' }));
|
|
546
|
+
return findings;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/* ---- check 7: permission modes ---- */
|
|
550
|
+
function checkPermissionModes(file) {
|
|
551
|
+
const findings = [];
|
|
552
|
+
const hasAllow = /allowedTools|disallowedTools|allowed_tools|permission[-_ ]?mode/i.test(file.text);
|
|
553
|
+
const skipRe = /dangerously-skip-permissions|dangerouslySkipPermissions|--no-sandbox|skipPermissions/i;
|
|
554
|
+
const rootGuard = /getuid\(\)\s*===?\s*0|cannot be used with root|IS_SANDBOX/i;
|
|
555
|
+
let skipLine = 0;
|
|
556
|
+
file.lines.forEach((line, i) => {
|
|
557
|
+
if (skipRe.test(line) && !skipLine) skipLine = i + 1;
|
|
558
|
+
});
|
|
559
|
+
if (hasAllow) {
|
|
560
|
+
const idx = file.text.search(/allowedTools|disallowedTools|allowed_tools/i);
|
|
561
|
+
const ln = idx >= 0 ? file.text.slice(0, idx).split('\n').length : 0;
|
|
562
|
+
findings.push(mkFinding(file, ln, 'info',
|
|
563
|
+
'检测到工具白名单/权限模式(allowedTools/disallowedTools/permissionMode)— 权限门存在',
|
|
564
|
+
{ suppressed: true, snippet: snippet(file.lines[ln - 1] || ''), checkId: 'allowed-tools' }));
|
|
565
|
+
}
|
|
566
|
+
if (skipLine) {
|
|
567
|
+
const guarded = rootGuard.test(file.text);
|
|
568
|
+
findings.push(mkFinding(file, skipLine, guarded ? 'info' : 'warn',
|
|
569
|
+
guarded
|
|
570
|
+
? '检测到 --dangerously-skip-permissions 旁路开关,但同包内存在 root/沙箱护栏(uid=0 拒绝)— 已降噪为 info'
|
|
571
|
+
: '检测到 --dangerously-skip-permissions/--no-sandbox 旁路开关且未见 root/沙箱护栏 — 确认其默认不可达',
|
|
572
|
+
{ suppressed: guarded, snippet: snippet(file.lines[skipLine - 1] || ''), checkId: 'allowed-tools' }));
|
|
573
|
+
}
|
|
574
|
+
if (!hasAllow && !skipLine) {
|
|
575
|
+
findings.push(mkFinding(file, 0, 'warn', '未检测到工具白名单/权限模式字符串 — 若该包执行 Agent 工具调用,需人工确认权限控制', { checkId: 'allowed-tools' }));
|
|
576
|
+
}
|
|
577
|
+
return findings;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/* ---- check 8: MCP transport auth ---- */
|
|
581
|
+
function checkMcpTransportAuth(file) {
|
|
582
|
+
const findings = [];
|
|
583
|
+
const transportRe = /["'](stdio|sse|sse-ide|http|streamable-?http|ws|wss)["']/g;
|
|
584
|
+
const transports = new Set();
|
|
585
|
+
let m;
|
|
586
|
+
while ((m = transportRe.exec(file.text)) !== null) transports.add(m[1]);
|
|
587
|
+
const hasRemote = [...transports].some(t => t !== 'stdio');
|
|
588
|
+
const authSignal = /Authorization\s*[:=]|Bearer\s+[$`"']|authProvider|X-Claude-Code-Ide-Authorization|getAccessToken|oauth/i.test(file.text);
|
|
589
|
+
const mcpSignal = /mcpServers|tools\/call|notifications\/initialized|ModelContextProtocol|@modelcontextprotocol/i.test(file.text);
|
|
590
|
+
if (transports.size > 0 && (mcpSignal || transports.has('sse-ide'))) {
|
|
591
|
+
const idx = file.text.search(transportRe);
|
|
592
|
+
const ln = idx >= 0 ? file.text.slice(0, idx).split('\n').length : 0;
|
|
593
|
+
findings.push(mkFinding(file, ln, 'info',
|
|
594
|
+
`MCP 传输类型: ${[...transports].join('/')}${hasRemote ? '(含远程传输)' : '(仅本地 stdio)'}`,
|
|
595
|
+
{ suppressed: true, snippet: snippet(file.lines[ln - 1] || ''), checkId: 'mcp-auth' }));
|
|
596
|
+
if (hasRemote && authSignal) {
|
|
597
|
+
const aidx = file.text.search(/Authorization\s*[:=]|Bearer\s+[$`"']|authProvider|X-Claude-Code-Ide-Authorization/);
|
|
598
|
+
const aln = aidx >= 0 ? file.text.slice(0, aidx).split('\n').length : 0;
|
|
599
|
+
findings.push(mkFinding(file, aln, 'info',
|
|
600
|
+
'远程传输分支检测到鉴权头注入(Authorization/Bearer/authProvider/IDE 鉴权头)— 鉴权机制存在',
|
|
601
|
+
{ suppressed: true, snippet: snippet(file.lines[aln - 1] || ''), checkId: 'mcp-auth' }));
|
|
602
|
+
} else if (hasRemote) {
|
|
603
|
+
findings.push(mkFinding(file, ln, 'warn',
|
|
604
|
+
'检测到远程 MCP 传输(sse/http/ws)但全包未见 Authorization/Bearer/authProvider 鉴权信号 — 确认远程连接是否鉴权',
|
|
605
|
+
{ checkId: 'mcp-auth' }));
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return findings;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/* ---- check 9: timeout ---- */
|
|
612
|
+
function checkTimeout(file) {
|
|
613
|
+
const findings = [];
|
|
614
|
+
const re = /AbortSignal\.timeout\s*\(\s*(\d+)\s*\)|timeout\s*:\s*(\d+[A-Za-z_]*|\d{3,})/g;
|
|
615
|
+
let m;
|
|
616
|
+
let n = 0;
|
|
617
|
+
while ((m = re.exec(file.text)) !== null && n < 8) {
|
|
618
|
+
const idx = file.text.slice(0, m.index).split('\n').length - 1;
|
|
619
|
+
findings.push(mkFinding(file, idx + 1, 'info',
|
|
620
|
+
`超时信号: ${m[0].slice(0, 60)}`,
|
|
621
|
+
{ suppressed: true, snippet: snippet(file.lines[idx] || ''), checkId: 'mcp-timeout' }));
|
|
622
|
+
n++;
|
|
623
|
+
}
|
|
624
|
+
if (!findings.length) {
|
|
625
|
+
findings.push(mkFinding(file, 0, 'warn', '未检测到 AbortSignal.timeout/timeout 选项 — 出站请求/子进程可能无超时', { checkId: 'mcp-timeout' }));
|
|
626
|
+
}
|
|
627
|
+
return findings;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/* ---- check 10: kill switch ---- */
|
|
631
|
+
function checkKillSwitch(file) {
|
|
632
|
+
const n = countMatches(/AbortController|AbortSignal|\.abort\(\)/g, file.text);
|
|
633
|
+
if (n > 0) {
|
|
634
|
+
const idx = file.text.search(/AbortController|AbortSignal/);
|
|
635
|
+
const ln = idx >= 0 ? file.text.slice(0, idx).split('\n').length : 0;
|
|
636
|
+
return [mkFinding(file, ln, 'info',
|
|
637
|
+
`检测到 AbortController/AbortSignal 信号 ${n} 处 — fetch/spawn 可被终止的基础设施存在`,
|
|
638
|
+
{ suppressed: true, snippet: snippet(file.lines[ln - 1] || ''), checkId: 'kill-switch' })];
|
|
639
|
+
}
|
|
640
|
+
return [mkFinding(file, 0, 'warn', '未检测到 AbortController/AbortSignal — 缺少紧急终止信号', { checkId: 'kill-switch' })];
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/* ---- check 11: sandbox ---- */
|
|
644
|
+
function checkSandbox(file) {
|
|
645
|
+
const re = /\b(bwrap|bubblewrap|--ro-bind|IS_SANDBOX|SandboxManager|sandbox)\b/i;
|
|
646
|
+
const m = file.text.match(re);
|
|
647
|
+
if (m) {
|
|
648
|
+
const idx = file.text.search(re);
|
|
649
|
+
const ln = idx >= 0 ? file.text.slice(0, idx).split('\n').length : 0;
|
|
650
|
+
return [mkFinding(file, ln, 'info',
|
|
651
|
+
`检测到沙箱机制信号 "${m[0]}"(bwrap/只读绑定/沙箱管理器等)— 隔离机制存在`,
|
|
652
|
+
{ suppressed: true, snippet: snippet(file.lines[ln - 1] || ''), checkId: 'sandbox' })];
|
|
653
|
+
}
|
|
654
|
+
return [mkFinding(file, 0, 'info', '未检测到 bwrap/sandbox 信号 — 若该包执行外部命令,建议人工确认隔离方式', { suppressed: true, checkId: 'sandbox' })];
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/* ---- check 12: input validation ---- */
|
|
658
|
+
function checkInputValidation(file) {
|
|
659
|
+
const re = /\.describe\s*\(|zod|Invalid name|sanitize|validateUrl|new URL\(/i;
|
|
660
|
+
const m = file.text.match(re);
|
|
661
|
+
if (m) {
|
|
662
|
+
const idx = file.text.search(re);
|
|
663
|
+
const ln = idx >= 0 ? file.text.slice(0, idx).split('\n').length : 0;
|
|
664
|
+
return [mkFinding(file, ln, 'info',
|
|
665
|
+
`检测到输入校验信号 "${m[0]}"(schema describe/URL 解析/校验错误串等)— 校验逻辑存在`,
|
|
666
|
+
{ suppressed: true, snippet: snippet(file.lines[ln - 1] || ''), checkId: 'input-validation' })];
|
|
667
|
+
}
|
|
668
|
+
return [mkFinding(file, 0, 'warn', '未检测到 schema/validate 类输入校验信号', { checkId: 'input-validation' })];
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/* ---- check 13 (semi): supply chain / vendor SBOM ---- */
|
|
672
|
+
function checkSupplyChain(file) {
|
|
673
|
+
const findings = [];
|
|
674
|
+
// vendor native artifacts named in strings
|
|
675
|
+
const re = /\b(napi-[0-9.]+|[a-z0-9_-]+-\d+\.\d+\.\d+\.jar|ripgrep|@anthropic-ai[\\/][a-z0-9-]+)/gi;
|
|
676
|
+
const hits = tally(re, file.text, 10);
|
|
677
|
+
for (const h of hits) {
|
|
678
|
+
findings.push(mkFinding(file, 0, 'info',
|
|
679
|
+
`vendor/原生件 SBOM 信号: "${h.value}" ×${h.count}(零依赖 bundle 下供应链审计转为核对 vendor 件版本,半自动)`,
|
|
680
|
+
{ suppressed: true, checkId: 'version-pin' }));
|
|
681
|
+
}
|
|
682
|
+
if (!findings.length) {
|
|
683
|
+
findings.push(mkFinding(file, 0, 'info', '未从代码字符串提取到 vendor 件版本信号(若包内含 .node/.jar/wasm,请人工核对 SBOM)', { suppressed: true, checkId: 'version-pin' }));
|
|
684
|
+
}
|
|
685
|
+
return findings;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/* ------------------------------------------------------------------ */
|
|
689
|
+
/* Scan orchestration */
|
|
690
|
+
/* ------------------------------------------------------------------ */
|
|
691
|
+
|
|
692
|
+
function scanFile(filePath, baseDir) {
|
|
693
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
694
|
+
const rel = path.relative(baseDir, filePath) || path.basename(filePath);
|
|
695
|
+
// Lightweight internal reformat for minified bundles: if the file is
|
|
696
|
+
// dominated by very long lines, expand ; { } so findings carry useful line
|
|
697
|
+
// numbers. Already-formatted source is scanned untouched (beautify would
|
|
698
|
+
// only churn line numbers of normal multi-line files).
|
|
699
|
+
const firstLines = raw.split('\n');
|
|
700
|
+
const longest = firstLines.slice(0, 50).reduce((m, l) => Math.max(m, l.length), 0);
|
|
701
|
+
const avgLen = raw.length / Math.max(1, firstLines.length);
|
|
702
|
+
// Minified = very long lines, or a small file that is just one/few dense
|
|
703
|
+
// lines (multiple statement separators on a single physical line).
|
|
704
|
+
const denseOneliners = firstLines.filter(l => (l.match(/[;{}]/g) || []).length >= 3 && l.length > 80).length;
|
|
705
|
+
const looksMinified = avgLen > 200 || (firstLines.length < 20 && longest > 800) ||
|
|
706
|
+
(firstLines.length <= 3 && denseOneliners >= 1);
|
|
707
|
+
const text = looksMinified ? beautifySource(raw) : raw;
|
|
708
|
+
const lines = text.split('\n');
|
|
709
|
+
const file = { path: filePath, rel, text, lines, minified: looksMinified };
|
|
710
|
+
const results = [];
|
|
711
|
+
for (const check of CHECKS) {
|
|
712
|
+
let findings = [];
|
|
713
|
+
try { findings = check.run(file) || []; } catch (e) { findings = []; }
|
|
714
|
+
const active = findings.filter(f => !f.suppressed);
|
|
715
|
+
let status = 'pass';
|
|
716
|
+
if (active.some(f => f.severity === 'fail')) status = 'fail';
|
|
717
|
+
else if (active.some(f => f.severity === 'warn')) status = 'warn';
|
|
718
|
+
else if (findings.length > 0) status = 'info';
|
|
719
|
+
results.push({
|
|
720
|
+
id: check.id,
|
|
721
|
+
category: check.category,
|
|
722
|
+
name: check.name,
|
|
723
|
+
severity: check.severity,
|
|
724
|
+
aisvs: check.aisvs,
|
|
725
|
+
fix: check.fix,
|
|
726
|
+
status,
|
|
727
|
+
findings,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
return { file: rel, results };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function summarize(fileResults) {
|
|
734
|
+
let pass = 0, warn = 0, fail = 0, info = 0;
|
|
735
|
+
let findingsFail = 0, findingsWarn = 0, findingsInfo = 0, findingsSuppressed = 0;
|
|
736
|
+
for (const fr of fileResults) {
|
|
737
|
+
for (const r of fr.results) {
|
|
738
|
+
if (r.status === 'pass') pass++;
|
|
739
|
+
else if (r.status === 'warn') warn++;
|
|
740
|
+
else if (r.status === 'fail') fail++;
|
|
741
|
+
else info++;
|
|
742
|
+
for (const f of r.findings) {
|
|
743
|
+
if (f.suppressed) findingsSuppressed++;
|
|
744
|
+
else if (f.severity === 'fail') findingsFail++;
|
|
745
|
+
else if (f.severity === 'warn') findingsWarn++;
|
|
746
|
+
else findingsInfo++;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
const total = pass + warn + fail + info;
|
|
751
|
+
const score = total ? Math.round(((pass * 10 + warn * 5 + info * 7) / (total * 10)) * 100) : 100;
|
|
752
|
+
return {
|
|
753
|
+
pass, warn, fail, info, total, score,
|
|
754
|
+
findings: { fail: findingsFail, warn: findingsWarn, info: findingsInfo, suppressed: findingsSuppressed },
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Run a bundle scan.
|
|
760
|
+
* @param {string[]} files - absolute JS file paths
|
|
761
|
+
* @param {string} baseDir - for relative display names
|
|
762
|
+
* @returns {{ files: Array, stats: Object, checks: Array }}
|
|
763
|
+
*/
|
|
764
|
+
function runBundleScan(files, baseDir) {
|
|
765
|
+
const fileResults = files.map(f => scanFile(f, baseDir));
|
|
766
|
+
const stats = summarize(fileResults);
|
|
767
|
+
return { files: fileResults, stats, checks: CHECKS.map(c => ({ id: c.id, name: c.name, aisvs: c.aisvs })) };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
module.exports = {
|
|
771
|
+
runBundleScan,
|
|
772
|
+
discoverBundleFiles,
|
|
773
|
+
beautifySource,
|
|
774
|
+
BUNDLE_CHECKS: CHECKS,
|
|
775
|
+
};
|