progmune-runtime 3.7.31 → 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 +105 -0
- package/dist/extract-ir-taint-guard.test.js +372 -0
- package/dist/extract-ir-taint-structural.test.js +289 -0
- package/dist/extract-ir.js +263 -47
- package/dist/ir-staleness.js +233 -0
- package/dist/ir-staleness.test.js +228 -0
- package/dist/sdk.js +1 -1
- package/dist/trust/engine.js +28 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,110 @@
|
|
|
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
|
+
|
|
98
|
+
## [3.7.32] — 2026-09-19
|
|
99
|
+
|
|
100
|
+
### IR 陈旧性判定(两次会话级误判的根因修复)
|
|
101
|
+
|
|
102
|
+
- **断点**:trust 引擎自动提取是「缺才提取」——项目已有 ir.json 就以文件为准不重生成。源码改了、ir.json 没更新时静默吃旧 IR:fr-005 首扫报 0(`rm ir.json` 后才报出)、污点试点「标记注入但 trust 报 0」同源
|
|
103
|
+
- **修复**(`src/ir-staleness.ts` + `src/trust/engine.ts` 集成):mtime 比对——仅当有源码新于 ir.json 才重提,不做每次全量重提(skyvern 级项目 5-10 分钟成本)
|
|
104
|
+
- **三档策略 `PROGMUNE_IR_REEXTRACT`**:auto(陈旧 + 遍历完整 + 源码 ≤5000 文件才自动重提,否则**只警告不重提**)/always(语料复测自担成本)/never(旧语义,但必须打「IR 可能陈旧」警告——**不接受静默**)
|
|
105
|
+
- **双闸设计**:完整遍历(不做「找到就早退」——早退会抹掉 truncated 信号,本仓库 22k 源文件实测会把单测推过 30s 超时)+ evidenceComplete + 规模预算闸;stat 一次遍历的常量成本换可靠的 fresh 判定
|
|
106
|
+
- **验收**:`src/ir-staleness.test.ts` 12 green + DSH 陷阱回归 + SSRF 死循环锁 35 passed;**TS 795 零漂移(3086 LOST 0/ADDED 0)**;build ✓
|
|
107
|
+
|
|
3
108
|
## [3.7.31] — 2026-09-18
|
|
4
109
|
|
|
5
110
|
### 提取器 SSRF 标记正则死循环修复(P0,真实工程可用性故障)
|
|
@@ -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
|
+
*/
|