progmune-runtime 3.7.35 → 3.7.37

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,98 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.7.37] — 2026-09-19
4
+
5
+ ### G2:自定义校验函数的调用点抑制(判别力)
6
+
7
+ 缺口实证(taintpath_B `dispatchToolGuarded`,measured):
8
+
9
+ ```ts
10
+ assertTemplateName(args.name); // 函数体内是 /^[A-Za-z0-9_-]+$/ —— 真校验
11
+ return loadTemplate(args.name); // 仍被标记(误报)
12
+ ```
13
+
14
+ `assertTemplateName` 名字不含路径语义后缀——`Name` 在修 `ensureDir()` 误判时被
15
+ 整体移出了 G-C 后缀表,于是调用点侧认不出来。这是 G1 收紧词表的已知代价。
16
+
17
+ 修法**不是**把 `Name` 加回词表(那是按名字猜语义,G-C 已经为这份宽松付过代价:
18
+ fr-007 pre 侧召回归零),而是看**被调用方函数体内到底有没有校验证据**:
19
+
20
+ - `pathGuardFunctionNames` 拆两档:tier-0 `direct`(自身含证据)/ tier-1+ `all`(推断)
21
+ - `collectSanitizedExprs`:被调用方属 tier-0 ⇒ 把调用实参里的取值表达式收进
22
+ 「已净化」集合;sink 实参命中污点但已被净化 ⇒ 不算污点
23
+
24
+ ### 三处精度取舍(全部比 G1 的函数级 selfGuarded 窄)
25
+
26
+ | 取舍 | 说明 |
27
+ |---|---|
28
+ | 表达式级,非函数级 | G1 是一个校验词汇压掉整个函数体的所有流;G2 只净化被传进守卫调用的那个表达式,同函数体里另一条未校验的流仍标记 |
29
+ | 只认 tier-0 | 推断出来的守卫不用于抑制 —— 升为方法学规则 **R9-no-inferred-suppression**(抑制不可逆,压掉的真阳性不会出现在任何回归统计里) |
30
+ | 前缀 `(?:^\|[^\w$.])` | 净化 `name` 不连坐 `other.name`;但 `args.name` 能命中 `path.join(DIR, args.name)` |
31
+
32
+ ### 验收
33
+
34
+ - taintpath 闸门 **24/24**(`dispatchToolGuarded` mark → suppressed;正对照
35
+ `dispatchToolBare` 仍 mark)
36
+ - **fr-007 openhop 真实语料维持 pre 5 / post 0** —— G2 未压掉真阳性
37
+ - TS 盲测(102 项目):**LOST 1 / ADDED 0**,唯一变化就是本条;覆盖力 19 → 18
38
+ - `extract-ir-taint-guard.test.ts` 新增 G2 组 6 条(含正/负对照,R6)
39
+ - 反向验证:回退 v3.7.36 后 G2 两条正例失败,其余 18 条仍绿
40
+ - 新增 `blind-benchmark/check-fr-corpus.ts` —— 把 fr-007 的 pre/post 复测固化成
41
+ 一条命令;快照置于 `blind-benchmark/fr-corpus/`(**受保护资产**,不再放 /tmp)
42
+
43
+ ### 已知缺口 G2b
44
+
45
+ 经项目自有 helper 转手的校验证据不生效(`checkName(n)` 内部再调守卫函数)——
46
+ 只认 tier-0 的代价,与 C4b 同族。缺口是可见的(仍有误报),压掉的真阳性不可见。
47
+
48
+ ## [3.7.36] — 2026-09-19
49
+
50
+ ### C4:污点经路径塑形表达式包装后仍传播
51
+
52
+ 缺口实证(taintpath_A,measured):`readBasename` / `readResolveOnly` /
53
+ `readJoinWrapped` / `readNormalizeWrapped` 四条语义上应标记、实测不标记——
54
+ 传播只认 `x = <污点>` 直赋,而 `path.join/resolve/normalize/basename` 是真实
55
+ 工程构造路径的**默认写法**,这条断链等于把最常见形态整片漏掉。
56
+
57
+ 政策是**白名单传播**,不是「RHS 含污点就传播」:
58
+ - 只经 path.* 家族与保值的字符串方法(trim/replace/…)传播;拼接与模板字面量
59
+ 同样只塑形、不改来源;有界迭代 3 跳(`normalize → join` 这类链式能接上)
60
+ - **认不出的调用不传播** ⇒ `const safe = sanitizeName(p)` 天然不污染。
61
+ 未知函数默认站在精度一侧——这是白名单相对黑名单的决定性优势
62
+ - 代价(记为缺口 C4b):项目自有 helper(如 `buildPath(p)`)仍不传播
63
+
64
+ ### 两处必须一并改的「假通过」(C4 把它们顶出来了)
65
+
66
+ C4 之前,下列用例的「不标记」**不是因为判别力对,而是污点根本没走到 sink**
67
+ ——方法学规则 R7 的第二种形态。C4 一放开它们就会翻成误报:
68
+
69
+ 1. **G-A2 漏 `startsWith(base)`**:原正则要求标识符在 base/root/dir 之外还有
70
+ 前导字符,最朴素的 `target.startsWith(base)` 匹配不上。改为取出整个实参再
71
+ 判定,并加 `database` 等反例名单(`database` 以 base 结尾但与目录无关)
72
+ 2. **G-A2 漏字符串字面量形态**:`startsWith("/srv/data")` 同样匹配不上。
73
+ 现支持绝对路径字面量(长度 >1,排除 `startsWith("/")`——那只是判绝对路径)
74
+
75
+ ### 闸门改为实时提取(第二处空过)
76
+
77
+ `check-taintpath.ts` 原默认读 `reports/batch-scan-results.json`——那是别人跑
78
+ batch-scan 留下的**陈旧产物**。实测踩到:C4 落地后 4 条 known-gap 已翻正,
79
+ 闸门仍报「未标记,符合预期」。已改为默认对 `generated/taintpath_*` 实时跑
80
+ extractIR(需走报告时显式 `--report`)。
81
+
82
+ ### 验收
83
+
84
+ - taintpath 闸门 **24/24,失败 0、缺口闭合待更新 0**;4 条 known-gap 转 mark
85
+ - fr-007 openhop 真实语料维持 **pre 5 / post 0** —— C4 只补召回,未削弱 G1 判别力
86
+ - `extract-ir-taint-structural.test.ts` 新增 C4 组 11 条(7 正 + 4 负对照,R6)
87
+ - 反向验证:回退 v3.7.35 后 **7 条正例全失败**,4 条负对照仍绿
88
+ - 全组回归 72 passed;`tsc -p tsconfig.json` 零错误
89
+
90
+ ### 实现期踩的一个坑(已入注释)
91
+
92
+ `taintedViaShaper` 最初写了 `if (tainted.size === 0) return`——而这 4 条
93
+ known-gap 的共同形态恰恰是「全函数没有任何直赋污点」,等于整条 C4 不生效。
94
+ 种子是根模式(taintPattern 恒定并入 UNTRUSTED_ROOT_SRC),不该依赖已有污点。
95
+
3
96
  ## [3.7.35] — 2026-09-19
4
97
 
5
98
  ### 重建盲测覆盖:taintpath 语料族(TS 795 空过的终结)
@@ -356,6 +356,157 @@ export function read(req: any) {
356
356
  }
357
357
  });
358
358
  });
359
+ (0, vitest_1.describe)("G2:调用点抑制 —— 按被调用方的实际证据定案(种子=taintpath_B assertTemplateName)", () => {
360
+ // 缺口形态(2026-09-19 measured,taintpath_B dispatchToolGuarded):
361
+ // `assertTemplateName(args.name); return loadTemplate(args.name);`
362
+ // `assertTemplateName` 函数体内是锚定字符集白名单(G-D),是真校验,
363
+ // 但它的名字不含路径语义后缀 —— `Name` 在修 `ensureDir()` 误判时被整体
364
+ // 移出了 G-C 守卫后缀表,于是调用点侧认不出来,仍被标记。
365
+ //
366
+ // G2 的修法不是把 `Name` 加回词表(那是按【名字】猜语义,G-C 已经为这份
367
+ // 宽松付过一次代价:fr-007 pre 侧召回归零),而是看**被调用方函数体内
368
+ // 到底有没有校验证据**。
369
+ //
370
+ // 精度取舍:净化作用在【表达式】上,不是整函数 —— 同函数体里另一条未被
371
+ // 校验的流仍会标记。这是 G2 相对 G1(函数级 selfGuarded)的收窄。
372
+ const body = (guardCall) => `
373
+ import * as fs from "fs";
374
+ export function read(req: any) {
375
+ const name = req.params.name;
376
+ ${guardCall} return fs.readFileSync("/data/" + name, "utf-8");
377
+ }
378
+ function assertTemplateName(name: string): void {
379
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) throw new Error("bad template name");
380
+ }
381
+ `;
382
+ (0, vitest_1.it)("正对照:去掉自定义校验调用后必须标记", () => {
383
+ const dir = makeProject({ "a.ts": body("") });
384
+ try {
385
+ (0, vitest_1.expect)(marksFor(dir, "read")).toContain(PATH_MARK);
386
+ }
387
+ finally {
388
+ fs.rmSync(dir, { recursive: true, force: true });
389
+ }
390
+ });
391
+ (0, vitest_1.it)("自定义校验函数(名字不含路径语义后缀)被调用 → 不得标记", () => {
392
+ const dir = makeProject({ "a.ts": body(" assertTemplateName(name);\n") });
393
+ try {
394
+ (0, vitest_1.expect)(marksFor(dir, "read")).not.toContain(PATH_MARK);
395
+ }
396
+ finally {
397
+ fs.rmSync(dir, { recursive: true, force: true });
398
+ }
399
+ });
400
+ (0, vitest_1.it)("负对照:被调用方体内**没有**校验证据时,调用它不得抑制(不能见调用就净化)", () => {
401
+ const dir = makeProject({
402
+ "a.ts": `
403
+ import * as fs from "fs";
404
+ export function read(req: any) {
405
+ const name = req.params.name;
406
+ logIt(name);
407
+ return fs.readFileSync("/data/" + name, "utf-8");
408
+ }
409
+ function logIt(x: string): void { console.log(x); }
410
+ `,
411
+ });
412
+ try {
413
+ (0, vitest_1.expect)(marksFor(dir, "read")).toContain(PATH_MARK);
414
+ }
415
+ finally {
416
+ fs.rmSync(dir, { recursive: true, force: true });
417
+ }
418
+ });
419
+ (0, vitest_1.it)("表达式级净化:同函数体里另一条未被校验的流仍要标记", () => {
420
+ // G1 的函数级 selfGuarded 会把整个函数体一起静默;G2 只净化真正被传进
421
+ // 守卫调用的表达式 —— 这条用例锁住这个差别。
422
+ const dir = makeProject({
423
+ "a.ts": `
424
+ import * as fs from "fs";
425
+ export function read(req: any) {
426
+ const name = req.params.name;
427
+ const other = req.params.other;
428
+ assertTemplateName(name);
429
+ fs.readFileSync("/data/" + name, "utf-8");
430
+ return fs.writeFileSync("/data/" + other, "x");
431
+ }
432
+ function assertTemplateName(name: string): void {
433
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) throw new Error("bad template name");
434
+ }
435
+ `,
436
+ });
437
+ try {
438
+ (0, vitest_1.expect)(marksFor(dir, "read")).toContain(PATH_MARK);
439
+ }
440
+ finally {
441
+ fs.rmSync(dir, { recursive: true, force: true });
442
+ }
443
+ });
444
+ (0, vitest_1.it)("跨函数(taintpath_B 形态):净化只发生在调用点,被调用方定义照旧被反标", () => {
445
+ // 语料里 `loadTemplate` 自身不含任何不可信根,它被标记是因为调用点命中了
446
+ // 跨函数一跳(onMethodHit 反标)。故本用例在同一文件里放两个几乎相同的
447
+ // 调用点 —— 一个净化、一个不净化 —— 才能同时锁住两侧。
448
+ const dir = makeProject({
449
+ "store.ts": `
450
+ import * as fs from "fs";
451
+ export function loadTemplate(p: string): string {
452
+ return fs.readFileSync("/srv/templates/" + p, "utf-8");
453
+ }
454
+ `,
455
+ "handler.ts": `
456
+ import { loadTemplate } from "./store";
457
+ export function dispatchToolGuarded(params: any) {
458
+ const args = params.arguments;
459
+ assertTemplateName(args.name);
460
+ return loadTemplate(args.name);
461
+ }
462
+ export function dispatchToolBare(params: any) {
463
+ const args = params.arguments;
464
+ return loadTemplate(args.name);
465
+ }
466
+ function assertTemplateName(name: string): void {
467
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) throw new Error("bad template name");
468
+ }
469
+ `,
470
+ });
471
+ try {
472
+ // 净化侧:污点表达式已被校验 ⇒ 调用点不标记
473
+ (0, vitest_1.expect)(marksFor(dir, "dispatchToolGuarded")).not.toContain(PATH_MARK);
474
+ // 正对照:同形状去掉校验 ⇒ 照旧标记,且被调用方被反标
475
+ (0, vitest_1.expect)(marksFor(dir, "dispatchToolBare")).toContain(PATH_MARK);
476
+ (0, vitest_1.expect)(marksFor(dir, "loadTemplate")).toContain(PATH_MARK);
477
+ }
478
+ finally {
479
+ fs.rmSync(dir, { recursive: true, force: true });
480
+ }
481
+ });
482
+ (0, vitest_1.it)("已知缺口 G2b:经项目自有 helper 转手的校验证据不生效(保守边界,仍标记)", () => {
483
+ // 只认 tier-0(函数体自身含校验证据)。经 helper 转手 ⇒ 那是【推断】出来
484
+ // 的守卫,拿推断结果做抑制会把推断误差直接放大成误报消除。
485
+ // 代价:这类形态仍会误报,与 C4b(helper 不传播)是同一族缺口。
486
+ const dir = makeProject({
487
+ "a.ts": `
488
+ import * as fs from "fs";
489
+ export function read(req: any) {
490
+ const name = req.params.name;
491
+ checkName(name);
492
+ return fs.readFileSync("/data/" + name, "utf-8");
493
+ }
494
+ function checkName(name: string): void {
495
+ assertTemplateName(name);
496
+ }
497
+ function assertTemplateName(name: string): void {
498
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) throw new Error("bad template name");
499
+ }
500
+ `,
501
+ });
502
+ try {
503
+ (0, vitest_1.expect)(marksFor(dir, "read")).toContain(PATH_MARK);
504
+ }
505
+ finally {
506
+ fs.rmSync(dir, { recursive: true, force: true });
507
+ }
508
+ });
509
+ });
359
510
  /**
360
511
  * ── 已知边界(本次写用例时实测踩到,记录以免后人重复踩)──
361
512
  *
@@ -287,3 +287,159 @@ export function seedFlow() {
287
287
  }, 150000);
288
288
  }
289
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,21 @@ 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
+ }
381
396
  const PATH_GUARD_RULES = [
382
397
  {
383
398
  id: "G-A",
@@ -388,9 +403,35 @@ const PATH_GUARD_RULES = [
388
403
  },
389
404
  {
390
405
  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",
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)",
394
435
  },
395
436
  {
396
437
  id: "G-B1",
@@ -456,7 +497,7 @@ function callsPathGuardFn(text) {
456
497
  /** 返回命中的守卫规则 id;无校验证据返回 null。 */
457
498
  function hasPathGuardEvidence(text) {
458
499
  for (const r of PATH_GUARD_RULES) {
459
- if (r.re.test(text))
500
+ if (r.decide ? r.decide(text) : r.re.test(text))
460
501
  return r.id;
461
502
  }
462
503
  if (callsPathGuardFn(text))
@@ -475,15 +516,20 @@ function escapeRe(s) {
475
516
  */
476
517
  function pathGuardFunctionNames(project) {
477
518
  const guarded = new Set();
519
+ /** tier-0:函数体自身含校验证据的名字(G1 原语义) */
520
+ const direct = new Set();
478
521
  const bodies = [];
479
522
  const note = (names, text) => {
480
523
  if (!text)
481
524
  return;
482
525
  bodies.push({ names, text });
483
- if (hasPathGuardEvidence(text))
526
+ if (hasPathGuardEvidence(text)) {
484
527
  for (const n of names)
485
- if (n)
528
+ if (n) {
486
529
  guarded.add(n);
530
+ direct.add(n);
531
+ }
532
+ }
487
533
  };
488
534
  for (const sf of project.getSourceFiles()) {
489
535
  for (const cls of sf.getClasses()) {
@@ -526,7 +572,55 @@ function pathGuardFunctionNames(project) {
526
572
  if (!added)
527
573
  break;
528
574
  }
529
- return guarded;
575
+ return { all: guarded, direct };
576
+ }
577
+ // ═══════════════════════════════════════════════════════════════
578
+ // G2(2026-09-19):调用点抑制 —— 污点【表达式级】净化
579
+ // ═══════════════════════════════════════════════════════════════
580
+ // 缺口实证(taintpath_B,2026-09-19 measured):
581
+ // `assertTemplateName(args.name); return loadTemplate(args.name);`
582
+ // 仍被标记。`assertTemplateName` 的函数体内是锚定字符集白名单(G-D),
583
+ // 是**真的**校验,但它的名字不含路径语义后缀 —— `Name` 在修 `ensureDir()`
584
+ // 误判时被整体移出了 G-C 守卫后缀表,于是调用点侧认不出来。
585
+ //
586
+ // 为什么不直接放宽后缀表:那是按【名字】猜语义,会把判别力稀释成猜函数名
587
+ // (G-C 的实证代价已经量过一次:放宽 ⇒ fr-007 pre 侧召回归零)。
588
+ // 本实现改为按【被调用方的实际证据】定案:
589
+ // 函数体内调用了 tier-0 守卫函数(自身含校验证据),且污点表达式被当作
590
+ // 实参传进去 ⇒ 该表达式视为已净化,再流进 sink 不算污点。
591
+ //
592
+ // 精度取舍(关键点):净化作用在【表达式】上,不是整函数。
593
+ // G1 的 selfGuarded 是函数级的(一个校验词汇压掉整个函数体所有流);
594
+ // G2 只净化真正被传进守卫调用的那个表达式 —— 同函数体里另一条未被校验
595
+ // 的流仍会标记。这是 G2 相对 G1 的收窄,而不是又一次放宽。
596
+ // ═══════════════════════════════════════════════════════════════
597
+ /**
598
+ * 收集函数体内「被传进 tier-0 守卫函数」的实参表达式,编译成一个判定正则。
599
+ *
600
+ * 边界:只收**简单取值表达式**(标识符 / 成员 / 下标 / 字符串字面量),
601
+ * 复杂表达式(模板串、拼接、调用)不收 —— 那些形态无法在 sink 侧可靠比对。
602
+ */
603
+ function collectSanitizedExprs(text, directGuardFns) {
604
+ if (!directGuardFns || directGuardFns.size === 0)
605
+ return null;
606
+ const names = [...directGuardFns].filter((n) => !n.includes("."));
607
+ if (names.length === 0)
608
+ return null;
609
+ const callRe = new RegExp(`(?:^|[^\\w$.])(?:${names.map(escapeRe).join("|")})\\s*\\(([\\s\\S]{0,250}?)\\)`, "g");
610
+ const exprs = new Set();
611
+ let m;
612
+ while ((m = callRe.exec(text)) !== null) {
613
+ for (const a of splitArgWindow(m[1] || "")) {
614
+ const t = a.trim();
615
+ if (t && /^[A-Za-z_$][\w$]*(?:\s*\.\s*[\w$]+|\[[^\]]*\])*$/.test(t))
616
+ exprs.add(t.replace(/\s+/g, ""));
617
+ }
618
+ }
619
+ if (exprs.size === 0)
620
+ return null;
621
+ // 前缀 `(?:^|[^\w$.])` 是精度关键:`name` 不能匹配 `other.name` 里的 name,
622
+ // 但 `args.name` 能匹配 `path.join(DIR, args.name)` 里的 args.name。
623
+ return new RegExp([...exprs].map((e) => `(?:^|[^\\w$.])${escapeRe(e)}\\b`).join("|"));
530
624
  }
531
625
  const TS_FILE_SINK_NAMES = [
532
626
  "readFile", "readFileSync", "writeFile", "writeFileSync",
@@ -569,7 +663,29 @@ function hasRequestRootedExpr(text) {
569
663
  function tsSinkCallRegex() {
570
664
  return new RegExp(`(?:^|[^\\w.$])(?:await\\s+)?(?:[\\w$]+\\.)?(${TS_FILE_SINK_NAMES.join("|")})\\s*\\(([\\s\\S]{0,250}?)\\)`, "g");
571
665
  }
572
- /** 函数体内被不可信根污染的局部名(含解构与单跳赋值,深度 ≤2) */
666
+ /**
667
+ * C4(2026-09-19):污点经「路径塑形表达式」包装后仍然传播。
668
+ *
669
+ * 缺口实证(taintpath_A,2026-09-19 measured):
670
+ * `const target = path.join(ROOT, req.params.name); fs.readFileSync(target)`
671
+ * 不标记 —— 因为传播只认 `x = <污点>` 直赋,`path.join(...)` 把流掐断了。
672
+ * 真实工程里 `join/resolve/normalize/basename` 是路径构造的**默认写法**,
673
+ * 这条断链等于把最常见的形态整片漏掉。
674
+ *
675
+ * 政策:**白名单传播**,不是「RHS 含污点就传播」。
676
+ * - 只经【路径塑形】的调用传播(path.* 家族 + 保值的字符串方法);
677
+ * - 字符串拼接与模板字面量同样只塑形、不改来源,故一并传播;
678
+ * - 认不出的调用**不传播** —— 于是 `const safe = sanitizeName(p)` 天然
679
+ * 不污染。这是白名单相对黑名单的决定性优势:未知函数默认站在精度一侧。
680
+ * 代价:项目自有 helper(如 `buildPath(p)`)仍不传播,记为 C4b 缺口。
681
+ *
682
+ * 已知限制:只扫单行赋值(`const x = …;` 一行写完)。跨行书写的调用
683
+ * 链不传播——保持简单,等 C4b 一并处理。
684
+ */
685
+ 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*\(/;
686
+ /** 拼接形态:`+` 或反引号(模板字面量)—— 同样只是塑形 */
687
+ const TS_CONCAT_RE = /\+|`/;
688
+ /** 函数体内被不可信根污染的局部名(含解构、单跳赋值与 C4 塑形传播,深度 ≤3) */
573
689
  function collectTaintedNames(text) {
574
690
  const tainted = new Set();
575
691
  let m;
@@ -588,6 +704,7 @@ function collectTaintedNames(text) {
588
704
  const bareAssign = new RegExp(`(?:^|[^\\w$.])([\\w$]+)\\s*=\\s*(?:await\\s+)?(?:${root})`, "g");
589
705
  while ((m = bareAssign.exec(text)) !== null)
590
706
  tainted.add(m[1]);
707
+ taintedViaShaper(text, tainted);
591
708
  // 单跳传播(深度 ≤2)
592
709
  for (let depth = 0; depth < 2; depth++) {
593
710
  if (tainted.size === 0)
@@ -606,6 +723,41 @@ function collectTaintedNames(text) {
606
723
  }
607
724
  return tainted;
608
725
  }
726
+ /**
727
+ * C4:把「经路径塑形表达式包装」的赋值名补进污点集合(有界迭代 ≤3 轮)。
728
+ *
729
+ * 按行扫描 `[(const|let|var)] NAME = RHS`:RHS 含污点证据 且
730
+ * (含塑形调用 或 含拼接/模板)⇒ NAME 污染。迭代是为了让
731
+ * `normalized = path.normalize(p)` → `savePath = path.join(normalized, f)`
732
+ * 这类链式构造能接上(fr-012 下载侧守卫块就是这个形状)。
733
+ */
734
+ function taintedViaShaper(text, tainted) {
735
+ for (let depth = 0; depth < 3; depth++) {
736
+ // 注意:这里**不能**以 `tainted.size === 0` 提前退出 —— 种子是根模式
737
+ // (taintPattern 恒定并入 UNTRUSTED_ROOT_SRC)。本轮这 4 条 known-gap
738
+ // 的共同形态恰恰是「全函数没有任何直赋污点,根只出现在塑形调用里」,
739
+ // 加这道闸门等于整条 C4 不生效(2026-09-19 实测踩到)。
740
+ const taintRe = taintPattern(tainted);
741
+ if (!taintRe)
742
+ return;
743
+ let added = false;
744
+ for (const line of text.split("\n")) {
745
+ const m = /^\s*(?:const|let|var)?\s*([\w$]+)\s*=\s*(?:await\s+)?(.+)$/.exec(line);
746
+ if (!m)
747
+ continue;
748
+ const name = m[1];
749
+ const rhs = m[2];
750
+ if (tainted.has(name))
751
+ continue;
752
+ if (taintRe.test(rhs) && (TS_PATH_SHAPER_RE.test(rhs) || TS_CONCAT_RE.test(rhs))) {
753
+ tainted.add(name);
754
+ added = true;
755
+ }
756
+ }
757
+ if (!added)
758
+ return;
759
+ }
760
+ }
609
761
  function taintPattern(tainted) {
610
762
  const parts = [UNTRUSTED_ROOT_SRC];
611
763
  if (tainted.size > 0) {
@@ -613,16 +765,33 @@ function taintPattern(tainted) {
613
765
  }
614
766
  return new RegExp(parts.join("|"));
615
767
  }
616
- /** 本函数体内:文件 sink 的实参窗口含 request 污点 → true */
617
- function hasTaintedSinkCall(text, tainted) {
768
+ /**
769
+ * 本函数体内:文件 sink 的实参窗口含 request 污点 → true。
770
+ *
771
+ * `sanitizedRe` 非空时(G2):实参里被净化过的表达式不再算污点 ——
772
+ * 逐实参判定,只要还有一个实参含【未被净化】的污点就照旧标记。
773
+ */
774
+ function hasTaintedSinkCall(text, tainted, sanitizedRe) {
618
775
  const taint = taintPattern(tainted);
619
776
  if (!taint)
620
777
  return false;
621
778
  const sinkRe = tsSinkCallRegex();
622
779
  let m;
623
780
  while ((m = sinkRe.exec(text)) !== null) {
624
- if (taint.test(m[2] || ""))
625
- return true;
781
+ const win = m[2] || "";
782
+ if (!taint.test(win))
783
+ continue;
784
+ if (sanitizedRe) {
785
+ const args = splitArgWindow(win);
786
+ if (args.length > 0) {
787
+ if (!args.some((a) => taint.test(a) && !sanitizedRe.test(a)))
788
+ continue;
789
+ }
790
+ else if (sanitizedRe.test(win)) {
791
+ continue;
792
+ }
793
+ }
794
+ return true;
626
795
  }
627
796
  return false;
628
797
  }
@@ -734,7 +903,7 @@ function collectUrlParamNames(params) {
734
903
  * 项目方法体内的文件 sink(跨函数一跳,onMethodHit 回调登记方法条目)
735
904
  * - SSRF:URL 形参/request 污点 → HTTP fetch sink,无守卫词汇
736
905
  */
737
- function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit, guardFns) {
906
+ function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit, guardFns, directGuardFns) {
738
907
  const markers = [];
739
908
  // ── 路径穿越 ──
740
909
  // G1(2026-09-19):有流还不够,必须【没有校验证据】才标记——
@@ -743,6 +912,10 @@ function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit, guardFns)
743
912
  if (hasRequestRootedExpr(text)) {
744
913
  const tainted = collectTaintedNames(text);
745
914
  const selfGuarded = guardFns ? hasPathGuardEvidence(text) !== null : false;
915
+ // G2:调用点抑制 —— 被传进「自身含校验证据的函数」的表达式视为已净化。
916
+ // 与 selfGuarded(函数级、按词形)互补:这条按【被调用方的实际证据】定案,
917
+ // 因此认得出 assertTemplateName 这类不含路径语义后缀的自定义校验函数。
918
+ const sanitizedRe = collectSanitizedExprs(text, directGuardFns);
746
919
  // C5(2026-09-19):不可信根可以直接写在 sink 实参里,不需要中间变量。
747
920
  // 此前外层要求 collectTaintedNames 非空,于是
748
921
  // `fs.readFileSync("/data/" + req.params.name)` —— 真实工程里最常见的形态
@@ -750,7 +923,7 @@ function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit, guardFns)
750
923
  // SSRF 侧从来不是这样(它把 UNTRUSTED_ROOT_SRC 并进 taint 模式去匹配实参窗口),
751
924
  // 这是同一条数据流上的又一处口径不一致。
752
925
  if (!selfGuarded) {
753
- if (hasTaintedSinkCall(text, tainted)) {
926
+ if (hasTaintedSinkCall(text, tainted, sanitizedRe)) {
754
927
  markers.push("__progmune_path_traversal__");
755
928
  }
756
929
  else {
@@ -773,7 +946,7 @@ function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit, guardFns)
773
946
  if (guardFns && guardFns.has(m[1]))
774
947
  continue;
775
948
  const args = splitArgWindow(m[2] || "");
776
- const hit = args.some((arg, i) => rec.idxs.has(i) && taint.test(arg));
949
+ const hit = args.some((arg, i) => rec.idxs.has(i) && taint.test(arg) && !(sanitizedRe && sanitizedRe.test(arg)));
777
950
  if (hit) {
778
951
  markers.push("__progmune_path_traversal__");
779
952
  onMethodHit(rec);
@@ -891,7 +1064,10 @@ function _extractSingleProject(absRoot, tsconfigPath) {
891
1064
  // pendingMethodMarks = 跨函数命中待标记的方法条目(主循环后统一应用)
892
1065
  const sinkParams = methodSinkParamMap(project, absRoot);
893
1066
  // G1(2026-09-19):路径校验识别。自身含校验证据的函数名 + 向调用方传播一跳。
894
- const guardFns = pathGuardFunctionNames(project);
1067
+ // direct = tier-0(自身含校验证据);all = 再向调用方传播一跳后的集合。
1068
+ // G2 只用 direct —— 传播得到的名字是「被推断为守卫」,拿它做抑制会把
1069
+ // 推断误差直接放大成误报消除。
1070
+ const { all: guardFns, direct: directGuardFns } = pathGuardFunctionNames(project);
895
1071
  const pendingMethodMarks = new Set();
896
1072
  const onMethodHit = (rec) => {
897
1073
  for (const me of rec.entries)
@@ -909,7 +1085,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
909
1085
  const fParams = f.getParameters();
910
1086
  const fText = f.getText();
911
1087
  const fCalls = extractDirectCalls(f, fText);
912
- fCalls.push(...computeMarkerCalls(fText, fParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns));
1088
+ fCalls.push(...computeMarkerCalls(fText, fParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns));
913
1089
  funcs.push({
914
1090
  name,
915
1091
  params: fParams.map(p => ({
@@ -939,7 +1115,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
939
1115
  const initParams = init.getParameters();
940
1116
  const initText = init.getText();
941
1117
  const initCalls = extractDirectCalls(init, initText);
942
- initCalls.push(...computeMarkerCalls(initText, initParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns));
1118
+ initCalls.push(...computeMarkerCalls(initText, initParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns));
943
1119
  funcs.push({
944
1120
  name,
945
1121
  params: initParams.map(p => ({
@@ -965,7 +1141,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
965
1141
  const argParams = arg.getParameters();
966
1142
  const argText = arg.getText();
967
1143
  const argCalls = extractDirectCalls(arg, argText);
968
- argCalls.push(...computeMarkerCalls(argText, argParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns));
1144
+ argCalls.push(...computeMarkerCalls(argText, argParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns));
969
1145
  funcs.push({
970
1146
  name,
971
1147
  params: argParams.map(p => ({
@@ -999,7 +1175,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
999
1175
  continue;
1000
1176
  const mParams = m.getParameters();
1001
1177
  const mText = m.getText();
1002
- const mCalls = computeMarkerCalls(mText, mParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns);
1178
+ const mCalls = computeMarkerCalls(mText, mParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns);
1003
1179
  funcs.push({
1004
1180
  name: `${cn}.${mn}`,
1005
1181
  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.35";
23
+ exports.RUNTIME_VERSION = "3.7.37";
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.35",
3
+ "version": "3.7.37",
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/",