progmune-runtime 3.7.32 → 3.7.36

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,148 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.7.36] — 2026-09-19
4
+
5
+ ### C4:污点经路径塑形表达式包装后仍传播
6
+
7
+ 缺口实证(taintpath_A,measured):`readBasename` / `readResolveOnly` /
8
+ `readJoinWrapped` / `readNormalizeWrapped` 四条语义上应标记、实测不标记——
9
+ 传播只认 `x = <污点>` 直赋,而 `path.join/resolve/normalize/basename` 是真实
10
+ 工程构造路径的**默认写法**,这条断链等于把最常见形态整片漏掉。
11
+
12
+ 政策是**白名单传播**,不是「RHS 含污点就传播」:
13
+ - 只经 path.* 家族与保值的字符串方法(trim/replace/…)传播;拼接与模板字面量
14
+ 同样只塑形、不改来源;有界迭代 3 跳(`normalize → join` 这类链式能接上)
15
+ - **认不出的调用不传播** ⇒ `const safe = sanitizeName(p)` 天然不污染。
16
+ 未知函数默认站在精度一侧——这是白名单相对黑名单的决定性优势
17
+ - 代价(记为缺口 C4b):项目自有 helper(如 `buildPath(p)`)仍不传播
18
+
19
+ ### 两处必须一并改的「假通过」(C4 把它们顶出来了)
20
+
21
+ C4 之前,下列用例的「不标记」**不是因为判别力对,而是污点根本没走到 sink**
22
+ ——方法学规则 R7 的第二种形态。C4 一放开它们就会翻成误报:
23
+
24
+ 1. **G-A2 漏 `startsWith(base)`**:原正则要求标识符在 base/root/dir 之外还有
25
+ 前导字符,最朴素的 `target.startsWith(base)` 匹配不上。改为取出整个实参再
26
+ 判定,并加 `database` 等反例名单(`database` 以 base 结尾但与目录无关)
27
+ 2. **G-A2 漏字符串字面量形态**:`startsWith("/srv/data")` 同样匹配不上。
28
+ 现支持绝对路径字面量(长度 >1,排除 `startsWith("/")`——那只是判绝对路径)
29
+
30
+ ### 闸门改为实时提取(第二处空过)
31
+
32
+ `check-taintpath.ts` 原默认读 `reports/batch-scan-results.json`——那是别人跑
33
+ batch-scan 留下的**陈旧产物**。实测踩到:C4 落地后 4 条 known-gap 已翻正,
34
+ 闸门仍报「未标记,符合预期」。已改为默认对 `generated/taintpath_*` 实时跑
35
+ extractIR(需走报告时显式 `--report`)。
36
+
37
+ ### 验收
38
+
39
+ - taintpath 闸门 **24/24,失败 0、缺口闭合待更新 0**;4 条 known-gap 转 mark
40
+ - fr-007 openhop 真实语料维持 **pre 5 / post 0** —— C4 只补召回,未削弱 G1 判别力
41
+ - `extract-ir-taint-structural.test.ts` 新增 C4 组 11 条(7 正 + 4 负对照,R6)
42
+ - 反向验证:回退 v3.7.35 后 **7 条正例全失败**,4 条负对照仍绿
43
+ - 全组回归 72 passed;`tsc -p tsconfig.json` 零错误
44
+
45
+ ### 实现期踩的一个坑(已入注释)
46
+
47
+ `taintedViaShaper` 最初写了 `if (tainted.size === 0) return`——而这 4 条
48
+ known-gap 的共同形态恰恰是「全函数没有任何直赋污点」,等于整条 C4 不生效。
49
+ 种子是根模式(taintPattern 恒定并入 UNTRUSTED_ROOT_SRC),不该依赖已有污点。
50
+
51
+ ## [3.7.35] — 2026-09-19
52
+
53
+ ### 重建盲测覆盖:taintpath 语料族(TS 795 空过的终结)
54
+
55
+ TS 795 盲测对「路径穿越标记」**连续三次零覆盖**——语料里没有任何
56
+ `不可信根 → 文件 sink` 的流,`__progmune_path_traversal__` 出现次数前 0 后 0。
57
+ 于是「3086 flags LOST 0 / ADDED 0」这道硬门在这项能力上一直是空过(3.7.32/33/34
58
+ 三次验收都把它当作证据)。
59
+
60
+ - 新增语料族 `generated/taintpath_A`(HTTP 请求面)与 `taintpath_B`
61
+ (MCP 工具实参面 + 跨文件/跨函数传播),共 24 条期望
62
+ - 新增生成器 `blind-benchmark/generate-projects-taintpath.ts`
63
+ (**只写自己前缀**,不像 generate-projects.ts 那样清理含 `_` 的目录)
64
+ - 新增闸门 `blind-benchmark/check-taintpath.ts` + 期望表
65
+ `blind-benchmark/taintpath-expectations.json`:逐函数断言
66
+ mark / suppressed / no-taint / known-gap 四类,退出码非 0 即失败
67
+ - 覆盖力:`__progmune_path_traversal__` **0 → 15 次**;24/24 符合期望
68
+ - 已有 100 个项目零漂移:LOST 0 / ADDED 0(总数 3086 → 3109,增量全部来自新语料)
69
+ - 升级为方法学规则 **R7-no-vacuous-gate**
70
+
71
+ ### C5:不可信根可以直连 sink(召回)
72
+
73
+ 此前外层要求 `collectTaintedNames` 非空,因此
74
+ `fs.readFileSync("/data/" + req.params.name)` —— 真实 Express 工程最常见的形态
75
+ ——不标记,只有先赋给局部变量才标记。SSRF 侧从来不要求中间变量,这是同一条
76
+ 数据流上的又一处口径不一致,与 G1 同类。改为:先看 sink 实参窗口有没有污点证据(taint 模式本来就含不可信根),
77
+ 跨函数传播仍须有具名污点。
78
+
79
+ - `src/extract-ir-taint-structural.test.ts` 新增 6 条(4 正 + 2 负对照)
80
+ - 反向验证:回退 v3.7.34 后其中 4 条失败、2 条负对照仍绿
81
+ - fr-007 真实语料维持 pre 5 / post **0**(C5 只补召回,未削弱 G1 判别力)
82
+
83
+ ### 本次暴露、尚未修的两处缺口(已进语料,作为下一轮的验收对象)
84
+
85
+ - **C4**:污点经表达式包装(`path.join/resolve/normalize/basename`)后不传播。
86
+ 语料里 readBasename / readResolveOnly / readJoinWrapped / readNormalizeWrapped
87
+ 四条语义上应标记、实测不标记,记为 known-gap(闸门会盯着,一旦补上就提醒更新)
88
+ - **G2**(反向):自定义校验函数名不含路径语义后缀时调用点不被抑制。语料里
89
+ `dispatchToolGuarded` 调了 `assertTemplateName()`(函数体内有字符集白名单),
90
+ 仍被标记——因为 `Name` 后缀在修 ensureDir 误判时被移出了守卫词表
91
+
92
+ ## [3.7.34] — 2026-09-19
93
+
94
+ ### G1 PATH_GUARD_EVIDENCE —— 路径穿越的「校验识别」
95
+
96
+ 路径穿越标记此前是 `taint → 文件 sink ⇒ 标记`,**不看中间有没有校验**;SSRF 侧
97
+ 不是这样(`taint → fetch sink 且无 SSRF_GUARD_EVIDENCE ⇒ 标记`)。两侧数据流同构、
98
+ 判别力差一档——这正是根集合不敢放宽的真正原因(fr-012/fr-015 的 MISS 由此从
99
+ 「词表缺口」升级为「机制缺口」)。本版把路径侧改成与 SSRF 对齐:
100
+ **`taint → 文件 sink 且无校验证据 ⇒ 标记`**。
101
+
102
+ 守卫形态的种子全部来自语料真实修复,逐条可追溯:
103
+
104
+ - **G-A 目录包含性**:`resolve(p).startsWith(resolve(root))`、`x.startsWith(baseDir)`
105
+ - **G-B 上跳/绝对路径拒绝**:`path.isAbsolute`、`startsWith(".."+sep)`、`=== ".."`
106
+ (种子 = fr-012 gitlab-mcp 下载侧 `localPath` 既有守卫块,index.ts:7968-7977)
107
+ - **G-C 独立校验函数**:`assertValidFlowId`(fr-007 openhop)/ `assertWithinDir`(fr-016 Redocly)
108
+ —— 含向调用方**有界传播 3 跳**:校验被抽进被调用函数时,调用方一个校验词汇都没有
109
+ - **G-D 锚定字符集白名单**:`/^[A-Za-z0-9_-]+$/`(fr-007 `FLOW_ID_PATTERN`)
110
+
111
+ 明确**不算**守卫:`path.basename`(fr-012 pre 实测反例,漏洞态就有它)、
112
+ 单独出现的 `join`/`resolve`、长度检查、`if (!p) throw`。
113
+
114
+ **验收**
115
+
116
+ - fr-007 openhop 真实语料:**pre 5 条 / post 0 条** —— 真实语料上首次出现
117
+ 「修复后流消失」的判别力证据(G1 之前是 5 / 5)
118
+ - `src/extract-ir-taint-guard.test.ts` 14 passed;反向验证回退 3.7.33 后 5 条失败
119
+ - TS 795 盲测 3086 flags LOST 0 / ADDED 0 —— ⚠️ **空过**:盲测语料里
120
+ `__progmune_path_traversal__` 出现 0 次,无覆盖;真正的门是 fr-007 与定向用例
121
+ - `tsc -p tsconfig.json` 零错误
122
+
123
+ **实现期修掉的两个反例**(都写进代码注释与方法学规则):
124
+
125
+ 1. `ensureDir()` / `isDirectory()` 曾被 G-C 误判为守卫(后缀 `Dir`),导致 fr-007
126
+ pre 侧 5 条被压成 0、召回归零。已移除 `Dir`/`Name` 后缀并加显式反例名单
127
+ 2. 「不得标记」类断言会**假通过**:污点经 `path.join(x)` 包装后不再传播,
128
+ sink 处根本没污点。已定为方法学规则 **R6**:负向断言必须配同形状正对照
129
+
130
+ ## [3.7.33] — 2026-09-19
131
+
132
+ ### 污点标记管线三处结构性修复(C1/C2/C3,正确性修复,不改变判别逻辑)
133
+
134
+ 从「污点源数据流试点 V1」拆出的**独立正确性条目**——它们只解决「看得见」,
135
+ 不解决「看得懂」(判别力是 `PATH_GUARD_EVIDENCE`,另一个条目,尚未实现)。
136
+
137
+ - **C3 污点根按传输面声明**(`UNTRUSTED_ROOTS`):此前整条链路只锚 Express 形态的 `req|request.(params|query|body|headers|cookies)`,即**根集合只有一种来源**。MCP 工具实参(`params.arguments`)、CLI 参数等来源天然不可见(fr-012 的 `args.file_path`)。改为按传输面声明根,每条附 `why`,不再靠补变量名清单打地鼠
138
+ - **C1 sink 形参表纳入顶层函数**:`methodSinkParamMap` 此前只遍历 `sf.getClasses()`,真实 TS 工程大量使用顶层函数声明(MCP handler / Nuxt runtime binding),跨函数一跳对它们完全不生效——fr-012 的 `markdownUpload` 正因如此从未入表
139
+ - **C2 跨函数传播识别裸调用**:调用匹配此前要求 `obj.sink(`(成员调用),新增 `directCallRe` 覆盖 `func(` 形态
140
+ - **验收**:
141
+ - TS 795 盲测 **3086 flags LOST 0 / ADDED 0**
142
+ - ⚠️ **但这是空过(vacuous pass)**:实测整份 generated 语料重跑后 per-function calls 差异 **0**、`__progmune` 标记 **826 vs 826**——盲测语料根本走不到这三个分支。真正的门是新增的定向用例
143
+ - 新增 `src/extract-ir-taint-structural.test.ts` **6 green**;**反向验证**:把 `src/extract-ir.ts` 回退到 3.7.32 后其中 **3 项失败**(MCP 根 / 顶层函数 / 裸调用),确认这三个用例真的锁住了行为,而非同义反复。另 3 项(Express 旧语义仍标记、常量路径不标记、常量裸调用不标记)在修复前后均通过——它们锁的是「不得回归 / 不得过度标记」
144
+ - **行为边界**:本条目**不**改变判别逻辑。路径穿越侧仍无「校验识别」,因此放宽根集合后对已校验流同样会标记——这正是试点 V1 记录为未过验收的原因,由后续 `PATH_GUARD_EVIDENCE` 解决
145
+
3
146
  ## [3.7.32] — 2026-09-19
4
147
 
5
148
  ### 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,445 @@
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
+ });
290
+ // ═══════════════════════════════════════════════════════════════
291
+ // C4(2026-09-19):污点经「路径塑形表达式」包装后仍应传播
292
+ //
293
+ // 缺口实证:taintpath_A 的 readBasename / readResolveOnly / readJoinWrapped /
294
+ // readNormalizeWrapped 四条,语义上应标记、实测不标记 —— 传播只认 `x = <污点>`
295
+ // 直赋,而 `path.join/resolve/normalize/basename` 是真实工程构造路径的**默认
296
+ // 写法**,这条断链等于把最常见的形态整片漏掉。
297
+ //
298
+ // 政策是**白名单传播**:只经 path.* 家族与保值的字符串方法传播,认不出的调用
299
+ // 不传播。于是 `const safe = sanitizeName(p)` 天然不污染 —— 未知函数默认站在
300
+ // 精度一侧。代价是项目自有 helper(buildPath(p))仍不传播(缺口 C4b)。
301
+ // ═══════════════════════════════════════════════════════════════
302
+ (0, vitest_1.describe)("C4:污点经路径塑形表达式包装后仍传播", () => {
303
+ const cases = [
304
+ {
305
+ name: "path.join 包装",
306
+ fn: "readJoin",
307
+ code: `export function readJoin(req: any) {
308
+ const target = path.join("/data", req.params.name);
309
+ return fs.readFileSync(target, "utf-8");
310
+ }`,
311
+ expectMark: true,
312
+ why: "最基础的塑形包装:join(root, taint) 不得断链",
313
+ },
314
+ {
315
+ name: "path.normalize 包装",
316
+ fn: "readNormalize",
317
+ code: `export function readNormalize(req: any) {
318
+ const target = path.normalize(req.params.name);
319
+ return fs.readFileSync(target, "utf-8");
320
+ }`,
321
+ expectMark: true,
322
+ why: "normalize 只规范化、不改变来源",
323
+ },
324
+ {
325
+ name: "path.basename 包装(N-A)",
326
+ fn: "readBasename",
327
+ code: `export function readBasename(req: any) {
328
+ const name = path.basename(req.params.name);
329
+ return fs.readFileSync("/data/" + name, "utf-8");
330
+ }`,
331
+ expectMark: true,
332
+ why: "N-A:basename 不算守卫(fr-012 pre 实测反例),包装后污点仍须流动",
333
+ },
334
+ {
335
+ name: "resolve 无包含性比较(N-C)",
336
+ fn: "readResolveOnly",
337
+ code: `export function readResolveOnly(req: any) {
338
+ const base = path.resolve("/data");
339
+ const target = path.resolve(base, req.params.name);
340
+ return fs.readFileSync(target, "utf-8");
341
+ }`,
342
+ expectMark: true,
343
+ why: "N-C:只有 resolve、没有 startsWith(base) 比较,不算守卫",
344
+ },
345
+ {
346
+ name: "链式两跳塑形",
347
+ fn: "readChained",
348
+ code: `export function readChained(req: any) {
349
+ const norm = path.normalize(req.params.dir);
350
+ const target = path.join(norm, "index.html");
351
+ return fs.readFileSync(target, "utf-8");
352
+ }`,
353
+ expectMark: true,
354
+ why: "fr-012 下载侧就是这个形状:normalize → join 两跳,须接上",
355
+ },
356
+ {
357
+ name: "模板字面量拼接",
358
+ fn: "readTemplate",
359
+ code: `export function readTemplate(req: any) {
360
+ const target = \`/data/\${req.params.name}\`;
361
+ return fs.readFileSync(target, "utf-8");
362
+ }`,
363
+ expectMark: true,
364
+ why: "模板字面量只塑形、不改来源",
365
+ },
366
+ {
367
+ name: "字符串方法 trim(须先经 C4 才能到这一步)",
368
+ fn: "readTrim",
369
+ // 注意形态:不能写成 `const n = req.params.name.trim()` —— 那样根就在
370
+ // 开头、旧版也能标记,用例就失去判别力了(反向验证时实测通过,白锁一场)。
371
+ // 必须先经一次塑形调用,trim 才是 C4 链条上的第二跳。
372
+ code: `export function readTrim(req: any) {
373
+ const normalized = path.normalize(req.params.name);
374
+ const trimmed = normalized.trim();
375
+ return fs.readFileSync("/data/" + trimmed, "utf-8");
376
+ }`,
377
+ expectMark: true,
378
+ why: "保值的字符串方法不得断链;本条同时验证 C4 的迭代(normalize → trim 两跳)",
379
+ },
380
+ // ── 负向:每一条都有同形状的正对照(方法学规则 R6)──
381
+ {
382
+ name: "负对照:守卫仍在(startsWith(base) 形态)",
383
+ fn: "readGuardWithinBase",
384
+ code: `export function readGuardWithinBase(req: any) {
385
+ const base = path.resolve("/data");
386
+ const target = path.resolve(base, req.params.name);
387
+ if (!target.startsWith(base)) throw new Error("outside");
388
+ return fs.readFileSync(target, "utf-8");
389
+ }`,
390
+ expectMark: false,
391
+ why: "正对照 = readResolveOnly(同形状去掉 startsWith 必须重新报出)",
392
+ },
393
+ {
394
+ name: "负对照:常量路径不因塑形而标记",
395
+ fn: "readConstantJoin",
396
+ code: `export function readConstantJoin() {
397
+ const target = path.join("/data", "index.html");
398
+ return fs.readFileSync(target, "utf-8");
399
+ }`,
400
+ expectMark: false,
401
+ why: "正对照 = readJoin(把 \"index.html\" 换成 req.params.name 必须报出)",
402
+ },
403
+ {
404
+ name: "负对照:未知 helper 不传播(白名单的精度代价,缺口 C4b)",
405
+ fn: "readUnknownHelper",
406
+ code: `function buildPath(name: string): string {
407
+ return "/data/" + name;
408
+ }
409
+ export function readUnknownHelper(req: any) {
410
+ const target = buildPath(req.params.name);
411
+ return fs.readFileSync(target, "utf-8");
412
+ }`,
413
+ expectMark: false,
414
+ why: "白名单外一律不传播(精度优先);正对照 = readJoin 必须报出",
415
+ },
416
+ {
417
+ name: "负对照:净化函数不传播",
418
+ fn: "readSanitized",
419
+ code: `function sanitizeName(name: string): string {
420
+ return name.replace(/[^a-z0-9]/gi, "");
421
+ }
422
+ export function readSanitized(req: any) {
423
+ const safe = sanitizeName(req.params.name);
424
+ return fs.readFileSync("/data/" + safe, "utf-8");
425
+ }`,
426
+ expectMark: false,
427
+ why: "未知(疑似净化)调用默认不传播——这是白名单相对黑名单的决定性优势",
428
+ },
429
+ ];
430
+ for (const c of cases) {
431
+ (0, vitest_1.it)(`${c.name} —— ${c.why}`, () => {
432
+ const dir = makeProject({ "a.ts": `import * as fs from "fs";\nimport * as path from "path";\n\n${c.code}\n` });
433
+ try {
434
+ const marks = marksFor(dir, c.fn);
435
+ if (c.expectMark)
436
+ (0, vitest_1.expect)(marks).toContain(PATH_MARK);
437
+ else
438
+ (0, vitest_1.expect)(marks).not.toContain(PATH_MARK);
439
+ }
440
+ finally {
441
+ fs.rmSync(dir, { recursive: true, force: true });
442
+ }
443
+ }, 150000);
444
+ }
445
+ });
@@ -378,6 +378,197 @@ function extractDirectCalls(func, preText) {
378
378
  // 与 protocol-detector 的同名规则(source-level benchmark 路径)。
379
379
  // ═══════════════════════════════════════════════════════════════
380
380
  const PATH_TRAVERSAL_MARKER = "__progmune_path_traversal__";
381
+ /**
382
+ * 标识符是否「看起来像基目录」—— G-A2 的定案依据。
383
+ *
384
+ * 判定:以 base / root / dir 结尾(大小写与 camel、下划线均可),
385
+ * 前缀任意(`base` / `baseDir` / `allowedRoot` / `root_dir` 都算)。
386
+ *
387
+ * 反例名单是实证加的:`database` 以 base 结尾但与目录无关,若不排除,
388
+ * 一旦有人写 `p.startsWith(database)` 就会被当成守卫 —— 那是精度漏洞。
389
+ */
390
+ const BASE_DIR_IDENT_DENY = /^(?:database|databases|codebase|knowledgebase|base64|basename|dirname|basenames)$/i;
391
+ function isBaseDirIdent(id) {
392
+ if (BASE_DIR_IDENT_DENY.test(id))
393
+ return false;
394
+ return /^(?:[A-Za-z_$][\w$]*)?(?:base|root|dir)$/i.test(id);
395
+ }
396
+ const PATH_GUARD_RULES = [
397
+ {
398
+ id: "G-A",
399
+ // resolve(p).startsWith(resolve(root)+sep) / x.startsWith(baseDir)
400
+ re: /(?:resolve|realpath|normalize)\s*\([^()]*\)\s*\.\s*startsWith\s*\(/,
401
+ why: "目录包含性校验(canonical 形态):规范化后判断是否落在基目录内",
402
+ source: "设计形态,canonical;fr-016 Redocly 修复的同族(见 G-C)",
403
+ },
404
+ {
405
+ id: "G-A2",
406
+ // 粗筛:任意 `.startsWith(<ident>)`;定案交给 decide —— 实参名是否像基目录。
407
+ re: /\.\s*startsWith\s*\(\s*([^)]*?)\s*\)/,
408
+ // 2026-09-19 两次修订,都是被 C4「顶出来」的真问题(C4 之前相关用例
409
+ // 的「不标记」是空过——污点根本没走到 sink,见方法学规则 R7):
410
+ // ① 原正则 `[A-Za-z_$][\w$]*(?:[Bb]ase|…)` 要求标识符在 base/root/dir
411
+ // **之外**还至少有一个前导字符,于是最朴素的 `startsWith(base)`
412
+ // (taintpath_A readGuardWithin)匹配不上;
413
+ // ② `startsWith("/srv/data")` —— 基目录写成**字符串字面量**——同样匹配
414
+ // 不上(C5 负对照 readInlineGuarded 就是这形态)。
415
+ // 故改为:先取出 startsWith 的整个实参,再判定它是「基目录名」还是
416
+ // 「绝对路径字面量」。
417
+ decide: (text) => {
418
+ const re = /\.\s*startsWith\s*\(\s*([^)]*?)\s*\)/g;
419
+ let m;
420
+ while ((m = re.exec(text)) !== null) {
421
+ const arg = m[1] || "";
422
+ // 形参里的标识符:`base` / `base + path.sep` / `rootDir + "/"`
423
+ const idm = /([A-Za-z_$][\w$]*)/.exec(arg);
424
+ if (idm && isBaseDirIdent(idm[1]))
425
+ return true;
426
+ // 绝对路径字面量:`"/srv/data"`。长度 >1 是为了排除 `startsWith("/")`
427
+ // —— 那只是「是否绝对路径」,不构成包含性。
428
+ if (/["'`]\/[^"'`]+["'`]/.test(arg))
429
+ return true;
430
+ }
431
+ return false;
432
+ },
433
+ why: "目录包含性校验(基目录名或绝对路径字面量):startsWith(base / baseDir / \"/srv/data\" / base + sep)",
434
+ source: "设计形态,canonical;2026-09-19 补 `base` 单标识符(taintpath_A readGuardWithin)与绝对路径字面量(C5 负对照 readInlineGuarded)",
435
+ },
436
+ {
437
+ id: "G-B1",
438
+ re: /\b(?:path\.)?isAbsolute\s*\(/,
439
+ why: "绝对路径拒绝:绝对路径不受 join(root, p) 约束",
440
+ source: "fr-012 gitlab-mcp 下载侧 localPath 既有守卫块(pre/post 同一段,@ index.ts:7968)",
441
+ },
442
+ {
443
+ id: "G-B2",
444
+ // startsWith(".." + sep) / includes(sep + ".." + sep) / === ".."
445
+ re: /\.\s*(?:startsWith|includes)\s*\(\s*(?:[^()]*["'`]\.\.)/,
446
+ why: "上跳拒绝:显式检测 .. 段",
447
+ source: "fr-012 gitlab-mcp 下载侧 localPath 既有守卫块(index.ts:7968-7977)",
448
+ },
449
+ {
450
+ id: "G-B3",
451
+ re: /(?:===?|!==)\s*["'`]\.\.["'`]/,
452
+ why: "上跳拒绝:与 .. 字面量直接比较",
453
+ source: "fr-012 gitlab-mcp 下载侧 localPath 既有守卫块(normalizedLocalPath === \"..\")",
454
+ },
455
+ // 注:G-C 不放在本数组里——它是「按函数名判定」,需要配反例名单
456
+ // (见 PATH_GUARD_FN_CALL_RE / PATH_GUARD_FN_DENY 与下方注释)。
457
+ {
458
+ id: "G-D",
459
+ // /^[A-Za-z0-9_-]+$/ —— 锚定的字符集白名单,天然排除 / \ .
460
+ re: /\/\^\[[^\]]*\]\s*[+*?]\$\//,
461
+ why: "锚定字符集白名单:^…$ 且字符类不含 ./\\ 时无法构造上跳",
462
+ source: "fr-007 openhop `FLOW_ID_PATTERN = /^[A-Za-z0-9_-]+$/`(flow-id.ts)",
463
+ },
464
+ ];
465
+ /**
466
+ * 明确**不算**守卫的形态(反例清单)。
467
+ *
468
+ * N-A `path.basename` 是这张表里最重要的一行:它只去目录、不去 `..`,
469
+ * 却极常被当成「已经 sanitize 过」。fr-012 pre 的实测反例:漏洞态代码里
470
+ * 就有 `path.basename`,漏洞依然成立。一旦把 basename 算作守卫,
471
+ * fr-012 的真值会被自己压掉 —— 见 src/extract-ir-taint-guard.test.ts。
472
+ */
473
+ const PATH_NON_GUARD_NOTE = "N-A path.basename / N-B 单独出现的 join·resolve / N-C 长度或字符数检查 / N-D if (!p) throw —— 均不计入守卫";
474
+ /**
475
+ * G-C:独立校验函数被调用(项目自有命名)。
476
+ *
477
+ * 后缀刻意**不收** `Dir` / `Name` / 裸的 `Valid` —— 实证代价:
478
+ * - `ensureDir()`(fr-007 openhop store.ts:46)与 `s.isDirectory()` 都会被
479
+ * `…Dir(` 命中,而它们是**建目录 / 类型判断**,与校验无关;第一版词表
480
+ * 因此把 fr-007 的 pre 侧 5 条全部误压成 0(召回归零)。
481
+ * - `isValid(x)` / `isSub(a,b)` 同理,会把判别力稀释成猜函数名。
482
+ * 保留 `Id`(fr-007 `assertValidFlowId`)、`Within`(fr-016 `assertWithinDir`)、
483
+ * `Safe|Path|Inside|InDir|Contained|Traversal|Root|Base`。
484
+ */
485
+ 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;
486
+ /** 形态上会命中 G-C 但**确定不是**路径校验的名字(实证反例,逐个加) */
487
+ const PATH_GUARD_FN_DENY = /^(?:ensureDir|isDirectory|isDir|assertDir|makeDir|mkdir|checkExists|isValid|isRoot|ensureId|getId)$/i;
488
+ function callsPathGuardFn(text) {
489
+ PATH_GUARD_FN_CALL_RE.lastIndex = 0;
490
+ let m;
491
+ while ((m = PATH_GUARD_FN_CALL_RE.exec(text)) !== null) {
492
+ if (!PATH_GUARD_FN_DENY.test(m[0]))
493
+ return true;
494
+ }
495
+ return false;
496
+ }
497
+ /** 返回命中的守卫规则 id;无校验证据返回 null。 */
498
+ function hasPathGuardEvidence(text) {
499
+ for (const r of PATH_GUARD_RULES) {
500
+ if (r.decide ? r.decide(text) : r.re.test(text))
501
+ return r.id;
502
+ }
503
+ if (callsPathGuardFn(text))
504
+ return "G-C";
505
+ return null;
506
+ }
507
+ function escapeRe(s) {
508
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
509
+ }
510
+ /**
511
+ * 项目内「自身含校验证据」的函数名集合,并向调用方传播一跳(有界)。
512
+ *
513
+ * 为什么必须传播:fr-007 的修复是把 `assertValidFlowId(id)` 放进 `filePath()`,
514
+ * 真正含 sink 的 `FlowStore.get/save/...` 与外层 `flowRoutes` 里**一个校验词汇都没有**——
515
+ * 只看函数体等于没做。传播上限 3 轮(防止公共函数名导致的无限扩散)。
516
+ */
517
+ function pathGuardFunctionNames(project) {
518
+ const guarded = new Set();
519
+ const bodies = [];
520
+ const note = (names, text) => {
521
+ if (!text)
522
+ return;
523
+ bodies.push({ names, text });
524
+ if (hasPathGuardEvidence(text))
525
+ for (const n of names)
526
+ if (n)
527
+ guarded.add(n);
528
+ };
529
+ for (const sf of project.getSourceFiles()) {
530
+ for (const cls of sf.getClasses()) {
531
+ const cn = cls.getName();
532
+ if (!cn)
533
+ continue;
534
+ for (const m of cls.getMethods()) {
535
+ const mn = m.getName();
536
+ if (!mn)
537
+ continue;
538
+ note([mn, `${cn}.${mn}`], m.getText());
539
+ }
540
+ }
541
+ for (const fn of sf.getFunctions()) {
542
+ const fnName = fn.getName();
543
+ if (!fnName)
544
+ continue;
545
+ note([fnName], fn.getText());
546
+ }
547
+ }
548
+ // 向调用方传播:调用了一个已校验函数 ⇒ 这条流已被拦截(有界 3 轮)
549
+ for (let round = 0; round < 3; round++) {
550
+ const names = [...guarded].filter((n) => !n.includes("."));
551
+ if (names.length === 0)
552
+ break;
553
+ const re = new RegExp(`\\b(?:${names.map(escapeRe).join("|")})\\s*\\(`, "g");
554
+ let added = false;
555
+ for (const b of bodies) {
556
+ if (b.names.every((n) => guarded.has(n)))
557
+ continue;
558
+ re.lastIndex = 0;
559
+ if (re.test(b.text)) {
560
+ for (const n of b.names)
561
+ if (n && !guarded.has(n)) {
562
+ guarded.add(n);
563
+ added = true;
564
+ }
565
+ }
566
+ }
567
+ if (!added)
568
+ break;
569
+ }
570
+ return guarded;
571
+ }
381
572
  const TS_FILE_SINK_NAMES = [
382
573
  "readFile", "readFileSync", "writeFile", "writeFileSync",
383
574
  "appendFile", "appendFileSync", "unlink", "unlinkSync", "rm", "rmSync",
@@ -419,7 +610,29 @@ function hasRequestRootedExpr(text) {
419
610
  function tsSinkCallRegex() {
420
611
  return new RegExp(`(?:^|[^\\w.$])(?:await\\s+)?(?:[\\w$]+\\.)?(${TS_FILE_SINK_NAMES.join("|")})\\s*\\(([\\s\\S]{0,250}?)\\)`, "g");
421
612
  }
422
- /** 函数体内被不可信根污染的局部名(含解构与单跳赋值,深度 ≤2) */
613
+ /**
614
+ * C4(2026-09-19):污点经「路径塑形表达式」包装后仍然传播。
615
+ *
616
+ * 缺口实证(taintpath_A,2026-09-19 measured):
617
+ * `const target = path.join(ROOT, req.params.name); fs.readFileSync(target)`
618
+ * 不标记 —— 因为传播只认 `x = <污点>` 直赋,`path.join(...)` 把流掐断了。
619
+ * 真实工程里 `join/resolve/normalize/basename` 是路径构造的**默认写法**,
620
+ * 这条断链等于把最常见的形态整片漏掉。
621
+ *
622
+ * 政策:**白名单传播**,不是「RHS 含污点就传播」。
623
+ * - 只经【路径塑形】的调用传播(path.* 家族 + 保值的字符串方法);
624
+ * - 字符串拼接与模板字面量同样只塑形、不改来源,故一并传播;
625
+ * - 认不出的调用**不传播** —— 于是 `const safe = sanitizeName(p)` 天然
626
+ * 不污染。这是白名单相对黑名单的决定性优势:未知函数默认站在精度一侧。
627
+ * 代价:项目自有 helper(如 `buildPath(p)`)仍不传播,记为 C4b 缺口。
628
+ *
629
+ * 已知限制:只扫单行赋值(`const x = …;` 一行写完)。跨行书写的调用
630
+ * 链不传播——保持简单,等 C4b 一并处理。
631
+ */
632
+ const TS_PATH_SHAPER_RE = /(?:path\s*\.\s*(?:posix|win32)\s*\.\s*|path\s*\.\s*)?\b(?:join|resolve|normalize|basename|dirname|extname|relative|format|toNamespacedPath)\s*\(|\.\s*(?:trim|trimStart|trimEnd|replace|replaceAll|toLowerCase|toUpperCase|toString|slice|substring|substr|padStart|padEnd|concat|normalize)\s*\(/;
633
+ /** 拼接形态:`+` 或反引号(模板字面量)—— 同样只是塑形 */
634
+ const TS_CONCAT_RE = /\+|`/;
635
+ /** 函数体内被不可信根污染的局部名(含解构、单跳赋值与 C4 塑形传播,深度 ≤3) */
423
636
  function collectTaintedNames(text) {
424
637
  const tainted = new Set();
425
638
  let m;
@@ -438,6 +651,7 @@ function collectTaintedNames(text) {
438
651
  const bareAssign = new RegExp(`(?:^|[^\\w$.])([\\w$]+)\\s*=\\s*(?:await\\s+)?(?:${root})`, "g");
439
652
  while ((m = bareAssign.exec(text)) !== null)
440
653
  tainted.add(m[1]);
654
+ taintedViaShaper(text, tainted);
441
655
  // 单跳传播(深度 ≤2)
442
656
  for (let depth = 0; depth < 2; depth++) {
443
657
  if (tainted.size === 0)
@@ -456,6 +670,41 @@ function collectTaintedNames(text) {
456
670
  }
457
671
  return tainted;
458
672
  }
673
+ /**
674
+ * C4:把「经路径塑形表达式包装」的赋值名补进污点集合(有界迭代 ≤3 轮)。
675
+ *
676
+ * 按行扫描 `[(const|let|var)] NAME = RHS`:RHS 含污点证据 且
677
+ * (含塑形调用 或 含拼接/模板)⇒ NAME 污染。迭代是为了让
678
+ * `normalized = path.normalize(p)` → `savePath = path.join(normalized, f)`
679
+ * 这类链式构造能接上(fr-012 下载侧守卫块就是这个形状)。
680
+ */
681
+ function taintedViaShaper(text, tainted) {
682
+ for (let depth = 0; depth < 3; depth++) {
683
+ // 注意:这里**不能**以 `tainted.size === 0` 提前退出 —— 种子是根模式
684
+ // (taintPattern 恒定并入 UNTRUSTED_ROOT_SRC)。本轮这 4 条 known-gap
685
+ // 的共同形态恰恰是「全函数没有任何直赋污点,根只出现在塑形调用里」,
686
+ // 加这道闸门等于整条 C4 不生效(2026-09-19 实测踩到)。
687
+ const taintRe = taintPattern(tainted);
688
+ if (!taintRe)
689
+ return;
690
+ let added = false;
691
+ for (const line of text.split("\n")) {
692
+ const m = /^\s*(?:const|let|var)?\s*([\w$]+)\s*=\s*(?:await\s+)?(.+)$/.exec(line);
693
+ if (!m)
694
+ continue;
695
+ const name = m[1];
696
+ const rhs = m[2];
697
+ if (tainted.has(name))
698
+ continue;
699
+ if (taintRe.test(rhs) && (TS_PATH_SHAPER_RE.test(rhs) || TS_CONCAT_RE.test(rhs))) {
700
+ tainted.add(name);
701
+ added = true;
702
+ }
703
+ }
704
+ if (!added)
705
+ return;
706
+ }
707
+ }
459
708
  function taintPattern(tainted) {
460
709
  const parts = [UNTRUSTED_ROOT_SRC];
461
710
  if (tainted.size > 0) {
@@ -584,12 +833,22 @@ function collectUrlParamNames(params) {
584
833
  * 项目方法体内的文件 sink(跨函数一跳,onMethodHit 回调登记方法条目)
585
834
  * - SSRF:URL 形参/request 污点 → HTTP fetch sink,无守卫词汇
586
835
  */
587
- function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit) {
836
+ function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit, guardFns) {
588
837
  const markers = [];
589
838
  // ── 路径穿越 ──
839
+ // G1(2026-09-19):有流还不够,必须【没有校验证据】才标记——
840
+ // 与 SSRF 侧「无 SSRF_GUARD_EVIDENCE 才标记」对齐。
841
+ // 未传 guardFns(旧调用点/单测)时按「不做校验识别」的旧语义处理。
590
842
  if (hasRequestRootedExpr(text)) {
591
843
  const tainted = collectTaintedNames(text);
592
- if (tainted.size > 0) {
844
+ const selfGuarded = guardFns ? hasPathGuardEvidence(text) !== null : false;
845
+ // C5(2026-09-19):不可信根可以直接写在 sink 实参里,不需要中间变量。
846
+ // 此前外层要求 collectTaintedNames 非空,于是
847
+ // `fs.readFileSync("/data/" + req.params.name)` —— 真实工程里最常见的形态
848
+ // 不标记;只有写成 `const n = req.params.name; readFileSync("/data/" + n)` 才标。
849
+ // SSRF 侧从来不是这样(它把 UNTRUSTED_ROOT_SRC 并进 taint 模式去匹配实参窗口),
850
+ // 这是同一条数据流上的又一处口径不一致。
851
+ if (!selfGuarded) {
593
852
  if (hasTaintedSinkCall(text, tainted)) {
594
853
  markers.push("__progmune_path_traversal__");
595
854
  }
@@ -609,6 +868,9 @@ function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit) {
609
868
  const rec = sinkParams.get(m[1]);
610
869
  if (!rec || rec.idxs.size === 0)
611
870
  continue;
871
+ // G1:被调用方自身已校验 ⇒ 这条跨函数流已被拦截,不标记
872
+ if (guardFns && guardFns.has(m[1]))
873
+ continue;
612
874
  const args = splitArgWindow(m[2] || "");
613
875
  const hit = args.some((arg, i) => rec.idxs.has(i) && taint.test(arg));
614
876
  if (hit) {
@@ -727,6 +989,8 @@ function _extractSingleProject(absRoot, tsconfigPath) {
727
989
  // sinkParams = 项目方法名 → 方法体内流入文件 sink 的形参下标 + 方法条目;
728
990
  // pendingMethodMarks = 跨函数命中待标记的方法条目(主循环后统一应用)
729
991
  const sinkParams = methodSinkParamMap(project, absRoot);
992
+ // G1(2026-09-19):路径校验识别。自身含校验证据的函数名 + 向调用方传播一跳。
993
+ const guardFns = pathGuardFunctionNames(project);
730
994
  const pendingMethodMarks = new Set();
731
995
  const onMethodHit = (rec) => {
732
996
  for (const me of rec.entries)
@@ -744,7 +1008,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
744
1008
  const fParams = f.getParameters();
745
1009
  const fText = f.getText();
746
1010
  const fCalls = extractDirectCalls(f, fText);
747
- fCalls.push(...computeMarkerCalls(fText, fParams.map((p) => p.getName()), sinkParams, onMethodHit));
1011
+ fCalls.push(...computeMarkerCalls(fText, fParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns));
748
1012
  funcs.push({
749
1013
  name,
750
1014
  params: fParams.map(p => ({
@@ -774,7 +1038,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
774
1038
  const initParams = init.getParameters();
775
1039
  const initText = init.getText();
776
1040
  const initCalls = extractDirectCalls(init, initText);
777
- initCalls.push(...computeMarkerCalls(initText, initParams.map((p) => p.getName()), sinkParams, onMethodHit));
1041
+ initCalls.push(...computeMarkerCalls(initText, initParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns));
778
1042
  funcs.push({
779
1043
  name,
780
1044
  params: initParams.map(p => ({
@@ -800,7 +1064,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
800
1064
  const argParams = arg.getParameters();
801
1065
  const argText = arg.getText();
802
1066
  const argCalls = extractDirectCalls(arg, argText);
803
- argCalls.push(...computeMarkerCalls(argText, argParams.map((p) => p.getName()), sinkParams, onMethodHit));
1067
+ argCalls.push(...computeMarkerCalls(argText, argParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns));
804
1068
  funcs.push({
805
1069
  name,
806
1070
  params: argParams.map(p => ({
@@ -834,7 +1098,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
834
1098
  continue;
835
1099
  const mParams = m.getParameters();
836
1100
  const mText = m.getText();
837
- const mCalls = computeMarkerCalls(mText, mParams.map((p) => p.getName()), sinkParams, onMethodHit);
1101
+ const mCalls = computeMarkerCalls(mText, mParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns);
838
1102
  funcs.push({
839
1103
  name: `${cn}.${mn}`,
840
1104
  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.36";
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.36",
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/",