progmune-runtime 3.7.41 → 3.7.42

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,65 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.7.42] — 2026-09-20
4
+
5
+ ### C4f:名字级传播的精度收窄(形参-实参位置对齐 + 返回值依赖分析)
6
+
7
+ 前三轮(C4c/C4d/C4e)都在补召回,这一轮反过来**收精度**。
8
+
9
+ 传播此前是**名字级**的:赋值右侧出现污点名、且右侧看起来像塑形 ⇒ 新变量判为
10
+ 污染。它不看「这个 helper 的返回值到底依不依赖那个实参」:
11
+
12
+ ```ts
13
+ const dropParam = (n: string): string => "fixed.md"; // 形参被丢掉
14
+ const rel = dropParam(k); // k 流不出来,仍被判污染
15
+ ```
16
+
17
+ 现在给每个纯塑形 helper 多算一份 `deps` = **返回值真正依赖的形参集**,调用点按
18
+ 形参-实参位置对齐后才认证据:`dropParam(k)` ⇒ 无证据;`pickFirst("safe", k)`
19
+ (只依赖第一个形参)⇒ 只留 `"safe"`。
20
+
21
+ | 形态 | 收窄前 | 语义 |
22
+ |---|---|---|
23
+ | `dropParam(k)`(return 常量) | 误标 → 不标 | 依赖集为空 |
24
+ | `pickFirst("safe", k)` | 误标 → 不标 | 位置不对齐 |
25
+ | `path.join("out", dropParam(k))` | 误标 → 不标 | 该按支路算,不该整行一锅端 |
26
+ | `wrapDrop(k)`(= dropParam(n)) | 误标 → 不标 | 依赖集跨函数传递后为空 |
27
+ | `wrapPick("safe", k)` | 误标 → 不标 | 跨函数位置不对齐 |
28
+ | `swapPick(k, "safe")`(= pickFirst(y, x)) | 误标 → 不标 | 换序后真正被依赖的是 y |
29
+
30
+ 换序那条最能说明价值:`swapPick("safe", k)` 该标、`swapPick(k, "safe")` 不该标 ——
31
+ 只看「实参里有没有污点名」两条都标,只看「形参有没有被用到」两条也都标,
32
+ 必须按位置穿过一次换序才分得开。
33
+
34
+ `shaped`(右侧像不像塑形)仍看原式:helper 是不是塑形,与它的返回值依不依赖
35
+ 实参是两件事,不能互相替代。依赖分析有次序依赖(helper 若在它依赖的 helper 之前
36
+ 被接受,那一刻 deps 会偏宽),名录定稿后按完整的 info 再算两轮消掉 —— 偏宽是
37
+ 保守侧(继续传播),不会造成漏报。
38
+
39
+ ### 验收
40
+
41
+ | 门 | 结果 |
42
+ |---|---|
43
+ | taintpath 闸门 | **81/81**(新增 G 族 12 条:4 mark / 7 no-taint / 1 suppressed) |
44
+ | fr-007 真实语料 | pre 5 / post 0 **维持** |
45
+ | fr-016 真实语料 | pre 7 / post 0 **维持** —— 收窄没有丢正例 |
46
+ | 定向用例 | **164 passed / 7 个测试文件**(新增 C4f 组 11 条:4 正 7 负;taint-guard、structural、ssrf-loop、project-ir、C/Go/Java 提取组全绿) |
47
+ | TS 盲测 | 107 项目,path_traversal **LOST 0 / ADDED 20**(G 4 + E 9 + F 6 + D 1),基线那 104 个项目**零漂移零丢失** |
48
+ | tsc | 零错误 |
49
+
50
+ ### 门的方向也反过来了(R14 的一次镜像使用)
51
+
52
+ 前四轮 R7 咬的都是「语料零覆盖 ⇒ 门空过」。收窄轮最该防的是另外两件事:
53
+ 收窄过头把真阳性收掉(看 LOST);以及那批 no-taint 用例其实空转(它们本来就不该
54
+ 标,旧代码也不会让它们红)。反向验证切了三刀:
55
+
56
+ - **刀 A**:调用点退回「不按依赖抹实参」(等价收窄前)⇒ G 族 6 条 no-taint 全部
57
+ 转红 —— 证明这批对照真的咬得住;
58
+ - **刀 B**:只关掉跨函数传递(deps 只算直接依赖)⇒ 只有两跳相关的 3 条转红,
59
+ 直接依赖那 3 条不动 —— 归因清晰;
60
+ - **刀 C**:把收窄做得更激进(一律抹空)⇒ 3 条位置对齐正例转漏报,而
61
+ `emitPlainShaper`(常规塑形)不受影响 —— 正说明收窄没波及常规路径。
62
+
3
63
  ## [3.7.41] — 2026-09-20
4
64
 
5
65
  ### C4e:helper 的现代写法(箭头 / 函数表达式 / 模块常量)
@@ -1314,3 +1314,69 @@ const WRITE_LAYER = [
1314
1314
  }, 150000);
1315
1315
  }
1316
1316
  });
1317
+ (0, vitest_1.describe)("C4f:只有落在「返回值真依赖的形参位」上的污点才算流过去", () => {
1318
+ // 与 C4e 同源的隔离写法:sink 实参里不出现污点变量 k,污点只能经 helper 返回值进来。
1319
+ const emit = (relExpr) => [
1320
+ 'import * as fs from "fs";',
1321
+ 'import { dropParam, pickFirst, withExt, wrapDrop, wrapPick, swapPick } from "./helpers";',
1322
+ "export function emit(doc: Record<string, any>, outDir: string) {",
1323
+ " Object.keys(doc).forEach((k) => {",
1324
+ ` const rel = ${relExpr};`,
1325
+ ' fs.writeFileSync(outDir + "/" + rel, "x");',
1326
+ " });",
1327
+ "}",
1328
+ ].join("\n");
1329
+ const HELPERS = [
1330
+ '// 依赖集为空:丢弃形参,返回常量',
1331
+ 'export const dropParam = (n: string): string => "fixed.md";',
1332
+ '// 只依赖第一个形参',
1333
+ 'export const pickFirst = (a: string, b: string): string => a + ".md";',
1334
+ '// 常规塑形(正对照)',
1335
+ 'export const withExt = (name: string): string => name + ".md";',
1336
+ '// 两跳丢弃',
1337
+ 'export const wrapDrop = (n: string): string => dropParam(n);',
1338
+ '// 两跳对齐',
1339
+ 'export const wrapPick = (a: string, b: string): string => pickFirst(a, b);',
1340
+ '// 换序两跳:真正被依赖的是第二个形参',
1341
+ 'export const swapPick = (x: string, y: string): string => pickFirst(y, x);',
1342
+ ].join("\n");
1343
+ const cases = [
1344
+ { name: "正例·常规塑形", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit("withExt(k)") },
1345
+ expectMark: true, why: "收精度不得收过头 —— 这条必须照标" },
1346
+ { name: "正例·污点在被依赖的形参位", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit('pickFirst(k, "safe")') },
1347
+ expectMark: true, why: "第一个形参真的影响返回值" },
1348
+ { name: "正例·两跳仍在被依赖的位置", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit('wrapPick(k, "safe")') },
1349
+ expectMark: true, why: "依赖集要能跨函数传递" },
1350
+ { name: "正例·换序两跳", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit('swapPick("safe", k)') },
1351
+ expectMark: true, why: "swapPick 真正依赖的是 y(第二个形参),位置对齐要跟着换" },
1352
+ { name: "负对照·helper 丢弃形参", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit("dropParam(k)") },
1353
+ expectMark: false, why: "return 常量 —— 实参带污点也流不出来;这是收窄前的真实误报" },
1354
+ { name: "负对照·污点在不被依赖的形参位", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit('pickFirst("safe", k)') },
1355
+ expectMark: false, why: "第二个形参进了函数也出不来;正对照 = 正例·污点在被依赖的形参位" },
1356
+ { name: "负对照·嵌套在 concat 里", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit('path.join("out", dropParam(k))') },
1357
+ expectMark: false, why: "整条 rhs 含 k,但那条支路不流出 —— 证据必须按支路算,不能整行一锅端" },
1358
+ { name: "负对照·两跳后依赖集为空", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit("wrapDrop(k)") },
1359
+ expectMark: false, why: "wrapDrop(n) = dropParam(n),依赖集同样为空(跨函数对齐)" },
1360
+ { name: "负对照·两跳位置不对齐", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit('wrapPick("safe", k)') },
1361
+ expectMark: false, why: "正对照 = 正例·两跳仍在被依赖的位置" },
1362
+ { name: "负对照·换序的反向", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit('swapPick(k, "safe")') },
1363
+ expectMark: false, why: "k 落在不被依赖的 x 位;正对照 = 正例·换序两跳" },
1364
+ { name: "负对照·实参本身无污点", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit('dropParam("lit")') },
1365
+ expectMark: false, why: "无污点流入" },
1366
+ ];
1367
+ for (const c of cases) {
1368
+ (0, vitest_1.it)(`${c.name} —— ${c.why}`, () => {
1369
+ const dir = makeProject(c.files);
1370
+ try {
1371
+ const marks = marksFor(dir, c.fn);
1372
+ if (c.expectMark)
1373
+ (0, vitest_1.expect)(marks).toContain(PATH_MARK);
1374
+ else
1375
+ (0, vitest_1.expect)(marks).not.toContain(PATH_MARK);
1376
+ }
1377
+ finally {
1378
+ fs.rmSync(dir, { recursive: true, force: true });
1379
+ }
1380
+ }, 150000);
1381
+ }
1382
+ });
@@ -772,6 +772,117 @@ function pathModuleAliases(project) {
772
772
  }
773
773
  return out;
774
774
  }
775
+ /** 按顶层逗号切分实参串(跳过字符串与已配平的括号,后者由调用方保证不含嵌套) */
776
+ function splitTopLevelArgs(argText) {
777
+ const out = [];
778
+ let depth = 0;
779
+ let quote = "";
780
+ let cur = "";
781
+ for (let i = 0; i < argText.length; i++) {
782
+ const ch = argText[i];
783
+ if (quote) {
784
+ cur += ch;
785
+ if (ch === quote && argText[i - 1] !== "\\")
786
+ quote = "";
787
+ continue;
788
+ }
789
+ if (ch === '"' || ch === "'" || ch === "`") {
790
+ quote = ch;
791
+ cur += ch;
792
+ continue;
793
+ }
794
+ if (ch === "(" || ch === "[" || ch === "{")
795
+ depth++;
796
+ else if (ch === ")" || ch === "]" || ch === "}")
797
+ depth--;
798
+ if (ch === "," && depth === 0) {
799
+ out.push(cur);
800
+ cur = "";
801
+ continue;
802
+ }
803
+ cur += ch;
804
+ }
805
+ if (cur.trim() !== "" || out.length > 0)
806
+ out.push(cur);
807
+ return out;
808
+ }
809
+ /**
810
+ * C4f:把 helper 调用里【返回值不依赖的那些实参】抹掉。
811
+ *
812
+ * 名字级传播的老问题是:`discard(k) { return "fixed.md"; }` 明明把形参丢了,
813
+ * 只要 `k` 出现在赋值右侧就会被当成污点证据 ⇒ 误标。返回值既然不依赖那个形参,
814
+ * 那条支路就没有数据流。按形参-实参位置对齐后:
815
+ *
816
+ * discard(k) ⇒ `""` (不依赖任何形参)
817
+ * pick("safe", k) ⇒ `("safe")` (只依赖第一个形参)
818
+ *
819
+ * 只处理实参里不再嵌套括号的调用(最内层优先),有界 3 轮向外。
820
+ * 抹掉的是**证据**,`shaped` 判定仍看原式 —— helper 是不是塑形,与它的返回值
821
+ * 依不依赖实参是两件事。
822
+ */
823
+ function maskNonDependentArgs(text, info) {
824
+ if (info.size === 0)
825
+ return text;
826
+ let cur = text;
827
+ for (let round = 0; round < 3; round++) {
828
+ const next = maskNonDependentArgsOnce(cur, info);
829
+ if (next === null)
830
+ break;
831
+ cur = next;
832
+ }
833
+ return cur;
834
+ }
835
+ /** 抹掉**一个**最内层调用;没有任何调用需要抹时返回 null(供调用方收敛) */
836
+ function maskNonDependentArgsOnce(text, info) {
837
+ for (const [name, si] of info) {
838
+ const re = new RegExp(`(?:^|[^\\w$.])${escapeRe(name)}\\s*\\(`, "g");
839
+ let m;
840
+ while ((m = re.exec(text)) !== null) {
841
+ const nameStart = m.index + (m[0].startsWith(name) ? 0 : 1);
842
+ const open = m.index + m[0].length - 1; // 开括号位置
843
+ let depth = 0;
844
+ let close = -1;
845
+ for (let i = open; i < text.length; i++) {
846
+ if (text[i] === "(")
847
+ depth++;
848
+ else if (text[i] === ")") {
849
+ depth--;
850
+ if (depth === 0) {
851
+ close = i;
852
+ break;
853
+ }
854
+ }
855
+ }
856
+ if (close < 0)
857
+ continue;
858
+ const argText = text.slice(open + 1, close);
859
+ if (/[([{]/.test(argText))
860
+ continue; // 还嵌着别的调用 —— 下一轮再处理
861
+ const args = splitTopLevelArgs(argText);
862
+ const kept = args.filter((_, i) => si.params[i] !== undefined && si.deps.has(si.params[i]));
863
+ if (kept.length === args.length)
864
+ continue; // 每个形参都被依赖 ⇒ 无需抹
865
+ const replacement = kept.length > 0 ? `(${kept.join(" + ")})` : `""`;
866
+ return text.slice(0, nameStart) + replacement + text.slice(close + 1);
867
+ }
868
+ }
869
+ return null;
870
+ }
871
+ /**
872
+ * return 表达式真正依赖哪些形参:把「已知 helper 调用里不被依赖的实参」抹掉后,
873
+ * 剩下的形参名就是会流进返回值的那些。`wrap(n) { return discard(n); }` ⇒ 空集。
874
+ */
875
+ function returnDeps(exprs, params, info) {
876
+ const deps = new Set();
877
+ for (const raw of exprs) {
878
+ const masked = maskNonDependentArgs(raw, info);
879
+ for (const p of params) {
880
+ if (new RegExp(`(?:^|[^\\w$.])${escapeRe(p)}(?:[^\\w$]|$)`).test(masked))
881
+ deps.add(p);
882
+ }
883
+ }
884
+ return deps;
885
+ }
775
886
  /** return 表达式列表:块体取 return 语句,箭头简洁体把整个体当作返回值 */
776
887
  function returnExprsOf(body) {
777
888
  if (!ts_morph_1.Node.isBlock(body))
@@ -784,13 +895,14 @@ function returnExprsOf(body) {
784
895
  }
785
896
  return rets;
786
897
  }
787
- /** 扫描全项目,收集「纯塑形」helper 的名字(有界两轮不动点) */
898
+ /** 扫描全项目,收集「纯塑形」helper 的名字 + 返回值依赖(有界两轮不动点) */
788
899
  function pureShaperFunctionNames(project) {
789
900
  const sinkRe = tsSinkCallRegex();
790
901
  const cands = [];
791
902
  /** 登记一个候选:三条判据 —— 有形参 / 有返回值 / 体内无 sink */
792
903
  const consider = (name, paramNodes, declText, bodyNode) => {
793
- const params = new Set(paramNodes.filter(Boolean));
904
+ const paramList = paramNodes.filter(Boolean);
905
+ const params = new Set(paramList);
794
906
  if (params.size === 0)
795
907
  return;
796
908
  sinkRe.lastIndex = 0;
@@ -801,7 +913,9 @@ function pureShaperFunctionNames(project) {
801
913
  const rets = returnExprsOf(bodyNode);
802
914
  if (rets.length === 0)
803
915
  return; // ①
804
- cands.push({ name, params, rets });
916
+ if (cands.some((c) => c.name === name))
917
+ return; // 同名重复登记:先到先得
918
+ cands.push({ name, params, paramList, rets });
805
919
  };
806
920
  for (const sf of project.getSourceFiles()) {
807
921
  // (a) 函数声明
@@ -836,22 +950,43 @@ function pureShaperFunctionNames(project) {
836
950
  const extraIdents = moduleLiteralConstNames(project);
837
951
  for (const a of pathModuleAliases(project))
838
952
  extraIdents.add(a);
839
- const shapers = new Set();
953
+ const names = new Set();
954
+ const info = new Map();
840
955
  for (let round = 0; round < 2; round++) {
841
956
  let added = false;
842
957
  for (const c of cands) {
843
- if (shapers.has(c.name))
958
+ if (names.has(c.name))
844
959
  continue;
845
- const ok = c.rets.every((e) => isPureShaperExpr(e, c.params, shapers, extraIdents));
960
+ const ok = c.rets.every((e) => isPureShaperExpr(e, c.params, names, extraIdents));
846
961
  if (!ok)
847
962
  continue;
848
- shapers.add(c.name);
963
+ names.add(c.name);
964
+ info.set(c.name, { params: c.paramList, deps: returnDeps(c.rets, c.paramList, info) });
849
965
  added = true;
850
966
  }
851
967
  if (!added)
852
968
  break;
853
969
  }
854
- return shapers;
970
+ // C4f:依赖分析有次序依赖 —— `wrap(n) { return discard(n); }` 若在 discard 之前
971
+ // 被接受,那一刻 info 里还没有 discard ⇒ deps 偏宽(偏宽 = 继续传播 = 保守侧,
972
+ // 不会误报,但收不紧)。名录定稿后按完整的 info 再算两轮,把次序依赖消掉。
973
+ const byName = new Map(cands.map((c) => [c.name, c]));
974
+ for (let round = 0; round < 2; round++) {
975
+ let changed = false;
976
+ for (const [name, si] of info) {
977
+ const c = byName.get(name);
978
+ if (!c)
979
+ continue;
980
+ const d = returnDeps(c.rets, si.params, info);
981
+ if (d.size !== si.deps.size || [...d].some((x) => !si.deps.has(x))) {
982
+ si.deps = d;
983
+ changed = true;
984
+ }
985
+ }
986
+ if (!changed)
987
+ break;
988
+ }
989
+ return { names, info };
855
990
  }
856
991
  const TS_FILE_SINK_NAMES = [
857
992
  "readFile", "readFileSync", "writeFile", "writeFileSync",
@@ -1075,9 +1210,11 @@ function collectTaintedNames(text, shapers) {
1075
1210
  function taintedViaShaper(text, tainted, shapers) {
1076
1211
  // C4b:项目自有「纯塑形」helper 的调用也算子(如 getFileNamePath(dir, name, ext))。
1077
1212
  // 见 pureShaperFunctionNames —— 只认三条判据全部成立的函数,认不出就不传播。
1078
- const localShaperRe = shapers && shapers.size > 0
1079
- ? new RegExp(`(?:^|[^\\w$.])(?:${[...shapers].map(escapeRe).join("|")})\\s*\\(`)
1213
+ const localShaperRe = shapers && shapers.names.size > 0
1214
+ ? new RegExp(`(?:^|[^\\w$.])(?:${[...shapers.names].map(escapeRe).join("|")})\\s*\\(`)
1080
1215
  : null;
1216
+ // C4f:helper 调用里【返回值不依赖的实参】不构成污点证据。
1217
+ const shaperInfo = shapers?.info;
1081
1218
  let grewAny = false;
1082
1219
  for (let depth = 0; depth < 3; depth++) {
1083
1220
  // 注意:这里**不能**以 `tainted.size === 0` 提前退出 —— 种子是根模式
@@ -1099,7 +1236,11 @@ function taintedViaShaper(text, tainted, shapers) {
1099
1236
  const shaped = TS_PATH_SHAPER_RE.test(rhs) ||
1100
1237
  TS_CONCAT_RE.test(rhs) ||
1101
1238
  (localShaperRe !== null && localShaperRe.test(rhs));
1102
- if (taintRe.test(rhs) && shaped) {
1239
+ // 证据看的是【抹掉不被依赖的实参之后】的 rhs:`discard(k)` 里 k 不流出,
1240
+ // 那条支路就不该算证据。`shaped` 仍看原式 —— helper 是不是塑形,与它的
1241
+ // 返回值依不依赖实参是两件事。
1242
+ const evidence = shaperInfo ? maskNonDependentArgs(rhs, shaperInfo) : rhs;
1243
+ if (taintRe.test(evidence) && shaped) {
1103
1244
  tainted.add(name);
1104
1245
  added = true;
1105
1246
  }
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.41";
23
+ exports.RUNTIME_VERSION = "3.7.42";
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.41",
3
+ "version": "3.7.42",
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/",