@perrylink/dsh-skill-pack-security-provider 2.0.0 → 2.0.1

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.
@@ -0,0 +1,755 @@
1
+ /**
2
+ * The eight plugin_vet checks. Every function is pure: it reads the shared
3
+ * inputs and returns one `VetCheck` with redacted, capped findings. No check
4
+ * touches the network or the filesystem — resolution happened upstream in
5
+ * `source.ts`.
6
+ *
7
+ * @module dsh-skill-pack-security/vet/checks
8
+ */
9
+ import { buildDependencyTree, unpinnedSpecs } from './manifest.js';
10
+ import { redactSnippet } from './redact.js';
11
+ import { isCommitRef } from './source.js';
12
+ import { CHECK_NAME, SKILL_REF } from './skills.js';
13
+ function clamp(value, min = 0, max = 100) {
14
+ return Math.min(max, Math.max(min, Math.round(value)));
15
+ }
16
+ /** Best level across findings: fail > warn > info. */
17
+ function worst(findings) {
18
+ if (findings.some(f => f.level === 'fail'))
19
+ return 'fail';
20
+ if (findings.some(f => f.level === 'warn'))
21
+ return 'warn';
22
+ if (findings.some(f => f.level === 'info'))
23
+ return 'info';
24
+ return null;
25
+ }
26
+ /** Assemble one check: verdict derived from capped findings. */
27
+ function makeCheck(id, score, findings, lang, config, skipReason) {
28
+ let capped = findings;
29
+ let truncated = false;
30
+ if (findings.length > config.maxFindingsPerCheck) {
31
+ capped = findings.slice(0, config.maxFindingsPerCheck);
32
+ truncated = true;
33
+ }
34
+ const worstLevel = worst(capped);
35
+ const verdict = skipReason !== undefined ? 'skip' : worstLevel === 'fail' ? 'fail' : worstLevel === 'warn' ? 'warn' : 'pass';
36
+ return {
37
+ id,
38
+ name: CHECK_NAME[lang][id],
39
+ verdict,
40
+ skipReason,
41
+ score: clamp(score),
42
+ findings: capped,
43
+ truncatedFindings: truncated,
44
+ skill: SKILL_REF[id],
45
+ };
46
+ }
47
+ // --- license -----------------------------------------------------------------
48
+ /** Curated SPDX id set (common enough to validate against without a dependency). */
49
+ const KNOWN_SPDX = new Set([
50
+ 'MIT', 'Apache-2.0', 'Apache-1.1', 'BSD-2-Clause', 'BSD-3-Clause', 'BSD-4-Clause',
51
+ 'ISC', 'MPL-2.0', 'MPL-1.1', 'Unlicense', 'CC0-1.0', 'CC-BY-4.0', 'CC-BY-SA-4.0',
52
+ 'WTFPL', 'Zlib', '0BSD', 'MIT-0', 'BlueOak-1.0.0', 'PostgreSQL', 'Python-2.0',
53
+ 'GPL-2.0-only', 'GPL-2.0-or-later', 'GPL-3.0-only', 'GPL-3.0-or-later',
54
+ 'LGPL-2.1-only', 'LGPL-2.1-or-later', 'LGPL-3.0-only', 'LGPL-3.0-or-later',
55
+ 'AGPL-3.0-only', 'AGPL-3.0-or-later', 'EPL-2.0', 'EPL-1.0', 'EUPL-1.2',
56
+ 'MS-PL', 'MS-RL', 'BSL-1.0', 'OFL-1.1', 'Artistic-2.0', 'CDDL-1.0',
57
+ 'SSPL-1.0', 'CPAL-1.0', 'OSL-3.0', 'AFL-3.0', 'GPL-2.0', 'GPL-3.0',
58
+ 'LGPL-2.1', 'LGPL-3.0', 'AGPL-3.0', 'EPL-1.0',
59
+ ]);
60
+ const UNKNOWN_LICENSE = new Set(['unknown', 'UNKNOWN', 'NOASSERTION', 'none', 'None', 'Custom', 'Other', 'UNLICENSED', 'SEE LICENSE IN LICENSE', 'SEE LICENSE IN FILE']);
61
+ const COPYLEFT_PREFIX = /^(GPL|AGPL|SSPL|CPAL)/i;
62
+ const WEAK_COPYLEFT_PREFIX = /^(LGPL|EUPL|MPL)/i;
63
+ function licenseFilePaths(files) {
64
+ return files
65
+ .filter(file => /^(licen[cs]e|copying)(\.|$)/i.test(file.path.split('/').pop() ?? ''))
66
+ .map(file => file.path);
67
+ }
68
+ export function licenseCheck(inputs) {
69
+ const { files, manifest, github, npm, lang, config } = inputs;
70
+ const findings = [];
71
+ const licenseFiles = licenseFilePaths(files);
72
+ const declared = manifest.license !== '' ? manifest.license : (npm?.license ?? '');
73
+ const spdx = github?.licenseSpdx ?? null;
74
+ let score = 100;
75
+ const zh = lang === 'zh';
76
+ if (licenseFiles.length === 0 && declared === '' && (spdx === null || spdx === '')) {
77
+ findings.push({
78
+ level: 'fail',
79
+ message: zh ? '仓库没有任何许可证:既无 LICENSE 文件,也无 license 字段声明' : 'No license at all: no LICENSE file and no license field',
80
+ skill: SKILL_REF.license,
81
+ evidence: zh ? '无 LICENSE* 文件;package.json 无 license 字段;GitHub 未检测到许可证' : 'no LICENSE* file; no package.json license field; GitHub detected no license',
82
+ });
83
+ score = 0;
84
+ }
85
+ else {
86
+ if (licenseFiles.length === 0) {
87
+ findings.push({
88
+ level: 'warn',
89
+ message: zh ? '未发现 LICENSE 文件(license 字段存在,但仓库内无许可证文本)' : 'No LICENSE file found (a license field exists but no license text is committed)',
90
+ skill: SKILL_REF.license,
91
+ });
92
+ score -= 15;
93
+ }
94
+ if (declared !== '') {
95
+ const base = declared.split(/\s+(?:OR|AND|WITH)\s+/i)[0]?.trim() ?? declared;
96
+ if (UNKNOWN_LICENSE.has(declared) || UNKNOWN_LICENSE.has(base)) {
97
+ findings.push({
98
+ level: 'fail',
99
+ message: zh ? `license 字段为 "${redactSnippet(declared, 40)}":缺失/unknown/NOASSERTION 视为无有效许可` : `license field is "${redactSnippet(declared, 40)}": missing/unknown/NOASSERTION counts as no effective license`,
100
+ skill: SKILL_REF.license,
101
+ evidence: redactSnippet(declared, 80),
102
+ });
103
+ score = Math.min(score, 55);
104
+ }
105
+ else if (!KNOWN_SPDX.has(base) && !declared.includes('SEE LICENSE')) {
106
+ findings.push({
107
+ level: 'warn',
108
+ message: zh ? `license 字段 "${redactSnippet(declared, 40)}" 不是常见 SPDX 标识,需人工确认` : `license field "${redactSnippet(declared, 40)}" is not a common SPDX id — verify manually`,
109
+ skill: SKILL_REF.license,
110
+ evidence: redactSnippet(declared, 80),
111
+ });
112
+ score -= 10;
113
+ }
114
+ else if (COPYLEFT_PREFIX.test(base)) {
115
+ findings.push({
116
+ level: 'warn',
117
+ message: zh ? `强 copyleft 许可证 ${base}:确认用途与依赖链后再引入` : `strong copyleft license ${base}: confirm usage and dependency chain before adopting`,
118
+ skill: SKILL_REF.license,
119
+ evidence: redactSnippet(declared, 80),
120
+ });
121
+ score = Math.min(score, 70);
122
+ }
123
+ else if (WEAK_COPYLEFT_PREFIX.test(base)) {
124
+ findings.push({
125
+ level: 'info',
126
+ message: zh ? `弱 copyleft 许可证 ${base}:静态/动态链接场景建议人工确认` : `weak copyleft license ${base}: confirm linking scenario manually`,
127
+ skill: SKILL_REF.license,
128
+ });
129
+ score = Math.min(score, 85);
130
+ }
131
+ else {
132
+ findings.push({
133
+ level: 'info',
134
+ message: zh ? `许可证 ${base} 为常见 SPDX 标识` : `license ${base} is a common SPDX id`,
135
+ skill: SKILL_REF.license,
136
+ evidence: redactSnippet(declared, 80),
137
+ });
138
+ }
139
+ }
140
+ if (spdx !== null && spdx !== '' && declared === '') {
141
+ if (UNKNOWN_LICENSE.has(spdx)) {
142
+ findings.push({
143
+ level: 'fail',
144
+ message: zh ? `GitHub 检测的许可证为 "${spdx}"(无有效许可)` : `GitHub-detected license is "${spdx}" (no effective license)`,
145
+ skill: SKILL_REF.license,
146
+ });
147
+ score = Math.min(score, 55);
148
+ }
149
+ else if (COPYLEFT_PREFIX.test(spdx)) {
150
+ findings.push({
151
+ level: 'warn',
152
+ message: zh ? `GitHub 检测为强 copyleft 许可证 ${spdx}` : `GitHub detects strong copyleft license ${spdx}`,
153
+ skill: SKILL_REF.license,
154
+ });
155
+ score = Math.min(score, 70);
156
+ }
157
+ }
158
+ if (licenseFiles.length > 0 && score > 90) {
159
+ findings.push({
160
+ level: 'info',
161
+ message: zh ? `发现许可证文件:${licenseFiles.join(', ')}` : `license file present: ${licenseFiles.join(', ')}`,
162
+ skill: SKILL_REF.license,
163
+ });
164
+ }
165
+ }
166
+ return makeCheck('license', score, findings, lang, config);
167
+ }
168
+ // --- sbom --------------------------------------------------------------------
169
+ export function sbomCheck(inputs) {
170
+ const { manifest, lock, config, lang } = inputs;
171
+ const zh = lang === 'zh';
172
+ const findings = [];
173
+ let score = 100;
174
+ const direct = Object.keys(manifest.dependencies).length;
175
+ const dev = Object.keys(manifest.devDependencies).length;
176
+ const unpinned = unpinnedSpecs(manifest);
177
+ if (!manifest.present) {
178
+ return {
179
+ check: makeCheck('sbom', 0, [], lang, config, zh ? '无 package.json,无法生成依赖树' : 'no package.json, cannot build a dependency tree'),
180
+ sbom: { lockfile: null, directDependencies: 0, directDevDependencies: 0, packages: [], truncated: false, totalPackages: 0, unpinned: [] },
181
+ };
182
+ }
183
+ if (lock.lockfile === null) {
184
+ if (direct + dev > 20) {
185
+ findings.push({
186
+ level: 'fail',
187
+ message: zh ? `无锁文件且直接依赖 ${direct + dev} 个(> 20):不可复现安装` : `no lockfile with ${direct + dev} direct dependencies (> 20): unreproducible install`,
188
+ skill: SKILL_REF.sbom,
189
+ });
190
+ score = Math.min(score, 45);
191
+ }
192
+ else {
193
+ findings.push({
194
+ level: 'warn',
195
+ message: zh ? '仓库未提交锁文件:依赖解析不可复现' : 'no committed lockfile: dependency resolution is not reproducible',
196
+ skill: SKILL_REF.sbom,
197
+ });
198
+ score = Math.min(score, 70);
199
+ }
200
+ }
201
+ else if (lock.kind === 'pnpm' && !lock.hasIntegrity) {
202
+ findings.push({
203
+ level: 'warn',
204
+ message: zh ? 'pnpm 锁文件缺少 integrity 字段:可能被手改或损坏' : 'pnpm lockfile lacks integrity fields: possibly hand-edited or corrupt',
205
+ skill: SKILL_REF.sbom,
206
+ });
207
+ score -= 15;
208
+ }
209
+ if (unpinned.length > 0) {
210
+ findings.push({
211
+ level: 'warn',
212
+ message: zh ? `${unpinned.length} 个直接依赖未锁定精确版本:${unpinned.slice(0, 5).join(', ')}${unpinned.length > 5 ? '…' : ''}` : `${unpinned.length} direct dependencies are not pinned to exact versions: ${unpinned.slice(0, 5).join(', ')}${unpinned.length > 5 ? '…' : ''}`,
213
+ skill: SKILL_REF.sbom,
214
+ });
215
+ score -= 10;
216
+ }
217
+ const tree = buildDependencyTree(manifest, lock, config.maxDepNodes);
218
+ const sbom = {
219
+ lockfile: lock.lockfile,
220
+ lockfileVersion: lock.lockfileVersion || undefined,
221
+ directDependencies: direct,
222
+ directDevDependencies: dev,
223
+ packages: tree.packages,
224
+ truncated: tree.truncated,
225
+ totalPackages: tree.total,
226
+ unpinned,
227
+ };
228
+ if (tree.truncated) {
229
+ findings.push({
230
+ level: 'info',
231
+ message: zh ? `依赖树超过 ${config.maxDepNodes} 节点上限被截断` : `dependency tree truncated at the ${config.maxDepNodes}-node cap`,
232
+ skill: SKILL_REF.sbom,
233
+ });
234
+ }
235
+ findings.push({
236
+ level: 'info',
237
+ message: zh ? `依赖树:${tree.packages.length} 个唯一包(直接 ${direct} + dev ${dev})${lock.lockfile !== null ? `,锁文件 ${lock.lockfile}${lock.lockfileVersion !== '' ? ` v${lock.lockfileVersion}` : ''}` : ''}` : `dependency tree: ${tree.packages.length} unique packages (direct ${direct} + dev ${dev})${lock.lockfile !== null ? `, lockfile ${lock.lockfile}${lock.lockfileVersion !== '' ? ` v${lock.lockfileVersion}` : ''}` : ''}`,
238
+ skill: SKILL_REF.sbom,
239
+ });
240
+ return { check: makeCheck('sbom', score, findings, lang, config), sbom };
241
+ }
242
+ /** Scan the collected files for git/action refs that must be 40-hex commits. */
243
+ function collectRefHits(files, manifest) {
244
+ const hits = [];
245
+ for (const file of files) {
246
+ if (file.text === null)
247
+ continue;
248
+ const lines = file.text.split('\n');
249
+ const isWorkflow = /^\.github\/workflows\/.+\.ya?ml$/.test(file.path);
250
+ const isPatch = /(cordis.*\.(yml|yaml)|\.patch\.ya?ml)$/.test(file.path);
251
+ const isInstallScript = /(^|\/)install\.(ps1|sh|psm1|mjs|js)$/.test(file.path) || /^scripts\/install/.test(file.path);
252
+ const isDoc = /\.md$/i.test(file.path);
253
+ lines.forEach((line, index) => {
254
+ if (isWorkflow) {
255
+ for (const match of line.matchAll(/uses:\s*([^\s@/]+\/[^\s@]+)@([^\s#]+)/g)) {
256
+ if (!isCommitRef(match[2])) {
257
+ hits.push({ path: `${file.path}:${index + 1}`, ref: match[2], kind: 'workflow' });
258
+ }
259
+ }
260
+ }
261
+ if (isPatch || isInstallScript || isDoc) {
262
+ for (const match of line.matchAll(/(?:github:[\w.-]+\/[\w.-]+|git\+https?:\/\/[^\s"'#]+\.git|https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\.git)@([^\s"'|\\]+)/g)) {
263
+ if (!isCommitRef(match[1])) {
264
+ hits.push({ path: `${file.path}:${index + 1}`, ref: match[1], kind: isPatch ? 'patch-row' : isInstallScript ? 'install-script' : 'install-doc' });
265
+ }
266
+ }
267
+ }
268
+ });
269
+ }
270
+ for (const [name, spec] of [...Object.entries(manifest.dependencies), ...Object.entries(manifest.devDependencies), ...Object.entries(manifest.optionalDependencies)]) {
271
+ const match = /(?:git\+https?:\/\/[^\s#]+|github:[^\s#]+)#([^\s"']+)/.exec(spec);
272
+ if (match !== null && !isCommitRef(match[1])) {
273
+ hits.push({ path: `package.json (${name})`, ref: match[1], kind: 'manifest-dep' });
274
+ }
275
+ }
276
+ return hits;
277
+ }
278
+ export function commitLockCheck(inputs) {
279
+ const { files, manifest, github, npm, target, localHead, lang, config } = inputs;
280
+ const zh = lang === 'zh';
281
+ const findings = [];
282
+ let score = 100;
283
+ const hits = collectRefHits(files, manifest);
284
+ const kindLabel = (kind) => {
285
+ switch (kind) {
286
+ case 'workflow': return zh ? 'workflow action' : 'workflow action';
287
+ case 'manifest-dep': return zh ? 'git 依赖' : 'git dependency';
288
+ case 'patch-row': return zh ? '挂载清单引用' : 'mount-manifest reference';
289
+ case 'install-doc': return zh ? '安装文档引用' : 'install-doc reference';
290
+ case 'install-script': return zh ? '安装脚本引用' : 'install-script reference';
291
+ }
292
+ };
293
+ for (const hit of hits) {
294
+ const level = hit.kind === 'manifest-dep' || hit.kind === 'patch-row' ? 'fail' : 'warn';
295
+ findings.push({
296
+ level,
297
+ message: zh
298
+ ? `${kindLabel(hit.kind)} "${hit.ref}" 未锁定 40 位 commit(tag/分支可被移动)`
299
+ : `${kindLabel(hit.kind)} "${hit.ref}" is not a pinned 40-hex commit (tags/branches are mutable)`,
300
+ location: hit.path,
301
+ skill: SKILL_REF['commit-lock'],
302
+ });
303
+ score -= level === 'fail' ? 25 : 10;
304
+ }
305
+ if (npm !== null && npm.exists) {
306
+ if (npm.gitHead !== '' && isCommitRef(npm.gitHead)) {
307
+ findings.push({
308
+ level: 'info',
309
+ message: zh ? `npm 包带 gitHead 40 位 commit:${npm.gitHead}` : `npm package carries a 40-hex gitHead: ${npm.gitHead}`,
310
+ skill: SKILL_REF['commit-lock'],
311
+ });
312
+ }
313
+ else {
314
+ findings.push({
315
+ level: 'warn',
316
+ message: zh ? 'npm 包缺少 gitHead(registry 未记录发布 commit),无法核对构建来源' : 'npm package lacks gitHead (registry does not record the publish commit); build origin cannot be verified',
317
+ skill: SKILL_REF['commit-lock'],
318
+ });
319
+ score -= 10;
320
+ }
321
+ }
322
+ if (target.kind === 'local-path' && localHead !== '') {
323
+ findings.push({
324
+ level: 'info',
325
+ message: zh ? `本地目标 HEAD 已锁定 40 位 commit:${localHead}` : `local target HEAD is a 40-hex commit: ${localHead}`,
326
+ skill: SKILL_REF['commit-lock'],
327
+ });
328
+ }
329
+ if (target.kind === 'github-repo' && !isCommitRef(target.ref) && github?.exists) {
330
+ findings.push({
331
+ level: 'info',
332
+ message: zh ? `本次扫描按 "${target.ref}"(非 commit)获取;安装时请用 40 位 commit 锁定(DSH git 安装会执行 prepare 脚本)` : `this scan fetched "${target.ref}" (not a commit); pin the install to a 40-hex commit (DSH git installs run prepare scripts)`,
333
+ skill: SKILL_REF['commit-lock'],
334
+ });
335
+ }
336
+ return makeCheck('commit-lock', score, findings, lang, config);
337
+ }
338
+ // --- install scripts ------------------------------------------------------------
339
+ const DOWNLOAD = /(curl|wget|iwr\b|Invoke-WebRequest|Invoke-RestMethod|bitsadmin|certutil|Start-BitsTransfer)\b/i;
340
+ const NETWORK_CALL = /(curl|wget|\bfetch\s*\(|https?\.(?:get|request)\s*\(|Invoke-WebRequest|Invoke-RestMethod|axios|\bgot\s*\(|undici|prebuild-install|node-pre-gyp|node-gyp-build)/i;
341
+ const PREBUILD_FAMILY = /(prebuild-install|node-pre-gyp|node-gyp-build)/i;
342
+ const EXEC = /(eval\b|\bexec\b|\bexecSync\b|\bsh\b|\bbash\b|\bnode\b|powershell|python3?|\bperl\b|\bruby\b|\bcmd\b|\bchmod\b|Invoke-Expression|\biex\b|spawn\s*\(|spawnSync|execFile|child_process)/i;
343
+ const ENCODED = /(base64\s+(-d|--decode|-D)\b|FromBase64String|\[Convert\]::FromBase64String|atob\(|Buffer\.from\([^)]*base64)/i;
344
+ const CRED_TOUCH = /(\.ssh\b|id_rsa|id_ed25519|\.npmrc|\.gitconfig|\.aws\b|credentials|known_hosts)/i;
345
+ const GLOBAL_WRITE = /(\/etc\/profile|\.zshrc|\.bashrc|\.bash_profile|\.profile\b|%APPDATA%|HKCU\\Software|HKEY_CURRENT_USER)/i;
346
+ const OUTPUT_DOWNLOAD = /(-o\s+|--output\s+|-O\s+|OutFile\s+)/i;
347
+ const LIFECYCLE_SCRIPTS = ['preinstall', 'install', 'postinstall', 'prepare'];
348
+ /** Build tools whose download/exec install scripts are ecosystem convention (manual verdict per skill). */
349
+ const KNOWN_BUILD_TOOLS = new Set(['esbuild', '@esbuild/win32-x64', 'sharp', 'core-js', 'node-gyp', 'puppeteer', 'cypress', 'playwright', 'electron', 'swc', '@swc/core']);
350
+ export function installScriptsCheck(inputs) {
351
+ const { files, manifest, npm, config, lang } = inputs;
352
+ const zh = lang === 'zh';
353
+ const findings = [];
354
+ let score = 100;
355
+ const scripts = manifest.present ? manifest.scripts : (npm?.scripts ?? {});
356
+ const knownBuildTool = KNOWN_BUILD_TOOLS.has(manifest.name) || (npm !== null && KNOWN_BUILD_TOOLS.has(npm.name));
357
+ let checked = 0;
358
+ for (const hook of LIFECYCLE_SCRIPTS) {
359
+ const body = scripts[hook];
360
+ if (body === undefined || body === '')
361
+ continue;
362
+ checked += 1;
363
+ const location = `${manifest.present ? 'package.json' : 'npm registry'} scripts.${hook}`;
364
+ const hasDownload = DOWNLOAD.test(body);
365
+ const hasExec = EXEC.test(body);
366
+ const hasEncoded = ENCODED.test(body);
367
+ const hasCred = CRED_TOUCH.test(body);
368
+ const hasGlobal = GLOBAL_WRITE.test(body);
369
+ const hasOutput = OUTPUT_DOWNLOAD.test(body);
370
+ const plainHttp = /http:\/\/(?!localhost|127\.0\.0\.1)/i.test(body);
371
+ let level = null;
372
+ let reason = '';
373
+ let evidence = redactSnippet(body);
374
+ let findingLocation = location;
375
+ if (hasEncoded && hasExec) {
376
+ level = 'fail';
377
+ reason = zh ? '混淆载荷(base64/hex 解码后执行)' : 'obfuscated payload (decoded then executed)';
378
+ }
379
+ else if (hasCred || hasGlobal) {
380
+ level = 'fail';
381
+ reason = zh ? '触碰用户凭据或全局配置' : 'touches user credentials or global config';
382
+ }
383
+ else if (hasDownload && hasExec) {
384
+ level = knownBuildTool ? 'warn' : 'fail';
385
+ reason = zh ? '下载可执行内容并执行' : 'downloads executable content and runs it';
386
+ if (knownBuildTool) {
387
+ reason += zh ? '(生态惯例的构建工具安装脚本——按 supply-chain-review §1 放行判据人工确认)' : ' (build-tool install script, an ecosystem convention — confirm with the supply-chain-review §1 allowlist criteria)';
388
+ }
389
+ }
390
+ else if (hasDownload && hasOutput) {
391
+ level = 'warn';
392
+ reason = zh ? '安装期下载二进制(未见执行,需确认用途)' : 'downloads a binary at install time (no exec seen; confirm purpose)';
393
+ }
394
+ else if (PREBUILD_FAMILY.test(body)) {
395
+ level = 'warn';
396
+ reason = zh ? '安装期下载/构建原生二进制(prebuild 生态惯例——按 supply-chain-review §1 放行判据人工确认)' : 'downloads/builds a native binary at install time (prebuild ecosystem convention — confirm with the supply-chain-review §1 allowlist criteria)';
397
+ }
398
+ else if (plainHttp) {
399
+ level = 'warn';
400
+ reason = zh ? '安装脚本使用明文 HTTP 下载' : 'install script downloads over plain HTTP';
401
+ }
402
+ else {
403
+ // Follow `node install.mjs`-style scripts into the referenced file and
404
+ // scan its real content (mirrors supply-chain-review §1: unpack and
405
+ // read the actual script, never trust the manifest alone).
406
+ const referenced = /(?:^|\s)(?:node|npm exec|npx)\s+['"]?([^\s"'`]+\.(?:mjs|js|cjs))['"]?/.exec(body);
407
+ if (referenced !== null) {
408
+ const scriptFile = files.find(file => file.path.endsWith(referenced[1]) || file.path === referenced[1]);
409
+ if (scriptFile !== undefined && scriptFile.text !== null && scriptFile.skipped === null) {
410
+ const fileHasNetwork = NETWORK_CALL.test(scriptFile.text);
411
+ const fileHasExec = EXEC.test(scriptFile.text);
412
+ const filePrebuildOnly = PREBUILD_FAMILY.test(scriptFile.text) && !/(curl|wget|\bfetch\s*\(|https?\.(?:get|request)\s*\(|axios|\bgot\s*\(|undici)/i.test(scriptFile.text);
413
+ if (fileHasNetwork && fileHasExec) {
414
+ level = (knownBuildTool || filePrebuildOnly) ? 'warn' : 'fail';
415
+ reason = zh ? `被调用的 ${referenced[1]} 下载内容并执行` : `the invoked ${referenced[1]} downloads content and executes it`;
416
+ if (knownBuildTool || filePrebuildOnly) {
417
+ reason += zh ? '(生态惯例的构建工具安装脚本——按 supply-chain-review §1 放行判据人工确认)' : ' (build-tool install script, an ecosystem convention — confirm with the supply-chain-review §1 allowlist criteria)';
418
+ }
419
+ }
420
+ else if (fileHasNetwork) {
421
+ level = 'warn';
422
+ reason = zh ? `被调用的 ${referenced[1]} 在安装期发起网络下载` : `the invoked ${referenced[1]} performs network downloads at install time`;
423
+ }
424
+ findingLocation = scriptFile.path;
425
+ evidence = redactSnippet(scriptFile.text);
426
+ }
427
+ }
428
+ }
429
+ if (level !== null) {
430
+ findings.push({ level, message: `${hook}: ${reason}`, location: findingLocation, skill: SKILL_REF['install-scripts'], evidence });
431
+ score -= level === 'fail' ? 30 : 15;
432
+ }
433
+ else {
434
+ findings.push({ level: 'info', message: zh ? `${hook} 脚本存在但未命中危险特征` : `${hook} script present, no dangerous pattern matched`, location, skill: SKILL_REF['install-scripts'] });
435
+ }
436
+ }
437
+ if (checked === 0) {
438
+ findings.push({ level: 'info', message: zh ? '无 preinstall/install/postinstall/prepare 生命周期脚本' : 'no preinstall/install/postinstall/prepare lifecycle scripts', skill: SKILL_REF['install-scripts'] });
439
+ }
440
+ return makeCheck('install-scripts', score, findings, lang, config);
441
+ }
442
+ // --- network exfiltration ---------------------------------------------------------
443
+ const EXFIL_DOMAINS = [
444
+ 'webhook.site', 'requestbin.net', 'requestcatcher.com', 'beeceptor.com', 'ngrok.io', 'ngrok-free.app',
445
+ 'localhost.run', 'oast.fun', 'oastify.com', 'oast.pro', 'interact.sh', 'burpcollaborator.net',
446
+ 'pipedream.net', 'pastebin.com', 'transfer.sh', 'file.io', '0x0.st', 'rentry.co', 'canarytokens.com',
447
+ 'discord.com/api/webhooks', 'api.telegram.org/bot',
448
+ ];
449
+ const CHEAP_TLD = /\.(tk|ml|ga|cf|gq|top|xyz|icu|rest|quest)$/i;
450
+ const SCRIPT_EXTS = /\.(js|mjs|cjs|ts|mts|cts|jsx|tsx|sh|bash|ps1|psm1|py|rb|pl|lua)$/i;
451
+ function collectUrlHits(files, maxFiles) {
452
+ const hits = [];
453
+ let scanned = 0;
454
+ const cap = maxFiles * 4;
455
+ for (const file of files) {
456
+ if (file.text === null || file.skipped !== null)
457
+ continue;
458
+ // package.json holds lifecycle-script bodies: executable context, treated
459
+ // like a script file for exfiltration ranking.
460
+ const isScript = SCRIPT_EXTS.test(file.path) || file.path === 'package.json';
461
+ const isManifest = /\.(json|yml|yaml|toml)$/.test(file.path);
462
+ if (!isScript && !isManifest)
463
+ continue;
464
+ scanned += 1;
465
+ const lines = file.text.split('\n');
466
+ for (let index = 0; index < lines.length; index += 1) {
467
+ for (const match of lines[index].matchAll(/https?:\/\/[^\s"'`<>)\]]+/g)) {
468
+ const raw = match[0];
469
+ let host = '';
470
+ try {
471
+ host = new URL(raw).host;
472
+ }
473
+ catch {
474
+ continue;
475
+ }
476
+ hits.push({ host, url: raw, path: `${file.path}:${index + 1}`, isScript });
477
+ if (hits.length >= cap)
478
+ return { hits, scanned };
479
+ }
480
+ }
481
+ }
482
+ return { hits, scanned };
483
+ }
484
+ export function networkExfilCheck(inputs) {
485
+ const { files, config, lang } = inputs;
486
+ const zh = lang === 'zh';
487
+ const findings = [];
488
+ let score = 100;
489
+ const { hits, scanned } = collectUrlHits(files, config.maxFiles);
490
+ const seenDomains = new Set();
491
+ for (const hit of hits) {
492
+ if (seenDomains.has(hit.host))
493
+ continue;
494
+ let level = null;
495
+ let reason = '';
496
+ if (EXFIL_DOMAINS.some(domain => hit.host === domain || hit.host.endsWith(`.${domain}`))) {
497
+ level = hit.isScript ? 'fail' : 'warn';
498
+ reason = zh ? '回传/接收器域名(数据外发特征)' : 'exfil/receiver domain (data-exfiltration indicator)';
499
+ }
500
+ else if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hit.host) || hit.host.includes(':')) {
501
+ level = hit.isScript ? 'warn' : 'info';
502
+ reason = zh ? '直连 IP 地址的 URL' : 'URL pointing at a raw IP address';
503
+ }
504
+ else if (CHEAP_TLD.test(hit.host)) {
505
+ level = hit.isScript ? 'warn' : 'info';
506
+ reason = zh ? '可疑免费 TLD 域名' : 'suspicious free-TLD domain';
507
+ }
508
+ else {
509
+ continue;
510
+ }
511
+ seenDomains.add(hit.host);
512
+ findings.push({
513
+ level,
514
+ message: zh ? `${reason}:${hit.host}` : `${reason}: ${hit.host}`,
515
+ location: hit.path,
516
+ skill: SKILL_REF['network-exfil'],
517
+ evidence: redactSnippet(hit.url, 140),
518
+ });
519
+ score -= level === 'fail' ? 40 : level === 'warn' ? 15 : 5;
520
+ }
521
+ if (findings.length === 0) {
522
+ findings.push({
523
+ level: 'info',
524
+ message: zh ? `扫描 ${scanned} 个脚本/清单文件,未发现回传域名特征` : `scanned ${scanned} script/manifest files, no exfiltration-domain indicators`,
525
+ skill: SKILL_REF['network-exfil'],
526
+ });
527
+ }
528
+ return makeCheck('network-exfil', score, findings, lang, config);
529
+ }
530
+ // --- obfuscation -------------------------------------------------------------------
531
+ const BASE64_BLOB = /(?:atob|FromBase64String|\[Convert\]::FromBase64String|Buffer\.from)\s*\(\s*['"][A-Za-z0-9+/]{40,}={0,2}['"]|[A-Za-z0-9+/]{100,}={0,2}/g;
532
+ const HEX_BLOB = /0x[0-9a-fA-F]{32,}|(?:\\x[0-9a-fA-F]{2}){20,}/g;
533
+ const FROM_CHAR_CODE = /String\.fromCharCode\([^)]*(?:,[^)]*){8,}\)/g;
534
+ const EVAL_CALL = /\beval\s*\(|(?:new\s+)?Function\s*\(/g;
535
+ const TEST_PATH = /(test|spec|fixture|__tests__)/i;
536
+ export function obfuscationCheck(inputs) {
537
+ const { files, config, lang } = inputs;
538
+ const zh = lang === 'zh';
539
+ const findings = [];
540
+ let score = 100;
541
+ let scanned = 0;
542
+ for (const file of files) {
543
+ if (file.text === null || file.skipped !== null || !SCRIPT_EXTS.test(file.path))
544
+ continue;
545
+ scanned += 1;
546
+ const isTest = TEST_PATH.test(file.path);
547
+ const text = file.text;
548
+ const evalHits = [...text.matchAll(EVAL_CALL)].length;
549
+ const base64Hits = [...text.matchAll(BASE64_BLOB)].length;
550
+ const hexHits = [...text.matchAll(HEX_BLOB)].length;
551
+ const charCodeHits = [...text.matchAll(FROM_CHAR_CODE)].length;
552
+ const minified = text.split('\n').filter(line => line.length > 600).length;
553
+ if (evalHits > 0 && (base64Hits > 0 || hexHits > 0)) {
554
+ findings.push({
555
+ level: isTest ? 'warn' : 'fail',
556
+ message: zh ? `动态求值 + 编码载荷(eval/Function ${evalHits} 处,编码块 ${base64Hits + hexHits} 处)` : `dynamic eval + encoded payload (eval/Function ×${evalHits}, encoded blobs ×${base64Hits + hexHits})`,
557
+ location: file.path,
558
+ skill: SKILL_REF.obfuscation,
559
+ });
560
+ score -= isTest ? 20 : 35;
561
+ }
562
+ else if (base64Hits > 0 || hexHits > 0 || charCodeHits > 0) {
563
+ findings.push({
564
+ level: isTest ? 'info' : 'warn',
565
+ message: zh ? `编码载荷特征(base64/hex/fromCharCode 块 ${base64Hits + hexHits + charCodeHits} 处)` : `encoded-payload indicators (base64/hex/fromCharCode blobs ×${base64Hits + hexHits + charCodeHits})`,
566
+ location: file.path,
567
+ skill: SKILL_REF.obfuscation,
568
+ });
569
+ score -= isTest ? 5 : 15;
570
+ }
571
+ else if (minified > 0) {
572
+ findings.push({
573
+ level: 'info',
574
+ message: zh ? `疑似压缩/混淆代码(${minified} 个超长高密度行)` : `possibly minified/obfuscated code (${minified} very long dense lines)`,
575
+ location: file.path,
576
+ skill: SKILL_REF.obfuscation,
577
+ });
578
+ score -= 5;
579
+ }
580
+ if (findings.length >= config.maxFindingsPerCheck * 2)
581
+ break;
582
+ }
583
+ if (findings.length === 0) {
584
+ findings.push({
585
+ level: 'info',
586
+ message: zh ? `扫描 ${scanned} 个代码文件,未发现混淆特征` : `scanned ${scanned} code files, no obfuscation indicators`,
587
+ skill: SKILL_REF.obfuscation,
588
+ });
589
+ }
590
+ return makeCheck('obfuscation', score, findings, lang, config);
591
+ }
592
+ // --- source trust signals -------------------------------------------------------------
593
+ export function sourceCheck(inputs) {
594
+ const { files, manifest, github, npm, target, lang, config } = inputs;
595
+ const zh = lang === 'zh';
596
+ const findings = [];
597
+ let score = 100;
598
+ const has = (re) => files.some(file => re.test(file.path));
599
+ if (manifest.present && manifest.repository !== '') {
600
+ const repoPath = manifest.repository.replace(/^git\+/, '').replace(/\.git$/, '').replace(/^https?:\/\/github\.com\//, '').replace(/^ssh:\/\/git@github\.com\//, '');
601
+ if (target.kind === 'github-repo' && repoPath !== '' && repoPath.toLowerCase() !== target.resolved.toLowerCase()) {
602
+ findings.push({
603
+ level: 'warn',
604
+ message: zh ? `package.json repository(${repoPath})与扫描目标(${target.resolved})不一致` : `package.json repository (${repoPath}) does not match the scan target (${target.resolved})`,
605
+ skill: SKILL_REF.source,
606
+ });
607
+ score -= 15;
608
+ }
609
+ }
610
+ else if (manifest.present) {
611
+ findings.push({
612
+ level: 'fail',
613
+ message: zh ? 'package.json 未声明 repository 字段:无法核对发布来源' : 'package.json declares no repository: publish origin cannot be verified',
614
+ skill: SKILL_REF.source,
615
+ });
616
+ score -= 25;
617
+ }
618
+ if (!has(/^readme(\.|$)/i)) {
619
+ findings.push({ level: 'warn', message: zh ? '无 README 文件' : 'no README file', skill: SKILL_REF.source });
620
+ score -= 10;
621
+ }
622
+ if (!has(/^\.github\/workflows\/.+\.ya?ml$/)) {
623
+ findings.push({ level: 'warn', message: zh ? '无 CI 工作流(缺少自动化构建/测试证据)' : 'no CI workflows (no automated build/test evidence)', skill: SKILL_REF.source });
624
+ score -= 10;
625
+ }
626
+ if (manifest.present && has(/(cordis\.patch\.yml|cordis\.yml|\.patch\.ya?ml)$/)) {
627
+ findings.push({ level: 'info', message: zh ? '含 DSH 挂载清单(cordis patch)' : 'carries a DSH mount manifest (cordis patch)', skill: SKILL_REF.source });
628
+ }
629
+ if (npm !== null && npm.exists) {
630
+ if (npm.gitHead !== '' && isCommitRef(npm.gitHead)) {
631
+ findings.push({ level: 'info', message: zh ? `发布 commit:${npm.gitHead}` : `publish commit: ${npm.gitHead}`, skill: SKILL_REF.source });
632
+ }
633
+ else {
634
+ findings.push({ level: 'warn', message: zh ? 'npm 包未记录 gitHead(registry 侧无发布 commit 证据)' : 'npm package records no gitHead (no publish-commit evidence on the registry)', skill: SKILL_REF.source });
635
+ score -= 10;
636
+ }
637
+ if (npm.distIntegrity === '') {
638
+ findings.push({ level: 'warn', message: zh ? 'npm 包缺少 dist.integrity' : 'npm package lacks dist.integrity', skill: SKILL_REF.source });
639
+ score -= 10;
640
+ }
641
+ }
642
+ if (findings.length === 0) {
643
+ findings.push({ level: 'info', message: zh ? '来源信号齐全(repository/README/CI 可核对)' : 'source signals complete (repository/README/CI verifiable)', skill: SKILL_REF.source });
644
+ }
645
+ return makeCheck('source', score, findings, lang, config);
646
+ }
647
+ // --- maintenance ----------------------------------------------------------------------
648
+ const DAY = 24 * 60 * 60 * 1000;
649
+ function ageDays(iso, now) {
650
+ if (iso === '')
651
+ return null;
652
+ const time = Date.parse(iso);
653
+ if (Number.isNaN(time))
654
+ return null;
655
+ return Math.floor((now - time) / DAY);
656
+ }
657
+ export function maintenanceCheck(inputs) {
658
+ const { github, npm, target, lang, config, now } = inputs;
659
+ const zh = lang === 'zh';
660
+ const findings = [];
661
+ let score = 60;
662
+ if (github !== null && github.exists) {
663
+ const pushed = ageDays(github.pushedAt, now);
664
+ const created = ageDays(github.createdAt, now);
665
+ if (github.rateLimited) {
666
+ findings.push({ level: 'warn', message: zh ? 'GitHub API 限流:pushed_at/archived 等维护数据不可用(文件扫描不受影响),维护状态按未知计' : 'GitHub API rate-limited: pushed_at/archived maintenance data unavailable (file scan unaffected); maintenance treated as unknown', skill: SKILL_REF.maintenance });
667
+ score = 60;
668
+ }
669
+ else if (github.archived) {
670
+ findings.push({ level: 'fail', message: zh ? '仓库已被归档(archived):不再维护' : 'repository is archived: unmaintained', skill: SKILL_REF.maintenance });
671
+ score = 5;
672
+ }
673
+ else if (pushed === null) {
674
+ score = 60;
675
+ findings.push({ level: 'warn', message: zh ? '无 pushed_at 数据,维护状态未知' : 'no pushed_at data; maintenance status unknown', skill: SKILL_REF.maintenance });
676
+ }
677
+ else if (pushed > 730) {
678
+ findings.push({ level: 'fail', message: zh ? `最后推送距今 ${pushed} 天(> 2 年):基本停止维护` : `last push ${pushed} days ago (> 2 years): effectively unmaintained`, skill: SKILL_REF.maintenance });
679
+ score = 15;
680
+ }
681
+ else if (pushed > 365) {
682
+ findings.push({ level: 'warn', message: zh ? `最后推送距今 ${pushed} 天(> 1 年)` : `last push ${pushed} days ago (> 1 year)`, skill: SKILL_REF.maintenance });
683
+ score = 40;
684
+ }
685
+ else if (pushed > 180) {
686
+ findings.push({ level: 'info', message: zh ? `最后推送距今 ${pushed} 天(> 半年)` : `last push ${pushed} days ago (> 6 months)`, skill: SKILL_REF.maintenance });
687
+ score = 70;
688
+ }
689
+ else if (pushed > 90) {
690
+ findings.push({ level: 'info', message: zh ? `最后推送距今 ${pushed} 天` : `last push ${pushed} days ago`, skill: SKILL_REF.maintenance });
691
+ score = 80;
692
+ }
693
+ else {
694
+ findings.push({ level: 'info', message: zh ? `最近 ${pushed} 天内有推送,维护活跃` : `pushed within the last ${pushed} days: actively maintained`, skill: SKILL_REF.maintenance });
695
+ score = 92;
696
+ }
697
+ if (created !== null && created < 30 && github.stars > 50) {
698
+ findings.push({
699
+ level: 'warn',
700
+ message: zh ? `仓库创建不足 30 天但已有 ${github.stars} stars:结合 supply-chain-review §2 做 typosquat/刷星判定` : `repository created < 30 days ago yet has ${github.stars} stars: run the supply-chain-review §2 typosquat/star-bomb check`,
701
+ skill: 'supply-chain-review §2',
702
+ });
703
+ score -= 10;
704
+ }
705
+ }
706
+ else if (npm !== null && npm.exists) {
707
+ if (npm.deprecated !== '') {
708
+ findings.push({ level: 'fail', message: zh ? 'npm 包已标记 deprecated' : 'npm package is deprecated', skill: SKILL_REF.maintenance });
709
+ score = 10;
710
+ }
711
+ else {
712
+ findings.push({ level: 'info', message: zh ? 'npm 包未标记 deprecated;修改时间未拉取(维护状态按未知计)' : 'npm package is not deprecated; modified time was not fetched (maintenance treated as unknown)', skill: SKILL_REF.maintenance });
713
+ score = 60;
714
+ }
715
+ }
716
+ else {
717
+ findings.push({ level: 'info', message: zh ? `本地目标(${target.resolved})无远程维护信息,按未知计` : `local target (${target.resolved}) has no remote maintenance metadata; treated as unknown`, skill: SKILL_REF.maintenance });
718
+ score = 60;
719
+ }
720
+ return makeCheck('maintenance', score, findings, lang, config);
721
+ }
722
+ // --- runner ----------------------------------------------------------------------------
723
+ /** Run the requested checks over shared inputs. */
724
+ export function runChecks(inputs, ids) {
725
+ const results = [];
726
+ for (const id of ids) {
727
+ switch (id) {
728
+ case 'license':
729
+ results.push({ check: licenseCheck(inputs) });
730
+ break;
731
+ case 'sbom':
732
+ results.push(sbomCheck(inputs));
733
+ break;
734
+ case 'commit-lock':
735
+ results.push({ check: commitLockCheck(inputs) });
736
+ break;
737
+ case 'install-scripts':
738
+ results.push({ check: installScriptsCheck(inputs) });
739
+ break;
740
+ case 'network-exfil':
741
+ results.push({ check: networkExfilCheck(inputs) });
742
+ break;
743
+ case 'obfuscation':
744
+ results.push({ check: obfuscationCheck(inputs) });
745
+ break;
746
+ case 'source':
747
+ results.push({ check: sourceCheck(inputs) });
748
+ break;
749
+ case 'maintenance':
750
+ results.push({ check: maintenanceCheck(inputs) });
751
+ break;
752
+ }
753
+ }
754
+ return results;
755
+ }