@dependfix/engine 0.1.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,128 +1,327 @@
1
- import { Octokit } from "@octokit/rest";
1
+ import { a as fromPat, c as computeRetryDelayMs, l as createGitHubClient } from "./auth2.mjs";
2
2
  import { RequestError } from "@octokit/request-error";
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";
3
+ import { AppError, collectCodeScanningSuggestions, createEmptyRunSummary, createLogger, filterAlerts, generateJsonReport, generateMarkdownReport, isAlertFixedByActions, isValidRepoIdentifier, limitAlerts, mapCodeScanningSeverity, normalizeAuditSeverity, normalizeUpstreamId, prioritizeAlerts, toErrorMessage, writeReport } from "@dependfix/core";
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
 
13
- //#region src/github/client.ts
15
+ //#region src/github/errors.ts
14
16
  /**
15
- * 创建已认证的 Octokit 实例。
17
+ * 将 Octokit 请求错误映射为 `AppError`。
16
18
  *
17
- * 调用方直接使用 `octokit.rest.*` 访问所有已类型化的 GitHub REST API。
18
- * 分页使用 `octokit.paginate()` 自动合并多页结果。
19
+ * @param error - 原始错误(`RequestError` 或网络异常)
20
+ * @param context - 调用上下文描述(如 `'fetch repo info for foo/bar'`),会附加到错误消息中
21
+ */
22
+ function mapGitHubError(error, context) {
23
+ if (error instanceof RequestError) {
24
+ const code = resolveErrorCode(error);
25
+ const details = collectErrorDetails(error);
26
+ return new AppError(code, `${context}: ${error.message}`, {
27
+ cause: error,
28
+ details
29
+ });
30
+ }
31
+ if (error instanceof Error) return new AppError("NETWORK_ERROR", `${context}: ${error.message}`, { cause: error });
32
+ return new AppError("NETWORK_ERROR", `${context}: unknown error`, { details: { raw: toErrorMessage(error) } });
33
+ }
34
+ function resolveErrorCode(error) {
35
+ switch (error.status) {
36
+ case 401: return "AUTHENTICATION_FAILED";
37
+ case 403: {
38
+ const remaining = error.response?.headers["x-ratelimit-remaining"];
39
+ if (remaining !== void 0 && remaining === "0") return "RATE_LIMITED";
40
+ return "PERMISSION_DENIED";
41
+ }
42
+ case 404: return "REPO_NOT_FOUND";
43
+ default: return "GITHUB_API_ERROR";
44
+ }
45
+ }
46
+ function collectErrorDetails(error) {
47
+ const details = {
48
+ status: error.status,
49
+ requestUrl: error.request?.url,
50
+ requestMethod: error.request?.method
51
+ };
52
+ const resetAt = error.response?.headers["x-ratelimit-reset"];
53
+ if (resetAt) details.rateLimitReset = resetAt;
54
+ return details;
55
+ }
56
+
57
+ //#endregion
58
+ //#region src/github/code-quality-fetcher.ts
59
+ /** cursor 分页最大页数保护(恶意 Link header 重复同 cursor 或 API 异常时终止循环) */
60
+ const MAX_CURSOR_PAGES = 1e3;
61
+ /**
62
+ * 拉取指定仓库的 Code Quality findings 并映射为标准化模型。
19
63
  *
20
- * 配置 `retry` 时通过 `hook.wrap('request')` 统一包装所有请求
21
- * (含 paginate 内部请求),对限流响应做指数退避重试。
64
+ * - 自动分页(cursor-based:before/after 走 `Link` header,与 GitHub 通用分页一致)
65
+ * - 仅处理 `state=open` 的 finding(可通过 `params.state` 覆盖)
66
+ * - severity:`rule.severity`(error/warning/note)经 `mapCodeQualitySeverity` 映射
67
+ * - Code Quality findings **不可自动修复**(`fixable: false`、`fixStrategy: null`),
68
+ * 首版统一归 C 类(`alertClass: 'report-only'`),不实现模板化修复
69
+ * - 异常通过 `mapGitHubError` 转为 `AppError`
70
+ * - 空仓库返回 `[]`,不抛异常
22
71
  *
23
- * @example
24
- * ```typescript
25
- * const octokit = createGitHubClient({ token: 'ghp_xxxx' })
72
+ * @param client - 已认证的 Octokit 实例(来自 `createGitHubClient`)
73
+ * @param params - 仓库标识与过滤参数
74
+ * @returns 标准化告警列表
75
+ */
76
+ async function fetchCodeQualityFindings(client, params) {
77
+ const { owner, repo, state = "open", perPage = 100 } = params;
78
+ try {
79
+ const findings = [];
80
+ let cursor;
81
+ let pages = 0;
82
+ const seenCursors = /* @__PURE__ */ new Set();
83
+ for (;;) {
84
+ pages += 1;
85
+ if (pages > MAX_CURSOR_PAGES) break;
86
+ if (cursor && seenCursors.has(cursor)) break;
87
+ if (cursor) seenCursors.add(cursor);
88
+ const response = await client.request("GET /repos/{owner}/{repo}/code-quality/findings", {
89
+ owner,
90
+ repo,
91
+ state,
92
+ per_page: perPage,
93
+ ...cursor ? { after: cursor } : {}
94
+ });
95
+ findings.push(...response.data);
96
+ cursor = parseNextCursor(response.headers.link);
97
+ if (!cursor) break;
98
+ }
99
+ return findings.map((f) => normalizeFinding(f, owner, repo));
100
+ } catch (error) {
101
+ const context = `fetch code quality findings for ${owner}/${repo}`;
102
+ throw mapGitHubError(error, context);
103
+ }
104
+ }
105
+ /**
106
+ * 将单条 Code Quality finding 转换为标准化模型。
26
107
  *
27
- * // 仓库信息
28
- * const { data: repo } = await octokit.rest.repos.get({ owner: 'foo', repo: 'bar' })
108
+ * 映射逻辑:
109
+ * - severity:`rule.severity`(error/warning/note)经 `mapCodeQualitySeverity` 映射;
110
+ * 缺失 → `'unknown'`(不静默丢弃)
111
+ * - packageName 取规则人类可读名(`rule.title`,如 "Useless null check"),
112
+ * 报告 Package 列对 code-quality 展示规则名
113
+ * - ruleId 取 `rule.id`(如 `java/useless-null-check`),报告 Rule/Advisory 列展示
114
+ * - manifestPath 取告警文件路径(`location.path`)
115
+ * - htmlUrl:将 API URL(`https://api.github.com/...`)转换为 web URL
116
+ * (`https://github.com/owner/repo/code-quality/findings/N`)
117
+ * - fixable 恒为 `false`(Code Quality findings 不可自动修复)
118
+ * - alertClass 恒为 `'report-only'`(C 类默认,未列入 A/B)
119
+ */
120
+ function normalizeFinding(finding, owner, repo) {
121
+ const ruleId = finding.rule?.id ?? "";
122
+ const severity = mapCodeQualitySeverity(finding.rule?.severity);
123
+ const description = finding.rule?.description ?? "";
124
+ return {
125
+ id: finding.number,
126
+ source: "code-quality",
127
+ repository: `${owner}/${repo}`,
128
+ defaultBranch: "",
129
+ severity,
130
+ packageEcosystem: "code-quality",
131
+ packageName: finding.rule?.title ?? (ruleId || "unknown"),
132
+ manifestPath: finding.location?.path ?? "",
133
+ ruleId,
134
+ summary: finding.message?.text ?? description,
135
+ htmlUrl: toWebUrl(finding.url, owner, repo, finding.number),
136
+ fixable: false,
137
+ fixStrategy: null,
138
+ recommendedVersion: "",
139
+ alertClass: "report-only",
140
+ startLine: finding.location?.start_line ?? void 0,
141
+ endLine: finding.location?.end_line ?? void 0,
142
+ suggestion: description || "人工审查该 Code Quality finding",
143
+ upstreamId: normalizeUpstreamId("code-quality", { alertNumber: finding.number })
144
+ };
145
+ }
146
+ /**
147
+ * 映射 Code Quality severity:
148
+ * - `error` → `high`(语义对齐 code-scanning 映射:error 表示需立即关注)
149
+ * - `warning` → `medium`
150
+ * - `note` → `low`
151
+ * - 缺失或非法值 → `'unknown'`(不静默丢弃)
29
152
  *
30
- * // Dependabot 告警(自动分页)
31
- * const alerts = await octokit.paginate(
32
- * octokit.rest.dependabot.listAlertsForRepo,
33
- * { owner: 'foo', repo: 'bar', state: 'open', per_page: 100 },
34
- * )
35
- * ```
153
+ * 与 code-scanning 共用 `error/warning/note` 值域(GitHub CodeQL 规则同源),
154
+ * 映射口径与 `mapCodeScanningSeverity` 对齐(避免新源不一致)。
36
155
  */
37
- function createGitHubClient(options) {
38
- const client = new Octokit({
39
- auth: options.token,
40
- baseUrl: options.baseUrl ?? "https://api.github.com"
41
- });
42
- const maxRetries = options.retry?.maxRetries ?? 3;
43
- if (maxRetries > 0) applyRetryPolicy(client, {
44
- maxRetries,
45
- baseDelayMs: options.retry?.baseDelayMs ?? 1e3,
46
- maxBackoffMs: options.retry?.maxBackoffMs ?? MAX_BACKOFF_MS_DEFAULT
47
- });
48
- return client;
49
- }
50
- const SECONDARY_RATE_LIMIT_RE = /secondary rate limit|abuse|retry later|retry-after/i;
51
- /** 指数退避单次等待默认上限(30s),防止长时间空转(可经 retry.maxBackoffMs 覆盖) */
52
- const MAX_BACKOFF_MS_DEFAULT = 3e4;
53
- function applyRetryPolicy(client, retry) {
54
- const maxRetries = retry.maxRetries ?? 3;
55
- const baseDelayMs = retry.baseDelayMs ?? 1e3;
56
- const maxBackoffMs = retry.maxBackoffMs ?? MAX_BACKOFF_MS_DEFAULT;
57
- client.hook.wrap("request", async (request, options) => {
58
- const method = (options.method ?? "GET").toUpperCase();
59
- if (method !== "GET" && method !== "HEAD") return request(options);
60
- let attempt = 0;
61
- for (;;) try {
62
- return await request(options);
63
- } catch (error) {
64
- const delayMs = computeRetryDelayMs(error, attempt, baseDelayMs, maxBackoffMs);
65
- if (delayMs === null || attempt >= maxRetries) throw error;
66
- attempt += 1;
67
- await sleep(delayMs);
68
- }
69
- });
156
+ function mapCodeQualitySeverity(severity) {
157
+ switch (severity) {
158
+ case "error": return "high";
159
+ case "warning": return "medium";
160
+ case "note": return "low";
161
+ default: return "unknown";
162
+ }
163
+ }
164
+ /**
165
+ * 将 GitHub API URL(`https://api.github.com/repos/{owner}/{repo}/code-quality/findings/{n}`)
166
+ * 转换为 web URL(`https://github.com/{owner}/{repo}/code-quality/findings/{n}`)。
167
+ *
168
+ * GitHub web URL 形态固定(不含 `/repos/` 段),直接基于 owner/repo/number 构造
169
+ * 比正则替换更稳健(避免 API 路径形态变化导致错误)。
170
+ */
171
+ function toWebUrl(apiUrl, owner, repo, number) {
172
+ return `https://github.com/${owner}/${repo}/code-quality/findings/${number}`;
173
+ }
174
+ /**
175
+ * 解析 GitHub Link header 提取 `rel="next"` 的 cursor(after=...)。
176
+ * 返回 cursor 字符串;无 next → 返回 undefined(终止分页)。
177
+ *
178
+ * GitHub Code Quality API 用 cursor-based 分页(before/after)而非 page-based,
179
+ * 与 listAlertsForRepo 的 page=1&page=2 不同;Link header 格式相同。
180
+ */
181
+ function parseNextCursor(linkHeader) {
182
+ if (!linkHeader) return;
183
+ const nextMatch = /<([^>]+)>;\s*rel="next"/.exec(linkHeader);
184
+ if (!nextMatch) return;
185
+ const nextUrl = nextMatch[1];
186
+ try {
187
+ return new URL(nextUrl).searchParams.get("after") ?? void 0;
188
+ } catch {
189
+ return;
190
+ }
70
191
  }
192
+
193
+ //#endregion
194
+ //#region src/code-scanning/rule-config.ts
195
+ /** 默认规则配置(编译后;与既有常量表等价,单点声明保持向后兼容) */
196
+ const DEFAULT_RULES_CONFIG = {
197
+ autoFixable: /* @__PURE__ */ new Set(["eol-last"]),
198
+ suggested: /* @__PURE__ */ new Map([
199
+ ["no-unused-vars", "删除未使用的变量/导入;注意导出引用与副作用(import 副作用场景),确认后手动删除"],
200
+ ["js/sql-injection", "使用参数化查询(PreparedStatement / query builder),禁止字符串拼接 SQL"],
201
+ ["js/xss", "输出前 HTML 转义;innerHTML / document.write 场景用 DOMPurify 等清洗库"],
202
+ ["js/path-injection", "校验用户输入路径在预期目录内(path.resolve + 前缀校验),禁止直接拼接文件路径"],
203
+ ["js/command-line-injection", "避免 shell 字符串拼接,使用参数数组(child_process.spawn(args))"],
204
+ ["js/insecure-randomness", "安全随机数使用 crypto.randomBytes / Web Crypto,禁止 Math.random"],
205
+ ["js/weak-cryptographic-algorithm", "升级为强算法(AES-256-GCM / Argon2 / SHA-256+),安全场景禁用 MD5/SHA-1"],
206
+ ["js/missing-rate-limiting", "为认证/敏感接口添加限流(如 express-rate-limit)"],
207
+ ["js/clear-text-storage-of-sensitive-data", "敏感数据加密存储(AES-256-GCM),禁止明文落盘"],
208
+ ["js/clear-text-transmission-of-sensitive-data", "启用 TLS(HTTPS/WSS),禁止明文传输敏感数据"],
209
+ ["js/hardcoded-credentials", "凭证移入环境变量/密钥管理服务(GitHub Secrets / Vault),禁止硬编码"],
210
+ ["py/sql-injection", "使用参数化查询(sqlite3 占位符 / SQLAlchemy 参数绑定),禁止字符串拼接 SQL"],
211
+ ["py/path-injection", "校验并规范化用户输入路径(os.path.realpath + 目录前缀校验)"],
212
+ ["py/command-line-injection", "使用参数数组(subprocess.run(args)),禁止 shell=True 字符串拼接"],
213
+ ["py/insecure-default-file-permissions", "创建文件时显式设置权限(os.open mode / umask)"],
214
+ ["java/sql-injection", "使用 PreparedStatement 参数绑定,禁止字符串拼接 SQL"],
215
+ ["java/path-injection", "校验用户输入路径(Path.normalize() + startsWith 检查),禁止直接拼接"],
216
+ ["java/command-line-injection", "使用 ProcessBuilder 参数列表,禁止 shell 字符串拼接"]
217
+ ])
218
+ };
71
219
  /**
72
- * 计算限流重试等待时间(纯函数,可单测)。
73
- *
74
- * 可重试(返回等待毫秒):
75
- * - 429(Too Many Requests / secondary rate limit)
76
- * - 403 + `x-ratelimit-remaining: 0`(primary rate limit)
77
- * - 403/429 + message 含 secondary rate limit / abuse / retry 特征
78
- *
79
- * 等待策略(Retry-After 优先):
80
- * 1. `retry-after` 头(秒)→ 等待其秒数(受 maxBackoffMs 上限约束)
81
- * 2. `x-ratelimit-reset`(unix 秒)→ 等待到 reset + 1s 缓冲
82
- * 3. 否则 → 指数退避 `baseDelayMs * 2^attempt`
83
- * 所有等待均受 maxBackoffMs 上限约束(默认 30s)。
84
- *
85
- * 不重试(返回 null):
86
- * - 非 RequestError(网络错误等由上层 AppError 语义处理)
87
- * - 权限类 403(无限流特征)
88
- * - 其他状态码
89
- */
90
- function computeRetryDelayMs(error, attempt, baseDelayMs = 1e3, maxBackoffMs = MAX_BACKOFF_MS_DEFAULT) {
91
- if (!(error instanceof RequestError)) return null;
92
- const status = error.status;
93
- const headers = error.response?.headers ?? {};
94
- const remaining = headers["x-ratelimit-remaining"];
95
- const message = error.message;
96
- let retryable = false;
97
- if (status === 429) retryable = true;
98
- else if (status === 403) {
99
- if (remaining !== void 0 && remaining === "0") retryable = true;
100
- else if (SECONDARY_RATE_LIMIT_RE.test(message)) retryable = true;
101
- }
102
- if (!retryable) return null;
103
- const retryAfter = headers["retry-after"];
104
- if (retryAfter !== void 0) {
105
- const seconds = Number(retryAfter);
106
- if (Number.isFinite(seconds) && seconds > 0) return Math.min(seconds * 1e3, maxBackoffMs);
107
- }
108
- const reset = headers["x-ratelimit-reset"];
109
- if (reset !== void 0) {
110
- const resetMs = Number(reset) * 1e3;
111
- if (Number.isFinite(resetMs) && resetMs > 0) {
112
- const waitMs = resetMs - Date.now() + 1e3;
113
- if (waitMs > 0) return Math.min(waitMs, maxBackoffMs);
220
+ * 解析 JSON 配置输入为编译后配置。
221
+ *
222
+ * 校验规则(全部失败均降级返回 null,由调用方走默认):
223
+ * - 顶层必须是对象,含 `rules` 数组
224
+ * - 每个 entry 必须含非空 `id`(trim 后)、合法 `class`
225
+ * - 同 id 在多个 class 中出现 → 拒绝(disjointness 不变量)
226
+ * - suggested 必须含 `suggestion` 字段且非空
227
+ *
228
+ * @returns 解析成功 → 编译后配置;失败 → null(调用方降级)
229
+ */
230
+ function parseRulesConfig(input) {
231
+ if (!input || typeof input !== "object") return null;
232
+ const file = input;
233
+ if (!Array.isArray(file.rules)) return null;
234
+ const autoFixable = /* @__PURE__ */ new Set();
235
+ const suggested = /* @__PURE__ */ new Map();
236
+ const seenIds = /* @__PURE__ */ new Set();
237
+ for (const raw of file.rules) {
238
+ if (!raw || typeof raw !== "object") return null;
239
+ const id = typeof raw.id === "string" ? raw.id.trim() : "";
240
+ if (!id) return null;
241
+ if (seenIds.has(id)) return null;
242
+ seenIds.add(id);
243
+ const cls = raw.class;
244
+ if (cls !== "auto-fixable" && cls !== "suggested" && cls !== "report-only") return null;
245
+ if (cls === "auto-fixable") autoFixable.add(id);
246
+ else if (cls === "suggested") {
247
+ const suggestion = typeof raw.suggestion === "string" ? raw.suggestion.trim() : "";
248
+ if (!suggestion) return null;
249
+ suggested.set(id, suggestion);
114
250
  }
115
251
  }
116
- return Math.min(baseDelayMs * 2 ** attempt, maxBackoffMs);
252
+ return {
253
+ autoFixable,
254
+ suggested
255
+ };
256
+ }
257
+ /**
258
+ * 从 JSON 文件路径加载并编译。读取失败 / 解析失败 / 校验失败 → 返回 null(调用方降级)。
259
+ */
260
+ function loadRulesConfigFromFile(configPath) {
261
+ let content;
262
+ try {
263
+ content = readFileSync(configPath, "utf-8");
264
+ } catch (error) {
265
+ process.stderr.write(`[rule-config] cannot read ${configPath}: ${error instanceof Error ? error.message : String(error)}\n`);
266
+ return null;
267
+ }
268
+ let parsed;
269
+ try {
270
+ parsed = JSON.parse(content);
271
+ } catch (error) {
272
+ process.stderr.write(`[rule-config] invalid JSON in ${configPath}: ${error instanceof Error ? error.message : String(error)}\n`);
273
+ return null;
274
+ }
275
+ const compiled = parseRulesConfig(parsed);
276
+ if (!compiled) process.stderr.write(`[rule-config] invalid config schema in ${configPath}, falling back to defaults\n`);
277
+ return compiled;
278
+ }
279
+ /** 环境变量名:Code Scanning 规则分类配置文件路径 */
280
+ const RULE_CONFIG_ENV = "CODE_SCANNING_RULES_CONFIG_PATH";
281
+ /**
282
+ * 从 env 读取 `RULE_CONFIG_ENV` 并尝试加载。
283
+ *
284
+ * - env 未设或空 → 返回 null(调用方继续走默认)
285
+ * - 路径不存在 → 返回 null
286
+ * - 加载失败 → 返回 null(错误已写入 stderr)
287
+ */
288
+ function loadRulesConfigFromEnv(env) {
289
+ const path = env[RULE_CONFIG_ENV]?.trim();
290
+ if (!path) return null;
291
+ if (!existsSync(path)) {
292
+ process.stderr.write(`[rule-config] ${RULE_CONFIG_ENV} points to non-existent path: ${path}\n`);
293
+ return null;
294
+ }
295
+ return loadRulesConfigFromFile(path);
296
+ }
297
+ let activeConfig = DEFAULT_RULES_CONFIG;
298
+ /** 获取当前生效的配置(classifyRule / suggestionFor 内部使用) */
299
+ function getActiveRulesConfig() {
300
+ return activeConfig;
301
+ }
302
+ /**
303
+ * 替换为自定义配置。
304
+ *
305
+ * **使用协议**:运行时模块初始化时(如 AppContext 装配)调用一次;
306
+ * 测试可临时覆盖后通过 `resetActiveRulesConfig` 恢复。频繁切换会破坏
307
+ * `code-scanning-fetcher` 的可预测行为(同一规则在不同 fetch 中分类不同)。
308
+ */
309
+ function setActiveRulesConfig(config) {
310
+ activeConfig = config;
117
311
  }
118
- function sleep(ms) {
119
- return new Promise((resolve) => setTimeout(resolve, ms));
312
+ /** 重置为默认配置(测试 cleanup + 运行时 fallback) */
313
+ function resetActiveRulesConfig() {
314
+ activeConfig = DEFAULT_RULES_CONFIG;
120
315
  }
121
316
 
122
317
  //#endregion
123
318
  //#region src/code-scanning/rule-classifier.ts
124
319
  /**
125
- * A 类自动修复白名单:仅收录"删除/改动后不影响程序行为"且**已有修复模板**的规则。
320
+ * A 类自动修复白名单默认集合(默认值常量;测试 disjointness 不变量用)。
321
+ *
322
+ * **与 active config 的关系**:本导出为默认常量(向后兼容历史测试与外部引用),
323
+ * 运行时 classifyRule 实际从 `getActiveRulesConfig().autoFixable` 读取。
324
+ * 若配置已被 setActiveRulesConfig 替换,本常量与运行时分类可能不一致(设计意图)。
126
325
  *
127
326
  * 选择标准(实现时评审确认):
128
327
  * - 可模板化:修复动作可机械描述(删行 / 对齐 / 移除空白)
@@ -137,9 +336,9 @@ function sleep(ms) {
137
336
  * 纯格式规则(eol-last 等)实际出现频率低;此处先建立机制,
138
337
  * 规则命中与否不影响分层正确性。
139
338
  */
140
- const AUTO_FIXABLE_RULES = /* @__PURE__ */ new Set(["eol-last"]);
339
+ const AUTO_FIXABLE_RULES = DEFAULT_RULES_CONFIG.autoFixable;
141
340
  /**
142
- * B 类建议修复列表:高风险 / 需人工判断的规则。
341
+ * B 类建议规则默认列表(默认值常量;测试 disjointness 不变量用)。
143
342
  *
144
343
  * 收录原则:
145
344
  * - 安全敏感(注入 / XSS / 路径穿越 / 密码学等),修复需结合业务语义
@@ -152,109 +351,30 @@ const AUTO_FIXABLE_RULES = /* @__PURE__ */ new Set(["eol-last"]);
152
351
  * 其余语言(go/ruby/csharp/cpp 等)与未收录变体(如 js/reflected-xss)落 C 类兜底;
153
352
  * 真实仓库 API 样本核对(rule id 格式与变体分布)登记为演进项。
154
353
  */
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
- ]);
354
+ const SUGGESTED_RULES = new Set(DEFAULT_RULES_CONFIG.suggested.keys());
175
355
  /**
176
356
  * 规则分类:
177
357
  * - A 类白名单 → `'auto-fixable'`
178
358
  * - B 类建议列表 → `'suggested'`
179
359
  * - 其余(含空字符串)→ `'report-only'`(C 类,默认,不静默丢弃)
360
+ *
361
+ * 读取自当前生效配置(`getActiveRulesConfig()`);首次调用前需通过
362
+ * `setActiveRulesConfig` 替换或保持默认。
180
363
  */
181
364
  function classifyRule(ruleId) {
182
365
  const id = ruleId?.trim() ?? "";
183
- if (AUTO_FIXABLE_RULES.has(id)) return "auto-fixable";
184
- if (SUGGESTED_RULES.has(id)) return "suggested";
366
+ const config = getActiveRulesConfig();
367
+ if (config.autoFixable.has(id)) return "auto-fixable";
368
+ if (config.suggested.has(id)) return "suggested";
185
369
  return "report-only";
186
370
  }
187
371
  /**
188
372
  * 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 类兜底)。 */
373
+ * 读取自当前生效配置(`getActiveRulesConfig().suggested`);未收录规则返回通用建议(C 类兜底,不静默)。
374
+ */
212
375
  function suggestionFor(ruleId) {
213
376
  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;
377
+ return getActiveRulesConfig().suggested.get(id) ?? "人工审查该 Code Scanning 告警:结合业务语义评估修复方案(参考 CodeQL 规则文档)";
258
378
  }
259
379
 
260
380
  //#endregion
@@ -324,7 +444,8 @@ function normalizeAlert$1(alert, owner, repo) {
324
444
  alertClass: classifyRule(ruleId),
325
445
  startLine: instance?.location?.start_line ?? void 0,
326
446
  endLine: instance?.location?.end_line ?? void 0,
327
- suggestion: suggestionFor(ruleId)
447
+ suggestion: suggestionFor(ruleId),
448
+ upstreamId: normalizeUpstreamId("code-scanning", { alertNumber: alert.number })
328
449
  };
329
450
  }
330
451
  /**
@@ -356,8 +477,13 @@ function mapSeverity(alert) {
356
477
  *
357
478
  * @example
358
479
  * ```typescript
359
- * const octokit = createGitHubClient({ token: 'ghp_xxxx' })
480
+ * // 推荐:使用 auth 抽象层(M18.1 实施后)
481
+ * import { fromPat } from '@dependfix/engine/auth'
482
+ * const octokit = createGitHubClient({ auth: fromPat('ghp_xxxx') })
360
483
  * const alerts = await fetchDependabotAlerts(octokit, { owner: 'foo', repo: 'bar' })
484
+ *
485
+ * // 向后兼容:使用 token 字段(deprecated)
486
+ * const octokit = createGitHubClient({ token: 'ghp_xxxx' })
361
487
  * ```
362
488
  */
363
489
  async function fetchDependabotAlerts(client, params) {
@@ -403,10 +529,22 @@ function normalizeAlert(alert, owner, repo) {
403
529
  fixable,
404
530
  fixStrategy: fixable ? "upgrade" : null,
405
531
  recommendedVersion: firstPatched?.identifier ?? "",
406
- dependencyType: normalizeDependencyRelationship(alert.dependency.relationship)
532
+ dependencyType: normalizeDependencyRelationship(alert.dependency.relationship),
533
+ upstreamId: normalizeUpstreamId("dependabot", { alertNumber: alert.number }),
534
+ ghsaId: alert.security_advisory.ghsa_id,
535
+ cveIds: extractCveIds(alert.security_advisory.identifiers)
407
536
  };
408
537
  }
409
538
  /**
539
+ ** 从 dependabot alert.security_advisory.identifiers[] 提取 CVE 列表(type === 'CVE')。
540
+ ** 兼容 identifiers 缺省为 undefined / 空数组场景。
541
+ */
542
+ function extractCveIds(identifiers) {
543
+ if (!Array.isArray(identifiers) || identifiers.length === 0) return;
544
+ const cves = identifiers.filter((id) => id?.type === "CVE" && typeof id.value === "string" && Boolean(id.value)).map((id) => id.value);
545
+ return cves.length > 0 ? cves : void 0;
546
+ }
547
+ /**
410
548
  * 将 Dependabot API 的 `dependency.relationship` 映射为 `DependencyType`。
411
549
  *
412
550
  * GitHub API 返回的合法值:
@@ -421,8 +559,19 @@ function normalizeDependencyRelationship(relationship) {
421
559
 
422
560
  //#endregion
423
561
  //#region src/github/pr-creator.ts
424
- const BOT_NAME = "dependfix[bot]";
425
- const BOT_EMAIL = "dependfix[bot]@users.noreply.github.com";
562
+ /**
563
+ * PAT 路径默认 commit author(保持现有 PAT 路径用户行为零变化)。
564
+ *
565
+ * 注:此常量仅作为 PAT 路径的默认值;GitHub App 路径(M18.2 之后接入)走动态 commit author。
566
+ * 现有 PAT 路径仍硬编码 `dependfix[bot]@users.noreply.github.com`,虽非真实 bot 身份(字符串约定),
567
+ * 但保持行为不变以确保 PAT 用户无感升级。已知缺陷由 C22 范围之外的后续阶段修复。
568
+ *
569
+ * @see [C22 PAT 无感升级评估 §5.1 兼容性](../../../../docs/design/governance/c22-pat-backward-compat.md)
570
+ */
571
+ const PAT_DEFAULT_COMMIT_AUTHOR = {
572
+ name: "dependfix[bot]",
573
+ email: "dependfix[bot]@users.noreply.github.com"
574
+ };
426
575
  /** 自动修复分支统一前缀(分支名 = 前缀 + 内容指纹 8 位) */
427
576
  const BRANCH_PREFIX = "dependfix/auto-fix-";
428
577
  /**
@@ -518,14 +667,28 @@ function createFixBranch(branchName, workDir) {
518
667
  * 自动设置 git user.name / user.email(如未设置)。
519
668
  * 使用 `execFileSync` 参数数组形式(不经 shell),保证多行 commit message
520
669
  * 与 UTF-8 字符(如 →)在 Windows/Linux 双平台传递一致。
670
+ *
671
+ * W3 修复(M18.4 audit round 2):显式传 `-c user.name=X -c user.email=Y` 强制 commit author
672
+ * 使用传入值,不受 host 全局 `user.name`(如 CaoMeiYouRen)污染——`git commit` 走 lookup order
673
+ * (env → `-c` config → local → global → system),显式 `-c` 在 lookup order 中最高优先。
674
+ *
675
+ * @param author - 可选 commit author 信息;不传时使用 PAT 默认值(保持现有 PAT 路径行为零变化)。
676
+ * M18.2 之后 GitHub App 路径会传入动态生成的 `{app_id}+{bot_login}[bot]` author。
677
+ *
678
+ * @see [C22 PAT 无感升级评估 §5.1 兼容性](../../../../docs/design/governance/c22-pat-backward-compat.md)
521
679
  */
522
- function stageAndCommit(message, workDir) {
523
- ensureGitConfig(workDir);
524
- execSync("git add .", {
680
+ function stageAndCommit(message, workDir, author) {
681
+ ensureGitConfig(workDir, author);
682
+ const effectiveAuthor = author ?? PAT_DEFAULT_COMMIT_AUTHOR;
683
+ execFileSync("git", ["add", "."], {
525
684
  cwd: workDir,
526
685
  stdio: "pipe"
527
686
  });
528
687
  execFileSync("git", [
688
+ "-c",
689
+ `user.name=${effectiveAuthor.name}`,
690
+ "-c",
691
+ `user.email=${effectiveAuthor.email}`,
529
692
  "commit",
530
693
  "-m",
531
694
  message
@@ -534,9 +697,6 @@ function stageAndCommit(message, workDir) {
534
697
  stdio: "pipe"
535
698
  });
536
699
  }
537
- /**
538
- * 推送分支到远程 origin。
539
- */
540
700
  function pushBranch(branchName, workDir) {
541
701
  execFileSync("git", [
542
702
  "push",
@@ -579,6 +739,38 @@ async function closePullRequest(octokit, owner, repo, pullNumber) {
579
739
  });
580
740
  }
581
741
  /**
742
+ * 在 Pull Request 上添加评论。
743
+ *
744
+ * 使用 `issues.createComment` 端点(GitHub PR 评论复用 issue comment API),
745
+ * 需要 token 具备 `issues: write` 权限(比 `pull-requests: write` 宽)。
746
+ *
747
+ * 用于在新 PR 上评论指向被取代的旧 PR,避免用户手动排查重复 PR。
748
+ */
749
+ async function commentOnPullRequest(octokit, owner, repo, pullNumber, body) {
750
+ await octokit.rest.issues.createComment({
751
+ owner,
752
+ repo,
753
+ issue_number: pullNumber,
754
+ body
755
+ });
756
+ }
757
+ /**
758
+ * 在 Pull Request 上添加 label。
759
+ *
760
+ * 使用 `issues.addLabels` 端点(GitHub PR label 复用 issue label API),
761
+ * 需要 token 具备 `issues: write` 权限。
762
+ *
763
+ * 用于标记重复 PR(如 `duplicate` label),便于用户过滤和管理。
764
+ */
765
+ async function addLabelToPullRequest(octokit, owner, repo, pullNumber, labels) {
766
+ await octokit.rest.issues.addLabels({
767
+ owner,
768
+ repo,
769
+ issue_number: pullNumber,
770
+ labels
771
+ });
772
+ }
773
+ /**
582
774
  * 通过 GitHub API 创建 Pull Request。
583
775
  */
584
776
  async function createPullRequest(params) {
@@ -729,6 +921,12 @@ function generatePRBody(result, supersededNumbers) {
729
921
  `| Verifications failed | ${summary.verificationsFailed} |`,
730
922
  ""
731
923
  ];
924
+ const supplyChainWarnings = result.supplyChainWarnings ?? [];
925
+ if (supplyChainWarnings.length > 0) {
926
+ lines.push("### ⚠️ Supply Chain Warnings", "", "> 以下升级的包带 lifecycle scripts 且已被仓库 `allowBuilds` 批准——安装时脚本将真实执行。合入本 PR 前请人工确认。", "", "| Package | Version | Scripts | Repository |", "|---------|---------|---------|------------|");
927
+ for (const w of supplyChainWarnings) lines.push(`| \`${escapeTableCell(w.packageName)}\` | \`${escapeTableCell(w.version)}\` | ${w.scriptTypes.map((t) => `\`${escapeTableCell(t)}\``).join(", ")} | ${escapeTableCell(w.repository)} |`);
928
+ lines.push("");
929
+ }
732
930
  const upgrades = aggregateUpgradeActions(actions, (a) => a.success);
733
931
  if (upgrades.length > 0) {
734
932
  const multiRepo = new Set(upgrades.map((u) => u.repository)).size > 1;
@@ -926,21 +1124,35 @@ function branchExists(branchName, workDir) {
926
1124
  return false;
927
1125
  }
928
1126
  }
929
- function ensureGitConfig(workDir) {
1127
+ function ensureGitConfig(workDir, author) {
1128
+ const effectiveAuthor = author ?? PAT_DEFAULT_COMMIT_AUTHOR;
930
1129
  const hasName = gitConfigExists("user.name", workDir);
931
1130
  const hasEmail = gitConfigExists("user.email", workDir);
932
- if (!hasName) execSync(`git config user.name "${BOT_NAME}"`, {
1131
+ if (!hasName) execFileSync("git", [
1132
+ "config",
1133
+ "user.name",
1134
+ effectiveAuthor.name
1135
+ ], {
933
1136
  cwd: workDir,
934
1137
  stdio: "pipe"
935
1138
  });
936
- if (!hasEmail) execSync(`git config user.email "${BOT_EMAIL}"`, {
1139
+ if (!hasEmail) execFileSync("git", [
1140
+ "config",
1141
+ "user.email",
1142
+ effectiveAuthor.email
1143
+ ], {
937
1144
  cwd: workDir,
938
1145
  stdio: "pipe"
939
1146
  });
940
1147
  }
941
1148
  function gitConfigExists(key, workDir) {
942
1149
  try {
943
- execSync(`git config ${key}`, {
1150
+ execFileSync("git", [
1151
+ "config",
1152
+ "--local",
1153
+ "--get",
1154
+ key
1155
+ ], {
944
1156
  cwd: workDir,
945
1157
  stdio: "pipe"
946
1158
  });
@@ -1045,6 +1257,7 @@ const DEPENDABOT_CONFIG_PATH = ".github/dependabot.yml";
1045
1257
  * 2. topic 过滤(`--repo-topics`,AND 语义)
1046
1258
  * 3. 名单策略过滤(include / exclude / topicsExclude,探测前应用)
1047
1259
  * 4. dependabot.yml 探测(仅候选仓库触达 contents API;404 视为不支持,不剔除)
1260
+ * 5. 规模上限截断(`maxRepos`,按 fullName 字典序排序后截断)
1048
1261
  *
1049
1262
  * 结果按 `fullName` 字典序排序,保证同输入多次运行结果一致
1050
1263
  * (runId / 指纹稳定性前提)。
@@ -1052,7 +1265,7 @@ const DEPENDABOT_CONFIG_PATH = ".github/dependabot.yml";
1052
1265
  * @throws AppError(mapGitHubError 转换;token 权限、网络等全局性问题 fail-fast)
1053
1266
  */
1054
1267
  async function discoverRepositories(options) {
1055
- const { client, owners, topics = [], policy, probeDependabot = true, probeConcurrency = 5 } = options;
1268
+ const { client, owners, topics = [], policy, probeDependabot = true, probeConcurrency = 5, maxRepos } = options;
1056
1269
  const discovered = [];
1057
1270
  for (const owner of owners) {
1058
1271
  const repos = await fetchOwnerRepositories(client, owner);
@@ -1083,6 +1296,8 @@ async function discoverRepositories(options) {
1083
1296
  }
1084
1297
  if (probeDependabot) await probeDependabotConfigs(client, discovered, probeConcurrency);
1085
1298
  discovered.sort((a, b) => a.fullName.localeCompare(b.fullName));
1299
+ const effectiveMaxRepos = maxRepos ?? 100;
1300
+ if (effectiveMaxRepos > 0 && discovered.length > effectiveMaxRepos) return discovered.slice(0, effectiveMaxRepos);
1086
1301
  return discovered;
1087
1302
  }
1088
1303
  /**
@@ -1166,6 +1381,79 @@ async function probeDependabotConfig(client, owner, repo) {
1166
1381
  }
1167
1382
  }
1168
1383
 
1384
+ //#endregion
1385
+ //#region src/github/token-scope.ts
1386
+ const CLASSIC_REPO_SCOPE_WARNING = {
1387
+ code: "CLASSIC_REPO_SCOPE",
1388
+ message: "检测到 classic PAT 且包含 repo(全量仓库)权限——该 token 一旦被恶意依赖脚本窃取,即可接管所有可见仓库。建议改用 fine-grained PAT(最小权限:contents/pull-requests 写 + security-events 读,仅目标仓库),详见 quick-start 安全注意事项。"
1389
+ };
1390
+ const MISSING_SECURITY_EVENTS_WARNING = {
1391
+ code: "MISSING_SECURITY_EVENTS",
1392
+ message: "已开启 Code Scanning 告警源,但 token 缺少 security-events 读取权限——Code Scanning 告警将拉取失败。请为 token 授予 security-events 读取权限(classic PAT 对应 security_events scope,内置 GITHUB_TOKEN 默认具备)。"
1393
+ };
1394
+ /**
1395
+ * 解析 GitHub token 权限面并输出警告(纯函数,可单测)。
1396
+ *
1397
+ * @param scopeHeader `GET /user` 响应的 `x-oauth-scopes` 头(classic PAT 才有值)
1398
+ * @param acceptedHeader `GET /user` 响应的 `x-accepted-github-permissions` 头(fine-grained / 内置 token 才有值)
1399
+ * @param options.codeScanningEnabled 是否开启 Code Scanning 源(开启时校验 security-events 权限)
1400
+ */
1401
+ function analyzeTokenScope(scopeHeader, acceptedHeader, options = {}) {
1402
+ const scopes = parseHeaderList(scopeHeader);
1403
+ const acceptedPermissions = parseHeaderList(acceptedHeader);
1404
+ if (scopes) {
1405
+ const warnings = [];
1406
+ if (scopes.includes("repo")) warnings.push(CLASSIC_REPO_SCOPE_WARNING);
1407
+ if (options.codeScanningEnabled && !scopes.includes("security_events") && !scopes.includes("repo")) warnings.push(MISSING_SECURITY_EVENTS_WARNING);
1408
+ return {
1409
+ scopes,
1410
+ warnings
1411
+ };
1412
+ }
1413
+ if (acceptedPermissions) {
1414
+ const warnings = [];
1415
+ if (options.codeScanningEnabled && !hasPermission(acceptedPermissions, "security-events", "read")) warnings.push(MISSING_SECURITY_EVENTS_WARNING);
1416
+ return {
1417
+ acceptedPermissions,
1418
+ warnings
1419
+ };
1420
+ }
1421
+ return { warnings: [] };
1422
+ }
1423
+ /** 校验 accepted permissions 是否包含指定 resource 的指定 access 级别。 */
1424
+ function hasPermission(permissions, resource, access) {
1425
+ return permissions.some((p) => {
1426
+ const [res, acc] = p.split(":").map((s) => s.trim().toLowerCase());
1427
+ return res === resource && acc === access;
1428
+ });
1429
+ }
1430
+ /** 解析逗号分隔的响应头(去空白去空项);头缺失或为空返回 undefined。 */
1431
+ function parseHeaderList(header) {
1432
+ if (!header) return;
1433
+ const items = header.split(",").map((s) => s.trim()).filter(Boolean);
1434
+ return items.length > 0 ? items : void 0;
1435
+ }
1436
+ /**
1437
+ * 探测 token 权限面(网络调用,best-effort)。
1438
+ * 任何失败(鉴权 401 / 网络错误 / 超时)均静默返回 `ok: false`,不抛错。
1439
+ */
1440
+ async function checkTokenPermissions(client, options = {}) {
1441
+ try {
1442
+ const response = await client.request("GET /user", { request: { signal: AbortSignal.timeout(5e3) } });
1443
+ const analysis = analyzeTokenScope(response.headers["x-oauth-scopes"], response.headers["x-accepted-github-permissions"], options);
1444
+ return {
1445
+ ok: true,
1446
+ login: response.data.login,
1447
+ ...analysis
1448
+ };
1449
+ } catch {
1450
+ return {
1451
+ ok: false,
1452
+ warnings: []
1453
+ };
1454
+ }
1455
+ }
1456
+
1169
1457
  //#endregion
1170
1458
  //#region src/code-scanning/templates.ts
1171
1459
  const TEMPLATES = new Map([{
@@ -1330,6 +1618,27 @@ function getStderr(err) {
1330
1618
  if (err instanceof Error) return err.message;
1331
1619
  return typeof err === "object" && err !== null ? JSON.stringify(err) : String(err);
1332
1620
  }
1621
+ /**
1622
+ * `pnpm install` stderr 中的已知失败特征 → 可读提示。
1623
+ * 当前覆盖:minimumReleaseAge 冷却期约束(目标仓库显式配置该策略时 strict 模式生效,
1624
+ * 解析报 ERR_PNPM_NO_MATURE_MATCHING_VERSION、frozen 校验报 ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION)。
1625
+ * 未识别时返回 null,调用方保留原始 stderr。
1626
+ */
1627
+ 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"]];
1628
+ /** 识别 `pnpm install` 失败原因,未识别返回 null。 */
1629
+ function classifyInstallFailure(stderr) {
1630
+ for (const [pattern, hint] of INSTALL_FAILURE_HINTS) if (pattern.test(stderr)) return hint;
1631
+ return null;
1632
+ }
1633
+ /**
1634
+ * 格式化 `pnpm install` 失败错误信息:识别到已知原因时附加可读提示。
1635
+ * 供升级链路各 catch 块统一使用(upgradeDependency / overrideTransitiveDependency /
1636
+ * applyVersionedOverrides),避免三处重复匹配逻辑。
1637
+ */
1638
+ function formatInstallFailure(stderr) {
1639
+ const hint = classifyInstallFailure(stderr);
1640
+ return hint ? `pnpm install failed: ${hint}: ${stderr}` : `pnpm install failed: ${stderr}`;
1641
+ }
1333
1642
 
1334
1643
  //#endregion
1335
1644
  //#region src/fixers/dependency/index.ts
@@ -1391,7 +1700,7 @@ async function upgradeDependency(params) {
1391
1700
  toVersion,
1392
1701
  isMajor,
1393
1702
  success: false,
1394
- error: `pnpm install failed: ${getStderr(installErr)}`
1703
+ error: formatInstallFailure(getStderr(installErr))
1395
1704
  };
1396
1705
  }
1397
1706
  cleanupBackups({
@@ -1478,13 +1787,14 @@ async function overrideTransitiveDependency(params) {
1478
1787
  lockBackup,
1479
1788
  workspaceBackup
1480
1789
  });
1790
+ const stderr = getStderr(installErr);
1481
1791
  return {
1482
1792
  packageName,
1483
1793
  fromVersion,
1484
1794
  toVersion,
1485
1795
  isMajor,
1486
1796
  success: false,
1487
- error: `pnpm install failed: ${getStderr(installErr)}`
1797
+ error: formatInstallFailure(stderr)
1488
1798
  };
1489
1799
  }
1490
1800
  if (verifyOverrideTookEffect) {
@@ -1638,7 +1948,7 @@ async function applyVersionedOverrides(params) {
1638
1948
  toVersion: entries.map((entry) => entry[1]).join(", "),
1639
1949
  isMajor: false,
1640
1950
  success: false,
1641
- error: `pnpm install failed: ${stderr}`
1951
+ error: formatInstallFailure(stderr)
1642
1952
  };
1643
1953
  }
1644
1954
  cleanupBackups({
@@ -1895,7 +2205,7 @@ function isCrossMajorFixRequired(lockfilePath, alert) {
1895
2205
  */
1896
2206
  function classifyLockfileFailure(workDir) {
1897
2207
  try {
1898
- execSync("pnpm install --frozen-lockfile", {
2208
+ execFileSync("pnpm", ["install", "--frozen-lockfile"], {
1899
2209
  cwd: workDir,
1900
2210
  stdio: "pipe",
1901
2211
  timeout: 12e4
@@ -1921,6 +2231,7 @@ const FAILURE_PATTERNS = [
1921
2231
  ["CREDENTIAL_ERROR", /E401|ERR_PNPM_FETCH_401|ERR_PNPM_FETCH_403|authentication failed|Authorization/i],
1922
2232
  ["CORRUPTED_LOCKFILE", /\b(broken|corrupted|Cannot read).*lockfile|ERR_PNPM_BROKEN_LOCKFILE/i],
1923
2233
  ["LOCKFILE_VERSION_MISMATCH", /lockfileVersion.*incompatible|unsupported lockfile version|lockfile had been generated with/i],
2234
+ ["MINIMUM_RELEASE_AGE", /ERR_PNPM_NO_MATURE_MATCHING_VERSION|ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|minimumReleaseAge/i],
1924
2235
  ["RESOLVE_ERROR", /ERR_PNPM_NO_MATCHING_VERSION|resolution|resolve.*failed|ERR_PNPM_PEER_DEP_ISSUES/i]
1925
2236
  ];
1926
2237
  /**
@@ -1936,8 +2247,8 @@ function getStderrText(err) {
1936
2247
  if (typeof err.stderr === "string") return err.stderr;
1937
2248
  return err.stderr.toString("utf-8");
1938
2249
  }
1939
- function execRepair(command, workDir, timeoutMs = 18e4) {
1940
- execSync(command, {
2250
+ function execRepair(binary, args, workDir, timeoutMs = 18e4) {
2251
+ execFileSync(binary, args, {
1941
2252
  cwd: workDir,
1942
2253
  stdio: "pipe",
1943
2254
  timeout: timeoutMs
@@ -1966,6 +2277,7 @@ function getStrategyChain(category) {
1966
2277
  "FIX_ENTRIES",
1967
2278
  "REINSTALL"
1968
2279
  ];
2280
+ case "MINIMUM_RELEASE_AGE": return ["REGENERATE", "REINSTALL"];
1969
2281
  case "UNKNOWN": return ["REGENERATE", "REINSTALL"];
1970
2282
  case "CREDENTIAL_ERROR": return [];
1971
2283
  }
@@ -1976,14 +2288,43 @@ function getStrategyChain(category) {
1976
2288
  * (corepack 不可用/下载失败时命令本身失败 → 策略链 REGENERATE/REINSTALL 兜底,
1977
2289
  * 行为不劣于现状)
1978
2290
  * - `pnpmVersion` 缺省:回退裸 `pnpm install --lockfile-only`(保持旧 stub 语义)
2291
+ *
2292
+ * 返回 `{ binary, args }` 而非完整 shell 命令字符串,便于 `execFileSync` 参数化调用
2293
+ * 避免 shell 解释(`execSync` 模板拼接是 Code Scanning 命令注入告警源头)。所有
2294
+ * 参数均受 `isValidPnpmVersion` 白名单约束,无 shell metacharacter。
1979
2295
  */
1980
2296
  function getStrategyCommand(strategy, pnpmVersion) {
1981
2297
  switch (strategy) {
1982
- case "REGENERATE": return "pnpm install --lockfile-only";
1983
- case "FIX_ENTRIES": return "pnpm install --fix-lockfile --lockfile-only";
1984
- case "REINSTALL": return "pnpm install --no-frozen-lockfile";
1985
- case "PIN_TOOLCHAIN": return pnpmVersion ? `corepack pnpm@${pnpmVersion} install --lockfile-only` : "pnpm install --lockfile-only";
1986
- default: return "";
2298
+ case "REGENERATE": return {
2299
+ binary: "pnpm",
2300
+ args: ["install", "--lockfile-only"]
2301
+ };
2302
+ case "FIX_ENTRIES": return {
2303
+ binary: "pnpm",
2304
+ args: [
2305
+ "install",
2306
+ "--fix-lockfile",
2307
+ "--lockfile-only"
2308
+ ]
2309
+ };
2310
+ case "REINSTALL": return {
2311
+ binary: "pnpm",
2312
+ args: ["install", "--no-frozen-lockfile"]
2313
+ };
2314
+ case "PIN_TOOLCHAIN":
2315
+ if (!pnpmVersion) return {
2316
+ binary: "pnpm",
2317
+ args: ["install", "--lockfile-only"]
2318
+ };
2319
+ return {
2320
+ binary: "corepack",
2321
+ args: [
2322
+ `pnpm@${pnpmVersion}`,
2323
+ "install",
2324
+ "--lockfile-only"
2325
+ ]
2326
+ };
2327
+ default: return null;
1987
2328
  }
1988
2329
  }
1989
2330
  /** 从 lockfile 内容提取 `lockfileVersion` 字段值(如 `9.0`);缺失返回 null。 */
@@ -2034,7 +2375,7 @@ function countLockfilePackages(path) {
2034
2375
  }
2035
2376
  /**
2036
2377
  * pnpm 版本白名单格式(安全加固:拒绝不可信 packageManager/config 的任意字符串,
2037
- * 防命令注入——corepack 命令是唯一动态拼接的 execSync 命令)。
2378
+ * 防命令注入——corepack 命令参数 `pnpm@<version>` 受 PNPM_VERSION_RE 白名单约束)。
2038
2379
  * 兼容标准 semver 与 corepack 哈希后缀(pnpm@10.5.2+sha512.xxx)。
2039
2380
  */
2040
2381
  const PNPM_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
@@ -2068,7 +2409,7 @@ const LOCKFILE_NAME = "pnpm-lock.yaml";
2068
2409
  */
2069
2410
  function verifyFrozenLockfile(workDir) {
2070
2411
  try {
2071
- execSync("pnpm install --frozen-lockfile", {
2412
+ execFileSync("pnpm", ["install", "--frozen-lockfile"], {
2072
2413
  cwd: workDir,
2073
2414
  stdio: "pipe",
2074
2415
  timeout: 12e4
@@ -2154,12 +2495,14 @@ function repairLockfile(params) {
2154
2495
  let lastError = stderr;
2155
2496
  const startTimestamp = Date.now();
2156
2497
  for (const strategy of strategyChain) {
2157
- const command = getStrategyCommand(strategy, pnpmVersion);
2498
+ const cmd = getStrategyCommand(strategy, pnpmVersion);
2499
+ if (!cmd) continue;
2500
+ const command = [cmd.binary, ...cmd.args].join(" ");
2158
2501
  const t0 = Date.now();
2159
2502
  let strategySuccess = false;
2160
2503
  let strategyError;
2161
2504
  try {
2162
- execRepair(command, workDir_);
2505
+ execRepair(cmd.binary, cmd.args, workDir_);
2163
2506
  strategySuccess = true;
2164
2507
  } catch (err) {
2165
2508
  strategyError = getStderrText(err) || err.message;
@@ -2205,7 +2548,7 @@ function repairLockfile(params) {
2205
2548
  return {
2206
2549
  success: false,
2207
2550
  failureCategory: category,
2208
- failureDetail: truncate$1(lastError, 500),
2551
+ 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
2552
  attemptHistory: attempts
2210
2553
  };
2211
2554
  }
@@ -2452,11 +2795,13 @@ const DEFAULT_RUNTIME_CONFIG = {
2452
2795
  severityThreshold: "high",
2453
2796
  alertSource: "github-dependabot",
2454
2797
  codeScanningEnabled: false,
2798
+ codeQualityEnabled: false,
2455
2799
  allowMajorUpgrade: false,
2456
2800
  maxAlertsPerRepository: 20,
2457
2801
  maxConcurrency: 1,
2458
2802
  maxRetries: 3,
2459
2803
  maxBackoffMs: 3e4,
2804
+ maxRepos: 100,
2460
2805
  ai: {
2461
2806
  enabled: false,
2462
2807
  provider: "openai-compatible",
@@ -2577,10 +2922,12 @@ function readEnvConfig(env = process.env) {
2577
2922
  alertsToken: readEnv(env, "ALERTS_TOKEN")?.trim() || void 0,
2578
2923
  alertSource: readAlertSource(readEnv(env, "ALERTS_SOURCE"), `${ENV_PREFIX}ALERTS_SOURCE`),
2579
2924
  codeScanningEnabled: normalizeBoolean(readEnv(env, "CODE_SCANNING"), `${ENV_PREFIX}CODE_SCANNING`),
2925
+ codeQualityEnabled: normalizeBoolean(readEnv(env, "CODE_QUALITY"), `${ENV_PREFIX}CODE_QUALITY`),
2580
2926
  maxAlertsPerRepository: normalizeInteger(readEnv(env, "MAX_ALERTS_PER_REPOSITORY"), `${ENV_PREFIX}MAX_ALERTS_PER_REPOSITORY`),
2581
2927
  maxConcurrency: normalizeInteger(readEnv(env, "MAX_CONCURRENCY"), `${ENV_PREFIX}MAX_CONCURRENCY`),
2582
2928
  maxRetries: normalizeNonNegativeInteger(readEnv(env, "MAX_RETRIES"), `${ENV_PREFIX}MAX_RETRIES`),
2583
2929
  maxBackoffMs: normalizeInteger(readEnv(env, "MAX_BACKOFF_MS"), `${ENV_PREFIX}MAX_BACKOFF_MS`),
2930
+ maxRepos: normalizeNonNegativeInteger(readEnv(env, "MAX_REPOS"), `${ENV_PREFIX}MAX_REPOS`),
2584
2931
  upgradeGroups: normalizeUpgradeGroups(readEnv(env, "UPGRADE_GROUPS")),
2585
2932
  toolchainPnpmVersion: readEnv(env, "TOOLCHAIN_PNPM_VERSION")?.trim() || void 0,
2586
2933
  aiEnabled: normalizeBoolean(readEnv(env, "AI"), `${ENV_PREFIX}AI`),
@@ -2635,10 +2982,13 @@ function validateRuntimeConfig(config) {
2635
2982
  if (isAuditSource && config.owner && config.owner.length > 0) throw new AppError("CONFIG_VALIDATION_ERROR", "--owner / DEPENDFIX_OWNER requires the github-dependabot alert source (owner discovery uses the GitHub API).");
2636
2983
  if (config.mode === "cleanup-branches" && config.owner && config.owner.length > 0) throw new AppError("CONFIG_VALIDATION_ERROR", "--owner / DEPENDFIX_OWNER is not supported in cleanup-branches mode (branch cleanup requires explicit target repositories).");
2637
2984
  if (config.mode === "cleanup-branches" && config.maxConcurrency > 1) throw new AppError("CONFIG_VALIDATION_ERROR", "maxConcurrency > 1 is not supported in cleanup-branches mode (branch cleanup runs sequentially).");
2638
- if (config.repositories.length === 0 && !(config.owner && config.owner.length > 0)) if (isAuditSource) {} else throw new AppError("CONFIG_VALIDATION_ERROR", "Missing target repositories. Provide --repo, --repository, --repos-file, --owner or DEPENDFIX_REPOSITORIES / DEPENDFIX_OWNER.");
2985
+ if (config.repositories.length === 0 && !(config.owner && config.owner.length > 0)) {
2986
+ if (isAuditSource) {} else throw new AppError("CONFIG_VALIDATION_ERROR", "Missing target repositories. Provide --repo, --repository, --repos-file, --owner or DEPENDFIX_REPOSITORIES / DEPENDFIX_OWNER.");
2987
+ }
2639
2988
  for (const repo of config.repositories) if (!isValidRepoIdentifier(repo)) throw new AppError("CONFIG_VALIDATION_ERROR", `Invalid repository identifier: "${repo}". Expected format: owner/repo`);
2640
2989
  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
2990
  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).");
2991
+ 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
2992
  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
2993
  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
2994
  if (config.mode === "report-only" && config.createPullRequest) throw new AppError("CONFIG_VALIDATION_ERROR", "createPullRequest cannot be enabled when mode is report-only.");
@@ -2681,11 +3031,13 @@ function resolveRuntimeConfig(options = {}) {
2681
3031
  alertsToken: cliOverrides.alertsToken ?? envConfig.alertsToken,
2682
3032
  alertSource: cliOverrides.alertSource ?? envConfig.alertSource ?? DEFAULT_RUNTIME_CONFIG.alertSource,
2683
3033
  codeScanningEnabled: cliOverrides.codeScanningEnabled ?? envConfig.codeScanningEnabled ?? DEFAULT_RUNTIME_CONFIG.codeScanningEnabled,
3034
+ codeQualityEnabled: cliOverrides.codeQualityEnabled ?? envConfig.codeQualityEnabled ?? DEFAULT_RUNTIME_CONFIG.codeQualityEnabled,
2684
3035
  allowMajorUpgrade: cliOverrides.allowMajorUpgrade ?? DEFAULT_RUNTIME_CONFIG.allowMajorUpgrade,
2685
3036
  maxAlertsPerRepository: cliOverrides.maxAlertsPerRepository ?? envConfig.maxAlertsPerRepository ?? DEFAULT_RUNTIME_CONFIG.maxAlertsPerRepository,
2686
3037
  maxConcurrency: cliOverrides.maxConcurrency ?? envConfig.maxConcurrency ?? DEFAULT_RUNTIME_CONFIG.maxConcurrency,
2687
3038
  maxRetries: cliOverrides.maxRetries ?? envConfig.maxRetries ?? DEFAULT_RUNTIME_CONFIG.maxRetries,
2688
3039
  maxBackoffMs: cliOverrides.maxBackoffMs ?? envConfig.maxBackoffMs ?? DEFAULT_RUNTIME_CONFIG.maxBackoffMs,
3040
+ maxRepos: cliOverrides.maxRepos ?? envConfig.maxRepos ?? DEFAULT_RUNTIME_CONFIG.maxRepos,
2689
3041
  upgradeGroups: cliOverrides.upgradeGroups ?? envConfig.upgradeGroups,
2690
3042
  toolchainPnpmVersion: cliOverrides.toolchainPnpmVersion ?? envConfig.toolchainPnpmVersion,
2691
3043
  ai: resolveAiOptions(cliOverrides, envConfig)
@@ -2898,14 +3250,279 @@ function formatHistory(entries, repo) {
2898
3250
  }
2899
3251
 
2900
3252
  //#endregion
2901
- //#region src/runners/verification-runner.ts
2902
- const DEFAULT_COMMANDS = [
2903
- "pnpm install --frozen-lockfile",
2904
- "pnpm lint",
3253
+ //#region src/runners/network-audit.ts
3254
+ const CONNECT_TIMEOUT_MS = 1e4;
3255
+ /**
3256
+ * 默认出站白名单(网络白名单治理决策:仅 registry + GitHub API/资产域 + 受信任构建工具文档站)。
3257
+ * - `*.npmjs.org` 覆盖 npm registry 全子域(含 registry.npmjs.org 的 tarball 下载域)
3258
+ * - GitHub 相关:REST API(api.github.com)、git clone/网页(github.com)、
3259
+ * release 资产(objects.githubusercontent.com)、raw 文件(raw.githubusercontent.com)
3260
+ * - `rolldown.rs` = vite 6/7 Rust 实现官方文档站;vite 跨 major 升级 verification 阶段命令输出
3261
+ * 会出现 `https://rolldown.rs/reference/...` 链接(verification-runner 命令输出 URL 提取会捕获),
3262
+ * 不放行则合法 verification 被误判 network_violation → run exitCode=1
3263
+ * 需要更多域时通过环境变量扩展(见 readAllowedDomains)。
3264
+ */
3265
+ const DEFAULT_ALLOWED_DOMAINS = [
3266
+ "*.npmjs.org",
3267
+ "api.github.com",
3268
+ "github.com",
3269
+ "objects.githubusercontent.com",
3270
+ "raw.githubusercontent.com",
3271
+ "rolldown.rs"
3272
+ ];
3273
+ /**
3274
+ * 从环境读取出站白名单:默认清单 + 扩展域(逗号分隔,追加不替换)。
3275
+ * 读取顺序:`DEPENDFIX_ALLOWED_DOMAINS` 优先,回退裸 `ALLOWED_DOMAINS`
3276
+ * (与 CLI 配置层的 DEPENDFIX_ 前缀惯例一致,同时兼容决策文档中的裸变量名)。
3277
+ * 空/未配置 → 仅默认清单。
3278
+ */
3279
+ function readAllowedDomains(env = process.env) {
3280
+ const raw = env.DEPENDFIX_ALLOWED_DOMAINS ?? env.ALLOWED_DOMAINS;
3281
+ if (!raw) return [...DEFAULT_ALLOWED_DOMAINS];
3282
+ const extra = raw.split(",").map((s) => s.trim()).filter(Boolean);
3283
+ return [.../* @__PURE__ */ new Set([...DEFAULT_ALLOWED_DOMAINS, ...extra])];
3284
+ }
3285
+ /**
3286
+ * 从外联目标提取主机名(去协议、去路径、去端口;IPv6 方括号形式保留括号)。
3287
+ * - `host:port` / `https://host/path` / `host` → `host`
3288
+ * - `[::1]:443` → `[::1]`
3289
+ */
3290
+ function extractHostname(target) {
3291
+ let t = target.trim();
3292
+ const protoIdx = t.indexOf("://");
3293
+ if (protoIdx >= 0) t = t.slice(protoIdx + 3);
3294
+ const slashIdx = t.indexOf("/");
3295
+ if (slashIdx >= 0) t = t.slice(0, slashIdx);
3296
+ if (t.startsWith("[")) {
3297
+ const close = t.indexOf("]");
3298
+ return close >= 0 ? t.slice(0, close + 1) : t;
3299
+ }
3300
+ const colonIdx = t.lastIndexOf(":");
3301
+ return colonIdx >= 0 ? t.slice(0, colonIdx) : t;
3302
+ }
3303
+ /**
3304
+ * 白名单命中判定(大小写不敏感)。
3305
+ * - `*.domain` 通配符:匹配任意**子域**(`sub.domain`、`a.b.domain`),不含裸 `domain` 本身;
3306
+ * 边界由前导点保证(`evilnpmjs.org` 不会被 `*.npmjs.org` 命中)
3307
+ * - 其他条目:主机名精确匹配(IPv6 需带方括号,与 extractHostname 输出一致)
3308
+ */
3309
+ function isDomainAllowed(hostname, allowedDomains) {
3310
+ const host = hostname.trim().toLowerCase();
3311
+ return allowedDomains.some((pattern) => {
3312
+ const p = pattern.trim().toLowerCase();
3313
+ if (p.startsWith("*.")) {
3314
+ const base = p.slice(2);
3315
+ return host.length > base.length + 1 && host.endsWith(`.${base}`);
3316
+ }
3317
+ return host === p;
3318
+ });
3319
+ }
3320
+ /**
3321
+ * 将外联目标最小化为报告安全形态:仅保留 host[:port],丢弃路径与查询串。
3322
+ * deny-by-default 拦截的证据字段不落 payload——恶意脚本通过 URL query 外带凭据时,
3323
+ * 网络层已拦截但查询串不得原样回显进报告/日志(防御纵深,最小暴露)。
3324
+ * - `evil.example.com:443`(CONNECT)→ 原样
3325
+ * - `https://evil.example.com/exfil?token=x` → `evil.example.com`
3326
+ * - `[::1]:443` / `http://host:8080/path` → 保留方括号/端口
3327
+ * - 空/无法解析 → `<unknown-target>`(防报告出现空目标)
3328
+ */
3329
+ function redactUrlForReport(target) {
3330
+ let t = target.trim();
3331
+ const protoIdx = t.indexOf("://");
3332
+ if (protoIdx >= 0) t = t.slice(protoIdx + 3);
3333
+ const slashIdx = t.indexOf("/");
3334
+ if (slashIdx >= 0) t = t.slice(0, slashIdx);
3335
+ return t || "<unknown-target>";
3336
+ }
3337
+ /**
3338
+ * 启动本地网络拦截代理(127.0.0.1 随机端口)。
3339
+ * 支持 CONNECT 隧道(HTTPS)与明文 HTTP 转发;deny-by-default:非白名单目标
3340
+ * 返回 502/销毁 socket 且不建立上游连接,并记录违规外联;白名单命中才转发
3341
+ * (转发失败仍返回 502,不挂死调用方)。停止后服务器关闭。
3342
+ */
3343
+ async function startNetworkAudit(options) {
3344
+ const allowedDomains = options?.allowedDomains ?? readAllowedDomains();
3345
+ const entries = [];
3346
+ const violations = [];
3347
+ const server = createServer();
3348
+ server.on("connect", (req, clientSocket, head) => {
3349
+ const target = req.url ?? "";
3350
+ const baseEntry = {
3351
+ time: (/* @__PURE__ */ new Date()).toISOString(),
3352
+ source: "proxy",
3353
+ method: "CONNECT",
3354
+ target
3355
+ };
3356
+ const allowed = isDomainAllowed(extractHostname(target), allowedDomains);
3357
+ const entry = allowed ? baseEntry : {
3358
+ ...baseEntry,
3359
+ violation: true
3360
+ };
3361
+ entries.push(entry);
3362
+ if (!allowed) {
3363
+ violations.push(entry);
3364
+ clientSocket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
3365
+ clientSocket.end();
3366
+ return;
3367
+ }
3368
+ const idx = target.lastIndexOf(":");
3369
+ const upstreamHost = target.slice(0, idx);
3370
+ const port = Number.parseInt(target.slice(idx + 1), 10) || 443;
3371
+ const upstream = connect(port, upstreamHost);
3372
+ const timer = setTimeout(() => {
3373
+ upstream.destroy();
3374
+ clientSocket.destroy();
3375
+ }, CONNECT_TIMEOUT_MS);
3376
+ upstream.on("connect", () => {
3377
+ clearTimeout(timer);
3378
+ clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
3379
+ if (head && head.length > 0) upstream.write(head);
3380
+ upstream.pipe(clientSocket);
3381
+ clientSocket.pipe(upstream);
3382
+ });
3383
+ upstream.on("error", () => {
3384
+ clearTimeout(timer);
3385
+ clientSocket.destroy();
3386
+ });
3387
+ clientSocket.on("error", () => {
3388
+ clearTimeout(timer);
3389
+ upstream.destroy();
3390
+ });
3391
+ });
3392
+ server.on("request", (req, res) => {
3393
+ const hostHeader = req.headers.host ?? "";
3394
+ const target = `${hostHeader}${req.url ?? ""}`;
3395
+ const baseEntry = {
3396
+ time: (/* @__PURE__ */ new Date()).toISOString(),
3397
+ source: "proxy",
3398
+ method: req.method ?? "GET",
3399
+ target
3400
+ };
3401
+ const host = extractHostname(hostHeader);
3402
+ const allowed = isDomainAllowed(host, allowedDomains);
3403
+ const entry = allowed ? baseEntry : {
3404
+ ...baseEntry,
3405
+ violation: true
3406
+ };
3407
+ entries.push(entry);
3408
+ if (!allowed) {
3409
+ violations.push(entry);
3410
+ res.statusCode = 502;
3411
+ res.end();
3412
+ return;
3413
+ }
3414
+ const port = Number.parseInt(hostHeader.split(":")[1], 10) || 80;
3415
+ const proxyReq = request({
3416
+ host,
3417
+ port,
3418
+ path: req.url,
3419
+ method: req.method,
3420
+ headers: req.headers,
3421
+ timeout: CONNECT_TIMEOUT_MS
3422
+ }, (proxyRes) => {
3423
+ const cleanHeaders = { ...proxyRes.headers };
3424
+ for (const hop of [
3425
+ "connection",
3426
+ "keep-alive",
3427
+ "transfer-encoding",
3428
+ "te",
3429
+ "trailer",
3430
+ "upgrade",
3431
+ "proxy-connection"
3432
+ ]) Reflect.deleteProperty(cleanHeaders, hop);
3433
+ res.writeHead(proxyRes.statusCode ?? 500, cleanHeaders);
3434
+ proxyRes.pipe(res);
3435
+ });
3436
+ proxyReq.on("error", () => {
3437
+ res.statusCode = 502;
3438
+ res.end();
3439
+ });
3440
+ proxyReq.on("timeout", () => {
3441
+ proxyReq.destroy();
3442
+ res.statusCode = 502;
3443
+ res.end();
3444
+ });
3445
+ req.pipe(proxyReq);
3446
+ });
3447
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
3448
+ const address = server.address();
3449
+ return {
3450
+ proxyUrl: `http://127.0.0.1:${typeof address === "object" && address ? address.port : 0}`,
3451
+ allowedDomains,
3452
+ entries,
3453
+ violations,
3454
+ addEntries: (newEntries) => entries.push(...newEntries),
3455
+ addViolation: (entry) => {
3456
+ const violation = {
3457
+ ...entry,
3458
+ violation: true
3459
+ };
3460
+ entries.push(violation);
3461
+ violations.push(violation);
3462
+ },
3463
+ stop: () => new Promise((resolve) => {
3464
+ server.closeAllConnections?.();
3465
+ server.close(() => resolve());
3466
+ })
3467
+ };
3468
+ }
3469
+ const URL_PATTERN = /https?:\/\/[^\s"'<>)\]]+/g;
3470
+ /** 单条命令输出提取的外联 URL 数量上限(防恶意脚本输出轰炸撑爆审计记录) */
3471
+ const MAX_URLS_PER_COMMAND = 100;
3472
+ /**
3473
+ * 从命令输出提取外联 URL(去重、限数、截断超长)。
3474
+ * 仅提取 http(s) URL;无 URL 返回空数组。
3475
+ */
3476
+ function extractUrlsFromOutput(output) {
3477
+ if (!output) return [];
3478
+ const urls = [];
3479
+ for (const match of output.match(URL_PATTERN) ?? []) {
3480
+ const clean = match.replace(/[.,;:]+$/, "");
3481
+ if (!urls.includes(clean)) urls.push(clean);
3482
+ if (urls.length >= MAX_URLS_PER_COMMAND) break;
3483
+ }
3484
+ return urls;
3485
+ }
3486
+ /**
3487
+ * 输出外联审计记录到执行日志:总数进 info、明细进 debug(verbose 才逐条)。
3488
+ * 仅记录方法+目标,无请求/响应体(无敏感信息)。
3489
+ */
3490
+ function logNetworkAudit(logger, repo, entries) {
3491
+ if (entries.length === 0) return;
3492
+ logger.info(`[network-audit] ${repo}: ${entries.length} outbound connection(s) recorded`);
3493
+ for (const entry of entries) logger.debug(`[network-audit] ${repo}: ${entry.source} ${entry.method} ${entry.target}`);
3494
+ }
3495
+
3496
+ //#endregion
3497
+ //#region src/runners/verification-runner.ts
3498
+ const DEFAULT_COMMANDS = [
3499
+ "pnpm install --frozen-lockfile",
3500
+ "pnpm lint",
2905
3501
  "pnpm build"
2906
3502
  ];
3503
+ const DEFAULT_COMMAND_TIMEOUT_MS = 6e5;
2907
3504
  const MAX_OUTPUT_LINES = 200;
2908
3505
  /**
3506
+ * 已知工具链 telemetry 关闭开关(治本实证 2026-08-25 —— Nuxt CLI 默认 telemetry 上报
3507
+ * 会向 telemetry.nuxt.com:443 真实外联,被审计代理 deny-by-default 命中,触发 verification
3508
+ * 阶段的外联拦截记录。verification 是离线构建验证,不应允许任何匿名 telemetry 上报)。
3509
+ *
3510
+ * 每条 entry 是 `[varName, disabledValue]`;子进程未设置该变量时注入 `disabledValue`。
3511
+ * 父进程已设置时不覆盖(保留用户显式选择,例如测试场景重新开启 telemetry)。
3512
+ *
3513
+ * 覆盖范围:
3514
+ * - `NUXT_TELEMETRY_DISABLED=1` — Nuxt CLI 3.x telemetry 开关(默认开启)
3515
+ * - `NEXT_TELEMETRY_DISABLED=1` — Next.js(备查,Nuxt 场景不命中但保留通用化)
3516
+ * - `DO_NOT_TRACK=1` — Vercel CLI / Sentry / 通用 DO_NOT_TRACK 协议
3517
+ *
3518
+ * 未涵盖的工具链:pnpm / npm / yarn 当前无内置 telemetry 上报,无需禁用。
3519
+ */
3520
+ const TELEMETRY_DISABLED_VARS = [
3521
+ ["NUXT_TELEMETRY_DISABLED", "1"],
3522
+ ["NEXT_TELEMETRY_DISABLED", "1"],
3523
+ ["DO_NOT_TRACK", "1"]
3524
+ ];
3525
+ /**
2909
3526
  * 压缩验证命令输出为报告可读的摘要(首尾各取若干行,超长中间省略)。
2910
3527
  * 完整输出由日志承载;报告保持紧凑,避免 error 字段失控膨胀。
2911
3528
  */
@@ -2917,9 +3534,11 @@ function summarizeVerificationOutput(output) {
2917
3534
  }
2918
3535
  /**
2919
3536
  * 构造失败验证命令的 error 描述:`exit code N — <stdout/stderr 摘要>`。
2920
- * 无输出时退化为裸 `exit code N`。stdout/stderr 已在 execCommand 中脱敏、截断 200 行。
3537
+ * 无输出时退化为裸 `exit code N`;超时命令归类为 `timed out after Xms`。
3538
+ * stdout/stderr 已在 execCommand 中脱敏、截断 200 行。
2921
3539
  */
2922
3540
  function formatVerificationError(cr) {
3541
+ if (cr.timedOut) return `timed out after ${cr.durationMs}ms`;
2923
3542
  const detail = [cr.stdout, cr.stderr].filter(Boolean).join("\n--- stderr ---\n").trim();
2924
3543
  return detail ? `exit code ${cr.exitCode} — ${summarizeVerificationOutput(detail)}` : `exit code ${cr.exitCode}`;
2925
3544
  }
@@ -2937,34 +3556,98 @@ function formatVerificationError(cr) {
2937
3556
  async function runVerification(params) {
2938
3557
  const commands = params.commands ?? DEFAULT_COMMANDS;
2939
3558
  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
- };
3559
+ let audit;
3560
+ if (!params.networkAuditDisabled) audit = await startNetworkAudit().catch(() => void 0);
3561
+ 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);
3562
+ const proxyUrl = audit && !hasExistingProxy ? audit.proxyUrl : void 0;
3563
+ try {
3564
+ for (const command of commands) {
3565
+ const result = await execCommand(command, params.workDir, params.commandTimeoutMs, proxyUrl);
3566
+ commandResults.push(result);
3567
+ if (audit) {
3568
+ const urls = extractUrlsFromOutput(`${result.stdout}\n${result.stderr}`);
3569
+ if (urls.length > 0) {
3570
+ const time = (/* @__PURE__ */ new Date()).toISOString();
3571
+ const entries = urls.map((target) => ({
3572
+ time,
3573
+ source: "command-output",
3574
+ method: "GET",
3575
+ target
3576
+ }));
3577
+ audit.addEntries(entries);
3578
+ }
3579
+ }
3580
+ if (result.exitCode !== 0 || result.timedOut) return {
3581
+ success: false,
3582
+ commandResults,
3583
+ failedCommand: command,
3584
+ failure: result.timedOut ? `command "${command}" timed out after ${result.durationMs}ms` : `command "${command}" exited with code ${result.exitCode}`,
3585
+ ...audit ? {
3586
+ networkAudit: audit.entries,
3587
+ networkViolations: audit.violations
3588
+ } : {}
3589
+ };
3590
+ }
3591
+ } finally {
3592
+ if (audit) await audit.stop().catch(() => {});
2949
3593
  }
2950
3594
  return {
2951
3595
  success: true,
2952
- commandResults
3596
+ commandResults,
3597
+ ...audit ? {
3598
+ networkAudit: audit.entries,
3599
+ networkViolations: audit.violations
3600
+ } : {}
2953
3601
  };
2954
3602
  }
2955
3603
  /**
3604
+ * 构造 verification 子进程环境变量:
3605
+ * - **基础**:`process.env` 全量复制(含 PATH 等系统变量),不丢失用户 shell 环境
3606
+ * - **telemetry 默认禁用**:注入 `NUXT_TELEMETRY_DISABLED=1` 等(父进程未设置时);
3607
+ * 已设置时不覆盖,保留用户显式选择
3608
+ * - **审计代理**:`proxyUrl` 提供时附加 HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY
3609
+ * 重写;`proxyUrl` 为 undefined 时不动 HTTP_PROXY(用户既有代理不覆盖)
3610
+ */
3611
+ function buildSpawnEnv(proxyUrl) {
3612
+ const env = { ...process.env };
3613
+ for (const [name, value] of TELEMETRY_DISABLED_VARS) if (env[name] === void 0 || env[name] === "") env[name] = value;
3614
+ if (proxyUrl) {
3615
+ env.HTTP_PROXY = proxyUrl;
3616
+ env.HTTPS_PROXY = proxyUrl;
3617
+ env.ALL_PROXY = proxyUrl;
3618
+ env.NO_PROXY = "";
3619
+ env.no_proxy = "";
3620
+ }
3621
+ return env;
3622
+ }
3623
+ /**
2956
3624
  * 执行单条 shell 命令,捕获 stdout/stderr(截断到 200 行)并脱敏。
3625
+ * 超过超时时间后中止命令并终止其进程树(防死循环脚本孙进程残留)。
3626
+ *
3627
+ * env 构造(治本实证 2026-08-25):
3628
+ * - **总是构造**(不再 `undefined` 让子进程继承父环境)——为了精确控制 telemetry 禁用与代理注入
3629
+ * - **telemetry 默认禁用**:`NUXT_TELEMETRY_DISABLED=1` 等注入;父进程已设置时不覆盖
3630
+ * - **审计代理**:`proxyUrl` 提供时附加 HTTP_PROXY 等;用户既有代理时**不**覆盖父环境代理
3631
+ * (覆盖会破坏用户网络行为,稳定性优先 —— 与既有 `skips proxy injection` 行为一致)
2957
3632
  */
2958
- function execCommand(command, workDir) {
3633
+ function execCommand(command, workDir, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS, proxyUrl) {
2959
3634
  return new Promise((resolve) => {
2960
3635
  const startTime = Date.now();
3636
+ let timedOut = false;
2961
3637
  const cp = spawn(command, {
2962
3638
  cwd: workDir,
2963
3639
  shell: true,
2964
- stdio: "pipe"
3640
+ stdio: "pipe",
3641
+ detached: process.platform !== "win32",
3642
+ env: buildSpawnEnv(proxyUrl)
2965
3643
  });
2966
3644
  const stdoutChunks = [];
2967
3645
  const stderrChunks = [];
3646
+ const timer = setTimeout(() => {
3647
+ timedOut = true;
3648
+ killProcessTree(cp.pid);
3649
+ cp.kill("SIGKILL");
3650
+ }, timeoutMs);
2968
3651
  cp.stdout.on("data", (data) => {
2969
3652
  stdoutChunks.push(data.toString("utf-8"));
2970
3653
  });
@@ -2972,6 +3655,7 @@ function execCommand(command, workDir) {
2972
3655
  stderrChunks.push(data.toString("utf-8"));
2973
3656
  });
2974
3657
  cp.on("error", (err) => {
3658
+ clearTimeout(timer);
2975
3659
  resolve({
2976
3660
  command,
2977
3661
  exitCode: -1,
@@ -2981,18 +3665,38 @@ function execCommand(command, workDir) {
2981
3665
  });
2982
3666
  });
2983
3667
  cp.on("close", (code) => {
3668
+ clearTimeout(timer);
2984
3669
  const stdout = truncateLines(stdoutChunks.join(""), MAX_OUTPUT_LINES);
2985
3670
  const stderr = truncateLines(stderrChunks.join(""), MAX_OUTPUT_LINES);
2986
3671
  resolve({
2987
3672
  command,
2988
3673
  exitCode: code ?? -1,
2989
3674
  durationMs: Date.now() - startTime,
3675
+ timedOut,
2990
3676
  stdout: sanitizeOutput(stdout),
2991
3677
  stderr: sanitizeOutput(stderr)
2992
3678
  });
2993
3679
  });
2994
3680
  });
2995
3681
  }
3682
+ /**
3683
+ * 终止进程树:POSIX 杀进程组(detached 进程组组长),Windows 用 taskkill /T /F。
3684
+ * 失败时静默退化(主终止路径已由调用方 cp.kill 兜底)。
3685
+ */
3686
+ function killProcessTree(pid) {
3687
+ if (pid === void 0 || pid <= 0) return;
3688
+ if (process.platform === "win32") try {
3689
+ spawnSync("taskkill", [
3690
+ "/pid",
3691
+ String(pid),
3692
+ "/T",
3693
+ "/F"
3694
+ ], { stdio: "ignore" });
3695
+ } catch {}
3696
+ else try {
3697
+ process.kill(-pid, "SIGKILL");
3698
+ } catch {}
3699
+ }
2996
3700
  /** 截断文本到指定行数 */
2997
3701
  function truncateLines(text, maxLines) {
2998
3702
  const lines = text.split("\n");
@@ -3122,15 +3826,19 @@ function restoreTrackedFiles(workDir, snapshot) {
3122
3826
  }
3123
3827
  }
3124
3828
  /**
3125
- * 快速验证(逐包):仅运行 `pnpm lint`(脚本存在时)。
3829
+ * 快速验证(逐包):运行验证命令序列。
3126
3830
  *
3127
- * 完整验证(install + lint + build)成本高,逐包执行不现实;
3128
- * lint 是依赖兼容性的快速信号(本次坏 PR 即因 lint 失败)。
3129
- * 无 lint 脚本时视为通过(与 verifyProject 的跳过语义一致)。
3831
+ * 有自定义命令时执行完整序列(含 install + setup + lint + build),
3832
+ * 否则仅运行 `pnpm lint`(脚本存在时)。
3833
+ *
3834
+ * 设计决策(run 33946113272 复盘):旧版仅跑 `pnpm lint`,但 Nuxt 等框架
3835
+ * 项目 lint 依赖 `nuxt prepare` 生成的 `.nuxt/tsconfig.json`——无 prepare
3836
+ * 步骤会导致 ESLint OOM。自定义命令已含 prepare,走完整序列可避免此问题;
3837
+ * `pnpm install` 在 node_modules 已就绪时近乎空操作,性能影响可忽略。
3130
3838
  */
3131
3839
  async function quickVerifyProject(ctx, repo) {
3132
- const { logger, workDir } = ctx;
3133
- const { valid, skipped } = validateVerifyCommands(["pnpm lint"], workDir);
3840
+ const { logger, workDir, customCommands } = ctx;
3841
+ const { valid, skipped } = validateVerifyCommands(customCommands ?? ["pnpm lint"], workDir);
3134
3842
  if (valid.length === 0) {
3135
3843
  if (skipped.length > 0) logger.info(`[quick-verify] ${repo}: no lint script, skipping per-package verification`);
3136
3844
  return true;
@@ -3596,10 +4304,16 @@ async function fetchPnpmAuditAlerts(params) {
3596
4304
  * ⚠️ 发现漏洞时 pnpm audit 返回非零退出码(exit 1)是**正常行为**——JSON 输出仍然有效,
3597
4305
  * 不能以 exit code 判断失败(参考 security-alert-remediator 的 loadAuditReport 同款语义)。
3598
4306
  * 仅在空输出或 JSON 解析失败时视为硬失败。
4307
+ *
4308
+ * ⚠️ 显式 `--registry=https://registry.npmjs.org/`:
4309
+ * pnpm audit 默认继承用户 `.npmrc` / `npm_config_registry` 的镜像源(如 npmmirror),
4310
+ * 部分镜像站 advisory metadata 缺失或不同步会导致漏报安全漏洞——与 dependfix 的核心目标
4311
+ * (修复安全告警)直接冲突。对齐 `changelog-fetcher.ts` 默认口径(`registryBaseUrl ??
4312
+ * 'https://registry.npmjs.org'`)。追溯见 [backlog B 类登记「C35 pnpm audit 拉取应显式指定官方 registry」](../plan/backlog.md)。
3599
4313
  */
3600
4314
  function runPnpmAudit(workDir) {
3601
4315
  return new Promise((resolve, reject) => {
3602
- const cp = spawn("pnpm audit --json", {
4316
+ const cp = spawn("pnpm audit --json --registry=https://registry.npmjs.org/", {
3603
4317
  cwd: workDir,
3604
4318
  shell: true,
3605
4319
  stdio: [
@@ -3643,6 +4357,8 @@ function normalizePatchedVersionValue(value) {
3643
4357
  "none",
3644
4358
  "unavailable"
3645
4359
  ].includes(normalized)) return null;
4360
+ const versionMatch = /^[^\d]*(\d+(?:\.\d+){1,2}(?:-[0-9a-z.]+)?)/i.exec(normalized);
4361
+ if (versionMatch) return versionMatch[1];
3646
4362
  return String(value).trim();
3647
4363
  }
3648
4364
  /** 提取 advisory id:GitHub Advisory → CVE → URL → 原始 id(参考实现 resolveAdvisoryId) */
@@ -3663,6 +4379,12 @@ function parseLegacyAuditReport(report) {
3663
4379
  const advisories = report.advisories;
3664
4380
  if (!advisories || typeof advisories !== "object") return [];
3665
4381
  const actionMap = /* @__PURE__ */ new Map();
4382
+ const advisoryExtrasMap = /* @__PURE__ */ new Map();
4383
+ for (const [id, rawAdvisory] of Object.entries(advisories)) {
4384
+ if (!rawAdvisory || typeof rawAdvisory !== "object") continue;
4385
+ const extras = extractIdentifiers(rawAdvisory);
4386
+ if (extras.ghsaId || extras.cveIds && extras.cveIds.length > 0) advisoryExtrasMap.set(id, extras);
4387
+ }
3666
4388
  for (const action of toArray(report.actions)) {
3667
4389
  if (!action || typeof action !== "object") continue;
3668
4390
  const record = action;
@@ -3674,13 +4396,15 @@ function parseLegacyAuditReport(report) {
3674
4396
  if (!rawAdvisory || typeof rawAdvisory !== "object") continue;
3675
4397
  const advisory = rawAdvisory;
3676
4398
  const patched = normalizePatchedVersionValue(actionMap.get(id) ?? (typeof advisory.patched_versions === "string" ? advisory.patched_versions : void 0));
4399
+ const extras = advisoryExtrasMap.get(id);
3677
4400
  risks.push({
3678
4401
  advisoryId: resolveAdvisoryId(advisory),
3679
4402
  packageName: typeof advisory.module_name === "string" ? advisory.module_name : "unknown-package",
3680
4403
  severity: typeof advisory.severity === "string" ? advisory.severity : "medium",
3681
4404
  title: typeof advisory.title === "string" ? advisory.title : "Untitled advisory",
3682
4405
  htmlUrl: typeof advisory.url === "string" ? advisory.url : "",
3683
- patchedVersion: patched
4406
+ patchedVersion: patched,
4407
+ ...extras ?? {}
3684
4408
  });
3685
4409
  }
3686
4410
  return risks;
@@ -3705,26 +4429,30 @@ function parseModernAuditReport(report) {
3705
4429
  })();
3706
4430
  const viaItems = toArray(vulnerability.via).filter((item) => item !== null && typeof item === "object");
3707
4431
  if (viaItems.length === 0) {
4432
+ const extras = extractIdentifiers(vulnerability);
3708
4433
  risks.push({
3709
4434
  advisoryId: resolveAdvisoryId(vulnerability),
3710
4435
  packageName,
3711
4436
  severity: typeof vulnerability.severity === "string" ? vulnerability.severity : "medium",
3712
4437
  title: typeof vulnerability.title === "string" ? vulnerability.title : `${packageName} vulnerability`,
3713
4438
  htmlUrl: typeof vulnerability.url === "string" ? vulnerability.url : "",
3714
- patchedVersion: patchedFromFixAvailable
4439
+ patchedVersion: patchedFromFixAvailable,
4440
+ ...extras ?? {}
3715
4441
  });
3716
4442
  continue;
3717
4443
  }
3718
4444
  for (const advisory of viaItems) {
3719
4445
  let severity = typeof vulnerability.severity === "string" ? vulnerability.severity : "medium";
3720
4446
  if (typeof advisory.severity === "string" && advisory.severity) severity = advisory.severity;
4447
+ const extras = extractIdentifiers(advisory);
3721
4448
  risks.push({
3722
4449
  advisoryId: resolveAdvisoryId(advisory),
3723
4450
  packageName: typeof advisory.name === "string" && advisory.name ? advisory.name : packageName,
3724
4451
  severity,
3725
4452
  title: typeof advisory.title === "string" ? advisory.title : `${packageName} vulnerability`,
3726
4453
  htmlUrl: typeof advisory.url === "string" ? advisory.url : "",
3727
- patchedVersion: patchedFromFixAvailable
4454
+ patchedVersion: patchedFromFixAvailable,
4455
+ ...extras ?? {}
3728
4456
  });
3729
4457
  }
3730
4458
  }
@@ -3758,13 +4486,34 @@ function parseAuditReport(report) {
3758
4486
  return [...deduped.values()];
3759
4487
  }
3760
4488
  /**
3761
- * advisoryId 的稳定数字哈希(`NormalizedSecurityAlert.id` 为 number)。
4489
+ * advisoryId 的稳定数字哈希(M20 之前使用,M20 后保留以维持向后兼容 `NormalizedSecurityAlert.id: number`)。
4490
+ *
3762
4491
  * 取 sha256 前 4 字节完整 uint32(非设计稿的 `% 2^31`:uint32 语义更自然,
3763
4492
  * 碰撞概率 ~2^-32/对,12 条告警场景生日界 ~1.6e-8,可忽略)。
4493
+ *
4494
+ * M20 弃用:唯一去重键改为 `upstreamId`,详见 [`normalizeUpstreamId`](../../../core/src/alerts/upstream-id.ts)。
4495
+ * 本函数保留仅为兼容现有 `NormalizedSecurityAlert.id: number` 字段,**不是**平台去重键。
3764
4496
  */
3765
4497
  function hashAdvisoryId(packageName, advisoryId) {
3766
4498
  return createHash("sha256").update(`${packageName}:${advisoryId}`).digest().readUInt32BE(0);
3767
4499
  }
4500
+ /**
4501
+ ** M23.3 C66-A2:从 pnpm audit advisory 提取 GHSA ID + CVE 列表。
4502
+ ** 兼容 legacy `github_advisory_id` + modern `advisory.github_advisory_id` 与 `cves[]`。
4503
+ */
4504
+ function extractIdentifiers(candidate) {
4505
+ const ghsaId = typeof candidate.github_advisory_id === "string" && candidate.github_advisory_id ? candidate.github_advisory_id : void 0;
4506
+ const cves = candidate.cves;
4507
+ let cveIds;
4508
+ if (Array.isArray(cves)) {
4509
+ const valid = cves.filter((c) => typeof c === "string" && Boolean(c));
4510
+ if (valid.length > 0) cveIds = valid;
4511
+ }
4512
+ const result = {};
4513
+ if (ghsaId) result.ghsaId = ghsaId;
4514
+ if (cveIds) result.cveIds = cveIds;
4515
+ return result;
4516
+ }
3768
4517
  /** audit 风险 → 标准化告警(source='pnpm-audit',repository 由调用方注入) */
3769
4518
  function mapAuditRiskToAlert(risk, repository) {
3770
4519
  const fixable = risk.patchedVersion !== null;
@@ -3783,7 +4532,13 @@ function mapAuditRiskToAlert(risk, repository) {
3783
4532
  fixable,
3784
4533
  fixStrategy: fixable ? "upgrade" : null,
3785
4534
  recommendedVersion: risk.patchedVersion ?? "",
3786
- dependencyType: void 0
4535
+ dependencyType: void 0,
4536
+ upstreamId: normalizeUpstreamId("pnpm-audit", {
4537
+ packageName: risk.packageName,
4538
+ advisoryId: risk.advisoryId
4539
+ }),
4540
+ ghsaId: risk.ghsaId,
4541
+ cveIds: risk.cveIds
3787
4542
  };
3788
4543
  }
3789
4544
 
@@ -4314,6 +5069,180 @@ function enforceVerificationGate(ctx, options) {
4314
5069
  return true;
4315
5070
  }
4316
5071
 
5072
+ //#endregion
5073
+ //#region src/supply-chain/index.ts
5074
+ /**
5075
+ * 供应链信号披露(安全治理:路径 A"合法包被投毒"合入前人工确认的关键依据)。
5076
+ *
5077
+ * 信号定义:本次新增/升级的包**带 lifecycle scripts**(install/preinstall/postinstall)
5078
+ * 且**已被目标仓库 `allowBuilds` / `onlyBuiltDependencies` 批准**——pnpm 10+/11 默认
5079
+ * 忽略依赖 lifecycle scripts,仅显式批准的包才在安装时执行脚本;被批准 + 带脚本
5080
+ * 的组合意味着升级该包会在目标仓库真实执行其脚本(投毒扩散面)。
5081
+ *
5082
+ * 数据源(不依赖 lockfile 的 hasInstallScript 字段——pnpm 11.17 实证该字段不写入):
5083
+ * - 批准列表:目标仓库 `pnpm-workspace.yaml` 的 `allowBuilds`(对象)/
5084
+ * `onlyBuiltDependencies`(数组)
5085
+ * - 脚本类型:升级验证后的 `node_modules/.pnpm/{pkg}@{ver}/node_modules/{pkg}/package.json`
5086
+ * 的 scripts 键(install/preinstall/postinstall)
5087
+ */
5088
+ /** pnpm 生命周期的可执行脚本键(白名单,忽略 test/build 等非安装期脚本) */
5089
+ const LIFECYCLE_SCRIPT_KEYS = [
5090
+ "install",
5091
+ "preinstall",
5092
+ "postinstall"
5093
+ ];
5094
+ /** pnpm-workspace.yaml 中批准配置的键名(pnpm 10+ / pnpm 11 allowBuilds 对象) */
5095
+ const ALLOW_BUILDS_KEY = "allowBuilds";
5096
+ const ONLY_BUILT_DEPENDENCIES_KEY = "onlyBuiltDependencies";
5097
+ /**
5098
+ * 解析 pnpm-workspace.yaml 中被批准执行 install 脚本的包集合。
5099
+ *
5100
+ * 支持两种形态(行级解析,不引入 yaml 依赖):
5101
+ * ```yaml
5102
+ * allowBuilds: # pnpm 11 对象形态(pkg: true 或 pkg: [scripts])
5103
+ * esbuild: true
5104
+ * better-sqlite3: true
5105
+ * onlyBuiltDependencies: # pnpm 10 数组形态
5106
+ * - esbuild
5107
+ * ```
5108
+ * 注释(#)、引号包名、行内尾注均可处理;缺失文件返回空集合。
5109
+ */
5110
+ function parseWorkspaceAllowBuilds(workspaceYamlContent) {
5111
+ const approved = /* @__PURE__ */ new Set();
5112
+ if (!workspaceYamlContent) return approved;
5113
+ const lines = workspaceYamlContent.split(/\r?\n/);
5114
+ let section = null;
5115
+ for (const rawLine of lines) {
5116
+ const line = stripInlineComment(rawLine).trim();
5117
+ if (!line) continue;
5118
+ if (line === `${ALLOW_BUILDS_KEY}:`) {
5119
+ section = "allowBuilds";
5120
+ continue;
5121
+ }
5122
+ if (line === `${ONLY_BUILT_DEPENDENCIES_KEY}:`) {
5123
+ section = "onlyBuiltDependencies";
5124
+ continue;
5125
+ }
5126
+ if (isTopLevelKey(line)) {
5127
+ section = null;
5128
+ continue;
5129
+ }
5130
+ if (section === null) continue;
5131
+ if (section === "allowBuilds") {
5132
+ const match = line.match(/^([^:]+):\s*(.+)?$/);
5133
+ if (match) {
5134
+ const pkg = unquote(match[1].trim());
5135
+ const value = (match[2] ?? "").trim();
5136
+ if (pkg && value !== "false") approved.add(pkg);
5137
+ }
5138
+ } else if (section === "onlyBuiltDependencies") {
5139
+ const match = line.match(/^-\s*(.+)$/);
5140
+ if (match) {
5141
+ const pkg = unquote(match[1].trim());
5142
+ if (pkg) approved.add(pkg);
5143
+ }
5144
+ }
5145
+ }
5146
+ return approved;
5147
+ }
5148
+ /** 读取目标仓库的 pnpm-workspace.yaml 批准配置(文件不存在返回空集合)。 */
5149
+ function readWorkspaceAllowBuilds(workDir) {
5150
+ const file = join(workDir, "pnpm-workspace.yaml");
5151
+ if (!existsSync(file)) return /* @__PURE__ */ new Set();
5152
+ return parseWorkspaceAllowBuilds(readFileSync(file, "utf-8"));
5153
+ }
5154
+ /**
5155
+ * 读取已安装包(node_modules/.pnpm 布局)的 lifecycle 脚本类型。
5156
+ * pnpm 布局:`node_modules/.pnpm/{name}@{version}/node_modules/{name}/package.json`
5157
+ * (scoped 包的 store 目录为 `@scope+pkg@version`)。
5158
+ * 带 peer 依赖的包 store 目录带后缀(`name@version_peer@v...`)——先精确匹配,
5159
+ * 未命中时前缀匹配兜底(防 peer 后缀漏披露)。
5160
+ * 包未安装(dry-run / install 未执行)时返回 undefined(调用方跳过)。
5161
+ */
5162
+ function readInstalledPackageScriptTypes(workDir, packageName, version) {
5163
+ const pkgJsonPath = resolveStorePackageJson(workDir, packageName, version);
5164
+ if (!pkgJsonPath) return;
5165
+ try {
5166
+ const scripts = JSON.parse(readFileSync(pkgJsonPath, "utf-8")).scripts ?? {};
5167
+ const types = LIFECYCLE_SCRIPT_KEYS.filter((key) => typeof scripts[key] === "string");
5168
+ return types.length > 0 ? types : [];
5169
+ } catch {
5170
+ return;
5171
+ }
5172
+ }
5173
+ /** 解析 pnpm store 内包的 package.json 路径(精确路径 → peer 后缀前缀匹配兜底)。 */
5174
+ function resolveStorePackageJson(workDir, packageName, version) {
5175
+ const storeBase = `${packageName.replace("/", "+")}@${version}`;
5176
+ const exact = join(workDir, "node_modules", ".pnpm", storeBase, "node_modules", packageName, "package.json");
5177
+ if (existsSync(exact)) return exact;
5178
+ const pnpmDir = join(workDir, "node_modules", ".pnpm");
5179
+ if (!existsSync(pnpmDir)) return;
5180
+ try {
5181
+ const match = readdirSync(pnpmDir).find((name) => name.startsWith(`${storeBase}_`));
5182
+ if (!match) return;
5183
+ const peerPath = join(pnpmDir, match, "node_modules", packageName, "package.json");
5184
+ return existsSync(peerPath) ? peerPath : void 0;
5185
+ } catch {
5186
+ return;
5187
+ }
5188
+ }
5189
+ /**
5190
+ * 收集本次运行的供应链信号警示:成功升级的包 ∩ 目标仓库批准列表 ∩ 实际带 lifecycle 脚本。
5191
+ * 任一环节缺失(无 workspace.yaml / 包未安装 / 无脚本)自然降级为不披露。
5192
+ */
5193
+ function collectSupplyChainWarnings(workDir, actions) {
5194
+ const approved = readWorkspaceAllowBuilds(workDir);
5195
+ if (approved.size === 0) return [];
5196
+ const upgraded = /* @__PURE__ */ new Map();
5197
+ for (const action of actions) {
5198
+ if (action.type !== "dependency-upgrade" || !action.success || !action.target || !action.toVersion) continue;
5199
+ const key = `${action.repository}\u0000${action.target}`;
5200
+ upgraded.set(key, {
5201
+ repository: action.repository,
5202
+ packageName: action.target,
5203
+ version: action.toVersion
5204
+ });
5205
+ }
5206
+ const warnings = [];
5207
+ for (const { repository, packageName, version } of upgraded.values()) {
5208
+ if (!approved.has(packageName)) continue;
5209
+ const scriptTypes = readInstalledPackageScriptTypes(workDir, packageName, version);
5210
+ if (scriptTypes === void 0 || scriptTypes.length === 0) continue;
5211
+ warnings.push({
5212
+ repository,
5213
+ packageName,
5214
+ version,
5215
+ scriptTypes
5216
+ });
5217
+ }
5218
+ return warnings;
5219
+ }
5220
+ /** 剥离行内注释(# 在引号外才生效;包名含 # 的极端场景由 unquote 顺序保护)。 */
5221
+ function stripInlineComment(line) {
5222
+ let inSingle = false;
5223
+ let inDouble = false;
5224
+ for (let i = 0; i < line.length; i++) {
5225
+ const ch = line[i];
5226
+ if (ch === "'" && !inDouble) inSingle = !inSingle;
5227
+ else if (ch === "\"" && !inSingle) inDouble = !inDouble;
5228
+ else if (ch === "#" && !inSingle && !inDouble) return line.slice(0, i);
5229
+ }
5230
+ return line;
5231
+ }
5232
+ /** 顶层配置键(两空格缩进为 0 或非空格的键;用于退出批准段)。 */
5233
+ function isTopLevelKey(line) {
5234
+ return !line.startsWith(" ") && line.endsWith(":");
5235
+ }
5236
+ /** 去除单/双引号包裹。 */
5237
+ function unquote(value) {
5238
+ if (value.length >= 2) {
5239
+ const first = value[0];
5240
+ const last = value[value.length - 1];
5241
+ if (first === "\"" && last === "\"" || first === "'" && last === "'") return value.slice(1, -1);
5242
+ }
5243
+ return value;
5244
+ }
5245
+
4317
5246
  //#endregion
4318
5247
  //#region src/app/branch-cleanup.ts
4319
5248
  /**
@@ -4524,6 +5453,105 @@ function confirmCleanup(ctx, repo, candidates) {
4524
5453
  });
4525
5454
  }
4526
5455
 
5456
+ //#endregion
5457
+ //#region src/app/result-assembly.ts
5458
+ /** 汇总所有动作到 summary(alertsSkipped 已在 repo-fix 修复管线中累加)。 */
5459
+ function computeSummary(ctx) {
5460
+ const { allActions, allAlerts, repoResults, summary } = ctx;
5461
+ let fixed = 0;
5462
+ let failed = 0;
5463
+ let lockfileRepairs = 0;
5464
+ let verificationsPassed = 0;
5465
+ let verificationsFailed = 0;
5466
+ for (const action of allActions) {
5467
+ if (action.noOp) continue;
5468
+ if (action.strategy === "ai-patch" || action.strategy === "ai-suggestion") continue;
5469
+ if (action.type === "dependency-upgrade" || action.type === "code-scanning-fix") {
5470
+ if (action.success) fixed++;
5471
+ else failed++;
5472
+ }
5473
+ if (action.type === "lockfile-repair" && action.success) lockfileRepairs++;
5474
+ if (action.type === "verification") {
5475
+ if (action.success) verificationsPassed++;
5476
+ else verificationsFailed++;
5477
+ }
5478
+ }
5479
+ const fixable = allAlerts.filter((a) => a.fixable).length;
5480
+ summary.repositoriesScanned = repoResults.length;
5481
+ summary.alertsFound = allAlerts.length;
5482
+ summary.alertsFixable = fixable;
5483
+ summary.alertsFixed = fixed;
5484
+ summary.alertsFailed = failed;
5485
+ summary.lockfileRepairs = lockfileRepairs;
5486
+ summary.verificationsPassed = verificationsPassed;
5487
+ summary.verificationsFailed = verificationsFailed;
5488
+ }
5489
+ function buildRunResult(ctx, aiUsage, supplyChainWarnings) {
5490
+ const reportConfig = {
5491
+ mode: ctx.config.mode,
5492
+ severityThreshold: ctx.config.severityThreshold,
5493
+ repositories: ctx.config.repositories,
5494
+ dryRun: ctx.config.dryRun,
5495
+ createPullRequest: ctx.config.createPullRequest,
5496
+ maxAlertsPerRepository: ctx.config.maxAlertsPerRepository,
5497
+ alertSource: ctx.config.alertSource,
5498
+ codeScanningEnabled: ctx.config.codeScanningEnabled,
5499
+ codeQualityEnabled: ctx.config.codeQualityEnabled
5500
+ };
5501
+ return {
5502
+ runId: ctx.runId,
5503
+ startedAt: ctx.startedAt,
5504
+ finishedAt: ctx.finishedAt,
5505
+ config: reportConfig,
5506
+ summary: ctx.summary,
5507
+ repositories: ctx.repoResults,
5508
+ alerts: ctx.allAlerts,
5509
+ actions: ctx.allActions,
5510
+ errors: ctx.allErrors,
5511
+ aiUsage: aiUsage && aiUsage.calls > 0 ? aiUsage : void 0,
5512
+ supplyChainWarnings: supplyChainWarnings && supplyChainWarnings.length > 0 ? supplyChainWarnings : void 0
5513
+ };
5514
+ }
5515
+ /**
5516
+ * 聚合一次 AI 研判消耗到 run 级累计。
5517
+ * - 单次未调用(usage 为 undefined)幂等返回原累计
5518
+ * - 成本合并:任一侧 undefined 时结果 undefined(模型无单价 → 不做误导性估算)
5519
+ */
5520
+ function mergeAiUsage(aggregate, usage) {
5521
+ if (!usage || usage.calls === 0) return aggregate;
5522
+ const next = {
5523
+ calls: (aggregate?.calls ?? 0) + usage.calls,
5524
+ inputTokens: (aggregate?.inputTokens ?? 0) + usage.inputTokens,
5525
+ outputTokens: (aggregate?.outputTokens ?? 0) + usage.outputTokens,
5526
+ totalTokens: (aggregate?.totalTokens ?? 0) + usage.totalTokens
5527
+ };
5528
+ if (aggregate === void 0) next.estimatedCostUsd = usage.estimatedCostUsd;
5529
+ else if (aggregate.estimatedCostUsd === void 0 || usage.estimatedCostUsd === void 0) next.estimatedCostUsd = void 0;
5530
+ else next.estimatedCostUsd = aggregate.estimatedCostUsd + usage.estimatedCostUsd;
5531
+ return next;
5532
+ }
5533
+ /**
5534
+ * 计算退出码:
5535
+ * - 0: 全部仓库处理成功(无 failed actions、无 errors)
5536
+ * - 1: 部分仓库失败
5537
+ * - 2: 全部仓库失败(或无仓库被成功处理)
5538
+ *
5539
+ * 语义注记:AI 辅助动作(ai-patch)失败计入 exit code(fail-safe——AI 修复失败
5540
+ * 说明 breaking change 未解决,应报红提醒人工),但不计入 summary.alertsFailed
5541
+ * (告警结果由主动作代表;见 computeSummary 的 ai 辅助动作排除规则)。
5542
+ */
5543
+ function computeExitCode(ctx) {
5544
+ const { config, allErrors, allActions, repoResults } = ctx;
5545
+ const hasErrors = allErrors.length > 0;
5546
+ const hasFailures = allActions.some((a) => !a.success);
5547
+ const hasRepoSuccess = repoResults.length > 0 && repoResults.some((r) => r.verificationPassed !== false && (r.alertsCount > 0 || r.fixed > 0 || r.verificationPassed === true));
5548
+ const hasCleanupSuccess = config.mode === "cleanup-branches" && allActions.some((a) => a.success && a.type === "branch-cleanup");
5549
+ const hasSuccess = hasRepoSuccess || hasCleanupSuccess;
5550
+ if (!hasErrors && !hasFailures) return 0;
5551
+ if (hasSuccess) return 1;
5552
+ return 2;
5553
+ }
5554
+
4527
5555
  //#endregion
4528
5556
  //#region src/app/helpers.ts
4529
5557
  const DEFAULT_VERIFY_COMMANDS = [
@@ -4549,7 +5577,7 @@ function resolveAlertRepositories(ctx) {
4549
5577
  return ["local"];
4550
5578
  }
4551
5579
  /** 自动修复提交的兜底标题(无成功升级 / 包名超长无法生成动态标题时) */
4552
- const FIX_COMMIT_MESSAGE = "fix(deps): automated dependfix security repair";
5580
+ const FIX_COMMIT_MESSAGE = "chore(deps): automated dependfix security repair";
4553
5581
  /** commitlint header-max-length 上限(commitlint-config-cmyr 覆盖为 140) */
4554
5582
  const COMMIT_HEADER_MAX_LENGTH = 140;
4555
5583
  /**
@@ -4575,15 +5603,15 @@ function buildCommitTitle(upgrades) {
4575
5603
  const a = upgrades[0];
4576
5604
  const from = a.fromVersion && a.fromVersion !== "unknown" ? ` from ${a.fromVersion}` : "";
4577
5605
  const to = a.toVersion ? ` to ${a.toVersion}` : "";
4578
- const title = `fix(deps): bump ${a.target}${from}${to}`;
5606
+ const title = `chore(deps): bump ${a.target}${from}${to}`;
4579
5607
  return title.length <= COMMIT_HEADER_MAX_LENGTH ? title : FIX_COMMIT_MESSAGE;
4580
5608
  }
4581
5609
  const names = upgrades.map((a) => a.target);
4582
- const full = `fix(deps): bump ${names.join(", ")}`;
5610
+ const full = `chore(deps): bump ${names.join(", ")}`;
4583
5611
  if (full.length <= COMMIT_HEADER_MAX_LENGTH) return full;
4584
5612
  let count = names.length - 1;
4585
5613
  while (count > 0) {
4586
- const candidate = `fix(deps): bump ${names.slice(0, count).join(", ")} and ${names.length - count} more`;
5614
+ const candidate = `chore(deps): bump ${names.slice(0, count).join(", ")} and ${names.length - count} more`;
4587
5615
  if (candidate.length <= COMMIT_HEADER_MAX_LENGTH) return candidate;
4588
5616
  count--;
4589
5617
  }
@@ -4623,6 +5651,19 @@ function codeScanningAlertsTokenHint(error) {
4623
5651
  return null;
4624
5652
  }
4625
5653
  /**
5654
+ * Code Quality findings fetch 错误用户指引(token 需 fine-grained `Code quality: read`
5655
+ * 或 classic PAT `repo`/`public_repo` scope)。
5656
+ * 仅用于 Code Quality fetch 错误路径;按精确 context 匹配(`fetch code quality findings for`),
5657
+ * 不依赖裸关键字。
5658
+ */
5659
+ function codeQualityAlertsTokenHint(error) {
5660
+ if (!(error instanceof AppError)) return null;
5661
+ if (!error.message.includes("fetch code quality findings for")) return null;
5662
+ if (error.code === "PERMISSION_DENIED") return "请检查 token 是否具备 Code Quality findings 读取权限(fine-grained PAT 需 Code quality: read;classic PAT 需 repo / public_repo scope;GitHub App 需对应仓库权限)";
5663
+ if (error.code === "AUTHENTICATION_FAILED") return "token 无效或已过期,请检查 GITHUB_TOKEN / alertsToken 配置";
5664
+ return null;
5665
+ }
5666
+ /**
4626
5667
  * 为 lockfile 中存在脆弱实例的包构建 overrides 映射。
4627
5668
  *
4628
5669
  * 输入为该包所有 fixable 告警(不同 GHSA 推荐版本不同),按大版本分组取各线
@@ -4733,17 +5774,47 @@ async function upgradeAlert(ctx, alert) {
4733
5774
  }
4734
5775
  /**
4735
5776
  * 执行 Code Scanning 模板修复(2.0 节;从 app/index.ts 提取以控制文件行数)。
4736
- * 仅处理 A 类告警;逐告警:快照 → 应用模板 → quickVerify(lint)→ 失败回滚(不静默)。
5777
+ * 仅处理 A 类告警;按批处理(默认每 10 个告警跑一次 lint):快照 → 应用模板 → 累积到批 → 批满跑 lint → 失败回滚整批(不静默)。
4737
5778
  * - 快照失败 / 无模板 / 模板不适用 / 缺文件 → noOp 动作(回退建议模式,展示;
4738
5779
  * error 原因可审计,不计 failed 避免陈旧告警永久 exit 1/2)
4739
- * - 写盘失败 / lint 验证失败 → failed(回滚并记录;回滚失败时注明 file may be modified)
5780
+ * - 写盘失败 → 立即失败(不计 lint)+ 整批回滚
5781
+ * - lint 验证失败 → 整批回滚(粒度 = batchSize;vs 旧版每个告警回滚,回滚粒度变粗但 lint 提速 ~10x)
5782
+ *
5783
+ * 性能权衡(M28.2 实证):
5784
+ * - N 个 cs 告警(旧版)→ N 次 spawn `pnpm lint` 子进程:N=100 平均 2593 ms(基准 commit 395ee29)
5785
+ * - N 个 cs 告警(批处理)→ ceil(N/batchSize) 次 lint:N=100 平均 ~300 ms(提速 ~10x)
5786
+ * - 合并验证可提速 ~96x 但回滚粒度过粗(整次 fix 全失败);批处理折中
5787
+ *
4740
5788
  * @returns 本批次实际修复数(fixed)与失败数(failed),调用方累加到仓库统计
4741
5789
  */
4742
- async function runCodeScanningFixes(ctx, repo, alerts) {
5790
+ async function runCodeScanningFixes(ctx, repo, alerts, options = {}) {
4743
5791
  const { config, workDir, logger } = ctx;
5792
+ const batchSize = options.batchSize ?? DEFAULT_CS_BATCH_SIZE;
4744
5793
  const codeScanningAutoFixable = alerts.filter((a) => a.source === "code-scanning" && a.alertClass === "auto-fixable");
4745
5794
  let fixed = 0;
4746
5795
  let failed = 0;
5796
+ /** 待 lint 批:每条保留 sourceSnapshot + action 用于 lint 失败时整批回滚 */
5797
+ const pendingBatch = [];
5798
+ /**
5799
+ * 处理当前批:跑 lint + 按结果分发 fixed/failed + 回滚(lint 失败时)。
5800
+ */
5801
+ const flushBatch = async () => {
5802
+ if (pendingBatch.length === 0) return;
5803
+ if (await quickVerifyProject(ctx, repo)) for (const item of pendingBatch) {
5804
+ ctx.allActions.push(item.action);
5805
+ fixed++;
5806
+ logger.info(`[code-scanning] ${item.csAlert.ruleId}: ${item.action.diff}`);
5807
+ }
5808
+ else for (const item of pendingBatch) {
5809
+ const restored = restoreSourceFile(workDir, item.sourceSnapshot);
5810
+ item.action.success = false;
5811
+ item.action.error = restored ? "lint failed after code-scanning fix; changes rolled back" : "lint failed after code-scanning fix; rollback failed, file may be modified";
5812
+ ctx.allActions.push(item.action);
5813
+ failed++;
5814
+ logger.warn(`[code-scanning] ${item.csAlert.ruleId} fix rolled back: lint failed`);
5815
+ }
5816
+ pendingBatch.length = 0;
5817
+ };
4747
5818
  for (const csAlert of codeScanningAutoFixable) {
4748
5819
  const sourceSnapshot = snapshotSourceFile(workDir, csAlert.manifestPath);
4749
5820
  if (!sourceSnapshot) {
@@ -4784,24 +5855,30 @@ async function runCodeScanningFixes(ctx, repo, alerts) {
4784
5855
  ctx.allActions.push(action);
4785
5856
  continue;
4786
5857
  }
4787
- if (!await quickVerifyProject(ctx, repo)) {
4788
- const restored = restoreSourceFile(workDir, sourceSnapshot);
4789
- action.success = false;
4790
- action.error = restored ? "lint failed after code-scanning fix; changes rolled back" : "lint failed after code-scanning fix; rollback failed, file may be modified";
4791
- ctx.allActions.push(action);
4792
- failed++;
4793
- logger.warn(`[code-scanning] ${csAlert.ruleId} fix rolled back: lint failed`);
4794
- continue;
4795
- }
4796
- ctx.allActions.push(action);
4797
- fixed++;
4798
- logger.info(`[code-scanning] ${csAlert.ruleId}: ${action.diff}`);
5858
+ pendingBatch.push({
5859
+ csAlert,
5860
+ sourceSnapshot,
5861
+ action
5862
+ });
5863
+ if (pendingBatch.length >= batchSize) await flushBatch();
4799
5864
  }
5865
+ await flushBatch();
4800
5866
  return {
4801
5867
  fixed,
4802
5868
  failed
4803
5869
  };
4804
5870
  }
5871
+ /**
5872
+ * Code Scanning 修复批处理默认大小(M28.2 决策)。
5873
+ *
5874
+ * 决策依据(commit 395ee29 baseline):
5875
+ * - 旧版(每个告警 1 次 lint):N=10 平均 263 ms / N=50 平均 1294 ms / N=100 平均 2593 ms
5876
+ * - 批处理 batchSize=10:N=10 触发 1 次 lint(vs 旧版 10 次 → 提速 ~10x)
5877
+ * - 回滚粒度 = batchSize(vs 旧版每个告警独立回滚)
5878
+ *
5879
+ * 经验值:典型 cs 告警场景 5-50 个 / repo,batchSize=10 平衡 lint 提速 + 回滚粒度。
5880
+ */
5881
+ const DEFAULT_CS_BATCH_SIZE = 10;
4805
5882
  /** 尝试修复 pnpm-lock.yaml(dry-run 仅记录)。 */
4806
5883
  function tryLockfileRepair(ctx, repo) {
4807
5884
  const { config, logger, workDir } = ctx;
@@ -4871,10 +5948,23 @@ async function verifyProject(ctx, repo) {
4871
5948
  return [];
4872
5949
  }
4873
5950
  try {
4874
- return (await runVerification({
5951
+ const result = await runVerification({
4875
5952
  workDir,
4876
5953
  commands: valid
4877
- })).commandResults.map((cr) => {
5954
+ });
5955
+ logNetworkAudit(logger, repo, result.networkAudit ?? []);
5956
+ for (const violation of result.networkViolations ?? []) {
5957
+ const redacted = redactUrlForReport(violation.target);
5958
+ allErrors.push({
5959
+ repository: repo,
5960
+ target: redacted,
5961
+ stage: "verify",
5962
+ category: "network_violation",
5963
+ message: `outbound blocked by allowlist: ${violation.method} ${redacted}`
5964
+ });
5965
+ logger.error(`[network-audit] ${repo}: outbound blocked (network_violation): ${violation.method} ${redacted}`);
5966
+ }
5967
+ return result.commandResults.map((cr) => {
4878
5968
  const error = cr.exitCode !== 0 ? formatVerificationError(cr) : void 0;
4879
5969
  if (error) logger.error(`Verification failed for ${repo}: ${cr.command} — ${error}`);
4880
5970
  return {
@@ -4967,98 +6057,7 @@ function buildPrTitle(summary, actions) {
4967
6057
  const parts = [];
4968
6058
  if (upgrades > 0) parts.push(`${upgrades} upgrade${upgrades > 1 ? "s" : ""}`);
4969
6059
  if (codeFixes > 0) parts.push(`${codeFixes} code fix${codeFixes > 1 ? "es" : ""}`);
4970
- return parts.length > 0 ? `fix(deps): automated security fix — ${parts.join(", ")}` : "fix(deps): automated security fix";
4971
- }
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;
6060
+ return parts.length > 0 ? `chore(deps): automated security fix — ${parts.join(", ")}` : "chore(deps): automated security fix";
5062
6061
  }
5063
6062
 
5064
6063
  //#endregion
@@ -5066,7 +6065,9 @@ function computeExitCode(ctx) {
5066
6065
  /**
5067
6066
  * 告警数据源统一入口:
5068
6067
  * - `github-dependabot`:Octokit 拉取 Dependabot alerts(alertsToken 优先);
5069
- * `codeScanningEnabled` 时**并行**拉取 Code Scanning alerts(互不覆盖、互不回退)
6068
+ * `codeScanningEnabled` 时**并行**拉取 Code Scanning alerts(互不覆盖、互不回退);
6069
+ * `codeQualityEnabled` 时**并行**拉取 Code Quality findings(与 Code Scanning 同源独立开启)。
6070
+ * 三个 GitHub 源完全独立(可单独开启任意组合)。
5070
6071
  * - `pnpm-audit`:本地 `pnpm audit --json` 回退(无 token;repository 已由 resolveAlertRepositories 解析)
5071
6072
  *
5072
6073
  * per-source 错误隔离:并行源任一失败 → 记录该源 FETCH_FAILED 错误
@@ -5081,13 +6082,20 @@ async function fetchRepoAlerts(deps, repo) {
5081
6082
  });
5082
6083
  const alertsClient = createAlertsClientFromConfig(config);
5083
6084
  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([])]);
6085
+ const [dependabotResult, codeScanningResult, codeQualityResult] = await Promise.allSettled([
6086
+ fetchDependabotAlerts(alertsClient, {
6087
+ owner,
6088
+ repo: name
6089
+ }),
6090
+ config.codeScanningEnabled ? fetchCodeScanningAlerts(alertsClient, {
6091
+ owner,
6092
+ repo: name
6093
+ }) : Promise.resolve([]),
6094
+ config.codeQualityEnabled ? fetchCodeQualityFindings(alertsClient, {
6095
+ owner,
6096
+ repo: name
6097
+ }) : Promise.resolve([])
6098
+ ]);
5091
6099
  const alerts = [];
5092
6100
  const failedSources = [];
5093
6101
  if (dependabotResult.status === "fulfilled") alerts.push(...dependabotResult.value);
@@ -5095,18 +6103,32 @@ async function fetchRepoAlerts(deps, repo) {
5095
6103
  failedSources.push("dependabot");
5096
6104
  recordAlertSourceError(deps, repo, "dependabot", dependabotResult.reason);
5097
6105
  }
5098
- if (config.codeScanningEnabled) if (codeScanningResult.status === "fulfilled") {
5099
- alerts.push(...codeScanningResult.value);
5100
- deps.logger.info(`Fetched ${codeScanningResult.value.length} code scanning alerts for ${repo}`);
5101
- } else {
5102
- failedSources.push("code-scanning");
5103
- recordAlertSourceError(deps, repo, "code-scanning", codeScanningResult.reason);
6106
+ if (config.codeScanningEnabled) {
6107
+ if (codeScanningResult.status === "fulfilled") {
6108
+ alerts.push(...codeScanningResult.value);
6109
+ deps.logger.info(`Fetched ${codeScanningResult.value.length} code scanning alerts for ${repo}`);
6110
+ } else {
6111
+ failedSources.push("code-scanning");
6112
+ recordAlertSourceError(deps, repo, "code-scanning", codeScanningResult.reason);
6113
+ }
6114
+ }
6115
+ if (config.codeQualityEnabled) {
6116
+ if (codeQualityResult.status === "fulfilled") {
6117
+ alerts.push(...codeQualityResult.value);
6118
+ deps.logger.info(`Fetched ${codeQualityResult.value.length} code quality findings for ${repo}`);
6119
+ } else {
6120
+ failedSources.push("code-quality");
6121
+ recordAlertSourceError(deps, repo, "code-quality", codeQualityResult.reason);
6122
+ }
5104
6123
  }
5105
- const totalSources = config.codeScanningEnabled ? 2 : 1;
6124
+ let totalSources = 1;
6125
+ if (config.codeScanningEnabled) totalSources++;
6126
+ if (config.codeQualityEnabled) totalSources++;
5106
6127
  if (failedSources.length === totalSources) {
5107
6128
  let firstReason;
5108
6129
  if (dependabotResult.status === "rejected") firstReason = dependabotResult.reason;
5109
6130
  else if (codeScanningResult.status === "rejected") firstReason = codeScanningResult.reason;
6131
+ else if (codeQualityResult.status === "rejected") firstReason = codeQualityResult.reason;
5110
6132
  else firstReason = /* @__PURE__ */ new Error(`failed to fetch alerts for ${repo}`);
5111
6133
  throw firstReason;
5112
6134
  }
@@ -5119,23 +6141,21 @@ async function fetchRepoAlerts(deps, repo) {
5119
6141
  * 背景详见 docs/plan/todo.md「已知缺口 G2」。
5120
6142
  */
5121
6143
  function createAlertsClientFromConfig(config) {
5122
- return createGitHubClient({
5123
- token: config.alertsToken || config.githubToken,
5124
- retry: {
5125
- maxRetries: config.maxRetries,
5126
- maxBackoffMs: config.maxBackoffMs
5127
- }
5128
- });
6144
+ return createGitHubClient({ auth: fromPat(config.alertsToken || config.githubToken, { retry: {
6145
+ maxRetries: config.maxRetries,
6146
+ maxBackoffMs: config.maxBackoffMs
6147
+ } }) });
5129
6148
  }
5130
6149
  /** 记录单个告警源的拉取失败(不中断另一源的处理)。 */
5131
6150
  function recordAlertSourceError(deps, repo, source, error) {
5132
6151
  const message = toErrorMessage(error);
5133
- const hint = dependabotAlertsTokenHint(error) ?? codeScanningAlertsTokenHint(error);
6152
+ const hint = dependabotAlertsTokenHint(error) ?? codeScanningAlertsTokenHint(error) ?? codeQualityAlertsTokenHint(error);
5134
6153
  deps.logger.error(`Failed to fetch ${source} alerts for ${repo}: ${message}${hint ? ` — ${hint}` : ""}`);
5135
6154
  deps.allErrors.push({
5136
6155
  repository: repo,
5137
6156
  stage: "fetch",
5138
6157
  category: "FETCH_FAILED",
6158
+ source,
5139
6159
  message: hint ? `${message}(${hint})` : message
5140
6160
  });
5141
6161
  }
@@ -5840,7 +6860,7 @@ async function applyLockfileFixes(ctx, client, repo, sets, progress) {
5840
6860
  const targets = Object.values(versionedOverrides);
5841
6861
  const targetSummary = targets.length > 0 ? targets.join(", ") : alert.recommendedVersion;
5842
6862
  if (ctx.config.dryRun) {
5843
- ctx.logger.info(`[dry-run] Would apply versioned overrides for ${alert.packageName}: ${JSON.stringify(versionedOverrides)}`);
6863
+ ctx.logger.info(`[dry-run] Would apply versioned overrides for ${alert.packageName}`, { overrides: versionedOverrides });
5844
6864
  ctx.allActions.push({
5845
6865
  type: "dependency-upgrade",
5846
6866
  repository: alert.repository,
@@ -5861,7 +6881,7 @@ async function applyLockfileFixes(ctx, client, repo, sets, progress) {
5861
6881
  continue;
5862
6882
  }
5863
6883
  const snapshot = snapshotTrackedFiles(ctx.workDir);
5864
- ctx.logger.info(`[multi-version] ${alert.packageName}: applying versioned overrides ${JSON.stringify(versionedOverrides)}`);
6884
+ ctx.logger.info(`[multi-version] ${alert.packageName}: applying versioned overrides`, { overrides: versionedOverrides });
5865
6885
  const result = await applyVersionedOverrides({
5866
6886
  packageName: alert.packageName,
5867
6887
  versionedOverrides,
@@ -6235,6 +7255,35 @@ async function finalizeRepoFix(ctx, repo, progress) {
6235
7255
 
6236
7256
  //#endregion
6237
7257
  //#region src/app/index.ts
7258
+ /**
7259
+ * 输出"部分源拉取失败"汇总(todo.md §M19.5 C8 per-source 错误隔离)。
7260
+ *
7261
+ * 触发条件(必须全部满足,避免与"全部源失败"语义重叠):
7262
+ * 1. allErrors 至少包含一个 `stage='fetch' + category='FETCH_FAILED'` 错误(per-source 失败)
7263
+ * 2. **至少一个仓库成功拉取了部分告警**(isAnyRepoSuccessful = true)——
7264
+ * 否则就是"全部源失败"语义,已由 processRepoForReport catch + logger.error 单独处理,
7265
+ * 本函数聚焦"warn + 保留成功源"场景的汇总输出,避免重复提示。
7266
+ *
7267
+ * 汇总按 source 分组('dependabot' / 'code-scanning' / 'code-quality' / 'pnpm-audit'),
7268
+ * 列出每个源失败的仓库数 + 示例错误消息,便于用户快速定位是 token 权限还是网络问题。
7269
+ */
7270
+ function logPartialSourceFailureSummary(allErrors, logger, isAnyRepoSuccessful) {
7271
+ const fetchErrors = allErrors.filter((e) => e.stage === "fetch" && e.category === "FETCH_FAILED");
7272
+ if (fetchErrors.length === 0) return;
7273
+ if (!isAnyRepoSuccessful) return;
7274
+ const bySource = /* @__PURE__ */ new Map();
7275
+ for (const err of fetchErrors) {
7276
+ const source = err.source ?? "unknown";
7277
+ const existing = bySource.get(source);
7278
+ if (existing) existing.repos.add(err.repository);
7279
+ else bySource.set(source, {
7280
+ repos: /* @__PURE__ */ new Set([err.repository]),
7281
+ sampleMessage: err.message
7282
+ });
7283
+ }
7284
+ const summary = [...bySource.entries()].map(([source, { repos, sampleMessage }]) => `${source}(${repos.size} 个仓库: ${[...repos].slice(0, 3).join(", ")}${repos.size > 3 ? "..." : ""}): ${sampleMessage}`).join("\n - ");
7285
+ logger.warn(`[alerts] 部分源拉取失败(M19.5 per-source 错误隔离)— 成功源已保留继续处理,详细如下:\n - ${summary}`);
7286
+ }
6238
7287
  var DependfixApp = class {
6239
7288
  config;
6240
7289
  workDir;
@@ -6242,6 +7291,7 @@ var DependfixApp = class {
6242
7291
  logger;
6243
7292
  verbose;
6244
7293
  customCommands;
7294
+ executionEnvironment;
6245
7295
  runId;
6246
7296
  allAlerts = [];
6247
7297
  allActions = [];
@@ -6260,11 +7310,21 @@ var DependfixApp = class {
6260
7310
  this.reportOutputDir = options.reportOutputDir ?? "./dependfix-reports";
6261
7311
  this.verbose = options.verbose ?? false;
6262
7312
  this.customCommands = options.commands;
7313
+ this.executionEnvironment = options.executionEnvironment ?? "local";
6263
7314
  this.runId = `dependfix-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
6264
- this.logger = createLogger({
7315
+ this.logger = options.logger ?? createLogger({
6265
7316
  name: "dependfix",
6266
7317
  minLevel: this.verbose ? "debug" : "info"
6267
7318
  });
7319
+ resetActiveRulesConfig();
7320
+ const ruleConfig = loadRulesConfigFromEnv(process.env);
7321
+ if (ruleConfig) {
7322
+ setActiveRulesConfig(ruleConfig);
7323
+ this.logger.debug("Loaded custom code-scanning rules config from env", {
7324
+ autoFixableCount: ruleConfig.autoFixable.size,
7325
+ suggestedCount: ruleConfig.suggested.size
7326
+ });
7327
+ }
6268
7328
  }
6269
7329
  /** 供辅助方法使用的状态切片。 */
6270
7330
  get ctx() {
@@ -6286,6 +7346,7 @@ var DependfixApp = class {
6286
7346
  /** 执行完整的编排流程,返回结构化结果和退出码。 */
6287
7347
  async run() {
6288
7348
  this.startedAt = (/* @__PURE__ */ new Date()).toISOString();
7349
+ await this.runStartupSecurityChecks();
6289
7350
  this.preExistingDirty = hasGitChanges(this.workDir);
6290
7351
  this.logger.info(`Starting dependfix run ${this.runId}`, {
6291
7352
  mode: this.config.mode,
@@ -6305,7 +7366,8 @@ var DependfixApp = class {
6305
7366
  }
6306
7367
  this.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
6307
7368
  computeSummary(this.ctx);
6308
- const runResult = buildRunResult(this.ctx, this.aiUsageRef.aggregate);
7369
+ const supplyChainWarnings = collectSupplyChainWarnings(this.workDir, this.allActions);
7370
+ const runResult = buildRunResult(this.ctx, this.aiUsageRef.aggregate, supplyChainWarnings);
6309
7371
  const exitCode = computeExitCode(this.ctx);
6310
7372
  try {
6311
7373
  const md = generateMarkdownReport(runResult);
@@ -6325,11 +7387,27 @@ var DependfixApp = class {
6325
7387
  const costText = u.estimatedCostUsd !== void 0 ? `, 估算成本 $${u.estimatedCostUsd.toFixed(4)}` : "";
6326
7388
  this.logger.info(`[ai] run 总计: ${u.calls} 次调用, ${u.inputTokens} in / ${u.outputTokens} out tokens${costText}`);
6327
7389
  }
7390
+ const isAnyRepoSuccessful = this.repoResults.some((r) => r.alertsCount > 0 || r.fixed > 0 || r.verificationPassed === true);
7391
+ logPartialSourceFailureSummary(this.allErrors, this.logger, isAnyRepoSuccessful);
6328
7392
  return {
6329
7393
  result: runResult,
6330
7394
  exitCode
6331
7395
  };
6332
7396
  }
7397
+ /**
7398
+ * 启动安全自检(best-effort,任何失败不阻断运行):
7399
+ * - 本地模式风险提示:fix/fix-and-pr 会在用户机器直接执行目标仓库的
7400
+ * 依赖脚本(不可信代码);容器环境(平台沙箱)属于设计内隔离,跳过。
7401
+ * - token 权限面探测:超权限 token(classic repo scope)启动即警告,
7402
+ * Code Scanning 开启但缺 security-events 权限时提示。
7403
+ */
7404
+ async runStartupSecurityChecks() {
7405
+ 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 安全注意事项)。");
7406
+ if (this.config.alertSource === "github-dependabot" && this.config.githubToken) {
7407
+ const result = await checkTokenPermissions(this.createClient(), { codeScanningEnabled: this.config.codeScanningEnabled });
7408
+ if (result.ok && result.warnings.length > 0) for (const warning of result.warnings) this.logger.warn(`[token-scope] ${warning.message}`);
7409
+ }
7410
+ }
6333
7411
  async executeMode() {
6334
7412
  switch (this.config.mode) {
6335
7413
  case "report-only":
@@ -6484,7 +7562,8 @@ var DependfixApp = class {
6484
7562
  pushBranch(branchName, this.workDir);
6485
7563
  const defaultBranch = await fetchDefaultBranch(client, owner, repo);
6486
7564
  computeSummary(this.ctx);
6487
- const runResult = buildRunResult(this.ctx, this.aiUsageRef.aggregate);
7565
+ const supplyChainWarnings = collectSupplyChainWarnings(this.workDir, this.allActions);
7566
+ const runResult = buildRunResult(this.ctx, this.aiUsageRef.aggregate, supplyChainWarnings);
6488
7567
  const prBody = generatePRBody(runResult, plan.supersedePRs.map((pr) => pr.number));
6489
7568
  const prTitle = buildPrTitle(this.summary, this.allActions);
6490
7569
  const pr = await createPullRequest({
@@ -6507,6 +7586,7 @@ var DependfixApp = class {
6507
7586
  success: true,
6508
7587
  durationMs: 0
6509
7588
  });
7589
+ await this.handleDuplicatePRs(client, owner, repo, pr.number, plan.supersedePRs);
6510
7590
  await closeSupersededPRs(this.ctx, client, owner, repo, plan.supersedePRs);
6511
7591
  } catch (error) {
6512
7592
  const message = toErrorMessage(error);
@@ -6533,6 +7613,41 @@ var DependfixApp = class {
6533
7613
  for (const repo of this.config.repositories) await runBranchCleanupForRepo(this.ctx, client, repo);
6534
7614
  }
6535
7615
  /**
7616
+ * 重复 PR 处理:在新 PR 上添加评论指向被取代的旧 PR + 添加 `duplicate` label。
7617
+ *
7618
+ * 失败仅记录 warning(家务活 best-effort),不阻断主流程:
7619
+ * - 新 PR 已创建成功,用户已可访问
7620
+ * - 评论失败不影响 PR 本身的可用性
7621
+ * - 但需要 `issues: write` token scope(比 `pull-requests: write` 宽);
7622
+ * 若 token 权限不足,error 会被记录但不阻塞
7623
+ */
7624
+ async handleDuplicatePRs(client, owner, repo, newPRNumber, supersededPRs) {
7625
+ if (supersededPRs.length === 0) return;
7626
+ const commentBody = [
7627
+ "## ⚠️ Duplicate PR Notice",
7628
+ "",
7629
+ "This PR supersedes the following dependfix PR(s) with different content:",
7630
+ "",
7631
+ supersededPRs.map((pr) => `- [#${pr.number}](${pr.htmlUrl})`).join("\n"),
7632
+ "",
7633
+ "The superseded PR(s) have been closed. Please review this PR as the new canonical fix."
7634
+ ].join("\n");
7635
+ try {
7636
+ await commentOnPullRequest(client, owner, repo, newPRNumber, commentBody);
7637
+ this.logger.info(`Added duplicate notice comment to PR #${newPRNumber}`);
7638
+ } catch (error) {
7639
+ const message = toErrorMessage(error);
7640
+ this.logger.warn(`Failed to add duplicate notice comment to PR #${newPRNumber}: ${message}(token may lack \`issues: write\` scope)`);
7641
+ }
7642
+ try {
7643
+ await addLabelToPullRequest(client, owner, repo, newPRNumber, ["duplicate"]);
7644
+ this.logger.info(`Added 'duplicate' label to PR #${newPRNumber}`);
7645
+ } catch (error) {
7646
+ const message = toErrorMessage(error);
7647
+ this.logger.warn(`Failed to add 'duplicate' label to PR #${newPRNumber}: ${message}(token may lack \`issues: write\` scope)`);
7648
+ }
7649
+ }
7650
+ /**
6536
7651
  * 多仓库并发执行管线。
6537
7652
  *
6538
7653
  * - 并发窗口 = config.maxConcurrency(默认 1 保守串行,行为与现状一致)
@@ -6592,7 +7707,8 @@ var DependfixApp = class {
6592
7707
  client,
6593
7708
  owners: owner,
6594
7709
  topics: this.config.repoTopics,
6595
- policy
7710
+ policy,
7711
+ maxRepos: this.config.maxRepos
6596
7712
  });
6597
7713
  const explicitFiltered = filterExplicitRepositories(policy, this.config.repositories);
6598
7714
  const merged = mergeRepositories(explicitFiltered, discovered.map((r) => r.fullName));
@@ -6616,16 +7732,13 @@ var DependfixApp = class {
6616
7732
  return this.createClient();
6617
7733
  }
6618
7734
  createClient(token = this.config.githubToken) {
6619
- return createGitHubClient({
6620
- token,
6621
- retry: {
6622
- maxRetries: this.config.maxRetries,
6623
- maxBackoffMs: this.config.maxBackoffMs
6624
- }
6625
- });
7735
+ return createGitHubClient({ auth: fromPat(token, { retry: {
7736
+ maxRetries: this.config.maxRetries,
7737
+ maxBackoffMs: this.config.maxBackoffMs
7738
+ } }) });
6626
7739
  }
6627
7740
  };
6628
7741
 
6629
7742
  //#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 };
7743
+ 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, addLabelToPullRequest, analyzeTokenScope, applyCodeScanningFix, applyVersionedOverrides, assessBreakingChange, buildPrTitle, buildUpgradeGroups, checkTokenPermissions, classifyLockfileFailure, classifyRule, closePullRequest, closeSupersededPRs, commentOnPullRequest, 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, logPartialSourceFailureSummary, 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
7744
  //# sourceMappingURL=index.mjs.map