progmune-runtime 3.7.32 → 3.7.35

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/CHANGELOG.md CHANGED
@@ -1,5 +1,100 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.7.35] — 2026-09-19
4
+
5
+ ### 重建盲测覆盖:taintpath 语料族(TS 795 空过的终结)
6
+
7
+ TS 795 盲测对「路径穿越标记」**连续三次零覆盖**——语料里没有任何
8
+ `不可信根 → 文件 sink` 的流,`__progmune_path_traversal__` 出现次数前 0 后 0。
9
+ 于是「3086 flags LOST 0 / ADDED 0」这道硬门在这项能力上一直是空过(3.7.32/33/34
10
+ 三次验收都把它当作证据)。
11
+
12
+ - 新增语料族 `generated/taintpath_A`(HTTP 请求面)与 `taintpath_B`
13
+ (MCP 工具实参面 + 跨文件/跨函数传播),共 24 条期望
14
+ - 新增生成器 `blind-benchmark/generate-projects-taintpath.ts`
15
+ (**只写自己前缀**,不像 generate-projects.ts 那样清理含 `_` 的目录)
16
+ - 新增闸门 `blind-benchmark/check-taintpath.ts` + 期望表
17
+ `blind-benchmark/taintpath-expectations.json`:逐函数断言
18
+ mark / suppressed / no-taint / known-gap 四类,退出码非 0 即失败
19
+ - 覆盖力:`__progmune_path_traversal__` **0 → 15 次**;24/24 符合期望
20
+ - 已有 100 个项目零漂移:LOST 0 / ADDED 0(总数 3086 → 3109,增量全部来自新语料)
21
+ - 升级为方法学规则 **R7-no-vacuous-gate**
22
+
23
+ ### C5:不可信根可以直连 sink(召回)
24
+
25
+ 此前外层要求 `collectTaintedNames` 非空,因此
26
+ `fs.readFileSync("/data/" + req.params.name)` —— 真实 Express 工程最常见的形态
27
+ ——不标记,只有先赋给局部变量才标记。SSRF 侧从来不要求中间变量,这是同一条
28
+ 数据流上的又一处口径不一致,与 G1 同类。改为:先看 sink 实参窗口有没有污点证据(taint 模式本来就含不可信根),
29
+ 跨函数传播仍须有具名污点。
30
+
31
+ - `src/extract-ir-taint-structural.test.ts` 新增 6 条(4 正 + 2 负对照)
32
+ - 反向验证:回退 v3.7.34 后其中 4 条失败、2 条负对照仍绿
33
+ - fr-007 真实语料维持 pre 5 / post **0**(C5 只补召回,未削弱 G1 判别力)
34
+
35
+ ### 本次暴露、尚未修的两处缺口(已进语料,作为下一轮的验收对象)
36
+
37
+ - **C4**:污点经表达式包装(`path.join/resolve/normalize/basename`)后不传播。
38
+ 语料里 readBasename / readResolveOnly / readJoinWrapped / readNormalizeWrapped
39
+ 四条语义上应标记、实测不标记,记为 known-gap(闸门会盯着,一旦补上就提醒更新)
40
+ - **G2**(反向):自定义校验函数名不含路径语义后缀时调用点不被抑制。语料里
41
+ `dispatchToolGuarded` 调了 `assertTemplateName()`(函数体内有字符集白名单),
42
+ 仍被标记——因为 `Name` 后缀在修 ensureDir 误判时被移出了守卫词表
43
+
44
+ ## [3.7.34] — 2026-09-19
45
+
46
+ ### G1 PATH_GUARD_EVIDENCE —— 路径穿越的「校验识别」
47
+
48
+ 路径穿越标记此前是 `taint → 文件 sink ⇒ 标记`,**不看中间有没有校验**;SSRF 侧
49
+ 不是这样(`taint → fetch sink 且无 SSRF_GUARD_EVIDENCE ⇒ 标记`)。两侧数据流同构、
50
+ 判别力差一档——这正是根集合不敢放宽的真正原因(fr-012/fr-015 的 MISS 由此从
51
+ 「词表缺口」升级为「机制缺口」)。本版把路径侧改成与 SSRF 对齐:
52
+ **`taint → 文件 sink 且无校验证据 ⇒ 标记`**。
53
+
54
+ 守卫形态的种子全部来自语料真实修复,逐条可追溯:
55
+
56
+ - **G-A 目录包含性**:`resolve(p).startsWith(resolve(root))`、`x.startsWith(baseDir)`
57
+ - **G-B 上跳/绝对路径拒绝**:`path.isAbsolute`、`startsWith(".."+sep)`、`=== ".."`
58
+ (种子 = fr-012 gitlab-mcp 下载侧 `localPath` 既有守卫块,index.ts:7968-7977)
59
+ - **G-C 独立校验函数**:`assertValidFlowId`(fr-007 openhop)/ `assertWithinDir`(fr-016 Redocly)
60
+ —— 含向调用方**有界传播 3 跳**:校验被抽进被调用函数时,调用方一个校验词汇都没有
61
+ - **G-D 锚定字符集白名单**:`/^[A-Za-z0-9_-]+$/`(fr-007 `FLOW_ID_PATTERN`)
62
+
63
+ 明确**不算**守卫:`path.basename`(fr-012 pre 实测反例,漏洞态就有它)、
64
+ 单独出现的 `join`/`resolve`、长度检查、`if (!p) throw`。
65
+
66
+ **验收**
67
+
68
+ - fr-007 openhop 真实语料:**pre 5 条 / post 0 条** —— 真实语料上首次出现
69
+ 「修复后流消失」的判别力证据(G1 之前是 5 / 5)
70
+ - `src/extract-ir-taint-guard.test.ts` 14 passed;反向验证回退 3.7.33 后 5 条失败
71
+ - TS 795 盲测 3086 flags LOST 0 / ADDED 0 —— ⚠️ **空过**:盲测语料里
72
+ `__progmune_path_traversal__` 出现 0 次,无覆盖;真正的门是 fr-007 与定向用例
73
+ - `tsc -p tsconfig.json` 零错误
74
+
75
+ **实现期修掉的两个反例**(都写进代码注释与方法学规则):
76
+
77
+ 1. `ensureDir()` / `isDirectory()` 曾被 G-C 误判为守卫(后缀 `Dir`),导致 fr-007
78
+ pre 侧 5 条被压成 0、召回归零。已移除 `Dir`/`Name` 后缀并加显式反例名单
79
+ 2. 「不得标记」类断言会**假通过**:污点经 `path.join(x)` 包装后不再传播,
80
+ sink 处根本没污点。已定为方法学规则 **R6**:负向断言必须配同形状正对照
81
+
82
+ ## [3.7.33] — 2026-09-19
83
+
84
+ ### 污点标记管线三处结构性修复(C1/C2/C3,正确性修复,不改变判别逻辑)
85
+
86
+ 从「污点源数据流试点 V1」拆出的**独立正确性条目**——它们只解决「看得见」,
87
+ 不解决「看得懂」(判别力是 `PATH_GUARD_EVIDENCE`,另一个条目,尚未实现)。
88
+
89
+ - **C3 污点根按传输面声明**(`UNTRUSTED_ROOTS`):此前整条链路只锚 Express 形态的 `req|request.(params|query|body|headers|cookies)`,即**根集合只有一种来源**。MCP 工具实参(`params.arguments`)、CLI 参数等来源天然不可见(fr-012 的 `args.file_path`)。改为按传输面声明根,每条附 `why`,不再靠补变量名清单打地鼠
90
+ - **C1 sink 形参表纳入顶层函数**:`methodSinkParamMap` 此前只遍历 `sf.getClasses()`,真实 TS 工程大量使用顶层函数声明(MCP handler / Nuxt runtime binding),跨函数一跳对它们完全不生效——fr-012 的 `markdownUpload` 正因如此从未入表
91
+ - **C2 跨函数传播识别裸调用**:调用匹配此前要求 `obj.sink(`(成员调用),新增 `directCallRe` 覆盖 `func(` 形态
92
+ - **验收**:
93
+ - TS 795 盲测 **3086 flags LOST 0 / ADDED 0**
94
+ - ⚠️ **但这是空过(vacuous pass)**:实测整份 generated 语料重跑后 per-function calls 差异 **0**、`__progmune` 标记 **826 vs 826**——盲测语料根本走不到这三个分支。真正的门是新增的定向用例
95
+ - 新增 `src/extract-ir-taint-structural.test.ts` **6 green**;**反向验证**:把 `src/extract-ir.ts` 回退到 3.7.32 后其中 **3 项失败**(MCP 根 / 顶层函数 / 裸调用),确认这三个用例真的锁住了行为,而非同义反复。另 3 项(Express 旧语义仍标记、常量路径不标记、常量裸调用不标记)在修复前后均通过——它们锁的是「不得回归 / 不得过度标记」
96
+ - **行为边界**:本条目**不**改变判别逻辑。路径穿越侧仍无「校验识别」,因此放宽根集合后对已校验流同样会标记——这正是试点 V1 记录为未过验收的原因,由后续 `PATH_GUARD_EVIDENCE` 解决
97
+
3
98
  ## [3.7.32] — 2026-09-19
4
99
 
5
100
  ### IR 陈旧性判定(两次会话级误判的根因修复)
@@ -0,0 +1,372 @@
1
+ "use strict";
2
+ /**
3
+ * G1 PATH_GUARD_EVIDENCE —— 路径穿越「校验识别」的定向回归(2026-09-19)。
4
+ *
5
+ * 立项背景:路径穿越标记此前是 `taint → file sink ⇒ 标记`,**不看中间有没有
6
+ * 校验**;SSRF 侧不是这样(无 SSRF_GUARD_EVIDENCE 才标记)。两侧数据流同构、
7
+ * 判别力差一档——这正是根集合不敢放宽的真正原因(fr-012/fr-015 的 MISS 由此
8
+ * 从「词表缺口」升级为「机制缺口」)。
9
+ *
10
+ * G1 把路径侧改成与 SSRF 对齐:`taint → file sink 且无校验证据 ⇒ 标记`。
11
+ *
12
+ * 本文件的每条正例/反例都锚定到**语料里真实存在的一段代码**,不是凭空造的词表:
13
+ * - fr-007 openhop `assertValidFlowId(id)`(fix 才出现,G-C)
14
+ * - fr-012 gitlab-mcp 下载侧 localPath 守卫块(pre/post 同一段,G-B)
15
+ * - fr-016 Redocly `assertWithinDir(openapiDir, pathFile, pathName)`(G-C)
16
+ * - fr-012 pre 实测反例:`path.basename` 在漏洞态就在(N-A)
17
+ */
18
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ var desc = Object.getOwnPropertyDescriptor(m, k);
21
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
22
+ desc = { enumerable: true, get: function() { return m[k]; } };
23
+ }
24
+ Object.defineProperty(o, k2, desc);
25
+ }) : (function(o, m, k, k2) {
26
+ if (k2 === undefined) k2 = k;
27
+ o[k2] = m[k];
28
+ }));
29
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
30
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
31
+ }) : function(o, v) {
32
+ o["default"] = v;
33
+ });
34
+ var __importStar = (this && this.__importStar) || (function () {
35
+ var ownKeys = function(o) {
36
+ ownKeys = Object.getOwnPropertyNames || function (o) {
37
+ var ar = [];
38
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
39
+ return ar;
40
+ };
41
+ return ownKeys(o);
42
+ };
43
+ return function (mod) {
44
+ if (mod && mod.__esModule) return mod;
45
+ var result = {};
46
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
47
+ __setModuleDefault(result, mod);
48
+ return result;
49
+ };
50
+ })();
51
+ Object.defineProperty(exports, "__esModule", { value: true });
52
+ const vitest_1 = require("vitest");
53
+ const fs = __importStar(require("fs"));
54
+ const os = __importStar(require("os"));
55
+ const path = __importStar(require("path"));
56
+ const extract_ir_1 = require("./extract-ir");
57
+ const TSCONFIG = JSON.stringify({
58
+ compilerOptions: {
59
+ target: "ES2020",
60
+ module: "commonjs",
61
+ moduleResolution: "node",
62
+ strict: false,
63
+ skipLibCheck: true,
64
+ noEmit: true,
65
+ },
66
+ include: ["**/*.ts"],
67
+ });
68
+ function makeProject(files) {
69
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pm-taint-guard-"));
70
+ fs.writeFileSync(path.join(dir, "tsconfig.json"), TSCONFIG);
71
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "fixture", version: "0.0.0", private: true }));
72
+ for (const [rel, body] of Object.entries(files)) {
73
+ const abs = path.join(dir, rel);
74
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
75
+ fs.writeFileSync(abs, body);
76
+ }
77
+ return dir;
78
+ }
79
+ function marksFor(dir, fnName) {
80
+ const fns = (0, extract_ir_1.extractIR)(dir);
81
+ const f = fns.find((x) => x.name === fnName);
82
+ return f ? (f.calls ?? []) : [];
83
+ }
84
+ const PATH_MARK = "__progmune_path_traversal__";
85
+ (0, vitest_1.describe)("G1 基线:无校验证据时必须照旧标记(不得因加判别力而失召回)", () => {
86
+ (0, vitest_1.it)("taint → 文件 sink,全程无校验 → 标记", () => {
87
+ const dir = makeProject({
88
+ "a.ts": `
89
+ import * as fs from "fs";
90
+ export function read(req: any) {
91
+ const name = req.params.name;
92
+ return fs.readFileSync("/data/" + name, "utf-8");
93
+ }
94
+ `,
95
+ });
96
+ try {
97
+ (0, vitest_1.expect)(marksFor(dir, "read")).toContain(PATH_MARK);
98
+ }
99
+ finally {
100
+ fs.rmSync(dir, { recursive: true, force: true });
101
+ }
102
+ });
103
+ });
104
+ (0, vitest_1.describe)("G-B:上跳/绝对路径拒绝(种子=fr-012 gitlab-mcp 下载侧 localPath 守卫块)", () => {
105
+ // index.ts:7968-7977 —— pre 与 post 完全一致的一段既有守卫。
106
+ // 它不能当「修复形态种子」,但它是真实世界的「已校验」样本。
107
+ //
108
+ // ⚠️ 写法约束(踩过坑):污点必须**直接出现在 sink 实参窗口里**。
109
+ // 「污点经 path.normalize(x) / path.join(x) 包装后仍传播」是另一条独立的
110
+ // 传播缺口(C4,见文末已知边界)—— 若让污点先被包装再进 sink,
111
+ // 「不得标记」会**假通过**(污点压根没到 sink,与守卫无关)。
112
+ // 故本组用例让 sink 直接使用污点名,守卫块则照抄真实代码。
113
+ const GUARD_BLOCK = `
114
+ const normalizedLocalPath = path.normalize(localPath);
115
+ if (
116
+ path.isAbsolute(normalizedLocalPath) ||
117
+ normalizedLocalPath === ".." ||
118
+ normalizedLocalPath.startsWith(".." + path.sep) ||
119
+ normalizedLocalPath.includes(path.sep + ".." + path.sep)
120
+ ) {
121
+ throw new Error("Invalid local_path: directory traversal is not allowed.");
122
+ }
123
+ `;
124
+ const wrap = (guard) => `
125
+ import * as fs from "fs";
126
+ import * as path from "path";
127
+ export function save(req: any, buffer: any) {
128
+ const localPath = req.params.local_path;
129
+ ${guard} fs.writeFileSync(localPath, buffer);
130
+ }
131
+ `;
132
+ (0, vitest_1.it)("去掉守卫块后必须标记(正对照:证明这条用例真的锁住了判别力)", () => {
133
+ const dir = makeProject({ "a.ts": wrap("") });
134
+ try {
135
+ (0, vitest_1.expect)(marksFor(dir, "save")).toContain(PATH_MARK);
136
+ }
137
+ finally {
138
+ fs.rmSync(dir, { recursive: true, force: true });
139
+ }
140
+ });
141
+ (0, vitest_1.it)("真实守卫块存在时不得标记", () => {
142
+ const dir = makeProject({ "a.ts": wrap(GUARD_BLOCK) });
143
+ try {
144
+ (0, vitest_1.expect)(marksFor(dir, "save")).not.toContain(PATH_MARK);
145
+ }
146
+ finally {
147
+ fs.rmSync(dir, { recursive: true, force: true });
148
+ }
149
+ });
150
+ });
151
+ (0, vitest_1.describe)("G-C:独立校验函数(种子=fr-007 openhop / fr-016 Redocly)", () => {
152
+ // fr-007 的修复形态:把 assertValidFlowId(id) 放进 filePath(),
153
+ // 真正含 sink 的 get/save 与外层 flowRoutes 里一个校验词汇都没有
154
+ // —— 只看函数体等于没做,必须向调用方传播。
155
+ const BASE = `
156
+ import * as fs from "fs";
157
+ import * as path from "path";
158
+ export class FlowStore {
159
+ private dir = "/data";
160
+ private filePath(id: string): string {
161
+ PATHIDGUARD
162
+ return path.join(this.dir, id + ".yaml");
163
+ }
164
+ async get(id: string) {
165
+ return fs.readFileSync(this.filePath(id), "utf-8");
166
+ }
167
+ }
168
+ export function flowRoutes(req: any, store: FlowStore) {
169
+ const id = req.params.id;
170
+ return store.get(id);
171
+ }
172
+ `;
173
+ (0, vitest_1.it)("pre(无校验函数):含 sink 的方法与外层调用点都要标记", () => {
174
+ const dir = makeProject({ "a.ts": BASE.replace("PATHIDGUARD", "") });
175
+ try {
176
+ (0, vitest_1.expect)(marksFor(dir, "flowRoutes")).toContain(PATH_MARK);
177
+ (0, vitest_1.expect)(marksFor(dir, "FlowStore.get")).toContain(PATH_MARK);
178
+ }
179
+ finally {
180
+ fs.rmSync(dir, { recursive: true, force: true });
181
+ }
182
+ });
183
+ (0, vitest_1.it)("post(fr-007 真修复形态 assertValidFlowId):两侧都不得标记", () => {
184
+ const dir = makeProject({
185
+ "flow-id.ts": `
186
+ export function assertValidFlowId(id: string): void {
187
+ if (!/^[A-Za-z0-9_-]+$/.test(id)) { throw new Error("Invalid flow id: " + id); }
188
+ }
189
+ `,
190
+ "a.ts": BASE.replace("PATHIDGUARD", "assertValidFlowId(id);"),
191
+ });
192
+ try {
193
+ (0, vitest_1.expect)(marksFor(dir, "flowRoutes")).not.toContain(PATH_MARK);
194
+ (0, vitest_1.expect)(marksFor(dir, "FlowStore.get")).not.toContain(PATH_MARK);
195
+ }
196
+ finally {
197
+ fs.rmSync(dir, { recursive: true, force: true });
198
+ }
199
+ });
200
+ (0, vitest_1.it)("fr-016 Redocly 形态 assertWithinDir:不得标记", () => {
201
+ const dir = makeProject({
202
+ "a.ts": `
203
+ import * as fs from "fs";
204
+ import * as path from "path";
205
+ // 同上:让污点直接进 sink 实参窗口,避免假通过
206
+ export function iteratePathItems(req: any, openapiDir: string, outDir: string) {
207
+ const pathName = req.params.name;
208
+ assertWithinDir(openapiDir, path.join(outDir, pathName) + ".yaml", pathName);
209
+ fs.writeFileSync(path.join(outDir, pathName) + ".yaml", "x");
210
+ }
211
+ `,
212
+ });
213
+ try {
214
+ (0, vitest_1.expect)(marksFor(dir, "iteratePathItems")).not.toContain(PATH_MARK);
215
+ }
216
+ finally {
217
+ fs.rmSync(dir, { recursive: true, force: true });
218
+ }
219
+ });
220
+ });
221
+ (0, vitest_1.describe)("G-A:目录包含性校验(canonical 形态)", () => {
222
+ const wrap = (guard) => `
223
+ import * as fs from "fs";
224
+ import * as path from "path";
225
+ export function read(req: any) {
226
+ const name = req.params.name;
227
+ const baseDir = path.resolve("/data");
228
+ const target = path.resolve(baseDir, name);
229
+ ${guard} return fs.readFileSync(path.join(baseDir, name), "utf-8");
230
+ }
231
+ `;
232
+ (0, vitest_1.it)("去掉包含性判断后必须标记(正对照)", () => {
233
+ const dir = makeProject({ "a.ts": wrap("") });
234
+ try {
235
+ (0, vitest_1.expect)(marksFor(dir, "read")).toContain(PATH_MARK);
236
+ }
237
+ finally {
238
+ fs.rmSync(dir, { recursive: true, force: true });
239
+ }
240
+ });
241
+ (0, vitest_1.it)("resolve + startsWith(baseDir) → 不得标记", () => {
242
+ const dir = makeProject({
243
+ "a.ts": wrap(` if (!target.startsWith(baseDir)) { throw new Error("outside"); }\n`),
244
+ });
245
+ try {
246
+ (0, vitest_1.expect)(marksFor(dir, "read")).not.toContain(PATH_MARK);
247
+ }
248
+ finally {
249
+ fs.rmSync(dir, { recursive: true, force: true });
250
+ }
251
+ });
252
+ });
253
+ (0, vitest_1.describe)("G-D:锚定字符集白名单(种子=fr-007 FLOW_ID_PATTERN)", () => {
254
+ const wrap = (guard) => `
255
+ import * as fs from "fs";
256
+ export function read(req: any) {
257
+ const name = req.params.name;
258
+ ${guard} return fs.readFileSync("/data/" + name, "utf-8");
259
+ }
260
+ `;
261
+ (0, vitest_1.it)("去掉白名单校验后必须标记(正对照)", () => {
262
+ const dir = makeProject({ "a.ts": wrap("") });
263
+ try {
264
+ (0, vitest_1.expect)(marksFor(dir, "read")).toContain(PATH_MARK);
265
+ }
266
+ finally {
267
+ fs.rmSync(dir, { recursive: true, force: true });
268
+ }
269
+ });
270
+ (0, vitest_1.it)("/^[A-Za-z0-9_-]+$/ 参与校验 → 不得标记", () => {
271
+ const dir = makeProject({
272
+ "a.ts": wrap(` if (!/^[A-Za-z0-9_-]+$/.test(name)) { throw new Error("bad id"); }\n`),
273
+ });
274
+ try {
275
+ (0, vitest_1.expect)(marksFor(dir, "read")).not.toContain(PATH_MARK);
276
+ }
277
+ finally {
278
+ fs.rmSync(dir, { recursive: true, force: true });
279
+ }
280
+ });
281
+ });
282
+ (0, vitest_1.describe)("反例清单:这些形态**不算**守卫(N-A 是头号陷阱)", () => {
283
+ (0, vitest_1.it)("N-A path.basename —— fr-012 pre 实测反例:漏洞态就有 basename,必须照旧标记", () => {
284
+ // 注意:污点直接写进 sink 实参窗口(不经 `const x = path.basename(...)` 赋值),
285
+ // 因为「污点经表达式包装后仍传播」是另一条独立的传播缺口(见文末已知边界),
286
+ // 本用例只锁「basename 是否算守卫」这一件事。
287
+ const dir = makeProject({
288
+ "a.ts": `
289
+ import * as fs from "fs";
290
+ import * as path from "path";
291
+ export function read(req: any) {
292
+ const name = req.params.name;
293
+ return fs.readFileSync("/data/" + path.basename(name), "utf-8");
294
+ }
295
+ `,
296
+ });
297
+ try {
298
+ (0, vitest_1.expect)(marksFor(dir, "read")).toContain(PATH_MARK);
299
+ }
300
+ finally {
301
+ fs.rmSync(dir, { recursive: true, force: true });
302
+ }
303
+ });
304
+ (0, vitest_1.it)("N-B 单独出现的 join/resolve —— 拼接本身不阻止上跳,必须标记", () => {
305
+ const dir = makeProject({
306
+ "a.ts": `
307
+ import * as fs from "fs";
308
+ import * as path from "path";
309
+ export function read(req: any) {
310
+ const name = req.params.name;
311
+ return fs.readFileSync(path.resolve("/data", name), "utf-8");
312
+ }
313
+ `,
314
+ });
315
+ try {
316
+ (0, vitest_1.expect)(marksFor(dir, "read")).toContain(PATH_MARK);
317
+ }
318
+ finally {
319
+ fs.rmSync(dir, { recursive: true, force: true });
320
+ }
321
+ });
322
+ (0, vitest_1.it)("N-C 长度检查 —— 无法阻止上跳,必须标记", () => {
323
+ const dir = makeProject({
324
+ "a.ts": `
325
+ import * as fs from "fs";
326
+ export function read(req: any) {
327
+ const name = req.params.name;
328
+ if (name.length > 100) { throw new Error("too long"); }
329
+ return fs.readFileSync("/data/" + name, "utf-8");
330
+ }
331
+ `,
332
+ });
333
+ try {
334
+ (0, vitest_1.expect)(marksFor(dir, "read")).toContain(PATH_MARK);
335
+ }
336
+ finally {
337
+ fs.rmSync(dir, { recursive: true, force: true });
338
+ }
339
+ });
340
+ (0, vitest_1.it)("N-D 空值检查 —— 空值检查 ≠ 路径包含性检查,必须标记", () => {
341
+ const dir = makeProject({
342
+ "a.ts": `
343
+ import * as fs from "fs";
344
+ export function read(req: any) {
345
+ const name = req.params.name;
346
+ if (!name) { throw new Error("missing"); }
347
+ return fs.readFileSync("/data/" + name, "utf-8");
348
+ }
349
+ `,
350
+ });
351
+ try {
352
+ (0, vitest_1.expect)(marksFor(dir, "read")).toContain(PATH_MARK);
353
+ }
354
+ finally {
355
+ fs.rmSync(dir, { recursive: true, force: true });
356
+ }
357
+ });
358
+ });
359
+ /**
360
+ * ── 已知边界(本次写用例时实测踩到,记录以免后人重复踩)──
361
+ *
362
+ * C4:污点经表达式包装后不再传播。
363
+ * `const q = path.normalize(x)` / `path.join(x)` / `path.basename(x)` 之后,
364
+ * q **不在** tainted 集合里(collectTaintedNames 的单跳只认 `= <name>` 直赋)。
365
+ * 后果有二:
366
+ * ① 召回缺口(这是 C 组的事,本条目不修);
367
+ * ② **测试陷阱**:写「不得标记」类用例时,若污点被包装后再进 sink,
368
+ * 用例会**假通过**——不是守卫生效,是污点压根没到 sink。
369
+ * 因此本文件所有 `not.toContain` 用例都配了一条同形状的正对照
370
+ * (去掉守卫后必须重新标记),缺了正对照的负向断言一律视为无效。
371
+ * 本轮就有 3 条用例最初因此假通过(G-A / G-D / fr-016 形态)。
372
+ */
@@ -0,0 +1,289 @@
1
+ "use strict";
2
+ /**
3
+ * 污点标记管线三处结构性修复(C1/C2/C3)的定向回归(2026-09-19)。
4
+ *
5
+ * 立项背景:这三处是从「污点源数据流试点 V1」里拆出来的**正确性修复**——
6
+ * 它们让标记管线对真实 TS 工程「看得见」,但不改变判别逻辑(判别力是 G1
7
+ * PATH_GUARD_EVIDENCE 的事,另一个条目)。
8
+ *
9
+ * 为什么必须有本文件(重要):
10
+ * TS 795 盲测对这三处【零覆盖】——实测把整份 generated 语料重跑后,
11
+ * per-function calls 差异 0、__progmune 标记 826 vs 826、flags 3086 LOST 0/ADDED 0。
12
+ * 即「TS 795 零漂移」在这里是**空过**(vacuous pass):它不是说这三处没风险,
13
+ * 而是说盲测语料根本走不到这些分支。因此真正的门是本文件的定向用例。
14
+ *
15
+ * 三处修复:
16
+ * C3 UNTRUSTED_ROOTS —— 污点根由「Express 专属变量名」改为「按传输面声明」
17
+ * C1 methodSinkParamMap—— 除类方法外,纳入**顶层函数**的 sink 形参
18
+ * C2 directCallRe —— 跨函数传播除成员调用外,识别**裸调用** func(x)
19
+ */
20
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ var desc = Object.getOwnPropertyDescriptor(m, k);
23
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
24
+ desc = { enumerable: true, get: function() { return m[k]; } };
25
+ }
26
+ Object.defineProperty(o, k2, desc);
27
+ }) : (function(o, m, k, k2) {
28
+ if (k2 === undefined) k2 = k;
29
+ o[k2] = m[k];
30
+ }));
31
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
32
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
33
+ }) : function(o, v) {
34
+ o["default"] = v;
35
+ });
36
+ var __importStar = (this && this.__importStar) || (function () {
37
+ var ownKeys = function(o) {
38
+ ownKeys = Object.getOwnPropertyNames || function (o) {
39
+ var ar = [];
40
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
41
+ return ar;
42
+ };
43
+ return ownKeys(o);
44
+ };
45
+ return function (mod) {
46
+ if (mod && mod.__esModule) return mod;
47
+ var result = {};
48
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
49
+ __setModuleDefault(result, mod);
50
+ return result;
51
+ };
52
+ })();
53
+ Object.defineProperty(exports, "__esModule", { value: true });
54
+ const vitest_1 = require("vitest");
55
+ const fs = __importStar(require("fs"));
56
+ const os = __importStar(require("os"));
57
+ const path = __importStar(require("path"));
58
+ const extract_ir_1 = require("./extract-ir");
59
+ const TSCONFIG = JSON.stringify({
60
+ compilerOptions: {
61
+ target: "ES2020",
62
+ module: "commonjs",
63
+ moduleResolution: "node",
64
+ strict: false,
65
+ skipLibCheck: true,
66
+ noEmit: true,
67
+ },
68
+ include: ["**/*.ts"],
69
+ });
70
+ function makeProject(files) {
71
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pm-taint-struct-"));
72
+ fs.writeFileSync(path.join(dir, "tsconfig.json"), TSCONFIG);
73
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "fixture", version: "0.0.0", private: true }));
74
+ for (const [rel, body] of Object.entries(files)) {
75
+ const abs = path.join(dir, rel);
76
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
77
+ fs.writeFileSync(abs, body);
78
+ }
79
+ return dir;
80
+ }
81
+ function marksFor(dir, fnName) {
82
+ const fns = (0, extract_ir_1.extractIR)(dir);
83
+ const f = fns.find((x) => x.name === fnName);
84
+ return f ? (f.calls ?? []) : [];
85
+ }
86
+ const PATH_MARK = "__progmune_path_traversal__";
87
+ (0, vitest_1.describe)("C3:污点根按传输面声明(不再只认 Express 的 req.*)", () => {
88
+ (0, vitest_1.it)("Express 形态仍必须标记(不得因改表回归)", () => {
89
+ const dir = makeProject({
90
+ "legacy.ts": `
91
+ import * as fs from "fs";
92
+ export function read(req: any) {
93
+ const name = req.params.name;
94
+ return fs.readFileSync("/data/" + name, "utf-8");
95
+ }
96
+ `,
97
+ });
98
+ try {
99
+ (0, vitest_1.expect)(marksFor(dir, "read")).toContain(PATH_MARK);
100
+ }
101
+ finally {
102
+ fs.rmSync(dir, { recursive: true, force: true });
103
+ }
104
+ }, 150000);
105
+ (0, vitest_1.it)("MCP 工具实参 params.arguments 作为根(fr-012 形态)", () => {
106
+ const dir = makeProject({
107
+ "mcp.ts": `
108
+ import * as fs from "fs";
109
+ export function uploadMarkdown(args: any) {
110
+ const filePath = params.arguments.file_path;
111
+ return fs.readFileSync(filePath, "utf-8");
112
+ }
113
+ `,
114
+ });
115
+ try {
116
+ (0, vitest_1.expect)(marksFor(dir, "uploadMarkdown")).toContain(PATH_MARK);
117
+ }
118
+ finally {
119
+ fs.rmSync(dir, { recursive: true, force: true });
120
+ }
121
+ }, 150000);
122
+ (0, vitest_1.it)("反例:常量路径不得标记(防止根表放宽后过度标记)", () => {
123
+ const dir = makeProject({
124
+ "benign.ts": `
125
+ import * as fs from "fs";
126
+ export function readDocs() {
127
+ return fs.readFileSync("/data/terms.md", "utf-8");
128
+ }
129
+ `,
130
+ });
131
+ try {
132
+ (0, vitest_1.expect)(marksFor(dir, "readDocs")).not.toContain(PATH_MARK);
133
+ }
134
+ finally {
135
+ fs.rmSync(dir, { recursive: true, force: true });
136
+ }
137
+ }, 150000);
138
+ });
139
+ (0, vitest_1.describe)("C1:顶层函数的 sink 形参必须入表(此前只登记类方法)", () => {
140
+ (0, vitest_1.it)("顶层函数 + 污点实参 → 跨函数传导后被标记", () => {
141
+ const dir = makeProject({
142
+ "handler.ts": `
143
+ import * as fs from "fs";
144
+ export function markdownUpload(filePath: string) {
145
+ return fs.readFileSync(filePath, "utf-8");
146
+ }
147
+ export function dispatchTool(args: any) {
148
+ const target = params.arguments.file_path;
149
+ return markdownUpload(target);
150
+ }
151
+ `,
152
+ });
153
+ try {
154
+ (0, vitest_1.expect)(marksFor(dir, "markdownUpload")).toContain(PATH_MARK);
155
+ }
156
+ finally {
157
+ fs.rmSync(dir, { recursive: true, force: true });
158
+ }
159
+ }, 150000);
160
+ });
161
+ (0, vitest_1.describe)("C2:裸调用形态的跨函数传播(此前只匹配 obj.sink(...))", () => {
162
+ (0, vitest_1.it)("顶层函数被裸调用且传入污点 → 标记(fr-012 的 markdownUpload 形态)", () => {
163
+ const dir = makeProject({
164
+ "store.ts": `
165
+ import * as fs from "fs";
166
+ export function save(name: string, content: string) {
167
+ fs.writeFileSync("/flows/" + name, content);
168
+ }
169
+ `,
170
+ "route.ts": `
171
+ import { save } from "./store";
172
+ export function createFlow(req: any) {
173
+ const name = req.body.name;
174
+ save(name, "{}");
175
+ }
176
+ `,
177
+ });
178
+ try {
179
+ (0, vitest_1.expect)(marksFor(dir, "createFlow")).toContain(PATH_MARK);
180
+ }
181
+ finally {
182
+ fs.rmSync(dir, { recursive: true, force: true });
183
+ }
184
+ }, 150000);
185
+ (0, vitest_1.it)("反例:给裸调用传常量不得标记", () => {
186
+ const dir = makeProject({
187
+ "store.ts": `
188
+ import * as fs from "fs";
189
+ export function save(name: string, content: string) {
190
+ fs.writeFileSync("/flows/" + name, content);
191
+ }
192
+ `,
193
+ "route.ts": `
194
+ import { save } from "./store";
195
+ export function seedFlow() {
196
+ save("default.json", "{}");
197
+ }
198
+ `,
199
+ });
200
+ try {
201
+ (0, vitest_1.expect)(marksFor(dir, "seedFlow")).not.toContain(PATH_MARK);
202
+ }
203
+ finally {
204
+ fs.rmSync(dir, { recursive: true, force: true });
205
+ }
206
+ }, 150000);
207
+ });
208
+ /**
209
+ * C5(2026-09-19):不可信根**直接写在 sink 实参里**(无中间变量)此前不标记。
210
+ *
211
+ * 发现途径:新建的 blind-benchmark taintpath 语料族里有两条最朴素的用例
212
+ * (`readFileSync("/d/" + req.params.name)`、模板字符串形态)跑出「不标记」。
213
+ * 根因是外层 `tainted.size > 0` 的闸门——而 SSRF 侧从来不要求中间变量。
214
+ *
215
+ * 按 R6:**每条「不得标记」都要配正对照**。这里的负对照是同形状无污点的常量路径。
216
+ */
217
+ (0, vitest_1.describe)("C5:内联不可信根(不经中间变量)必须标记", () => {
218
+ const cases = [
219
+ {
220
+ name: "字符串拼接直连",
221
+ fn: "readInline",
222
+ code: `export function readInline(req: any) {
223
+ return fs.readFileSync("/srv/data/" + req.params.name, "utf-8");
224
+ }`,
225
+ expectMark: true,
226
+ why: "真实 Express 工程最常见的写法",
227
+ },
228
+ {
229
+ name: "模板字符串直连",
230
+ fn: "readTemplate",
231
+ code: "export function readTemplate(req: any) {\n return fs.readFileSync(`/srv/data/${req.query.name}`, \"utf-8\");\n}",
232
+ expectMark: true,
233
+ why: "query 根 + 模板插值",
234
+ },
235
+ {
236
+ name: "写侧 header 根",
237
+ fn: "writeHeader",
238
+ code: `export function writeHeader(request: any, data: any) {
239
+ return fs.writeFileSync("/srv/data/" + request.headers["x-name"], data);
240
+ }`,
241
+ expectMark: true,
242
+ why: "写 sink + request.headers 根",
243
+ },
244
+ {
245
+ name: "MCP 实参直连",
246
+ fn: "writeMcp",
247
+ code: `export function writeMcp(params: any, data: any) {
248
+ return fs.writeFileSync(params.arguments.path, data);
249
+ }`,
250
+ expectMark: true,
251
+ why: "传输面里的第二个根:MCP 工具实参",
252
+ },
253
+ {
254
+ name: "负对照:常量路径不得标记",
255
+ fn: "readConstant",
256
+ code: `export function readConstant() {
257
+ return fs.readFileSync("/srv/data/index.html", "utf-8");
258
+ }`,
259
+ expectMark: false,
260
+ why: "R6:证明前面的用例不是靠「只要有 fs sink 就标记」蒙对",
261
+ },
262
+ {
263
+ name: "负对照:有守卫时内联根仍不标记",
264
+ fn: "readInlineGuarded",
265
+ code: `export function readInlineGuarded(req: any) {
266
+ const target = path.resolve("/srv/data", req.params.name);
267
+ if (!target.startsWith("/srv/data")) throw new Error("outside");
268
+ return fs.readFileSync(target, "utf-8");
269
+ }`,
270
+ expectMark: false,
271
+ why: "C5 只补召回,不得绕过 G1 的判别力",
272
+ },
273
+ ];
274
+ for (const c of cases) {
275
+ (0, vitest_1.it)(`${c.name} —— ${c.why}`, () => {
276
+ const dir = makeProject({ "a.ts": `import * as fs from "fs";\nimport * as path from "path";\n\n${c.code}\n` });
277
+ try {
278
+ const marks = marksFor(dir, c.fn);
279
+ if (c.expectMark)
280
+ (0, vitest_1.expect)(marks).toContain(PATH_MARK);
281
+ else
282
+ (0, vitest_1.expect)(marks).not.toContain(PATH_MARK);
283
+ }
284
+ finally {
285
+ fs.rmSync(dir, { recursive: true, force: true });
286
+ }
287
+ }, 150000);
288
+ }
289
+ });
@@ -378,6 +378,156 @@ function extractDirectCalls(func, preText) {
378
378
  // 与 protocol-detector 的同名规则(source-level benchmark 路径)。
379
379
  // ═══════════════════════════════════════════════════════════════
380
380
  const PATH_TRAVERSAL_MARKER = "__progmune_path_traversal__";
381
+ const PATH_GUARD_RULES = [
382
+ {
383
+ id: "G-A",
384
+ // resolve(p).startsWith(resolve(root)+sep) / x.startsWith(baseDir)
385
+ re: /(?:resolve|realpath|normalize)\s*\([^()]*\)\s*\.\s*startsWith\s*\(/,
386
+ why: "目录包含性校验(canonical 形态):规范化后判断是否落在基目录内",
387
+ source: "设计形态,canonical;fr-016 Redocly 修复的同族(见 G-C)",
388
+ },
389
+ {
390
+ id: "G-A2",
391
+ re: /\.\s*startsWith\s*\(\s*[A-Za-z_$][\w$]*(?:[Bb]ase|[Rr]oot|[Dd]ir|Dir|Root|Base)[\w$]*\s*\)/,
392
+ why: "目录包含性校验(基目录名形态):startsWith(baseDir/rootDir/...)",
393
+ source: "设计形态,canonical",
394
+ },
395
+ {
396
+ id: "G-B1",
397
+ re: /\b(?:path\.)?isAbsolute\s*\(/,
398
+ why: "绝对路径拒绝:绝对路径不受 join(root, p) 约束",
399
+ source: "fr-012 gitlab-mcp 下载侧 localPath 既有守卫块(pre/post 同一段,@ index.ts:7968)",
400
+ },
401
+ {
402
+ id: "G-B2",
403
+ // startsWith(".." + sep) / includes(sep + ".." + sep) / === ".."
404
+ re: /\.\s*(?:startsWith|includes)\s*\(\s*(?:[^()]*["'`]\.\.)/,
405
+ why: "上跳拒绝:显式检测 .. 段",
406
+ source: "fr-012 gitlab-mcp 下载侧 localPath 既有守卫块(index.ts:7968-7977)",
407
+ },
408
+ {
409
+ id: "G-B3",
410
+ re: /(?:===?|!==)\s*["'`]\.\.["'`]/,
411
+ why: "上跳拒绝:与 .. 字面量直接比较",
412
+ source: "fr-012 gitlab-mcp 下载侧 localPath 既有守卫块(normalizedLocalPath === \"..\")",
413
+ },
414
+ // 注:G-C 不放在本数组里——它是「按函数名判定」,需要配反例名单
415
+ // (见 PATH_GUARD_FN_CALL_RE / PATH_GUARD_FN_DENY 与下方注释)。
416
+ {
417
+ id: "G-D",
418
+ // /^[A-Za-z0-9_-]+$/ —— 锚定的字符集白名单,天然排除 / \ .
419
+ re: /\/\^\[[^\]]*\]\s*[+*?]\$\//,
420
+ why: "锚定字符集白名单:^…$ 且字符类不含 ./\\ 时无法构造上跳",
421
+ source: "fr-007 openhop `FLOW_ID_PATTERN = /^[A-Za-z0-9_-]+$/`(flow-id.ts)",
422
+ },
423
+ ];
424
+ /**
425
+ * 明确**不算**守卫的形态(反例清单)。
426
+ *
427
+ * N-A `path.basename` 是这张表里最重要的一行:它只去目录、不去 `..`,
428
+ * 却极常被当成「已经 sanitize 过」。fr-012 pre 的实测反例:漏洞态代码里
429
+ * 就有 `path.basename`,漏洞依然成立。一旦把 basename 算作守卫,
430
+ * fr-012 的真值会被自己压掉 —— 见 src/extract-ir-taint-guard.test.ts。
431
+ */
432
+ const PATH_NON_GUARD_NOTE = "N-A path.basename / N-B 单独出现的 join·resolve / N-C 长度或字符数检查 / N-D if (!p) throw —— 均不计入守卫";
433
+ /**
434
+ * G-C:独立校验函数被调用(项目自有命名)。
435
+ *
436
+ * 后缀刻意**不收** `Dir` / `Name` / 裸的 `Valid` —— 实证代价:
437
+ * - `ensureDir()`(fr-007 openhop store.ts:46)与 `s.isDirectory()` 都会被
438
+ * `…Dir(` 命中,而它们是**建目录 / 类型判断**,与校验无关;第一版词表
439
+ * 因此把 fr-007 的 pre 侧 5 条全部误压成 0(召回归零)。
440
+ * - `isValid(x)` / `isSub(a,b)` 同理,会把判别力稀释成猜函数名。
441
+ * 保留 `Id`(fr-007 `assertValidFlowId`)、`Within`(fr-016 `assertWithinDir`)、
442
+ * `Safe|Path|Inside|InDir|Contained|Traversal|Root|Base`。
443
+ */
444
+ const PATH_GUARD_FN_CALL_RE = /\b(?:assert|ensure|check|validate|verify|sanitize|is)[A-Za-z$]*(?:Safe|Path|Paths|Within|Inside|InDir|Contained|Traversal|Id|Root|Base)[A-Za-z0-9_$]*(?=\s*\()/g;
445
+ /** 形态上会命中 G-C 但**确定不是**路径校验的名字(实证反例,逐个加) */
446
+ const PATH_GUARD_FN_DENY = /^(?:ensureDir|isDirectory|isDir|assertDir|makeDir|mkdir|checkExists|isValid|isRoot|ensureId|getId)$/i;
447
+ function callsPathGuardFn(text) {
448
+ PATH_GUARD_FN_CALL_RE.lastIndex = 0;
449
+ let m;
450
+ while ((m = PATH_GUARD_FN_CALL_RE.exec(text)) !== null) {
451
+ if (!PATH_GUARD_FN_DENY.test(m[0]))
452
+ return true;
453
+ }
454
+ return false;
455
+ }
456
+ /** 返回命中的守卫规则 id;无校验证据返回 null。 */
457
+ function hasPathGuardEvidence(text) {
458
+ for (const r of PATH_GUARD_RULES) {
459
+ if (r.re.test(text))
460
+ return r.id;
461
+ }
462
+ if (callsPathGuardFn(text))
463
+ return "G-C";
464
+ return null;
465
+ }
466
+ function escapeRe(s) {
467
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
468
+ }
469
+ /**
470
+ * 项目内「自身含校验证据」的函数名集合,并向调用方传播一跳(有界)。
471
+ *
472
+ * 为什么必须传播:fr-007 的修复是把 `assertValidFlowId(id)` 放进 `filePath()`,
473
+ * 真正含 sink 的 `FlowStore.get/save/...` 与外层 `flowRoutes` 里**一个校验词汇都没有**——
474
+ * 只看函数体等于没做。传播上限 3 轮(防止公共函数名导致的无限扩散)。
475
+ */
476
+ function pathGuardFunctionNames(project) {
477
+ const guarded = new Set();
478
+ const bodies = [];
479
+ const note = (names, text) => {
480
+ if (!text)
481
+ return;
482
+ bodies.push({ names, text });
483
+ if (hasPathGuardEvidence(text))
484
+ for (const n of names)
485
+ if (n)
486
+ guarded.add(n);
487
+ };
488
+ for (const sf of project.getSourceFiles()) {
489
+ for (const cls of sf.getClasses()) {
490
+ const cn = cls.getName();
491
+ if (!cn)
492
+ continue;
493
+ for (const m of cls.getMethods()) {
494
+ const mn = m.getName();
495
+ if (!mn)
496
+ continue;
497
+ note([mn, `${cn}.${mn}`], m.getText());
498
+ }
499
+ }
500
+ for (const fn of sf.getFunctions()) {
501
+ const fnName = fn.getName();
502
+ if (!fnName)
503
+ continue;
504
+ note([fnName], fn.getText());
505
+ }
506
+ }
507
+ // 向调用方传播:调用了一个已校验函数 ⇒ 这条流已被拦截(有界 3 轮)
508
+ for (let round = 0; round < 3; round++) {
509
+ const names = [...guarded].filter((n) => !n.includes("."));
510
+ if (names.length === 0)
511
+ break;
512
+ const re = new RegExp(`\\b(?:${names.map(escapeRe).join("|")})\\s*\\(`, "g");
513
+ let added = false;
514
+ for (const b of bodies) {
515
+ if (b.names.every((n) => guarded.has(n)))
516
+ continue;
517
+ re.lastIndex = 0;
518
+ if (re.test(b.text)) {
519
+ for (const n of b.names)
520
+ if (n && !guarded.has(n)) {
521
+ guarded.add(n);
522
+ added = true;
523
+ }
524
+ }
525
+ }
526
+ if (!added)
527
+ break;
528
+ }
529
+ return guarded;
530
+ }
381
531
  const TS_FILE_SINK_NAMES = [
382
532
  "readFile", "readFileSync", "writeFile", "writeFileSync",
383
533
  "appendFile", "appendFileSync", "unlink", "unlinkSync", "rm", "rmSync",
@@ -584,12 +734,22 @@ function collectUrlParamNames(params) {
584
734
  * 项目方法体内的文件 sink(跨函数一跳,onMethodHit 回调登记方法条目)
585
735
  * - SSRF:URL 形参/request 污点 → HTTP fetch sink,无守卫词汇
586
736
  */
587
- function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit) {
737
+ function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit, guardFns) {
588
738
  const markers = [];
589
739
  // ── 路径穿越 ──
740
+ // G1(2026-09-19):有流还不够,必须【没有校验证据】才标记——
741
+ // 与 SSRF 侧「无 SSRF_GUARD_EVIDENCE 才标记」对齐。
742
+ // 未传 guardFns(旧调用点/单测)时按「不做校验识别」的旧语义处理。
590
743
  if (hasRequestRootedExpr(text)) {
591
744
  const tainted = collectTaintedNames(text);
592
- if (tainted.size > 0) {
745
+ const selfGuarded = guardFns ? hasPathGuardEvidence(text) !== null : false;
746
+ // C5(2026-09-19):不可信根可以直接写在 sink 实参里,不需要中间变量。
747
+ // 此前外层要求 collectTaintedNames 非空,于是
748
+ // `fs.readFileSync("/data/" + req.params.name)` —— 真实工程里最常见的形态
749
+ // 不标记;只有写成 `const n = req.params.name; readFileSync("/data/" + n)` 才标。
750
+ // SSRF 侧从来不是这样(它把 UNTRUSTED_ROOT_SRC 并进 taint 模式去匹配实参窗口),
751
+ // 这是同一条数据流上的又一处口径不一致。
752
+ if (!selfGuarded) {
593
753
  if (hasTaintedSinkCall(text, tainted)) {
594
754
  markers.push("__progmune_path_traversal__");
595
755
  }
@@ -609,6 +769,9 @@ function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit) {
609
769
  const rec = sinkParams.get(m[1]);
610
770
  if (!rec || rec.idxs.size === 0)
611
771
  continue;
772
+ // G1:被调用方自身已校验 ⇒ 这条跨函数流已被拦截,不标记
773
+ if (guardFns && guardFns.has(m[1]))
774
+ continue;
612
775
  const args = splitArgWindow(m[2] || "");
613
776
  const hit = args.some((arg, i) => rec.idxs.has(i) && taint.test(arg));
614
777
  if (hit) {
@@ -727,6 +890,8 @@ function _extractSingleProject(absRoot, tsconfigPath) {
727
890
  // sinkParams = 项目方法名 → 方法体内流入文件 sink 的形参下标 + 方法条目;
728
891
  // pendingMethodMarks = 跨函数命中待标记的方法条目(主循环后统一应用)
729
892
  const sinkParams = methodSinkParamMap(project, absRoot);
893
+ // G1(2026-09-19):路径校验识别。自身含校验证据的函数名 + 向调用方传播一跳。
894
+ const guardFns = pathGuardFunctionNames(project);
730
895
  const pendingMethodMarks = new Set();
731
896
  const onMethodHit = (rec) => {
732
897
  for (const me of rec.entries)
@@ -744,7 +909,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
744
909
  const fParams = f.getParameters();
745
910
  const fText = f.getText();
746
911
  const fCalls = extractDirectCalls(f, fText);
747
- fCalls.push(...computeMarkerCalls(fText, fParams.map((p) => p.getName()), sinkParams, onMethodHit));
912
+ fCalls.push(...computeMarkerCalls(fText, fParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns));
748
913
  funcs.push({
749
914
  name,
750
915
  params: fParams.map(p => ({
@@ -774,7 +939,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
774
939
  const initParams = init.getParameters();
775
940
  const initText = init.getText();
776
941
  const initCalls = extractDirectCalls(init, initText);
777
- initCalls.push(...computeMarkerCalls(initText, initParams.map((p) => p.getName()), sinkParams, onMethodHit));
942
+ initCalls.push(...computeMarkerCalls(initText, initParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns));
778
943
  funcs.push({
779
944
  name,
780
945
  params: initParams.map(p => ({
@@ -800,7 +965,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
800
965
  const argParams = arg.getParameters();
801
966
  const argText = arg.getText();
802
967
  const argCalls = extractDirectCalls(arg, argText);
803
- argCalls.push(...computeMarkerCalls(argText, argParams.map((p) => p.getName()), sinkParams, onMethodHit));
968
+ argCalls.push(...computeMarkerCalls(argText, argParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns));
804
969
  funcs.push({
805
970
  name,
806
971
  params: argParams.map(p => ({
@@ -834,7 +999,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
834
999
  continue;
835
1000
  const mParams = m.getParameters();
836
1001
  const mText = m.getText();
837
- const mCalls = computeMarkerCalls(mText, mParams.map((p) => p.getName()), sinkParams, onMethodHit);
1002
+ const mCalls = computeMarkerCalls(mText, mParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns);
838
1003
  funcs.push({
839
1004
  name: `${cn}.${mn}`,
840
1005
  params: mParams.map((p) => ({ name: p.getName(), type: getParamType(p), typeDetail: getParamTypeDetail(p) })),
package/dist/sdk.js CHANGED
@@ -20,7 +20,7 @@ const risk_model_1 = require("./risk-model");
20
20
  const protocol_knowledge_1 = require("./protocol-knowledge");
21
21
  const evidence_repository_1 = require("./evidence-repository");
22
22
  /** Runtime version — stable public identifier. Internal layers evolve underneath. */
23
- exports.RUNTIME_VERSION = "3.7.32";
23
+ exports.RUNTIME_VERSION = "3.7.35";
24
24
  function verify(filePath) {
25
25
  const cert = (0, certify_1.certify)(filePath);
26
26
  const kb = (0, protocol_knowledge_1.buildKnowledgeBase)();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "progmune-runtime",
3
- "version": "3.7.32",
3
+ "version": "3.7.35",
4
4
  "description": "Progmune — AI Trust Decision Engine. Verify AI-generated code before it reaches production. Outputs APPROVED / NEEDS_REVIEW / BLOCKED with evidence.",
5
5
  "files": [
6
6
  "dist/",