@dependfix/engine 0.1.3 → 0.3.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/dist/index.mjs CHANGED
@@ -1,12 +1,14 @@
1
1
  import { Octokit } from "@octokit/rest";
2
2
  import { RequestError } from "@octokit/request-error";
3
3
  import { AppError, collectCodeScanningSuggestions, createEmptyRunSummary, createLogger, filterAlerts, generateJsonReport, generateMarkdownReport, isAlertFixedByActions, isValidRepoIdentifier, limitAlerts, mapCodeScanningSeverity, normalizeAuditSeverity, prioritizeAlerts, toErrorMessage, writeReport } from "@dependfix/core";
4
- import { execFileSync, execSync, spawn } from "node:child_process";
5
- import { createHash } from "node:crypto";
6
4
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { execFileSync, execSync, spawn, spawnSync } from "node:child_process";
6
+ import { createHash } from "node:crypto";
7
7
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
8
8
  import YAML, { parse } from "yaml";
9
9
  import PQueue from "p-queue";
10
+ import { createServer, request } from "node:http";
11
+ import { connect } from "node:net";
10
12
  import { z } from "zod";
11
13
  import { createInterface } from "node:readline";
12
14
 
@@ -119,10 +121,316 @@ function sleep(ms) {
119
121
  return new Promise((resolve) => setTimeout(resolve, ms));
120
122
  }
121
123
 
124
+ //#endregion
125
+ //#region src/github/errors.ts
126
+ /**
127
+ * 将 Octokit 请求错误映射为 `AppError`。
128
+ *
129
+ * @param error - 原始错误(`RequestError` 或网络异常)
130
+ * @param context - 调用上下文描述(如 `'fetch repo info for foo/bar'`),会附加到错误消息中
131
+ */
132
+ function mapGitHubError(error, context) {
133
+ if (error instanceof RequestError) {
134
+ const code = resolveErrorCode(error);
135
+ const details = collectErrorDetails(error);
136
+ return new AppError(code, `${context}: ${error.message}`, {
137
+ cause: error,
138
+ details
139
+ });
140
+ }
141
+ if (error instanceof Error) return new AppError("NETWORK_ERROR", `${context}: ${error.message}`, { cause: error });
142
+ return new AppError("NETWORK_ERROR", `${context}: unknown error`, { details: { raw: toErrorMessage(error) } });
143
+ }
144
+ function resolveErrorCode(error) {
145
+ switch (error.status) {
146
+ case 401: return "AUTHENTICATION_FAILED";
147
+ case 403: {
148
+ const remaining = error.response?.headers["x-ratelimit-remaining"];
149
+ if (remaining !== void 0 && remaining === "0") return "RATE_LIMITED";
150
+ return "PERMISSION_DENIED";
151
+ }
152
+ case 404: return "REPO_NOT_FOUND";
153
+ default: return "GITHUB_API_ERROR";
154
+ }
155
+ }
156
+ function collectErrorDetails(error) {
157
+ const details = {
158
+ status: error.status,
159
+ requestUrl: error.request?.url,
160
+ requestMethod: error.request?.method
161
+ };
162
+ const resetAt = error.response?.headers["x-ratelimit-reset"];
163
+ if (resetAt) details.rateLimitReset = resetAt;
164
+ return details;
165
+ }
166
+
167
+ //#endregion
168
+ //#region src/github/code-quality-fetcher.ts
169
+ /** cursor 分页最大页数保护(恶意 Link header 重复同 cursor 或 API 异常时终止循环) */
170
+ const MAX_CURSOR_PAGES = 1e3;
171
+ /**
172
+ * 拉取指定仓库的 Code Quality findings 并映射为标准化模型。
173
+ *
174
+ * - 自动分页(cursor-based:before/after 走 `Link` header,与 GitHub 通用分页一致)
175
+ * - 仅处理 `state=open` 的 finding(可通过 `params.state` 覆盖)
176
+ * - severity:`rule.severity`(error/warning/note)经 `mapCodeQualitySeverity` 映射
177
+ * - Code Quality findings **不可自动修复**(`fixable: false`、`fixStrategy: null`),
178
+ * 首版统一归 C 类(`alertClass: 'report-only'`),不实现模板化修复
179
+ * - 异常通过 `mapGitHubError` 转为 `AppError`
180
+ * - 空仓库返回 `[]`,不抛异常
181
+ *
182
+ * @param client - 已认证的 Octokit 实例(来自 `createGitHubClient`)
183
+ * @param params - 仓库标识与过滤参数
184
+ * @returns 标准化告警列表
185
+ */
186
+ async function fetchCodeQualityFindings(client, params) {
187
+ const { owner, repo, state = "open", perPage = 100 } = params;
188
+ try {
189
+ const findings = [];
190
+ let cursor;
191
+ let pages = 0;
192
+ const seenCursors = /* @__PURE__ */ new Set();
193
+ for (;;) {
194
+ pages += 1;
195
+ if (pages > MAX_CURSOR_PAGES) break;
196
+ if (cursor && seenCursors.has(cursor)) break;
197
+ if (cursor) seenCursors.add(cursor);
198
+ const response = await client.request("GET /repos/{owner}/{repo}/code-quality/findings", {
199
+ owner,
200
+ repo,
201
+ state,
202
+ per_page: perPage,
203
+ ...cursor ? { after: cursor } : {}
204
+ });
205
+ findings.push(...response.data);
206
+ cursor = parseNextCursor(response.headers.link);
207
+ if (!cursor) break;
208
+ }
209
+ return findings.map((f) => normalizeFinding(f, owner, repo));
210
+ } catch (error) {
211
+ const context = `fetch code quality findings for ${owner}/${repo}`;
212
+ throw mapGitHubError(error, context);
213
+ }
214
+ }
215
+ /**
216
+ * 将单条 Code Quality finding 转换为标准化模型。
217
+ *
218
+ * 映射逻辑:
219
+ * - severity:`rule.severity`(error/warning/note)经 `mapCodeQualitySeverity` 映射;
220
+ * 缺失 → `'unknown'`(不静默丢弃)
221
+ * - packageName 取规则人类可读名(`rule.title`,如 "Useless null check"),
222
+ * 报告 Package 列对 code-quality 展示规则名
223
+ * - ruleId 取 `rule.id`(如 `java/useless-null-check`),报告 Rule/Advisory 列展示
224
+ * - manifestPath 取告警文件路径(`location.path`)
225
+ * - htmlUrl:将 API URL(`https://api.github.com/...`)转换为 web URL
226
+ * (`https://github.com/owner/repo/code-quality/findings/N`)
227
+ * - fixable 恒为 `false`(Code Quality findings 不可自动修复)
228
+ * - alertClass 恒为 `'report-only'`(C 类默认,未列入 A/B)
229
+ */
230
+ function normalizeFinding(finding, owner, repo) {
231
+ const ruleId = finding.rule?.id ?? "";
232
+ const severity = mapCodeQualitySeverity(finding.rule?.severity);
233
+ const description = finding.rule?.description ?? "";
234
+ return {
235
+ id: finding.number,
236
+ source: "code-quality",
237
+ repository: `${owner}/${repo}`,
238
+ defaultBranch: "",
239
+ severity,
240
+ packageEcosystem: "code-quality",
241
+ packageName: finding.rule?.title ?? (ruleId || "unknown"),
242
+ manifestPath: finding.location?.path ?? "",
243
+ ruleId,
244
+ summary: finding.message?.text ?? description,
245
+ htmlUrl: toWebUrl(finding.url, owner, repo, finding.number),
246
+ fixable: false,
247
+ fixStrategy: null,
248
+ recommendedVersion: "",
249
+ alertClass: "report-only",
250
+ startLine: finding.location?.start_line ?? void 0,
251
+ endLine: finding.location?.end_line ?? void 0,
252
+ suggestion: description || "人工审查该 Code Quality finding"
253
+ };
254
+ }
255
+ /**
256
+ * 映射 Code Quality severity:
257
+ * - `error` → `high`(语义对齐 code-scanning 映射:error 表示需立即关注)
258
+ * - `warning` → `medium`
259
+ * - `note` → `low`
260
+ * - 缺失或非法值 → `'unknown'`(不静默丢弃)
261
+ *
262
+ * 与 code-scanning 共用 `error/warning/note` 值域(GitHub CodeQL 规则同源),
263
+ * 映射口径与 `mapCodeScanningSeverity` 对齐(避免新源不一致)。
264
+ */
265
+ function mapCodeQualitySeverity(severity) {
266
+ switch (severity) {
267
+ case "error": return "high";
268
+ case "warning": return "medium";
269
+ case "note": return "low";
270
+ default: return "unknown";
271
+ }
272
+ }
273
+ /**
274
+ * 将 GitHub API URL(`https://api.github.com/repos/{owner}/{repo}/code-quality/findings/{n}`)
275
+ * 转换为 web URL(`https://github.com/{owner}/{repo}/code-quality/findings/{n}`)。
276
+ *
277
+ * GitHub web URL 形态固定(不含 `/repos/` 段),直接基于 owner/repo/number 构造
278
+ * 比正则替换更稳健(避免 API 路径形态变化导致错误)。
279
+ */
280
+ function toWebUrl(apiUrl, owner, repo, number) {
281
+ return `https://github.com/${owner}/${repo}/code-quality/findings/${number}`;
282
+ }
283
+ /**
284
+ * 解析 GitHub Link header 提取 `rel="next"` 的 cursor(after=...)。
285
+ * 返回 cursor 字符串;无 next → 返回 undefined(终止分页)。
286
+ *
287
+ * GitHub Code Quality API 用 cursor-based 分页(before/after)而非 page-based,
288
+ * 与 listAlertsForRepo 的 page=1&page=2 不同;Link header 格式相同。
289
+ */
290
+ function parseNextCursor(linkHeader) {
291
+ if (!linkHeader) return;
292
+ const nextMatch = /<([^>]+)>;\s*rel="next"/.exec(linkHeader);
293
+ if (!nextMatch) return;
294
+ const nextUrl = nextMatch[1];
295
+ try {
296
+ return new URL(nextUrl).searchParams.get("after") ?? void 0;
297
+ } catch {
298
+ return;
299
+ }
300
+ }
301
+
302
+ //#endregion
303
+ //#region src/code-scanning/rule-config.ts
304
+ /** 默认规则配置(编译后;与既有常量表等价,单点声明保持向后兼容) */
305
+ const DEFAULT_RULES_CONFIG = {
306
+ autoFixable: /* @__PURE__ */ new Set(["eol-last"]),
307
+ suggested: /* @__PURE__ */ new Map([
308
+ ["no-unused-vars", "删除未使用的变量/导入;注意导出引用与副作用(import 副作用场景),确认后手动删除"],
309
+ ["js/sql-injection", "使用参数化查询(PreparedStatement / query builder),禁止字符串拼接 SQL"],
310
+ ["js/xss", "输出前 HTML 转义;innerHTML / document.write 场景用 DOMPurify 等清洗库"],
311
+ ["js/path-injection", "校验用户输入路径在预期目录内(path.resolve + 前缀校验),禁止直接拼接文件路径"],
312
+ ["js/command-line-injection", "避免 shell 字符串拼接,使用参数数组(child_process.spawn(args))"],
313
+ ["js/insecure-randomness", "安全随机数使用 crypto.randomBytes / Web Crypto,禁止 Math.random"],
314
+ ["js/weak-cryptographic-algorithm", "升级为强算法(AES-256-GCM / Argon2 / SHA-256+),安全场景禁用 MD5/SHA-1"],
315
+ ["js/missing-rate-limiting", "为认证/敏感接口添加限流(如 express-rate-limit)"],
316
+ ["js/clear-text-storage-of-sensitive-data", "敏感数据加密存储(AES-256-GCM),禁止明文落盘"],
317
+ ["js/clear-text-transmission-of-sensitive-data", "启用 TLS(HTTPS/WSS),禁止明文传输敏感数据"],
318
+ ["js/hardcoded-credentials", "凭证移入环境变量/密钥管理服务(GitHub Secrets / Vault),禁止硬编码"],
319
+ ["py/sql-injection", "使用参数化查询(sqlite3 占位符 / SQLAlchemy 参数绑定),禁止字符串拼接 SQL"],
320
+ ["py/path-injection", "校验并规范化用户输入路径(os.path.realpath + 目录前缀校验)"],
321
+ ["py/command-line-injection", "使用参数数组(subprocess.run(args)),禁止 shell=True 字符串拼接"],
322
+ ["py/insecure-default-file-permissions", "创建文件时显式设置权限(os.open mode / umask)"],
323
+ ["java/sql-injection", "使用 PreparedStatement 参数绑定,禁止字符串拼接 SQL"],
324
+ ["java/path-injection", "校验用户输入路径(Path.normalize() + startsWith 检查),禁止直接拼接"],
325
+ ["java/command-line-injection", "使用 ProcessBuilder 参数列表,禁止 shell 字符串拼接"]
326
+ ])
327
+ };
328
+ /**
329
+ * 解析 JSON 配置输入为编译后配置。
330
+ *
331
+ * 校验规则(全部失败均降级返回 null,由调用方走默认):
332
+ * - 顶层必须是对象,含 `rules` 数组
333
+ * - 每个 entry 必须含非空 `id`(trim 后)、合法 `class`
334
+ * - 同 id 在多个 class 中出现 → 拒绝(disjointness 不变量)
335
+ * - suggested 必须含 `suggestion` 字段且非空
336
+ *
337
+ * @returns 解析成功 → 编译后配置;失败 → null(调用方降级)
338
+ */
339
+ function parseRulesConfig(input) {
340
+ if (!input || typeof input !== "object") return null;
341
+ const file = input;
342
+ if (!Array.isArray(file.rules)) return null;
343
+ const autoFixable = /* @__PURE__ */ new Set();
344
+ const suggested = /* @__PURE__ */ new Map();
345
+ const seenIds = /* @__PURE__ */ new Set();
346
+ for (const raw of file.rules) {
347
+ if (!raw || typeof raw !== "object") return null;
348
+ const id = typeof raw.id === "string" ? raw.id.trim() : "";
349
+ if (!id) return null;
350
+ if (seenIds.has(id)) return null;
351
+ seenIds.add(id);
352
+ const cls = raw.class;
353
+ if (cls !== "auto-fixable" && cls !== "suggested" && cls !== "report-only") return null;
354
+ if (cls === "auto-fixable") autoFixable.add(id);
355
+ else if (cls === "suggested") {
356
+ const suggestion = typeof raw.suggestion === "string" ? raw.suggestion.trim() : "";
357
+ if (!suggestion) return null;
358
+ suggested.set(id, suggestion);
359
+ }
360
+ }
361
+ return {
362
+ autoFixable,
363
+ suggested
364
+ };
365
+ }
366
+ /**
367
+ * 从 JSON 文件路径加载并编译。读取失败 / 解析失败 / 校验失败 → 返回 null(调用方降级)。
368
+ */
369
+ function loadRulesConfigFromFile(configPath) {
370
+ let content;
371
+ try {
372
+ content = readFileSync(configPath, "utf-8");
373
+ } catch (error) {
374
+ process.stderr.write(`[rule-config] cannot read ${configPath}: ${error instanceof Error ? error.message : String(error)}\n`);
375
+ return null;
376
+ }
377
+ let parsed;
378
+ try {
379
+ parsed = JSON.parse(content);
380
+ } catch (error) {
381
+ process.stderr.write(`[rule-config] invalid JSON in ${configPath}: ${error instanceof Error ? error.message : String(error)}\n`);
382
+ return null;
383
+ }
384
+ const compiled = parseRulesConfig(parsed);
385
+ if (!compiled) process.stderr.write(`[rule-config] invalid config schema in ${configPath}, falling back to defaults\n`);
386
+ return compiled;
387
+ }
388
+ /** 环境变量名:Code Scanning 规则分类配置文件路径 */
389
+ const RULE_CONFIG_ENV = "CODE_SCANNING_RULES_CONFIG_PATH";
390
+ /**
391
+ * 从 env 读取 `RULE_CONFIG_ENV` 并尝试加载。
392
+ *
393
+ * - env 未设或空 → 返回 null(调用方继续走默认)
394
+ * - 路径不存在 → 返回 null
395
+ * - 加载失败 → 返回 null(错误已写入 stderr)
396
+ */
397
+ function loadRulesConfigFromEnv(env) {
398
+ const path = env[RULE_CONFIG_ENV]?.trim();
399
+ if (!path) return null;
400
+ if (!existsSync(path)) {
401
+ process.stderr.write(`[rule-config] ${RULE_CONFIG_ENV} points to non-existent path: ${path}\n`);
402
+ return null;
403
+ }
404
+ return loadRulesConfigFromFile(path);
405
+ }
406
+ let activeConfig = DEFAULT_RULES_CONFIG;
407
+ /** 获取当前生效的配置(classifyRule / suggestionFor 内部使用) */
408
+ function getActiveRulesConfig() {
409
+ return activeConfig;
410
+ }
411
+ /**
412
+ * 替换为自定义配置。
413
+ *
414
+ * **使用协议**:运行时模块初始化时(如 AppContext 装配)调用一次;
415
+ * 测试可临时覆盖后通过 `resetActiveRulesConfig` 恢复。频繁切换会破坏
416
+ * `code-scanning-fetcher` 的可预测行为(同一规则在不同 fetch 中分类不同)。
417
+ */
418
+ function setActiveRulesConfig(config) {
419
+ activeConfig = config;
420
+ }
421
+ /** 重置为默认配置(测试 cleanup + 运行时 fallback) */
422
+ function resetActiveRulesConfig() {
423
+ activeConfig = DEFAULT_RULES_CONFIG;
424
+ }
425
+
122
426
  //#endregion
123
427
  //#region src/code-scanning/rule-classifier.ts
124
428
  /**
125
- * A 类自动修复白名单:仅收录"删除/改动后不影响程序行为"且**已有修复模板**的规则。
429
+ * A 类自动修复白名单默认集合(默认值常量;测试 disjointness 不变量用)。
430
+ *
431
+ * **与 active config 的关系**:本导出为默认常量(向后兼容历史测试与外部引用),
432
+ * 运行时 classifyRule 实际从 `getActiveRulesConfig().autoFixable` 读取。
433
+ * 若配置已被 setActiveRulesConfig 替换,本常量与运行时分类可能不一致(设计意图)。
126
434
  *
127
435
  * 选择标准(实现时评审确认):
128
436
  * - 可模板化:修复动作可机械描述(删行 / 对齐 / 移除空白)
@@ -137,9 +445,9 @@ function sleep(ms) {
137
445
  * 纯格式规则(eol-last 等)实际出现频率低;此处先建立机制,
138
446
  * 规则命中与否不影响分层正确性。
139
447
  */
140
- const AUTO_FIXABLE_RULES = /* @__PURE__ */ new Set(["eol-last"]);
448
+ const AUTO_FIXABLE_RULES = DEFAULT_RULES_CONFIG.autoFixable;
141
449
  /**
142
- * B 类建议修复列表:高风险 / 需人工判断的规则。
450
+ * B 类建议规则默认列表(默认值常量;测试 disjointness 不变量用)。
143
451
  *
144
452
  * 收录原则:
145
453
  * - 安全敏感(注入 / XSS / 路径穿越 / 密码学等),修复需结合业务语义
@@ -152,109 +460,30 @@ const AUTO_FIXABLE_RULES = /* @__PURE__ */ new Set(["eol-last"]);
152
460
  * 其余语言(go/ruby/csharp/cpp 等)与未收录变体(如 js/reflected-xss)落 C 类兜底;
153
461
  * 真实仓库 API 样本核对(rule id 格式与变体分布)登记为演进项。
154
462
  */
155
- const SUGGESTED_RULES = /* @__PURE__ */ new Set([
156
- "no-unused-vars",
157
- "js/sql-injection",
158
- "js/xss",
159
- "js/path-injection",
160
- "js/command-line-injection",
161
- "js/insecure-randomness",
162
- "js/weak-cryptographic-algorithm",
163
- "js/missing-rate-limiting",
164
- "js/clear-text-storage-of-sensitive-data",
165
- "js/clear-text-transmission-of-sensitive-data",
166
- "js/hardcoded-credentials",
167
- "py/sql-injection",
168
- "py/path-injection",
169
- "py/command-line-injection",
170
- "py/insecure-default-file-permissions",
171
- "java/sql-injection",
172
- "java/path-injection",
173
- "java/command-line-injection"
174
- ]);
463
+ const SUGGESTED_RULES = new Set(DEFAULT_RULES_CONFIG.suggested.keys());
175
464
  /**
176
465
  * 规则分类:
177
466
  * - A 类白名单 → `'auto-fixable'`
178
467
  * - B 类建议列表 → `'suggested'`
179
468
  * - 其余(含空字符串)→ `'report-only'`(C 类,默认,不静默丢弃)
469
+ *
470
+ * 读取自当前生效配置(`getActiveRulesConfig()`);首次调用前需通过
471
+ * `setActiveRulesConfig` 替换或保持默认。
180
472
  */
181
473
  function classifyRule(ruleId) {
182
474
  const id = ruleId?.trim() ?? "";
183
- if (AUTO_FIXABLE_RULES.has(id)) return "auto-fixable";
184
- if (SUGGESTED_RULES.has(id)) return "suggested";
475
+ const config = getActiveRulesConfig();
476
+ if (config.autoFixable.has(id)) return "auto-fixable";
477
+ if (config.suggested.has(id)) return "suggested";
185
478
  return "report-only";
186
479
  }
187
480
  /**
188
481
  * B 类建议规则的人工修复方向(报告/PR body 建议区块展示)。
189
- * 收录 SUGGESTED_RULES 的修复指引;未收录规则返回通用建议(C 类兜底,不静默)。
190
- */
191
- const SUGGESTION_MAP = /* @__PURE__ */ new Map([
192
- ["no-unused-vars", "删除未使用的变量/导入;注意导出引用与副作用(import 副作用场景),确认后手动删除"],
193
- ["js/sql-injection", "使用参数化查询(PreparedStatement / query builder),禁止字符串拼接 SQL"],
194
- ["js/xss", "输出前 HTML 转义;innerHTML / document.write 场景用 DOMPurify 等清洗库"],
195
- ["js/path-injection", "校验用户输入路径在预期目录内(path.resolve + 前缀校验),禁止直接拼接文件路径"],
196
- ["js/command-line-injection", "避免 shell 字符串拼接,使用参数数组(child_process.spawn(args))"],
197
- ["js/insecure-randomness", "安全随机数使用 crypto.randomBytes / Web Crypto,禁止 Math.random"],
198
- ["js/weak-cryptographic-algorithm", "升级为强算法(AES-256-GCM / Argon2 / SHA-256+),安全场景禁用 MD5/SHA-1"],
199
- ["js/missing-rate-limiting", "为认证/敏感接口添加限流(如 express-rate-limit)"],
200
- ["js/clear-text-storage-of-sensitive-data", "敏感数据加密存储(AES-256-GCM),禁止明文落盘"],
201
- ["js/clear-text-transmission-of-sensitive-data", "启用 TLS(HTTPS/WSS),禁止明文传输敏感数据"],
202
- ["js/hardcoded-credentials", "凭证移入环境变量/密钥管理服务(GitHub Secrets / Vault),禁止硬编码"],
203
- ["py/sql-injection", "使用参数化查询(sqlite3 占位符 / SQLAlchemy 参数绑定),禁止字符串拼接 SQL"],
204
- ["py/path-injection", "校验并规范化用户输入路径(os.path.realpath + 目录前缀校验)"],
205
- ["py/command-line-injection", "使用参数数组(subprocess.run(args)),禁止 shell=True 字符串拼接"],
206
- ["py/insecure-default-file-permissions", "创建文件时显式设置权限(os.open mode / umask)"],
207
- ["java/sql-injection", "使用 PreparedStatement 参数绑定,禁止字符串拼接 SQL"],
208
- ["java/path-injection", "校验用户输入路径(Path.normalize() + startsWith 检查),禁止直接拼接"],
209
- ["java/command-line-injection", "使用 ProcessBuilder 参数列表,禁止 shell 字符串拼接"]
210
- ]);
211
- /** 生成修复建议方向文本;未知规则返回通用建议(C 类兜底)。 */
482
+ * 读取自当前生效配置(`getActiveRulesConfig().suggested`);未收录规则返回通用建议(C 类兜底,不静默)。
483
+ */
212
484
  function suggestionFor(ruleId) {
213
485
  const id = ruleId?.trim() ?? "";
214
- return SUGGESTION_MAP.get(id) ?? "人工审查该 Code Scanning 告警:结合业务语义评估修复方案(参考 CodeQL 规则文档)";
215
- }
216
-
217
- //#endregion
218
- //#region src/github/errors.ts
219
- /**
220
- * 将 Octokit 请求错误映射为 `AppError`。
221
- *
222
- * @param error - 原始错误(`RequestError` 或网络异常)
223
- * @param context - 调用上下文描述(如 `'fetch repo info for foo/bar'`),会附加到错误消息中
224
- */
225
- function mapGitHubError(error, context) {
226
- if (error instanceof RequestError) {
227
- const code = resolveErrorCode(error);
228
- const details = collectErrorDetails(error);
229
- return new AppError(code, `${context}: ${error.message}`, {
230
- cause: error,
231
- details
232
- });
233
- }
234
- if (error instanceof Error) return new AppError("NETWORK_ERROR", `${context}: ${error.message}`, { cause: error });
235
- return new AppError("NETWORK_ERROR", `${context}: unknown error`, { details: { raw: toErrorMessage(error) } });
236
- }
237
- function resolveErrorCode(error) {
238
- switch (error.status) {
239
- case 401: return "AUTHENTICATION_FAILED";
240
- case 403: {
241
- const remaining = error.response?.headers["x-ratelimit-remaining"];
242
- if (remaining !== void 0 && remaining === "0") return "RATE_LIMITED";
243
- return "PERMISSION_DENIED";
244
- }
245
- case 404: return "REPO_NOT_FOUND";
246
- default: return "GITHUB_API_ERROR";
247
- }
248
- }
249
- function collectErrorDetails(error) {
250
- const details = {
251
- status: error.status,
252
- requestUrl: error.request?.url,
253
- requestMethod: error.request?.method
254
- };
255
- const resetAt = error.response?.headers["x-ratelimit-reset"];
256
- if (resetAt) details.rateLimitReset = resetAt;
257
- return details;
486
+ return getActiveRulesConfig().suggested.get(id) ?? "人工审查该 Code Scanning 告警:结合业务语义评估修复方案(参考 CodeQL 规则文档)";
258
487
  }
259
488
 
260
489
  //#endregion
@@ -729,6 +958,12 @@ function generatePRBody(result, supersededNumbers) {
729
958
  `| Verifications failed | ${summary.verificationsFailed} |`,
730
959
  ""
731
960
  ];
961
+ const supplyChainWarnings = result.supplyChainWarnings ?? [];
962
+ if (supplyChainWarnings.length > 0) {
963
+ lines.push("### ⚠️ Supply Chain Warnings", "", "> 以下升级的包带 lifecycle scripts 且已被仓库 `allowBuilds` 批准——安装时脚本将真实执行。合入本 PR 前请人工确认。", "", "| Package | Version | Scripts | Repository |", "|---------|---------|---------|------------|");
964
+ for (const w of supplyChainWarnings) lines.push(`| \`${escapeTableCell(w.packageName)}\` | \`${escapeTableCell(w.version)}\` | ${w.scriptTypes.map((t) => `\`${escapeTableCell(t)}\``).join(", ")} | ${escapeTableCell(w.repository)} |`);
965
+ lines.push("");
966
+ }
732
967
  const upgrades = aggregateUpgradeActions(actions, (a) => a.success);
733
968
  if (upgrades.length > 0) {
734
969
  const multiRepo = new Set(upgrades.map((u) => u.repository)).size > 1;
@@ -1166,6 +1401,79 @@ async function probeDependabotConfig(client, owner, repo) {
1166
1401
  }
1167
1402
  }
1168
1403
 
1404
+ //#endregion
1405
+ //#region src/github/token-scope.ts
1406
+ const CLASSIC_REPO_SCOPE_WARNING = {
1407
+ code: "CLASSIC_REPO_SCOPE",
1408
+ message: "检测到 classic PAT 且包含 repo(全量仓库)权限——该 token 一旦被恶意依赖脚本窃取,即可接管所有可见仓库。建议改用 fine-grained PAT(最小权限:contents/pull-requests 写 + security-events 读,仅目标仓库),详见 quick-start 安全注意事项。"
1409
+ };
1410
+ const MISSING_SECURITY_EVENTS_WARNING = {
1411
+ code: "MISSING_SECURITY_EVENTS",
1412
+ message: "已开启 Code Scanning 告警源,但 token 缺少 security-events 读取权限——Code Scanning 告警将拉取失败。请为 token 授予 security-events 读取权限(classic PAT 对应 security_events scope,内置 GITHUB_TOKEN 默认具备)。"
1413
+ };
1414
+ /**
1415
+ * 解析 GitHub token 权限面并输出警告(纯函数,可单测)。
1416
+ *
1417
+ * @param scopeHeader `GET /user` 响应的 `x-oauth-scopes` 头(classic PAT 才有值)
1418
+ * @param acceptedHeader `GET /user` 响应的 `x-accepted-github-permissions` 头(fine-grained / 内置 token 才有值)
1419
+ * @param options.codeScanningEnabled 是否开启 Code Scanning 源(开启时校验 security-events 权限)
1420
+ */
1421
+ function analyzeTokenScope(scopeHeader, acceptedHeader, options = {}) {
1422
+ const scopes = parseHeaderList(scopeHeader);
1423
+ const acceptedPermissions = parseHeaderList(acceptedHeader);
1424
+ if (scopes) {
1425
+ const warnings = [];
1426
+ if (scopes.includes("repo")) warnings.push(CLASSIC_REPO_SCOPE_WARNING);
1427
+ if (options.codeScanningEnabled && !scopes.includes("security_events") && !scopes.includes("repo")) warnings.push(MISSING_SECURITY_EVENTS_WARNING);
1428
+ return {
1429
+ scopes,
1430
+ warnings
1431
+ };
1432
+ }
1433
+ if (acceptedPermissions) {
1434
+ const warnings = [];
1435
+ if (options.codeScanningEnabled && !hasPermission(acceptedPermissions, "security-events", "read")) warnings.push(MISSING_SECURITY_EVENTS_WARNING);
1436
+ return {
1437
+ acceptedPermissions,
1438
+ warnings
1439
+ };
1440
+ }
1441
+ return { warnings: [] };
1442
+ }
1443
+ /** 校验 accepted permissions 是否包含指定 resource 的指定 access 级别。 */
1444
+ function hasPermission(permissions, resource, access) {
1445
+ return permissions.some((p) => {
1446
+ const [res, acc] = p.split(":").map((s) => s.trim().toLowerCase());
1447
+ return res === resource && acc === access;
1448
+ });
1449
+ }
1450
+ /** 解析逗号分隔的响应头(去空白去空项);头缺失或为空返回 undefined。 */
1451
+ function parseHeaderList(header) {
1452
+ if (!header) return;
1453
+ const items = header.split(",").map((s) => s.trim()).filter(Boolean);
1454
+ return items.length > 0 ? items : void 0;
1455
+ }
1456
+ /**
1457
+ * 探测 token 权限面(网络调用,best-effort)。
1458
+ * 任何失败(鉴权 401 / 网络错误 / 超时)均静默返回 `ok: false`,不抛错。
1459
+ */
1460
+ async function checkTokenPermissions(client, options = {}) {
1461
+ try {
1462
+ const response = await client.request("GET /user", { request: { signal: AbortSignal.timeout(5e3) } });
1463
+ const analysis = analyzeTokenScope(response.headers["x-oauth-scopes"], response.headers["x-accepted-github-permissions"], options);
1464
+ return {
1465
+ ok: true,
1466
+ login: response.data.login,
1467
+ ...analysis
1468
+ };
1469
+ } catch {
1470
+ return {
1471
+ ok: false,
1472
+ warnings: []
1473
+ };
1474
+ }
1475
+ }
1476
+
1169
1477
  //#endregion
1170
1478
  //#region src/code-scanning/templates.ts
1171
1479
  const TEMPLATES = new Map([{
@@ -1330,6 +1638,27 @@ function getStderr(err) {
1330
1638
  if (err instanceof Error) return err.message;
1331
1639
  return typeof err === "object" && err !== null ? JSON.stringify(err) : String(err);
1332
1640
  }
1641
+ /**
1642
+ * `pnpm install` stderr 中的已知失败特征 → 可读提示。
1643
+ * 当前覆盖:minimumReleaseAge 冷却期约束(目标仓库显式配置该策略时 strict 模式生效,
1644
+ * 解析报 ERR_PNPM_NO_MATURE_MATCHING_VERSION、frozen 校验报 ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION)。
1645
+ * 未识别时返回 null,调用方保留原始 stderr。
1646
+ */
1647
+ const INSTALL_FAILURE_HINTS = [[/ERR_PNPM_NO_MATURE_MATCHING_VERSION|ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|minimumReleaseAge/i, "target repo has a minimumReleaseAge policy; the upgrade target version is too fresh (cooldown not elapsed). Retry after the cooldown period, or relax the policy"]];
1648
+ /** 识别 `pnpm install` 失败原因,未识别返回 null。 */
1649
+ function classifyInstallFailure(stderr) {
1650
+ for (const [pattern, hint] of INSTALL_FAILURE_HINTS) if (pattern.test(stderr)) return hint;
1651
+ return null;
1652
+ }
1653
+ /**
1654
+ * 格式化 `pnpm install` 失败错误信息:识别到已知原因时附加可读提示。
1655
+ * 供升级链路各 catch 块统一使用(upgradeDependency / overrideTransitiveDependency /
1656
+ * applyVersionedOverrides),避免三处重复匹配逻辑。
1657
+ */
1658
+ function formatInstallFailure(stderr) {
1659
+ const hint = classifyInstallFailure(stderr);
1660
+ return hint ? `pnpm install failed: ${hint}: ${stderr}` : `pnpm install failed: ${stderr}`;
1661
+ }
1333
1662
 
1334
1663
  //#endregion
1335
1664
  //#region src/fixers/dependency/index.ts
@@ -1391,7 +1720,7 @@ async function upgradeDependency(params) {
1391
1720
  toVersion,
1392
1721
  isMajor,
1393
1722
  success: false,
1394
- error: `pnpm install failed: ${getStderr(installErr)}`
1723
+ error: formatInstallFailure(getStderr(installErr))
1395
1724
  };
1396
1725
  }
1397
1726
  cleanupBackups({
@@ -1478,13 +1807,14 @@ async function overrideTransitiveDependency(params) {
1478
1807
  lockBackup,
1479
1808
  workspaceBackup
1480
1809
  });
1810
+ const stderr = getStderr(installErr);
1481
1811
  return {
1482
1812
  packageName,
1483
1813
  fromVersion,
1484
1814
  toVersion,
1485
1815
  isMajor,
1486
1816
  success: false,
1487
- error: `pnpm install failed: ${getStderr(installErr)}`
1817
+ error: formatInstallFailure(stderr)
1488
1818
  };
1489
1819
  }
1490
1820
  if (verifyOverrideTookEffect) {
@@ -1638,7 +1968,7 @@ async function applyVersionedOverrides(params) {
1638
1968
  toVersion: entries.map((entry) => entry[1]).join(", "),
1639
1969
  isMajor: false,
1640
1970
  success: false,
1641
- error: `pnpm install failed: ${stderr}`
1971
+ error: formatInstallFailure(stderr)
1642
1972
  };
1643
1973
  }
1644
1974
  cleanupBackups({
@@ -1921,6 +2251,7 @@ const FAILURE_PATTERNS = [
1921
2251
  ["CREDENTIAL_ERROR", /E401|ERR_PNPM_FETCH_401|ERR_PNPM_FETCH_403|authentication failed|Authorization/i],
1922
2252
  ["CORRUPTED_LOCKFILE", /\b(broken|corrupted|Cannot read).*lockfile|ERR_PNPM_BROKEN_LOCKFILE/i],
1923
2253
  ["LOCKFILE_VERSION_MISMATCH", /lockfileVersion.*incompatible|unsupported lockfile version|lockfile had been generated with/i],
2254
+ ["MINIMUM_RELEASE_AGE", /ERR_PNPM_NO_MATURE_MATCHING_VERSION|ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|minimumReleaseAge/i],
1924
2255
  ["RESOLVE_ERROR", /ERR_PNPM_NO_MATCHING_VERSION|resolution|resolve.*failed|ERR_PNPM_PEER_DEP_ISSUES/i]
1925
2256
  ];
1926
2257
  /**
@@ -1966,6 +2297,7 @@ function getStrategyChain(category) {
1966
2297
  "FIX_ENTRIES",
1967
2298
  "REINSTALL"
1968
2299
  ];
2300
+ case "MINIMUM_RELEASE_AGE": return ["REGENERATE", "REINSTALL"];
1969
2301
  case "UNKNOWN": return ["REGENERATE", "REINSTALL"];
1970
2302
  case "CREDENTIAL_ERROR": return [];
1971
2303
  }
@@ -2205,7 +2537,7 @@ function repairLockfile(params) {
2205
2537
  return {
2206
2538
  success: false,
2207
2539
  failureCategory: category,
2208
- failureDetail: truncate$1(lastError, 500),
2540
+ failureDetail: `${category === "MINIMUM_RELEASE_AGE" ? "target repo has a minimumReleaseAge policy; locked versions are too fresh. Retry after the cooldown period, or relax the policy: " : ""}${truncate$1(lastError, 500)}`,
2209
2541
  attemptHistory: attempts
2210
2542
  };
2211
2543
  }
@@ -2452,6 +2784,7 @@ const DEFAULT_RUNTIME_CONFIG = {
2452
2784
  severityThreshold: "high",
2453
2785
  alertSource: "github-dependabot",
2454
2786
  codeScanningEnabled: false,
2787
+ codeQualityEnabled: false,
2455
2788
  allowMajorUpgrade: false,
2456
2789
  maxAlertsPerRepository: 20,
2457
2790
  maxConcurrency: 1,
@@ -2577,6 +2910,7 @@ function readEnvConfig(env = process.env) {
2577
2910
  alertsToken: readEnv(env, "ALERTS_TOKEN")?.trim() || void 0,
2578
2911
  alertSource: readAlertSource(readEnv(env, "ALERTS_SOURCE"), `${ENV_PREFIX}ALERTS_SOURCE`),
2579
2912
  codeScanningEnabled: normalizeBoolean(readEnv(env, "CODE_SCANNING"), `${ENV_PREFIX}CODE_SCANNING`),
2913
+ codeQualityEnabled: normalizeBoolean(readEnv(env, "CODE_QUALITY"), `${ENV_PREFIX}CODE_QUALITY`),
2580
2914
  maxAlertsPerRepository: normalizeInteger(readEnv(env, "MAX_ALERTS_PER_REPOSITORY"), `${ENV_PREFIX}MAX_ALERTS_PER_REPOSITORY`),
2581
2915
  maxConcurrency: normalizeInteger(readEnv(env, "MAX_CONCURRENCY"), `${ENV_PREFIX}MAX_CONCURRENCY`),
2582
2916
  maxRetries: normalizeNonNegativeInteger(readEnv(env, "MAX_RETRIES"), `${ENV_PREFIX}MAX_RETRIES`),
@@ -2639,6 +2973,7 @@ function validateRuntimeConfig(config) {
2639
2973
  for (const repo of config.repositories) if (!isValidRepoIdentifier(repo)) throw new AppError("CONFIG_VALIDATION_ERROR", `Invalid repository identifier: "${repo}". Expected format: owner/repo`);
2640
2974
  if (isAuditSource && config.repositories.length > 1) throw new AppError("CONFIG_VALIDATION_ERROR", "pnpm-audit alert source supports at most one repository (it scans the current workspace lockfile).");
2641
2975
  if (isAuditSource && config.codeScanningEnabled) throw new AppError("CONFIG_VALIDATION_ERROR", "code-scanning requires the github-dependabot alert source (Code Scanning alerts are fetched from the GitHub API).");
2976
+ if (isAuditSource && config.codeQualityEnabled) throw new AppError("CONFIG_VALIDATION_ERROR", "code-quality requires the github-dependabot alert source (Code Quality findings are fetched from the GitHub API).");
2642
2977
  if (isAuditSource && config.mode === "fix-and-pr") throw new AppError("CONFIG_VALIDATION_ERROR", "fix-and-pr mode requires the github-dependabot alert source. Use pnpm-audit with report-only/fix mode instead.");
2643
2978
  if (isAuditSource && config.mode === "cleanup-branches") throw new AppError("CONFIG_VALIDATION_ERROR", "cleanup-branches mode requires the github-dependabot alert source (branch cleanup needs GitHub API).");
2644
2979
  if (config.mode === "report-only" && config.createPullRequest) throw new AppError("CONFIG_VALIDATION_ERROR", "createPullRequest cannot be enabled when mode is report-only.");
@@ -2681,6 +3016,7 @@ function resolveRuntimeConfig(options = {}) {
2681
3016
  alertsToken: cliOverrides.alertsToken ?? envConfig.alertsToken,
2682
3017
  alertSource: cliOverrides.alertSource ?? envConfig.alertSource ?? DEFAULT_RUNTIME_CONFIG.alertSource,
2683
3018
  codeScanningEnabled: cliOverrides.codeScanningEnabled ?? envConfig.codeScanningEnabled ?? DEFAULT_RUNTIME_CONFIG.codeScanningEnabled,
3019
+ codeQualityEnabled: cliOverrides.codeQualityEnabled ?? envConfig.codeQualityEnabled ?? DEFAULT_RUNTIME_CONFIG.codeQualityEnabled,
2684
3020
  allowMajorUpgrade: cliOverrides.allowMajorUpgrade ?? DEFAULT_RUNTIME_CONFIG.allowMajorUpgrade,
2685
3021
  maxAlertsPerRepository: cliOverrides.maxAlertsPerRepository ?? envConfig.maxAlertsPerRepository ?? DEFAULT_RUNTIME_CONFIG.maxAlertsPerRepository,
2686
3022
  maxConcurrency: cliOverrides.maxConcurrency ?? envConfig.maxConcurrency ?? DEFAULT_RUNTIME_CONFIG.maxConcurrency,
@@ -2897,6 +3233,250 @@ function formatHistory(entries, repo) {
2897
3233
  return lines.join("\n");
2898
3234
  }
2899
3235
 
3236
+ //#endregion
3237
+ //#region src/runners/network-audit.ts
3238
+ const CONNECT_TIMEOUT_MS = 1e4;
3239
+ /**
3240
+ * 默认出站白名单(网络白名单治理决策:仅 registry + GitHub API/资产域 + 受信任构建工具文档站)。
3241
+ * - `*.npmjs.org` 覆盖 npm registry 全子域(含 registry.npmjs.org 的 tarball 下载域)
3242
+ * - GitHub 相关:REST API(api.github.com)、git clone/网页(github.com)、
3243
+ * release 资产(objects.githubusercontent.com)、raw 文件(raw.githubusercontent.com)
3244
+ * - `rolldown.rs` = vite 6/7 Rust 实现官方文档站;vite 跨 major 升级 verification 阶段命令输出
3245
+ * 会出现 `https://rolldown.rs/reference/...` 链接(verification-runner 命令输出 URL 提取会捕获),
3246
+ * 不放行则合法 verification 被误判 network_violation → run exitCode=1
3247
+ * 需要更多域时通过环境变量扩展(见 readAllowedDomains)。
3248
+ */
3249
+ const DEFAULT_ALLOWED_DOMAINS = [
3250
+ "*.npmjs.org",
3251
+ "api.github.com",
3252
+ "github.com",
3253
+ "objects.githubusercontent.com",
3254
+ "raw.githubusercontent.com",
3255
+ "rolldown.rs"
3256
+ ];
3257
+ /**
3258
+ * 从环境读取出站白名单:默认清单 + 扩展域(逗号分隔,追加不替换)。
3259
+ * 读取顺序:`DEPENDFIX_ALLOWED_DOMAINS` 优先,回退裸 `ALLOWED_DOMAINS`
3260
+ * (与 CLI 配置层的 DEPENDFIX_ 前缀惯例一致,同时兼容决策文档中的裸变量名)。
3261
+ * 空/未配置 → 仅默认清单。
3262
+ */
3263
+ function readAllowedDomains(env = process.env) {
3264
+ const raw = env.DEPENDFIX_ALLOWED_DOMAINS ?? env.ALLOWED_DOMAINS;
3265
+ if (!raw) return [...DEFAULT_ALLOWED_DOMAINS];
3266
+ const extra = raw.split(",").map((s) => s.trim()).filter(Boolean);
3267
+ return [.../* @__PURE__ */ new Set([...DEFAULT_ALLOWED_DOMAINS, ...extra])];
3268
+ }
3269
+ /**
3270
+ * 从外联目标提取主机名(去协议、去路径、去端口;IPv6 方括号形式保留括号)。
3271
+ * - `host:port` / `https://host/path` / `host` → `host`
3272
+ * - `[::1]:443` → `[::1]`
3273
+ */
3274
+ function extractHostname(target) {
3275
+ let t = target.trim();
3276
+ const protoIdx = t.indexOf("://");
3277
+ if (protoIdx >= 0) t = t.slice(protoIdx + 3);
3278
+ const slashIdx = t.indexOf("/");
3279
+ if (slashIdx >= 0) t = t.slice(0, slashIdx);
3280
+ if (t.startsWith("[")) {
3281
+ const close = t.indexOf("]");
3282
+ return close >= 0 ? t.slice(0, close + 1) : t;
3283
+ }
3284
+ const colonIdx = t.lastIndexOf(":");
3285
+ return colonIdx >= 0 ? t.slice(0, colonIdx) : t;
3286
+ }
3287
+ /**
3288
+ * 白名单命中判定(大小写不敏感)。
3289
+ * - `*.domain` 通配符:匹配任意**子域**(`sub.domain`、`a.b.domain`),不含裸 `domain` 本身;
3290
+ * 边界由前导点保证(`evilnpmjs.org` 不会被 `*.npmjs.org` 命中)
3291
+ * - 其他条目:主机名精确匹配(IPv6 需带方括号,与 extractHostname 输出一致)
3292
+ */
3293
+ function isDomainAllowed(hostname, allowedDomains) {
3294
+ const host = hostname.trim().toLowerCase();
3295
+ return allowedDomains.some((pattern) => {
3296
+ const p = pattern.trim().toLowerCase();
3297
+ if (p.startsWith("*.")) {
3298
+ const base = p.slice(2);
3299
+ return host.length > base.length + 1 && host.endsWith(`.${base}`);
3300
+ }
3301
+ return host === p;
3302
+ });
3303
+ }
3304
+ /**
3305
+ * 将外联目标最小化为报告安全形态:仅保留 host[:port],丢弃路径与查询串。
3306
+ * deny-by-default 拦截的证据字段不落 payload——恶意脚本通过 URL query 外带凭据时,
3307
+ * 网络层已拦截但查询串不得原样回显进报告/日志(防御纵深,最小暴露)。
3308
+ * - `evil.example.com:443`(CONNECT)→ 原样
3309
+ * - `https://evil.example.com/exfil?token=x` → `evil.example.com`
3310
+ * - `[::1]:443` / `http://host:8080/path` → 保留方括号/端口
3311
+ * - 空/无法解析 → `<unknown-target>`(防报告出现空目标)
3312
+ */
3313
+ function redactUrlForReport(target) {
3314
+ let t = target.trim();
3315
+ const protoIdx = t.indexOf("://");
3316
+ if (protoIdx >= 0) t = t.slice(protoIdx + 3);
3317
+ const slashIdx = t.indexOf("/");
3318
+ if (slashIdx >= 0) t = t.slice(0, slashIdx);
3319
+ return t || "<unknown-target>";
3320
+ }
3321
+ /**
3322
+ * 启动本地网络拦截代理(127.0.0.1 随机端口)。
3323
+ * 支持 CONNECT 隧道(HTTPS)与明文 HTTP 转发;deny-by-default:非白名单目标
3324
+ * 返回 502/销毁 socket 且不建立上游连接,并记录违规外联;白名单命中才转发
3325
+ * (转发失败仍返回 502,不挂死调用方)。停止后服务器关闭。
3326
+ */
3327
+ async function startNetworkAudit(options) {
3328
+ const allowedDomains = options?.allowedDomains ?? readAllowedDomains();
3329
+ const entries = [];
3330
+ const violations = [];
3331
+ const server = createServer();
3332
+ server.on("connect", (req, clientSocket, head) => {
3333
+ const target = req.url ?? "";
3334
+ const baseEntry = {
3335
+ time: (/* @__PURE__ */ new Date()).toISOString(),
3336
+ source: "proxy",
3337
+ method: "CONNECT",
3338
+ target
3339
+ };
3340
+ const allowed = isDomainAllowed(extractHostname(target), allowedDomains);
3341
+ const entry = allowed ? baseEntry : {
3342
+ ...baseEntry,
3343
+ violation: true
3344
+ };
3345
+ entries.push(entry);
3346
+ if (!allowed) {
3347
+ violations.push(entry);
3348
+ clientSocket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
3349
+ clientSocket.end();
3350
+ return;
3351
+ }
3352
+ const idx = target.lastIndexOf(":");
3353
+ const upstreamHost = target.slice(0, idx);
3354
+ const port = Number.parseInt(target.slice(idx + 1), 10) || 443;
3355
+ const upstream = connect(port, upstreamHost);
3356
+ const timer = setTimeout(() => {
3357
+ upstream.destroy();
3358
+ clientSocket.destroy();
3359
+ }, CONNECT_TIMEOUT_MS);
3360
+ upstream.on("connect", () => {
3361
+ clearTimeout(timer);
3362
+ clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
3363
+ if (head && head.length > 0) upstream.write(head);
3364
+ upstream.pipe(clientSocket);
3365
+ clientSocket.pipe(upstream);
3366
+ });
3367
+ upstream.on("error", () => {
3368
+ clearTimeout(timer);
3369
+ clientSocket.destroy();
3370
+ });
3371
+ clientSocket.on("error", () => {
3372
+ clearTimeout(timer);
3373
+ upstream.destroy();
3374
+ });
3375
+ });
3376
+ server.on("request", (req, res) => {
3377
+ const hostHeader = req.headers.host ?? "";
3378
+ const target = `${hostHeader}${req.url ?? ""}`;
3379
+ const baseEntry = {
3380
+ time: (/* @__PURE__ */ new Date()).toISOString(),
3381
+ source: "proxy",
3382
+ method: req.method ?? "GET",
3383
+ target
3384
+ };
3385
+ const host = extractHostname(hostHeader);
3386
+ const allowed = isDomainAllowed(host, allowedDomains);
3387
+ const entry = allowed ? baseEntry : {
3388
+ ...baseEntry,
3389
+ violation: true
3390
+ };
3391
+ entries.push(entry);
3392
+ if (!allowed) {
3393
+ violations.push(entry);
3394
+ res.statusCode = 502;
3395
+ res.end();
3396
+ return;
3397
+ }
3398
+ const port = Number.parseInt(hostHeader.split(":")[1], 10) || 80;
3399
+ const proxyReq = request({
3400
+ host,
3401
+ port,
3402
+ path: req.url,
3403
+ method: req.method,
3404
+ headers: req.headers,
3405
+ timeout: CONNECT_TIMEOUT_MS
3406
+ }, (proxyRes) => {
3407
+ const cleanHeaders = { ...proxyRes.headers };
3408
+ for (const hop of [
3409
+ "connection",
3410
+ "keep-alive",
3411
+ "transfer-encoding",
3412
+ "te",
3413
+ "trailer",
3414
+ "upgrade",
3415
+ "proxy-connection"
3416
+ ]) Reflect.deleteProperty(cleanHeaders, hop);
3417
+ res.writeHead(proxyRes.statusCode ?? 500, cleanHeaders);
3418
+ proxyRes.pipe(res);
3419
+ });
3420
+ proxyReq.on("error", () => {
3421
+ res.statusCode = 502;
3422
+ res.end();
3423
+ });
3424
+ proxyReq.on("timeout", () => {
3425
+ proxyReq.destroy();
3426
+ res.statusCode = 502;
3427
+ res.end();
3428
+ });
3429
+ req.pipe(proxyReq);
3430
+ });
3431
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
3432
+ const address = server.address();
3433
+ return {
3434
+ proxyUrl: `http://127.0.0.1:${typeof address === "object" && address ? address.port : 0}`,
3435
+ allowedDomains,
3436
+ entries,
3437
+ violations,
3438
+ addEntries: (newEntries) => entries.push(...newEntries),
3439
+ addViolation: (entry) => {
3440
+ const violation = {
3441
+ ...entry,
3442
+ violation: true
3443
+ };
3444
+ entries.push(violation);
3445
+ violations.push(violation);
3446
+ },
3447
+ stop: () => new Promise((resolve) => {
3448
+ server.closeAllConnections?.();
3449
+ server.close(() => resolve());
3450
+ })
3451
+ };
3452
+ }
3453
+ const URL_PATTERN = /https?:\/\/[^\s"'<>)\]]+/g;
3454
+ /** 单条命令输出提取的外联 URL 数量上限(防恶意脚本输出轰炸撑爆审计记录) */
3455
+ const MAX_URLS_PER_COMMAND = 100;
3456
+ /**
3457
+ * 从命令输出提取外联 URL(去重、限数、截断超长)。
3458
+ * 仅提取 http(s) URL;无 URL 返回空数组。
3459
+ */
3460
+ function extractUrlsFromOutput(output) {
3461
+ if (!output) return [];
3462
+ const urls = [];
3463
+ for (const match of output.match(URL_PATTERN) ?? []) {
3464
+ const clean = match.replace(/[.,;:]+$/, "");
3465
+ if (!urls.includes(clean)) urls.push(clean);
3466
+ if (urls.length >= MAX_URLS_PER_COMMAND) break;
3467
+ }
3468
+ return urls;
3469
+ }
3470
+ /**
3471
+ * 输出外联审计记录到执行日志:总数进 info、明细进 debug(verbose 才逐条)。
3472
+ * 仅记录方法+目标,无请求/响应体(无敏感信息)。
3473
+ */
3474
+ function logNetworkAudit(logger, repo, entries) {
3475
+ if (entries.length === 0) return;
3476
+ logger.info(`[network-audit] ${repo}: ${entries.length} outbound connection(s) recorded`);
3477
+ for (const entry of entries) logger.debug(`[network-audit] ${repo}: ${entry.source} ${entry.method} ${entry.target}`);
3478
+ }
3479
+
2900
3480
  //#endregion
2901
3481
  //#region src/runners/verification-runner.ts
2902
3482
  const DEFAULT_COMMANDS = [
@@ -2904,8 +3484,29 @@ const DEFAULT_COMMANDS = [
2904
3484
  "pnpm lint",
2905
3485
  "pnpm build"
2906
3486
  ];
3487
+ const DEFAULT_COMMAND_TIMEOUT_MS = 6e5;
2907
3488
  const MAX_OUTPUT_LINES = 200;
2908
3489
  /**
3490
+ * 已知工具链 telemetry 关闭开关(治本实证 2026-08-25 —— Nuxt CLI 默认 telemetry 上报
3491
+ * 会向 telemetry.nuxt.com:443 真实外联,被审计代理 deny-by-default 命中,触发 verification
3492
+ * 阶段的外联拦截记录。verification 是离线构建验证,不应允许任何匿名 telemetry 上报)。
3493
+ *
3494
+ * 每条 entry 是 `[varName, disabledValue]`;子进程未设置该变量时注入 `disabledValue`。
3495
+ * 父进程已设置时不覆盖(保留用户显式选择,例如测试场景重新开启 telemetry)。
3496
+ *
3497
+ * 覆盖范围:
3498
+ * - `NUXT_TELEMETRY_DISABLED=1` — Nuxt CLI 3.x telemetry 开关(默认开启)
3499
+ * - `NEXT_TELEMETRY_DISABLED=1` — Next.js(备查,Nuxt 场景不命中但保留通用化)
3500
+ * - `DO_NOT_TRACK=1` — Vercel CLI / Sentry / 通用 DO_NOT_TRACK 协议
3501
+ *
3502
+ * 未涵盖的工具链:pnpm / npm / yarn 当前无内置 telemetry 上报,无需禁用。
3503
+ */
3504
+ const TELEMETRY_DISABLED_VARS = [
3505
+ ["NUXT_TELEMETRY_DISABLED", "1"],
3506
+ ["NEXT_TELEMETRY_DISABLED", "1"],
3507
+ ["DO_NOT_TRACK", "1"]
3508
+ ];
3509
+ /**
2909
3510
  * 压缩验证命令输出为报告可读的摘要(首尾各取若干行,超长中间省略)。
2910
3511
  * 完整输出由日志承载;报告保持紧凑,避免 error 字段失控膨胀。
2911
3512
  */
@@ -2917,9 +3518,11 @@ function summarizeVerificationOutput(output) {
2917
3518
  }
2918
3519
  /**
2919
3520
  * 构造失败验证命令的 error 描述:`exit code N — <stdout/stderr 摘要>`。
2920
- * 无输出时退化为裸 `exit code N`。stdout/stderr 已在 execCommand 中脱敏、截断 200 行。
3521
+ * 无输出时退化为裸 `exit code N`;超时命令归类为 `timed out after Xms`。
3522
+ * stdout/stderr 已在 execCommand 中脱敏、截断 200 行。
2921
3523
  */
2922
3524
  function formatVerificationError(cr) {
3525
+ if (cr.timedOut) return `timed out after ${cr.durationMs}ms`;
2923
3526
  const detail = [cr.stdout, cr.stderr].filter(Boolean).join("\n--- stderr ---\n").trim();
2924
3527
  return detail ? `exit code ${cr.exitCode} — ${summarizeVerificationOutput(detail)}` : `exit code ${cr.exitCode}`;
2925
3528
  }
@@ -2937,34 +3540,98 @@ function formatVerificationError(cr) {
2937
3540
  async function runVerification(params) {
2938
3541
  const commands = params.commands ?? DEFAULT_COMMANDS;
2939
3542
  const commandResults = [];
2940
- for (const command of commands) {
2941
- const result = await execCommand(command, params.workDir);
2942
- commandResults.push(result);
2943
- if (result.exitCode !== 0) return {
2944
- success: false,
2945
- commandResults,
2946
- failedCommand: command,
2947
- failure: `command "${command}" exited with code ${result.exitCode}`
2948
- };
3543
+ let audit;
3544
+ if (!params.networkAuditDisabled) audit = await startNetworkAudit().catch(() => void 0);
3545
+ const hasExistingProxy = Boolean(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.ALL_PROXY || process.env.http_proxy || process.env.https_proxy || process.env.all_proxy);
3546
+ const proxyUrl = audit && !hasExistingProxy ? audit.proxyUrl : void 0;
3547
+ try {
3548
+ for (const command of commands) {
3549
+ const result = await execCommand(command, params.workDir, params.commandTimeoutMs, proxyUrl);
3550
+ commandResults.push(result);
3551
+ if (audit) {
3552
+ const urls = extractUrlsFromOutput(`${result.stdout}\n${result.stderr}`);
3553
+ if (urls.length > 0) {
3554
+ const time = (/* @__PURE__ */ new Date()).toISOString();
3555
+ const entries = urls.map((target) => ({
3556
+ time,
3557
+ source: "command-output",
3558
+ method: "GET",
3559
+ target
3560
+ }));
3561
+ audit.addEntries(entries);
3562
+ }
3563
+ }
3564
+ if (result.exitCode !== 0 || result.timedOut) return {
3565
+ success: false,
3566
+ commandResults,
3567
+ failedCommand: command,
3568
+ failure: result.timedOut ? `command "${command}" timed out after ${result.durationMs}ms` : `command "${command}" exited with code ${result.exitCode}`,
3569
+ ...audit ? {
3570
+ networkAudit: audit.entries,
3571
+ networkViolations: audit.violations
3572
+ } : {}
3573
+ };
3574
+ }
3575
+ } finally {
3576
+ if (audit) await audit.stop().catch(() => {});
2949
3577
  }
2950
3578
  return {
2951
3579
  success: true,
2952
- commandResults
3580
+ commandResults,
3581
+ ...audit ? {
3582
+ networkAudit: audit.entries,
3583
+ networkViolations: audit.violations
3584
+ } : {}
2953
3585
  };
2954
3586
  }
2955
3587
  /**
3588
+ * 构造 verification 子进程环境变量:
3589
+ * - **基础**:`process.env` 全量复制(含 PATH 等系统变量),不丢失用户 shell 环境
3590
+ * - **telemetry 默认禁用**:注入 `NUXT_TELEMETRY_DISABLED=1` 等(父进程未设置时);
3591
+ * 已设置时不覆盖,保留用户显式选择
3592
+ * - **审计代理**:`proxyUrl` 提供时附加 HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY
3593
+ * 重写;`proxyUrl` 为 undefined 时不动 HTTP_PROXY(用户既有代理不覆盖)
3594
+ */
3595
+ function buildSpawnEnv(proxyUrl) {
3596
+ const env = { ...process.env };
3597
+ for (const [name, value] of TELEMETRY_DISABLED_VARS) if (env[name] === void 0 || env[name] === "") env[name] = value;
3598
+ if (proxyUrl) {
3599
+ env.HTTP_PROXY = proxyUrl;
3600
+ env.HTTPS_PROXY = proxyUrl;
3601
+ env.ALL_PROXY = proxyUrl;
3602
+ env.NO_PROXY = "";
3603
+ env.no_proxy = "";
3604
+ }
3605
+ return env;
3606
+ }
3607
+ /**
2956
3608
  * 执行单条 shell 命令,捕获 stdout/stderr(截断到 200 行)并脱敏。
3609
+ * 超过超时时间后中止命令并终止其进程树(防死循环脚本孙进程残留)。
3610
+ *
3611
+ * env 构造(治本实证 2026-08-25):
3612
+ * - **总是构造**(不再 `undefined` 让子进程继承父环境)——为了精确控制 telemetry 禁用与代理注入
3613
+ * - **telemetry 默认禁用**:`NUXT_TELEMETRY_DISABLED=1` 等注入;父进程已设置时不覆盖
3614
+ * - **审计代理**:`proxyUrl` 提供时附加 HTTP_PROXY 等;用户既有代理时**不**覆盖父环境代理
3615
+ * (覆盖会破坏用户网络行为,稳定性优先 —— 与既有 `skips proxy injection` 行为一致)
2957
3616
  */
2958
- function execCommand(command, workDir) {
3617
+ function execCommand(command, workDir, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS, proxyUrl) {
2959
3618
  return new Promise((resolve) => {
2960
3619
  const startTime = Date.now();
3620
+ let timedOut = false;
2961
3621
  const cp = spawn(command, {
2962
3622
  cwd: workDir,
2963
3623
  shell: true,
2964
- stdio: "pipe"
3624
+ stdio: "pipe",
3625
+ detached: process.platform !== "win32",
3626
+ env: buildSpawnEnv(proxyUrl)
2965
3627
  });
2966
3628
  const stdoutChunks = [];
2967
3629
  const stderrChunks = [];
3630
+ const timer = setTimeout(() => {
3631
+ timedOut = true;
3632
+ killProcessTree(cp.pid);
3633
+ cp.kill("SIGKILL");
3634
+ }, timeoutMs);
2968
3635
  cp.stdout.on("data", (data) => {
2969
3636
  stdoutChunks.push(data.toString("utf-8"));
2970
3637
  });
@@ -2972,6 +3639,7 @@ function execCommand(command, workDir) {
2972
3639
  stderrChunks.push(data.toString("utf-8"));
2973
3640
  });
2974
3641
  cp.on("error", (err) => {
3642
+ clearTimeout(timer);
2975
3643
  resolve({
2976
3644
  command,
2977
3645
  exitCode: -1,
@@ -2981,18 +3649,38 @@ function execCommand(command, workDir) {
2981
3649
  });
2982
3650
  });
2983
3651
  cp.on("close", (code) => {
3652
+ clearTimeout(timer);
2984
3653
  const stdout = truncateLines(stdoutChunks.join(""), MAX_OUTPUT_LINES);
2985
3654
  const stderr = truncateLines(stderrChunks.join(""), MAX_OUTPUT_LINES);
2986
3655
  resolve({
2987
3656
  command,
2988
3657
  exitCode: code ?? -1,
2989
3658
  durationMs: Date.now() - startTime,
3659
+ timedOut,
2990
3660
  stdout: sanitizeOutput(stdout),
2991
3661
  stderr: sanitizeOutput(stderr)
2992
3662
  });
2993
3663
  });
2994
3664
  });
2995
3665
  }
3666
+ /**
3667
+ * 终止进程树:POSIX 杀进程组(detached 进程组组长),Windows 用 taskkill /T /F。
3668
+ * 失败时静默退化(主终止路径已由调用方 cp.kill 兜底)。
3669
+ */
3670
+ function killProcessTree(pid) {
3671
+ if (pid === void 0 || pid <= 0) return;
3672
+ if (process.platform === "win32") try {
3673
+ spawnSync("taskkill", [
3674
+ "/pid",
3675
+ String(pid),
3676
+ "/T",
3677
+ "/F"
3678
+ ], { stdio: "ignore" });
3679
+ } catch {}
3680
+ else try {
3681
+ process.kill(-pid, "SIGKILL");
3682
+ } catch {}
3683
+ }
2996
3684
  /** 截断文本到指定行数 */
2997
3685
  function truncateLines(text, maxLines) {
2998
3686
  const lines = text.split("\n");
@@ -3596,10 +4284,16 @@ async function fetchPnpmAuditAlerts(params) {
3596
4284
  * ⚠️ 发现漏洞时 pnpm audit 返回非零退出码(exit 1)是**正常行为**——JSON 输出仍然有效,
3597
4285
  * 不能以 exit code 判断失败(参考 security-alert-remediator 的 loadAuditReport 同款语义)。
3598
4286
  * 仅在空输出或 JSON 解析失败时视为硬失败。
4287
+ *
4288
+ * ⚠️ 显式 `--registry=https://registry.npmjs.org/`:
4289
+ * pnpm audit 默认继承用户 `.npmrc` / `npm_config_registry` 的镜像源(如 npmmirror),
4290
+ * 部分镜像站 advisory metadata 缺失或不同步会导致漏报安全漏洞——与 dependfix 的核心目标
4291
+ * (修复安全告警)直接冲突。对齐 `changelog-fetcher.ts` 默认口径(`registryBaseUrl ??
4292
+ * 'https://registry.npmjs.org'`)。追溯见 [backlog B 类登记「C35 pnpm audit 拉取应显式指定官方 registry」](../plan/backlog.md)。
3599
4293
  */
3600
4294
  function runPnpmAudit(workDir) {
3601
4295
  return new Promise((resolve, reject) => {
3602
- const cp = spawn("pnpm audit --json", {
4296
+ const cp = spawn("pnpm audit --json --registry=https://registry.npmjs.org/", {
3603
4297
  cwd: workDir,
3604
4298
  shell: true,
3605
4299
  stdio: [
@@ -3643,6 +4337,8 @@ function normalizePatchedVersionValue(value) {
3643
4337
  "none",
3644
4338
  "unavailable"
3645
4339
  ].includes(normalized)) return null;
4340
+ const versionMatch = /^[^\d]*(\d+(?:\.\d+){1,2}(?:-[0-9a-z.]+)?)/i.exec(normalized);
4341
+ if (versionMatch) return versionMatch[1];
3646
4342
  return String(value).trim();
3647
4343
  }
3648
4344
  /** 提取 advisory id:GitHub Advisory → CVE → URL → 原始 id(参考实现 resolveAdvisoryId) */
@@ -4314,6 +5010,180 @@ function enforceVerificationGate(ctx, options) {
4314
5010
  return true;
4315
5011
  }
4316
5012
 
5013
+ //#endregion
5014
+ //#region src/supply-chain/index.ts
5015
+ /**
5016
+ * 供应链信号披露(安全治理:路径 A"合法包被投毒"合入前人工确认的关键依据)。
5017
+ *
5018
+ * 信号定义:本次新增/升级的包**带 lifecycle scripts**(install/preinstall/postinstall)
5019
+ * 且**已被目标仓库 `allowBuilds` / `onlyBuiltDependencies` 批准**——pnpm 10+/11 默认
5020
+ * 忽略依赖 lifecycle scripts,仅显式批准的包才在安装时执行脚本;被批准 + 带脚本
5021
+ * 的组合意味着升级该包会在目标仓库真实执行其脚本(投毒扩散面)。
5022
+ *
5023
+ * 数据源(不依赖 lockfile 的 hasInstallScript 字段——pnpm 11.17 实证该字段不写入):
5024
+ * - 批准列表:目标仓库 `pnpm-workspace.yaml` 的 `allowBuilds`(对象)/
5025
+ * `onlyBuiltDependencies`(数组)
5026
+ * - 脚本类型:升级验证后的 `node_modules/.pnpm/{pkg}@{ver}/node_modules/{pkg}/package.json`
5027
+ * 的 scripts 键(install/preinstall/postinstall)
5028
+ */
5029
+ /** pnpm 生命周期的可执行脚本键(白名单,忽略 test/build 等非安装期脚本) */
5030
+ const LIFECYCLE_SCRIPT_KEYS = [
5031
+ "install",
5032
+ "preinstall",
5033
+ "postinstall"
5034
+ ];
5035
+ /** pnpm-workspace.yaml 中批准配置的键名(pnpm 10+ / pnpm 11 allowBuilds 对象) */
5036
+ const ALLOW_BUILDS_KEY = "allowBuilds";
5037
+ const ONLY_BUILT_DEPENDENCIES_KEY = "onlyBuiltDependencies";
5038
+ /**
5039
+ * 解析 pnpm-workspace.yaml 中被批准执行 install 脚本的包集合。
5040
+ *
5041
+ * 支持两种形态(行级解析,不引入 yaml 依赖):
5042
+ * ```yaml
5043
+ * allowBuilds: # pnpm 11 对象形态(pkg: true 或 pkg: [scripts])
5044
+ * esbuild: true
5045
+ * better-sqlite3: true
5046
+ * onlyBuiltDependencies: # pnpm 10 数组形态
5047
+ * - esbuild
5048
+ * ```
5049
+ * 注释(#)、引号包名、行内尾注均可处理;缺失文件返回空集合。
5050
+ */
5051
+ function parseWorkspaceAllowBuilds(workspaceYamlContent) {
5052
+ const approved = /* @__PURE__ */ new Set();
5053
+ if (!workspaceYamlContent) return approved;
5054
+ const lines = workspaceYamlContent.split(/\r?\n/);
5055
+ let section = null;
5056
+ for (const rawLine of lines) {
5057
+ const line = stripInlineComment(rawLine).trim();
5058
+ if (!line) continue;
5059
+ if (line === `${ALLOW_BUILDS_KEY}:`) {
5060
+ section = "allowBuilds";
5061
+ continue;
5062
+ }
5063
+ if (line === `${ONLY_BUILT_DEPENDENCIES_KEY}:`) {
5064
+ section = "onlyBuiltDependencies";
5065
+ continue;
5066
+ }
5067
+ if (isTopLevelKey(line)) {
5068
+ section = null;
5069
+ continue;
5070
+ }
5071
+ if (section === null) continue;
5072
+ if (section === "allowBuilds") {
5073
+ const match = line.match(/^([^:]+):\s*(.+)?$/);
5074
+ if (match) {
5075
+ const pkg = unquote(match[1].trim());
5076
+ const value = (match[2] ?? "").trim();
5077
+ if (pkg && value !== "false") approved.add(pkg);
5078
+ }
5079
+ } else if (section === "onlyBuiltDependencies") {
5080
+ const match = line.match(/^-\s*(.+)$/);
5081
+ if (match) {
5082
+ const pkg = unquote(match[1].trim());
5083
+ if (pkg) approved.add(pkg);
5084
+ }
5085
+ }
5086
+ }
5087
+ return approved;
5088
+ }
5089
+ /** 读取目标仓库的 pnpm-workspace.yaml 批准配置(文件不存在返回空集合)。 */
5090
+ function readWorkspaceAllowBuilds(workDir) {
5091
+ const file = join(workDir, "pnpm-workspace.yaml");
5092
+ if (!existsSync(file)) return /* @__PURE__ */ new Set();
5093
+ return parseWorkspaceAllowBuilds(readFileSync(file, "utf-8"));
5094
+ }
5095
+ /**
5096
+ * 读取已安装包(node_modules/.pnpm 布局)的 lifecycle 脚本类型。
5097
+ * pnpm 布局:`node_modules/.pnpm/{name}@{version}/node_modules/{name}/package.json`
5098
+ * (scoped 包的 store 目录为 `@scope+pkg@version`)。
5099
+ * 带 peer 依赖的包 store 目录带后缀(`name@version_peer@v...`)——先精确匹配,
5100
+ * 未命中时前缀匹配兜底(防 peer 后缀漏披露)。
5101
+ * 包未安装(dry-run / install 未执行)时返回 undefined(调用方跳过)。
5102
+ */
5103
+ function readInstalledPackageScriptTypes(workDir, packageName, version) {
5104
+ const pkgJsonPath = resolveStorePackageJson(workDir, packageName, version);
5105
+ if (!pkgJsonPath) return;
5106
+ try {
5107
+ const scripts = JSON.parse(readFileSync(pkgJsonPath, "utf-8")).scripts ?? {};
5108
+ const types = LIFECYCLE_SCRIPT_KEYS.filter((key) => typeof scripts[key] === "string");
5109
+ return types.length > 0 ? types : [];
5110
+ } catch {
5111
+ return;
5112
+ }
5113
+ }
5114
+ /** 解析 pnpm store 内包的 package.json 路径(精确路径 → peer 后缀前缀匹配兜底)。 */
5115
+ function resolveStorePackageJson(workDir, packageName, version) {
5116
+ const storeBase = `${packageName.replace("/", "+")}@${version}`;
5117
+ const exact = join(workDir, "node_modules", ".pnpm", storeBase, "node_modules", packageName, "package.json");
5118
+ if (existsSync(exact)) return exact;
5119
+ const pnpmDir = join(workDir, "node_modules", ".pnpm");
5120
+ if (!existsSync(pnpmDir)) return;
5121
+ try {
5122
+ const match = readdirSync(pnpmDir).find((name) => name.startsWith(`${storeBase}_`));
5123
+ if (!match) return;
5124
+ const peerPath = join(pnpmDir, match, "node_modules", packageName, "package.json");
5125
+ return existsSync(peerPath) ? peerPath : void 0;
5126
+ } catch {
5127
+ return;
5128
+ }
5129
+ }
5130
+ /**
5131
+ * 收集本次运行的供应链信号警示:成功升级的包 ∩ 目标仓库批准列表 ∩ 实际带 lifecycle 脚本。
5132
+ * 任一环节缺失(无 workspace.yaml / 包未安装 / 无脚本)自然降级为不披露。
5133
+ */
5134
+ function collectSupplyChainWarnings(workDir, actions) {
5135
+ const approved = readWorkspaceAllowBuilds(workDir);
5136
+ if (approved.size === 0) return [];
5137
+ const upgraded = /* @__PURE__ */ new Map();
5138
+ for (const action of actions) {
5139
+ if (action.type !== "dependency-upgrade" || !action.success || !action.target || !action.toVersion) continue;
5140
+ const key = `${action.repository}\u0000${action.target}`;
5141
+ upgraded.set(key, {
5142
+ repository: action.repository,
5143
+ packageName: action.target,
5144
+ version: action.toVersion
5145
+ });
5146
+ }
5147
+ const warnings = [];
5148
+ for (const { repository, packageName, version } of upgraded.values()) {
5149
+ if (!approved.has(packageName)) continue;
5150
+ const scriptTypes = readInstalledPackageScriptTypes(workDir, packageName, version);
5151
+ if (scriptTypes === void 0 || scriptTypes.length === 0) continue;
5152
+ warnings.push({
5153
+ repository,
5154
+ packageName,
5155
+ version,
5156
+ scriptTypes
5157
+ });
5158
+ }
5159
+ return warnings;
5160
+ }
5161
+ /** 剥离行内注释(# 在引号外才生效;包名含 # 的极端场景由 unquote 顺序保护)。 */
5162
+ function stripInlineComment(line) {
5163
+ let inSingle = false;
5164
+ let inDouble = false;
5165
+ for (let i = 0; i < line.length; i++) {
5166
+ const ch = line[i];
5167
+ if (ch === "'" && !inDouble) inSingle = !inSingle;
5168
+ else if (ch === "\"" && !inSingle) inDouble = !inDouble;
5169
+ else if (ch === "#" && !inSingle && !inDouble) return line.slice(0, i);
5170
+ }
5171
+ return line;
5172
+ }
5173
+ /** 顶层配置键(两空格缩进为 0 或非空格的键;用于退出批准段)。 */
5174
+ function isTopLevelKey(line) {
5175
+ return !line.startsWith(" ") && line.endsWith(":");
5176
+ }
5177
+ /** 去除单/双引号包裹。 */
5178
+ function unquote(value) {
5179
+ if (value.length >= 2) {
5180
+ const first = value[0];
5181
+ const last = value[value.length - 1];
5182
+ if (first === "\"" && last === "\"" || first === "'" && last === "'") return value.slice(1, -1);
5183
+ }
5184
+ return value;
5185
+ }
5186
+
4317
5187
  //#endregion
4318
5188
  //#region src/app/branch-cleanup.ts
4319
5189
  /**
@@ -4524,6 +5394,101 @@ function confirmCleanup(ctx, repo, candidates) {
4524
5394
  });
4525
5395
  }
4526
5396
 
5397
+ //#endregion
5398
+ //#region src/app/result-assembly.ts
5399
+ /** 汇总所有动作到 summary(alertsSkipped 已在 repo-fix 修复管线中累加)。 */
5400
+ function computeSummary(ctx) {
5401
+ const { allActions, allAlerts, repoResults, summary } = ctx;
5402
+ let fixed = 0;
5403
+ let failed = 0;
5404
+ let lockfileRepairs = 0;
5405
+ let verificationsPassed = 0;
5406
+ let verificationsFailed = 0;
5407
+ for (const action of allActions) {
5408
+ if (action.noOp) continue;
5409
+ if (action.strategy === "ai-patch" || action.strategy === "ai-suggestion") continue;
5410
+ if (action.type === "dependency-upgrade" || action.type === "code-scanning-fix") if (action.success) fixed++;
5411
+ else failed++;
5412
+ if (action.type === "lockfile-repair" && action.success) lockfileRepairs++;
5413
+ if (action.type === "verification") if (action.success) verificationsPassed++;
5414
+ else verificationsFailed++;
5415
+ }
5416
+ const fixable = allAlerts.filter((a) => a.fixable).length;
5417
+ summary.repositoriesScanned = repoResults.length;
5418
+ summary.alertsFound = allAlerts.length;
5419
+ summary.alertsFixable = fixable;
5420
+ summary.alertsFixed = fixed;
5421
+ summary.alertsFailed = failed;
5422
+ summary.lockfileRepairs = lockfileRepairs;
5423
+ summary.verificationsPassed = verificationsPassed;
5424
+ summary.verificationsFailed = verificationsFailed;
5425
+ }
5426
+ function buildRunResult(ctx, aiUsage, supplyChainWarnings) {
5427
+ const reportConfig = {
5428
+ mode: ctx.config.mode,
5429
+ severityThreshold: ctx.config.severityThreshold,
5430
+ repositories: ctx.config.repositories,
5431
+ dryRun: ctx.config.dryRun,
5432
+ createPullRequest: ctx.config.createPullRequest,
5433
+ maxAlertsPerRepository: ctx.config.maxAlertsPerRepository,
5434
+ alertSource: ctx.config.alertSource,
5435
+ codeScanningEnabled: ctx.config.codeScanningEnabled,
5436
+ codeQualityEnabled: ctx.config.codeQualityEnabled
5437
+ };
5438
+ return {
5439
+ runId: ctx.runId,
5440
+ startedAt: ctx.startedAt,
5441
+ finishedAt: ctx.finishedAt,
5442
+ config: reportConfig,
5443
+ summary: ctx.summary,
5444
+ repositories: ctx.repoResults,
5445
+ alerts: ctx.allAlerts,
5446
+ actions: ctx.allActions,
5447
+ errors: ctx.allErrors,
5448
+ aiUsage: aiUsage && aiUsage.calls > 0 ? aiUsage : void 0,
5449
+ supplyChainWarnings: supplyChainWarnings && supplyChainWarnings.length > 0 ? supplyChainWarnings : void 0
5450
+ };
5451
+ }
5452
+ /**
5453
+ * 聚合一次 AI 研判消耗到 run 级累计。
5454
+ * - 单次未调用(usage 为 undefined)幂等返回原累计
5455
+ * - 成本合并:任一侧 undefined 时结果 undefined(模型无单价 → 不做误导性估算)
5456
+ */
5457
+ function mergeAiUsage(aggregate, usage) {
5458
+ if (!usage || usage.calls === 0) return aggregate;
5459
+ const next = {
5460
+ calls: (aggregate?.calls ?? 0) + usage.calls,
5461
+ inputTokens: (aggregate?.inputTokens ?? 0) + usage.inputTokens,
5462
+ outputTokens: (aggregate?.outputTokens ?? 0) + usage.outputTokens,
5463
+ totalTokens: (aggregate?.totalTokens ?? 0) + usage.totalTokens
5464
+ };
5465
+ if (aggregate === void 0) next.estimatedCostUsd = usage.estimatedCostUsd;
5466
+ else if (aggregate.estimatedCostUsd === void 0 || usage.estimatedCostUsd === void 0) next.estimatedCostUsd = void 0;
5467
+ else next.estimatedCostUsd = aggregate.estimatedCostUsd + usage.estimatedCostUsd;
5468
+ return next;
5469
+ }
5470
+ /**
5471
+ * 计算退出码:
5472
+ * - 0: 全部仓库处理成功(无 failed actions、无 errors)
5473
+ * - 1: 部分仓库失败
5474
+ * - 2: 全部仓库失败(或无仓库被成功处理)
5475
+ *
5476
+ * 语义注记:AI 辅助动作(ai-patch)失败计入 exit code(fail-safe——AI 修复失败
5477
+ * 说明 breaking change 未解决,应报红提醒人工),但不计入 summary.alertsFailed
5478
+ * (告警结果由主动作代表;见 computeSummary 的 ai 辅助动作排除规则)。
5479
+ */
5480
+ function computeExitCode(ctx) {
5481
+ const { config, allErrors, allActions, repoResults } = ctx;
5482
+ const hasErrors = allErrors.length > 0;
5483
+ const hasFailures = allActions.some((a) => !a.success);
5484
+ const hasRepoSuccess = repoResults.length > 0 && repoResults.some((r) => r.verificationPassed !== false && (r.alertsCount > 0 || r.fixed > 0 || r.verificationPassed === true));
5485
+ const hasCleanupSuccess = config.mode === "cleanup-branches" && allActions.some((a) => a.success && a.type === "branch-cleanup");
5486
+ const hasSuccess = hasRepoSuccess || hasCleanupSuccess;
5487
+ if (!hasErrors && !hasFailures) return 0;
5488
+ if (hasSuccess) return 1;
5489
+ return 2;
5490
+ }
5491
+
4527
5492
  //#endregion
4528
5493
  //#region src/app/helpers.ts
4529
5494
  const DEFAULT_VERIFY_COMMANDS = [
@@ -4623,6 +5588,19 @@ function codeScanningAlertsTokenHint(error) {
4623
5588
  return null;
4624
5589
  }
4625
5590
  /**
5591
+ * Code Quality findings fetch 错误用户指引(token 需 fine-grained `Code quality: read`
5592
+ * 或 classic PAT `repo`/`public_repo` scope)。
5593
+ * 仅用于 Code Quality fetch 错误路径;按精确 context 匹配(`fetch code quality findings for`),
5594
+ * 不依赖裸关键字。
5595
+ */
5596
+ function codeQualityAlertsTokenHint(error) {
5597
+ if (!(error instanceof AppError)) return null;
5598
+ if (!error.message.includes("fetch code quality findings for")) return null;
5599
+ if (error.code === "PERMISSION_DENIED") return "请检查 token 是否具备 Code Quality findings 读取权限(fine-grained PAT 需 Code quality: read;classic PAT 需 repo / public_repo scope;GitHub App 需对应仓库权限)";
5600
+ if (error.code === "AUTHENTICATION_FAILED") return "token 无效或已过期,请检查 GITHUB_TOKEN / alertsToken 配置";
5601
+ return null;
5602
+ }
5603
+ /**
4626
5604
  * 为 lockfile 中存在脆弱实例的包构建 overrides 映射。
4627
5605
  *
4628
5606
  * 输入为该包所有 fixable 告警(不同 GHSA 推荐版本不同),按大版本分组取各线
@@ -4871,10 +5849,23 @@ async function verifyProject(ctx, repo) {
4871
5849
  return [];
4872
5850
  }
4873
5851
  try {
4874
- return (await runVerification({
5852
+ const result = await runVerification({
4875
5853
  workDir,
4876
5854
  commands: valid
4877
- })).commandResults.map((cr) => {
5855
+ });
5856
+ logNetworkAudit(logger, repo, result.networkAudit ?? []);
5857
+ for (const violation of result.networkViolations ?? []) {
5858
+ const redacted = redactUrlForReport(violation.target);
5859
+ allErrors.push({
5860
+ repository: repo,
5861
+ target: redacted,
5862
+ stage: "verify",
5863
+ category: "network_violation",
5864
+ message: `outbound blocked by allowlist: ${violation.method} ${redacted}`
5865
+ });
5866
+ logger.error(`[network-audit] ${repo}: outbound blocked (network_violation): ${violation.method} ${redacted}`);
5867
+ }
5868
+ return result.commandResults.map((cr) => {
4878
5869
  const error = cr.exitCode !== 0 ? formatVerificationError(cr) : void 0;
4879
5870
  if (error) logger.error(`Verification failed for ${repo}: ${cr.command} — ${error}`);
4880
5871
  return {
@@ -4969,104 +5960,15 @@ function buildPrTitle(summary, actions) {
4969
5960
  if (codeFixes > 0) parts.push(`${codeFixes} code fix${codeFixes > 1 ? "es" : ""}`);
4970
5961
  return parts.length > 0 ? `fix(deps): automated security fix — ${parts.join(", ")}` : "fix(deps): automated security fix";
4971
5962
  }
4972
- /** 汇总所有动作到 summary(alertsSkipped 已在 repo-fix 修复管线中累加)。 */
4973
- function computeSummary(ctx) {
4974
- const { allActions, allAlerts, repoResults, summary } = ctx;
4975
- let fixed = 0;
4976
- let failed = 0;
4977
- let lockfileRepairs = 0;
4978
- let verificationsPassed = 0;
4979
- let verificationsFailed = 0;
4980
- for (const action of allActions) {
4981
- if (action.noOp) continue;
4982
- if (action.strategy === "ai-patch" || action.strategy === "ai-suggestion") continue;
4983
- if (action.type === "dependency-upgrade" || action.type === "code-scanning-fix") if (action.success) fixed++;
4984
- else failed++;
4985
- if (action.type === "lockfile-repair" && action.success) lockfileRepairs++;
4986
- if (action.type === "verification") if (action.success) verificationsPassed++;
4987
- else verificationsFailed++;
4988
- }
4989
- const fixable = allAlerts.filter((a) => a.fixable).length;
4990
- summary.repositoriesScanned = repoResults.length;
4991
- summary.alertsFound = allAlerts.length;
4992
- summary.alertsFixable = fixable;
4993
- summary.alertsFixed = fixed;
4994
- summary.alertsFailed = failed;
4995
- summary.lockfileRepairs = lockfileRepairs;
4996
- summary.verificationsPassed = verificationsPassed;
4997
- summary.verificationsFailed = verificationsFailed;
4998
- }
4999
- /** 组装最终运行结果。 */
5000
- function buildRunResult(ctx, aiUsage) {
5001
- const reportConfig = {
5002
- mode: ctx.config.mode,
5003
- severityThreshold: ctx.config.severityThreshold,
5004
- repositories: ctx.config.repositories,
5005
- dryRun: ctx.config.dryRun,
5006
- createPullRequest: ctx.config.createPullRequest,
5007
- maxAlertsPerRepository: ctx.config.maxAlertsPerRepository,
5008
- alertSource: ctx.config.alertSource,
5009
- codeScanningEnabled: ctx.config.codeScanningEnabled
5010
- };
5011
- return {
5012
- runId: ctx.runId,
5013
- startedAt: ctx.startedAt,
5014
- finishedAt: ctx.finishedAt,
5015
- config: reportConfig,
5016
- summary: ctx.summary,
5017
- repositories: ctx.repoResults,
5018
- alerts: ctx.allAlerts,
5019
- actions: ctx.allActions,
5020
- errors: ctx.allErrors,
5021
- aiUsage: aiUsage && aiUsage.calls > 0 ? aiUsage : void 0
5022
- };
5023
- }
5024
- /**
5025
- * 聚合一次 AI 研判消耗到 run 级累计。
5026
- * - 单次未调用(usage 为 undefined)幂等返回原累计
5027
- * - 成本合并:任一侧 undefined 时结果 undefined(模型无单价 → 不做误导性估算)
5028
- */
5029
- function mergeAiUsage(aggregate, usage) {
5030
- if (!usage || usage.calls === 0) return aggregate;
5031
- const next = {
5032
- calls: (aggregate?.calls ?? 0) + usage.calls,
5033
- inputTokens: (aggregate?.inputTokens ?? 0) + usage.inputTokens,
5034
- outputTokens: (aggregate?.outputTokens ?? 0) + usage.outputTokens,
5035
- totalTokens: (aggregate?.totalTokens ?? 0) + usage.totalTokens
5036
- };
5037
- if (aggregate === void 0) next.estimatedCostUsd = usage.estimatedCostUsd;
5038
- else if (aggregate.estimatedCostUsd === void 0 || usage.estimatedCostUsd === void 0) next.estimatedCostUsd = void 0;
5039
- else next.estimatedCostUsd = aggregate.estimatedCostUsd + usage.estimatedCostUsd;
5040
- return next;
5041
- }
5042
- /**
5043
- * 计算退出码:
5044
- * - 0: 全部仓库处理成功(无 failed actions、无 errors)
5045
- * - 1: 部分仓库失败
5046
- * - 2: 全部仓库失败(或无仓库被成功处理)
5047
- *
5048
- * 语义注记:AI 辅助动作(ai-patch)失败计入 exit code(fail-safe——AI 修复失败
5049
- * 说明 breaking change 未解决,应报红提醒人工),但不计入 summary.alertsFailed
5050
- * (告警结果由主动作代表;见 computeSummary 的 ai 辅助动作排除规则)。
5051
- */
5052
- function computeExitCode(ctx) {
5053
- const { config, allErrors, allActions, repoResults } = ctx;
5054
- const hasErrors = allErrors.length > 0;
5055
- const hasFailures = allActions.some((a) => !a.success);
5056
- const hasRepoSuccess = repoResults.length > 0 && repoResults.some((r) => r.verificationPassed !== false && (r.alertsCount > 0 || r.fixed > 0 || r.verificationPassed === true));
5057
- const hasCleanupSuccess = config.mode === "cleanup-branches" && allActions.some((a) => a.success && a.type === "branch-cleanup");
5058
- const hasSuccess = hasRepoSuccess || hasCleanupSuccess;
5059
- if (!hasErrors && !hasFailures) return 0;
5060
- if (hasSuccess) return 1;
5061
- return 2;
5062
- }
5063
5963
 
5064
5964
  //#endregion
5065
5965
  //#region src/app/repo-alerts.ts
5066
5966
  /**
5067
5967
  * 告警数据源统一入口:
5068
5968
  * - `github-dependabot`:Octokit 拉取 Dependabot alerts(alertsToken 优先);
5069
- * `codeScanningEnabled` 时**并行**拉取 Code Scanning alerts(互不覆盖、互不回退)
5969
+ * `codeScanningEnabled` 时**并行**拉取 Code Scanning alerts(互不覆盖、互不回退);
5970
+ * `codeQualityEnabled` 时**并行**拉取 Code Quality findings(与 Code Scanning 同源独立开启)。
5971
+ * 三个 GitHub 源完全独立(可单独开启任意组合)。
5070
5972
  * - `pnpm-audit`:本地 `pnpm audit --json` 回退(无 token;repository 已由 resolveAlertRepositories 解析)
5071
5973
  *
5072
5974
  * per-source 错误隔离:并行源任一失败 → 记录该源 FETCH_FAILED 错误
@@ -5081,13 +5983,20 @@ async function fetchRepoAlerts(deps, repo) {
5081
5983
  });
5082
5984
  const alertsClient = createAlertsClientFromConfig(config);
5083
5985
  const [owner, name] = repo.split("/");
5084
- const [dependabotResult, codeScanningResult] = await Promise.allSettled([fetchDependabotAlerts(alertsClient, {
5085
- owner,
5086
- repo: name
5087
- }), config.codeScanningEnabled ? fetchCodeScanningAlerts(alertsClient, {
5088
- owner,
5089
- repo: name
5090
- }) : Promise.resolve([])]);
5986
+ const [dependabotResult, codeScanningResult, codeQualityResult] = await Promise.allSettled([
5987
+ fetchDependabotAlerts(alertsClient, {
5988
+ owner,
5989
+ repo: name
5990
+ }),
5991
+ config.codeScanningEnabled ? fetchCodeScanningAlerts(alertsClient, {
5992
+ owner,
5993
+ repo: name
5994
+ }) : Promise.resolve([]),
5995
+ config.codeQualityEnabled ? fetchCodeQualityFindings(alertsClient, {
5996
+ owner,
5997
+ repo: name
5998
+ }) : Promise.resolve([])
5999
+ ]);
5091
6000
  const alerts = [];
5092
6001
  const failedSources = [];
5093
6002
  if (dependabotResult.status === "fulfilled") alerts.push(...dependabotResult.value);
@@ -5102,11 +6011,21 @@ async function fetchRepoAlerts(deps, repo) {
5102
6011
  failedSources.push("code-scanning");
5103
6012
  recordAlertSourceError(deps, repo, "code-scanning", codeScanningResult.reason);
5104
6013
  }
5105
- const totalSources = config.codeScanningEnabled ? 2 : 1;
6014
+ if (config.codeQualityEnabled) if (codeQualityResult.status === "fulfilled") {
6015
+ alerts.push(...codeQualityResult.value);
6016
+ deps.logger.info(`Fetched ${codeQualityResult.value.length} code quality findings for ${repo}`);
6017
+ } else {
6018
+ failedSources.push("code-quality");
6019
+ recordAlertSourceError(deps, repo, "code-quality", codeQualityResult.reason);
6020
+ }
6021
+ let totalSources = 1;
6022
+ if (config.codeScanningEnabled) totalSources++;
6023
+ if (config.codeQualityEnabled) totalSources++;
5106
6024
  if (failedSources.length === totalSources) {
5107
6025
  let firstReason;
5108
6026
  if (dependabotResult.status === "rejected") firstReason = dependabotResult.reason;
5109
6027
  else if (codeScanningResult.status === "rejected") firstReason = codeScanningResult.reason;
6028
+ else if (codeQualityResult.status === "rejected") firstReason = codeQualityResult.reason;
5110
6029
  else firstReason = /* @__PURE__ */ new Error(`failed to fetch alerts for ${repo}`);
5111
6030
  throw firstReason;
5112
6031
  }
@@ -5130,7 +6049,7 @@ function createAlertsClientFromConfig(config) {
5130
6049
  /** 记录单个告警源的拉取失败(不中断另一源的处理)。 */
5131
6050
  function recordAlertSourceError(deps, repo, source, error) {
5132
6051
  const message = toErrorMessage(error);
5133
- const hint = dependabotAlertsTokenHint(error) ?? codeScanningAlertsTokenHint(error);
6052
+ const hint = dependabotAlertsTokenHint(error) ?? codeScanningAlertsTokenHint(error) ?? codeQualityAlertsTokenHint(error);
5134
6053
  deps.logger.error(`Failed to fetch ${source} alerts for ${repo}: ${message}${hint ? ` — ${hint}` : ""}`);
5135
6054
  deps.allErrors.push({
5136
6055
  repository: repo,
@@ -5840,7 +6759,7 @@ async function applyLockfileFixes(ctx, client, repo, sets, progress) {
5840
6759
  const targets = Object.values(versionedOverrides);
5841
6760
  const targetSummary = targets.length > 0 ? targets.join(", ") : alert.recommendedVersion;
5842
6761
  if (ctx.config.dryRun) {
5843
- ctx.logger.info(`[dry-run] Would apply versioned overrides for ${alert.packageName}: ${JSON.stringify(versionedOverrides)}`);
6762
+ ctx.logger.info(`[dry-run] Would apply versioned overrides for ${alert.packageName}`, { overrides: versionedOverrides });
5844
6763
  ctx.allActions.push({
5845
6764
  type: "dependency-upgrade",
5846
6765
  repository: alert.repository,
@@ -5861,7 +6780,7 @@ async function applyLockfileFixes(ctx, client, repo, sets, progress) {
5861
6780
  continue;
5862
6781
  }
5863
6782
  const snapshot = snapshotTrackedFiles(ctx.workDir);
5864
- ctx.logger.info(`[multi-version] ${alert.packageName}: applying versioned overrides ${JSON.stringify(versionedOverrides)}`);
6783
+ ctx.logger.info(`[multi-version] ${alert.packageName}: applying versioned overrides`, { overrides: versionedOverrides });
5865
6784
  const result = await applyVersionedOverrides({
5866
6785
  packageName: alert.packageName,
5867
6786
  versionedOverrides,
@@ -6242,6 +7161,7 @@ var DependfixApp = class {
6242
7161
  logger;
6243
7162
  verbose;
6244
7163
  customCommands;
7164
+ executionEnvironment;
6245
7165
  runId;
6246
7166
  allAlerts = [];
6247
7167
  allActions = [];
@@ -6260,11 +7180,21 @@ var DependfixApp = class {
6260
7180
  this.reportOutputDir = options.reportOutputDir ?? "./dependfix-reports";
6261
7181
  this.verbose = options.verbose ?? false;
6262
7182
  this.customCommands = options.commands;
7183
+ this.executionEnvironment = options.executionEnvironment ?? "local";
6263
7184
  this.runId = `dependfix-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
6264
7185
  this.logger = createLogger({
6265
7186
  name: "dependfix",
6266
7187
  minLevel: this.verbose ? "debug" : "info"
6267
7188
  });
7189
+ resetActiveRulesConfig();
7190
+ const ruleConfig = loadRulesConfigFromEnv(process.env);
7191
+ if (ruleConfig) {
7192
+ setActiveRulesConfig(ruleConfig);
7193
+ this.logger.debug("Loaded custom code-scanning rules config from env", {
7194
+ autoFixableCount: ruleConfig.autoFixable.size,
7195
+ suggestedCount: ruleConfig.suggested.size
7196
+ });
7197
+ }
6268
7198
  }
6269
7199
  /** 供辅助方法使用的状态切片。 */
6270
7200
  get ctx() {
@@ -6286,6 +7216,7 @@ var DependfixApp = class {
6286
7216
  /** 执行完整的编排流程,返回结构化结果和退出码。 */
6287
7217
  async run() {
6288
7218
  this.startedAt = (/* @__PURE__ */ new Date()).toISOString();
7219
+ await this.runStartupSecurityChecks();
6289
7220
  this.preExistingDirty = hasGitChanges(this.workDir);
6290
7221
  this.logger.info(`Starting dependfix run ${this.runId}`, {
6291
7222
  mode: this.config.mode,
@@ -6305,7 +7236,8 @@ var DependfixApp = class {
6305
7236
  }
6306
7237
  this.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
6307
7238
  computeSummary(this.ctx);
6308
- const runResult = buildRunResult(this.ctx, this.aiUsageRef.aggregate);
7239
+ const supplyChainWarnings = collectSupplyChainWarnings(this.workDir, this.allActions);
7240
+ const runResult = buildRunResult(this.ctx, this.aiUsageRef.aggregate, supplyChainWarnings);
6309
7241
  const exitCode = computeExitCode(this.ctx);
6310
7242
  try {
6311
7243
  const md = generateMarkdownReport(runResult);
@@ -6330,6 +7262,20 @@ var DependfixApp = class {
6330
7262
  exitCode
6331
7263
  };
6332
7264
  }
7265
+ /**
7266
+ * 启动安全自检(best-effort,任何失败不阻断运行):
7267
+ * - 本地模式风险提示:fix/fix-and-pr 会在用户机器直接执行目标仓库的
7268
+ * 依赖脚本(不可信代码);容器环境(平台沙箱)属于设计内隔离,跳过。
7269
+ * - token 权限面探测:超权限 token(classic repo scope)启动即警告,
7270
+ * Code Scanning 开启但缺 security-events 权限时提示。
7271
+ */
7272
+ async runStartupSecurityChecks() {
7273
+ if (this.executionEnvironment === "local" && !this.config.dryRun && (this.config.mode === "fix" || this.config.mode === "fix-and-pr") && process.env.DEPENDFIX_SUPPRESS_LOCAL_EXECUTION_WARNING !== "1") this.logger.warn("[local-exec] 本地模式将直接执行目标仓库的依赖安装/验证脚本(install/lint/build 钩子,属不可信代码)——若仓库或依赖被恶意控制,脚本可读取本机环境变量(含 GITHUB_TOKEN)。建议:使用专用低权限 token,并在专用环境(容器/VM/CI runner)运行;已确认风险可设置 DEPENDFIX_SUPPRESS_LOCAL_EXECUTION_WARNING=1 抑制本提示(详见 quick-start 安全注意事项)。");
7274
+ if (this.config.alertSource === "github-dependabot" && this.config.githubToken) {
7275
+ const result = await checkTokenPermissions(this.createClient(), { codeScanningEnabled: this.config.codeScanningEnabled });
7276
+ if (result.ok && result.warnings.length > 0) for (const warning of result.warnings) this.logger.warn(`[token-scope] ${warning.message}`);
7277
+ }
7278
+ }
6333
7279
  async executeMode() {
6334
7280
  switch (this.config.mode) {
6335
7281
  case "report-only":
@@ -6484,7 +7430,8 @@ var DependfixApp = class {
6484
7430
  pushBranch(branchName, this.workDir);
6485
7431
  const defaultBranch = await fetchDefaultBranch(client, owner, repo);
6486
7432
  computeSummary(this.ctx);
6487
- const runResult = buildRunResult(this.ctx, this.aiUsageRef.aggregate);
7433
+ const supplyChainWarnings = collectSupplyChainWarnings(this.workDir, this.allActions);
7434
+ const runResult = buildRunResult(this.ctx, this.aiUsageRef.aggregate, supplyChainWarnings);
6488
7435
  const prBody = generatePRBody(runResult, plan.supersedePRs.map((pr) => pr.number));
6489
7436
  const prTitle = buildPrTitle(this.summary, this.allActions);
6490
7437
  const pr = await createPullRequest({
@@ -6627,5 +7574,5 @@ var DependfixApp = class {
6627
7574
  };
6628
7575
 
6629
7576
  //#endregion
6630
- export { ALERT_SOURCES, AUTO_FIXABLE_RULES, AiUsageTracker, DEFAULT_CONCURRENCY, DEFAULT_RUNTIME_CONFIG, DependfixApp, ENV_PREFIX, MAX_CONCURRENCY_LIMIT, MAX_GLOB_PATTERN_LENGTH, RUNTIME_MODES, SEVERITY_THRESHOLDS, SUGGESTED_RULES, applyCodeScanningFix, applyVersionedOverrides, assessBreakingChange, buildUpgradeGroups, classifyLockfileFailure, classifyRule, closePullRequest, compareSemver, computeFixAndPrPlan, computeFixFingerprint, computeLockfileDiff, computeRetryDelayMs, createFixBranch, createGitHubClient, createPullRequest, dedupeFixableAlerts, deleteRemoteBranch, detectPnpmMajor, discoverRepositories, ensurePnpmOverrides, estimateCostUsd, extractFingerprintFromBranch, extractLockfileVersion, extractMainPackage, extractPrefix, fetchCodeScanningAlerts, fetchDependabotAlerts, fetchPnpmAuditAlerts, filterExplicitRepositories, findDependencyVersion, findDependfixOpenPR, formatHistory, formatVerificationError, generatePRBody, getBranchPrStatus, getCodeScanningFixTemplate, hashAdvisoryId, inferRepoFromGitRemote, isConfirmAnswer, isCrossMajorFixRequired, isMainPackagePresent, isNonSemverDeclaration, isRootDirectDependency, isValidConcurrency, isValidPnpmVersion, listDependfixBranches, mapGitHubError, maskSecrets, matchesPattern, matchesRepoExclude, matchesRepoGlob, matchesRepoInclude, matchesTopicsExclude, mergeRepositories, overrideTransitiveDependency, parseAuditReport, parseDependabotGroups, parseMajorVersion, partitionSubmanifestAlerts, pushBranch, queryRepoHistory, quickVerifyProject, readArchiveIndex, readEnv, readEnvConfig, readExistingOverrides, readLockfileVersion, readLockfileVersions, readReposFile, repairLockfile, repoGlobToRegExp, resolvePnpmVersion, resolveRepoList, resolveRuntimeConfig, resolveWithinWorkDir, restoreSourceFile, restoreTrackedFiles, runVerification, runWithConcurrency, sanitizeOutput, snapshotSourceFile, snapshotTrackedFiles, stageAndCommit, suggestionFor, summarizeVerificationOutput, upgradeDependency, writeArchive };
7577
+ export { ALERT_SOURCES, AUTO_FIXABLE_RULES, AiUsageTracker, DEFAULT_CONCURRENCY, DEFAULT_RULES_CONFIG, DEFAULT_RUNTIME_CONFIG, DependfixApp, ENV_PREFIX, MAX_CONCURRENCY_LIMIT, MAX_GLOB_PATTERN_LENGTH, RULE_CONFIG_ENV, RUNTIME_MODES, SEVERITY_THRESHOLDS, SUGGESTED_RULES, analyzeTokenScope, applyCodeScanningFix, applyVersionedOverrides, assessBreakingChange, buildPrTitle, buildUpgradeGroups, checkTokenPermissions, classifyLockfileFailure, classifyRule, closePullRequest, compareSemver, computeFixAndPrPlan, computeFixFingerprint, computeLockfileDiff, computeRetryDelayMs, createFixBranch, createGitHubClient, createPullRequest, dedupeFixableAlerts, deleteRemoteBranch, detectPnpmMajor, discoverRepositories, ensurePnpmOverrides, estimateCostUsd, extractFingerprintFromBranch, extractLockfileVersion, extractMainPackage, extractPrefix, fetchCodeQualityFindings, fetchCodeScanningAlerts, fetchDefaultBranch, fetchDependabotAlerts, fetchPnpmAuditAlerts, filterExplicitRepositories, findDependencyVersion, findDependfixOpenPR, formatHistory, formatVerificationError, generatePRBody, getActiveRulesConfig, getBranchPrStatus, getCodeScanningFixTemplate, hashAdvisoryId, inferRepoFromGitRemote, isConfirmAnswer, isCrossMajorFixRequired, isMainPackagePresent, isNonSemverDeclaration, isRootDirectDependency, isValidConcurrency, isValidPnpmVersion, listDependfixBranches, loadRulesConfigFromEnv, loadRulesConfigFromFile, mapCodeQualitySeverity, mapGitHubError, maskSecrets, matchesPattern, matchesRepoExclude, matchesRepoGlob, matchesRepoInclude, matchesTopicsExclude, mergeRepositories, overrideTransitiveDependency, parseAuditReport, parseDependabotGroups, parseMajorVersion, parseRulesConfig, partitionSubmanifestAlerts, pushBranch, queryRepoHistory, quickVerifyProject, readArchiveIndex, readEnv, readEnvConfig, readExistingOverrides, readLockfileVersion, readLockfileVersions, readReposFile, repairLockfile, repoGlobToRegExp, resetActiveRulesConfig, resolvePnpmVersion, resolveRepoList, resolveRuntimeConfig, resolveWithinWorkDir, restoreSourceFile, restoreTrackedFiles, runVerification, runWithConcurrency, sanitizeOutput, setActiveRulesConfig, snapshotSourceFile, snapshotTrackedFiles, stageAndCommit, suggestionFor, summarizeVerificationOutput, upgradeDependency, writeArchive };
6631
7578
  //# sourceMappingURL=index.mjs.map