progmune-runtime 3.7.36 → 3.7.41

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.
@@ -516,15 +516,20 @@ function escapeRe(s) {
516
516
  */
517
517
  function pathGuardFunctionNames(project) {
518
518
  const guarded = new Set();
519
+ /** tier-0:函数体自身含校验证据的名字(G1 原语义) */
520
+ const direct = new Set();
519
521
  const bodies = [];
520
522
  const note = (names, text) => {
521
523
  if (!text)
522
524
  return;
523
525
  bodies.push({ names, text });
524
- if (hasPathGuardEvidence(text))
526
+ if (hasPathGuardEvidence(text)) {
525
527
  for (const n of names)
526
- if (n)
528
+ if (n) {
527
529
  guarded.add(n);
530
+ direct.add(n);
531
+ }
532
+ }
528
533
  };
529
534
  for (const sf of project.getSourceFiles()) {
530
535
  for (const cls of sf.getClasses()) {
@@ -567,7 +572,286 @@ function pathGuardFunctionNames(project) {
567
572
  if (!added)
568
573
  break;
569
574
  }
570
- 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("|"));
624
+ }
625
+ // ═══════════════════════════════════════════════════════════════
626
+ // C4b(2026-09-20):项目自有的「纯塑形」helper —— 补齐 C4 的最后一跳
627
+ //
628
+ // 缺口形态(fr-016 真实语料,iterateAsyncApiComponents / iterateComponents):
629
+ // const filename = getFileNamePath(componentDirPath, componentName, ext);
630
+ // writeToFileByExtension(componentData, filename); // sink 侧已继承(fr-016)
631
+ // // getFileNamePath(a, b, c) { return path.join(a, b) + `.${c}`; }
632
+ // `componentName` 已被 Object.keys 污染,但 `getFileNamePath` 不在
633
+ // TS_PATH_SHAPER_RE 里(那是 node:path 家族 + String 原型方法的固定清单),
634
+ // 于是 `filename` 收不到污点 —— 整条流断在最后一跳。
635
+ //
636
+ // 判据(三条同时成立才认,宁可漏、不可错):
637
+ // ① 有形参,且有带表达式的 return(void / 只写文件的函数不算)
638
+ // ② 函数体内没有文件 sink —— 含 sink 说明它不只是塑形
639
+ // ③ 每个 return 表达式里:所有调用都是塑形调用(node:path 家族 / 保值字符串
640
+ // 方法 / 已认定的纯塑形 helper),且所有自由标识符都是自己的形参
641
+ // ③ 的后半句是精度核心:return 里只要出现任何**不是形参**的自由标识符
642
+ // (模块常量、闭包变量、别的调用结果),就不认。
643
+ //
644
+ // 这与 C4 是同一条原则(白名单优于黑名单):只对**能证明**的形态传播。
645
+ // 认不出来的 helper 不传播 —— 那是可见的漏报,比不可见的误报便宜(R9)。
646
+ // 有界:两轮不动点,允许 helper 调用已认定的 helper,但不追环。
647
+ // ═══════════════════════════════════════════════════════════════
648
+ /** node:path 家族里只做塑形、不做校验的方法名 */
649
+ const PATH_SHAPER_METHODS = new Set([
650
+ "join", "resolve", "normalize", "basename", "dirname",
651
+ "extname", "relative", "format", "toNamespacedPath",
652
+ ]);
653
+ /**
654
+ * 字符过滤方法 —— **故意不进** C4b 的证据集。
655
+ *
656
+ * C4 的内联规则里 `.replace/.trim/...` 是算塑形的(TS_PATH_SHAPER_RE 第二组),
657
+ * 但那是「参数窗口里看得见整个表达式」时的取舍。helper 形式看不见实参窗口,
658
+ * 判据就该更严:`.replace(/[^a-z0-9]/gi, "")` 恰恰是净化函数最常见的写法,
659
+ * 把字符过滤当成「确定不净化」的证据,正是 C4 当初用白名单避开的那类错误
660
+ * (`readSanitized` 用例锁的就是它)。
661
+ * 代价:`toSlug(p) { return p.trim().toLowerCase(); }` 仍不传播(已知缺口)。
662
+ */
663
+ const VALUE_STRING_METHODS = new Set([
664
+ "trim", "trimStart", "trimEnd", "replace", "replaceAll",
665
+ "toLowerCase", "toUpperCase", "toString", "slice", "substring",
666
+ "substr", "padStart", "padEnd", "concat", "normalize",
667
+ ]);
668
+ /** return 表达式里允许出现的非形参自由标识符(语言/模块级常量) */
669
+ const SHAPER_ALLOWED_FREE_IDENTS = new Set([
670
+ "path", "sep", "undefined", "null", "true", "false", "String", "Number",
671
+ ]);
672
+ /** 允许出现在调用位的值转换函数(不改变路径语义) */
673
+ const VALUE_CONVERTER_CALLS = new Set(["String", "Number", "Boolean"]);
674
+ /**
675
+ * 判断 return 表达式是不是「纯塑形」:
676
+ * 所有调用都在塑形白名单内,且所有自由标识符都是形参(或模块级字面量常量)。
677
+ *
678
+ * 实现上刻意做**单趟标识符扫描**(看每个标识符的紧邻字符),而不是
679
+ * `NAME(?!\s*\()` 这种前瞻 —— 前瞻会被正则回溯绕过:
680
+ * 对 `withExt(...)`,`([A-Za-z_$][\w$]*)(?!\s*\()` 可以退化成匹配 `withEx`
681
+ * 让前瞻通过,于是把调用名误判成自由标识符(2026-09-20 实测踩到)。
682
+ */
683
+ function isPureShaperExpr(expr, params, shapers, extraIdents) {
684
+ // 先抹掉字符串与模板字面量:里面的 `${...}` 可能带花括号,会干扰后续扫描;
685
+ // 抹掉后如果表达式里还有别的自由标识符,仍会被下面的检查拦住。
686
+ const s = expr
687
+ .replace(/`(?:\\.|\$\{[^}]*\}|[^`\\])*`/g, '""')
688
+ .replace(/'(?:\\.|[^'\\])*'/g, '""')
689
+ .replace(/"(?:\\.|[^"\\])*"/g, '""');
690
+ const idRe = /[A-Za-z_$][\w$]*/g;
691
+ let m;
692
+ while ((m = idRe.exec(s)) !== null) {
693
+ const n = m[0];
694
+ // 紧前一个非空字符 / 紧后一个非空字符
695
+ let i = m.index - 1;
696
+ while (i >= 0 && /\s/.test(s[i]))
697
+ i--;
698
+ const before = i >= 0 ? s[i] : "";
699
+ let j = m.index + n.length;
700
+ while (j < s.length && /\s/.test(s[j]))
701
+ j++;
702
+ const after = j < s.length ? s[j] : "";
703
+ if (before === ".")
704
+ continue; // 成员名(`.join` / `.name`),不参与判定
705
+ if (after === "(") {
706
+ // 调用位:必须落在塑形白名单。字符过滤方法(`VALUE_STRING_METHODS`)
707
+ // **不算**证据 —— 见其定义处注释。
708
+ if (!PATH_SHAPER_METHODS.has(n) && !VALUE_CONVERTER_CALLS.has(n) && !shapers.has(n)) {
709
+ return false;
710
+ }
711
+ continue;
712
+ }
713
+ // 自由标识符:必须是自己的形参、语言级常量白名单,或**已确证的字面量常量 / path 别名**
714
+ if (params.has(n) || SHAPER_ALLOWED_FREE_IDENTS.has(n))
715
+ continue;
716
+ if (extraIdents && extraIdents.has(n))
717
+ continue;
718
+ return false;
719
+ }
720
+ return true;
721
+ }
722
+ /** 收集模块级(top-level)**字面量**常量名 —— helper 的 return 引用它们仍是纯塑形 */
723
+ function moduleLiteralConstNames(project) {
724
+ const out = new Set();
725
+ for (const sf of project.getSourceFiles()) {
726
+ for (const vd of sf.getVariableDeclarations()) {
727
+ // 只要模块作用域:VariableStatement 的父节点必须是 SourceFile
728
+ const stmt = vd.getVariableStatement();
729
+ if (!stmt || !ts_morph_1.Node.isSourceFile(stmt.getParent()))
730
+ continue;
731
+ const init = vd.getInitializer();
732
+ if (!init)
733
+ continue;
734
+ // 只收「字面量」—— 一旦允许任意表达式,helper 就能把别处的污点藏在常量后面
735
+ // (C4e 政策:看不见的一律不传播)
736
+ const isLiteral = ts_morph_1.Node.isStringLiteral(init) ||
737
+ ts_morph_1.Node.isNoSubstitutionTemplateLiteral(init) ||
738
+ ts_morph_1.Node.isNumericLiteral(init) ||
739
+ init.getKind() === ts.SyntaxKind.TrueKeyword ||
740
+ init.getKind() === ts.SyntaxKind.FalseKeyword;
741
+ if (isLiteral)
742
+ out.add(vd.getName());
743
+ }
744
+ }
745
+ return out;
746
+ }
747
+ /**
748
+ * C4e:`import * as p from "path"` / `import nodePath from "node:path"` /
749
+ * `import { join } from "path"` —— 这些局部名随项目各异,但**出处可确证**是
750
+ * path 模块,helper 的 return 引用它们仍是纯塑形。
751
+ * 只放行 path 模块本身(含 node: 前缀),不放行 fs/os —— 后者会被 helper 用来把
752
+ * IO 藏进 return(判据②只扫 sink 调用,覆盖不到「返回内嵌读写」这一类)。
753
+ */
754
+ function pathModuleAliases(project) {
755
+ const out = new Set(["path"]);
756
+ for (const sf of project.getSourceFiles()) {
757
+ for (const id of sf.getImportDeclarations()) {
758
+ const spec = id.getModuleSpecifierValue();
759
+ if (spec !== "path" && spec !== "node:path")
760
+ continue;
761
+ // 实测(ts-morph 本仓库版本):NamespaceImport 上既无 getName() 也无
762
+ // getNameNode(),只有 getText() —— 返回的就是别名本身。
763
+ const ns = id.getNamespaceImport(); // `import * as X`
764
+ if (ns)
765
+ out.add(ns.getText().replace(/[^\w$]/g, ""));
766
+ const def = id.getDefaultImport();
767
+ if (def)
768
+ out.add(def.getText());
769
+ for (const nb of id.getNamedImports())
770
+ out.add(nb.getName());
771
+ }
772
+ }
773
+ return out;
774
+ }
775
+ /** return 表达式列表:块体取 return 语句,箭头简洁体把整个体当作返回值 */
776
+ function returnExprsOf(body) {
777
+ if (!ts_morph_1.Node.isBlock(body))
778
+ return [body.getText()]; // 箭头简洁体
779
+ const rets = [];
780
+ for (const rs of body.getDescendantsOfKind(ts.SyntaxKind.ReturnStatement)) {
781
+ const e = rs.getExpression();
782
+ if (e)
783
+ rets.push(e.getText());
784
+ }
785
+ return rets;
786
+ }
787
+ /** 扫描全项目,收集「纯塑形」helper 的名字(有界两轮不动点) */
788
+ function pureShaperFunctionNames(project) {
789
+ const sinkRe = tsSinkCallRegex();
790
+ const cands = [];
791
+ /** 登记一个候选:三条判据 —— 有形参 / 有返回值 / 体内无 sink */
792
+ const consider = (name, paramNodes, declText, bodyNode) => {
793
+ const params = new Set(paramNodes.filter(Boolean));
794
+ if (params.size === 0)
795
+ return;
796
+ sinkRe.lastIndex = 0;
797
+ if (sinkRe.test(declText))
798
+ return; // ②
799
+ // 用 AST 取 return 表达式,而不是正则 —— 模板字面量里的 `${}` 会
800
+ // 让「遇到花括号就停」的朴素正则在 `path.join(a,b) + \`.${c}\`` 上截断。
801
+ const rets = returnExprsOf(bodyNode);
802
+ if (rets.length === 0)
803
+ return; // ①
804
+ cands.push({ name, params, rets });
805
+ };
806
+ for (const sf of project.getSourceFiles()) {
807
+ // (a) 函数声明
808
+ for (const fn of sf.getFunctions()) {
809
+ const name = fn.getName();
810
+ if (!name)
811
+ continue;
812
+ const body = fn.getBody();
813
+ if (!body)
814
+ continue; // 重载签名 / declare 只有签名
815
+ consider(name, fn.getParameters().map((p) => p.getName()), fn.getText(), body);
816
+ }
817
+ // (b) 变量承载的箭头 / 函数表达式 —— 现代 TS 里 helper 的主力写法
818
+ // `export const toPath = (n: string) => n + ".md";`
819
+ // sf.getFunctions() 收不到这些,2026-09-20 实测整片漏。
820
+ for (const vd of sf.getVariableDeclarations()) {
821
+ const init = vd.getInitializer();
822
+ if (!init)
823
+ continue;
824
+ const isArrow = ts_morph_1.Node.isArrowFunction(init);
825
+ const isFnExpr = ts_morph_1.Node.isFunctionExpression(init);
826
+ if (!isArrow && !isFnExpr)
827
+ continue;
828
+ const fnLike = init;
829
+ const body = fnLike.getBody();
830
+ if (!body)
831
+ continue;
832
+ consider(vd.getName(), fnLike.getParameters().map((p) => p.getName()), init.getText(), body);
833
+ }
834
+ }
835
+ // 形参之外允许出现的自由标识符 = 模块级字面量常量 ∪ 确证的 path 模块别名
836
+ const extraIdents = moduleLiteralConstNames(project);
837
+ for (const a of pathModuleAliases(project))
838
+ extraIdents.add(a);
839
+ const shapers = new Set();
840
+ for (let round = 0; round < 2; round++) {
841
+ let added = false;
842
+ for (const c of cands) {
843
+ if (shapers.has(c.name))
844
+ continue;
845
+ const ok = c.rets.every((e) => isPureShaperExpr(e, c.params, shapers, extraIdents));
846
+ if (!ok)
847
+ continue;
848
+ shapers.add(c.name);
849
+ added = true;
850
+ }
851
+ if (!added)
852
+ break;
853
+ }
854
+ return shapers;
571
855
  }
572
856
  const TS_FILE_SINK_NAMES = [
573
857
  "readFile", "readFileSync", "writeFile", "writeFileSync",
@@ -600,6 +884,29 @@ const UNTRUSTED_ROOTS = [
600
884
  expr: String.raw `\bparams\.arguments\b[.[]?`,
601
885
  why: "MCP CallToolRequest.params.arguments —— 工具调用实参,调用方可控(fr-012)",
602
886
  },
887
+ {
888
+ id: "document_parse",
889
+ expr: String.raw `\b(?:JSON|JSON5)\s*\.\s*parse\b|\b(?:YAML|yaml)\s*\.\s*(?:parse|load)\b|\b(?:parse|load|read)(?:Yaml|YAML|Json|JSON|Toml|TOML|Xml|XML|Csv|CSV)\s*\(`,
890
+ why: "外部文档的解析产物:值的**内容**由被解析的输入决定,而非由代码写死(fr-016 的 OpenAPI/AsyncAPI 描述文件经 parseYaml 进入后再 split 落盘)",
891
+ },
892
+ // ── 一类性质不同的根(务必读懂再改)──
893
+ // 上面两条是**传输面**:值跨进程边界进来,来源性质无歧义。这一条不是。
894
+ // `Object.keys/values/entries` 是语言运算,声明的是一件更弱的事:
895
+ // 「这些名字/值不是字面量,而是运行时数据结构的产物」。
896
+ // 之所以仍然列为根,理由与取舍:
897
+ // - 根的职责是【如实描述值的来源】,不是做是否有害的价值判断。
898
+ // 键名能否落到 fs sink、以及有没有被校验,那是前缀修剪与守卫证据的事
899
+ // (G1/G2)。压掉来源的代价是不可见的漏报,而留下来的误报是可见的。
900
+ // - 它是 fr-016 唯一本地可观测的入口:文档本体已由**上游**解析好作为形参
901
+ // 传入(`channels: Record<string, any>`),函数体内没有任何解析调用,
902
+ // 看得见的只有这次枚举。
903
+ // 代价:键名来自内部固定字典(如 `for (const l of Object.keys(locales))`)
904
+ // 也会被标记。若工具体现为噪声过大,应优先收窄**这里**,不要在守卫侧放宽。
905
+ {
906
+ id: "runtime_key_enum",
907
+ expr: String.raw `\bObject\s*\.\s*(?:keys|values|entries)\s*\(`,
908
+ why: "运行时枚举产物:键名/键值由数据结构内容决定,不是字面量(fr-016 的 Object.keys(channels) → channelName)",
909
+ },
603
910
  ];
604
911
  /** 所有根的来源片段,供 **无标志** 的布尔探测正则使用 */
605
912
  const UNTRUSTED_ROOT_SRC = UNTRUSTED_ROOTS.map((r) => `(?:${r.expr})`).join("|");
@@ -633,7 +940,7 @@ const TS_PATH_SHAPER_RE = /(?:path\s*\.\s*(?:posix|win32)\s*\.\s*|path\s*\.\s*)?
633
940
  /** 拼接形态:`+` 或反引号(模板字面量)—— 同样只是塑形 */
634
941
  const TS_CONCAT_RE = /\+|`/;
635
942
  /** 函数体内被不可信根污染的局部名(含解构、单跳赋值与 C4 塑形传播,深度 ≤3) */
636
- function collectTaintedNames(text) {
943
+ function collectTaintedNames(text, shapers) {
637
944
  const tainted = new Set();
638
945
  let m;
639
946
  const root = UNTRUSTED_ROOT_SRC;
@@ -651,21 +958,108 @@ function collectTaintedNames(text) {
651
958
  const bareAssign = new RegExp(`(?:^|[^\\w$.])([\\w$]+)\\s*=\\s*(?:await\\s+)?(?:${root})`, "g");
652
959
  while ((m = bareAssign.exec(text)) !== null)
653
960
  tainted.add(m[1]);
654
- taintedViaShaper(text, tainted);
655
- // 单跳传播(深度 ≤2)
656
- for (let depth = 0; depth < 2; depth++) {
657
- if (tainted.size === 0)
658
- break;
659
- const names = [...tainted].join("|");
660
- const hop = new RegExp(`(?:const|let|var)\\s+([\\w$]+)\\s*=\\s*\\b(?:${names})\\b`, "g");
661
- let added = false;
662
- while ((m = hop.exec(text)) !== null) {
663
- if (!tainted.has(m[1])) {
664
- tainted.add(m[1]);
665
- added = true;
961
+ // 枚举绑定:`for (const k of Object.keys(doc))` / `for (const [k, v] of Object.entries(doc))`
962
+ // fr-016 的污点就是这样进来的 —— 文档本体已由上游解析好作为形参传入,
963
+ // 函数体内没有赋值语句,只有这次枚举。少了这条,iterate-* 系列函数的
964
+ // 局部变量一个都收不到(此前 MISS 的一半原因在此)。
965
+ const bindFromEnum = (pattern) => {
966
+ const enumBind = new RegExp(`for\\s*\\(\\s*(?:const|let|var)\\s+([\\w$\\[\\],\\s]+?)\\s+(?:of|in)\\s+(?:await\\s+)?(?:${pattern})`, "g");
967
+ let hit = false;
968
+ let em;
969
+ while ((em = enumBind.exec(text)) !== null) {
970
+ const binding = em[1].replace(/[[\]]/g, " ");
971
+ for (const part of binding.split(",")) {
972
+ const name = part.trim().split(":")[0].trim();
973
+ if (/^[\w$]+$/.test(name) && !tainted.has(name)) {
974
+ tainted.add(name);
975
+ hit = true;
976
+ }
666
977
  }
667
978
  }
668
- if (!added)
979
+ return hit;
980
+ };
981
+ bindFromEnum(root);
982
+ // ── C4d(2026-09-20):高阶枚举方法的回调形参 ──
983
+ // `Object.keys(doc).forEach(k => …)` / `ks.map(k => …)` 与 for-of 语义等价
984
+ // (形参 = 被枚举到的元素),但此前只绑 for-of/for-in,这类整片漏。
985
+ // 实测(2026-09-20 探针):Object.keys/entries 后接 forEach/map 的 4 种
986
+ // 写法全部 MISS,而 for-in 其实一直是通的 —— 缺口在回调,不在 for-in。
987
+ //
988
+ // 方法表的取舍(务必读懂再扩):只收【枚举全部元素、且形参 = 元素本身】的
989
+ // forEach/map/flatMap/filter 四个。不收:
990
+ // - find/some/every —— 谓词,形参虽是元素但语义是「判定」,且常配合
991
+ // 白名单做守卫,盲目绑进去反而给守卫侧喂误报;
992
+ // - reduce —— 第一个形参是累加器不是元素,语义错位。
993
+ // 认不出就不传播,代价是漏报,不是误报。
994
+ //
995
+ // ── C4d-b(2026-09-20,同日第二次修正)──
996
+ // 初版正则以 `[,)]` 收尾,带来两件事:
997
+ // ① `forEach(handleOne)` —— 回调是【函数引用】而非内联函数,`handleOne` 被
998
+ // 当成形参绑进污点集合。探针 `Object.keys(doc).forEach(handle); … = handle`
999
+ // 实测误标:这是把回调名当元素,语义错误;
1000
+ // ② 只认 `(k) =>` 带括号的箭头,`k =>` / `function (k) {}` 两种常见写法漏。
1001
+ // 改法:分两条 —— 箭头分支形参后必须见 `=>`(括号可有可无),ES5 分支必须见
1002
+ // `function` 关键字。两者都要求【回调体是内联的】,函数引用形态自然被排除。
1003
+ const CALLBACK_ENUM_METHODS = "forEach|map|flatMap|filter";
1004
+ const bindFromCallback = (receiver) => {
1005
+ const callbackRe = (paramsTail) => new RegExp(`${receiver}\\s*\\.\\s*(?:${CALLBACK_ENUM_METHODS})\\s*\\(\\s*(?:async\\s+)?${paramsTail}`, "g");
1006
+ // 形参字符集要容得下 TS 的**类型标注**(`(s: any) =>` 是 TS 工程里的常态,
1007
+ // 漏了 `:` 会让整环不匹配 —— 2026-09-20 实测踩到),因此放行 `:.|<>`
1008
+ // (联合类型与泛型形参);`=>` 与 `{` 仍不在集合内,函数体不会被吃进来。
1009
+ const params = String.raw `\(?\s*([\w$\[\],.:\s<>|]+?)\s*\)?`;
1010
+ // 箭头回调:`(k) =>` / `k =>` / `async (k) =>` / `([k, v]) =>`
1011
+ const arrow = callbackRe(`${params}\\s*=>`);
1012
+ // ES5 回调:`function (k) { … }` —— 不是箭头,但形参同样是元素
1013
+ const fnCb = callbackRe(String.raw `function\s*\(?\s*([\w$\[\],.:\s<>|]+?)\s*\)`);
1014
+ let hit = false;
1015
+ for (const re of [arrow, fnCb]) {
1016
+ let cm;
1017
+ while ((cm = re.exec(text)) !== null) {
1018
+ const raw = cm[1].trim();
1019
+ // `[k, v]` 解构:两个都是元素,都绑;`k, i` 只取第一个(第二个是索引)
1020
+ const parts = raw.startsWith("[")
1021
+ ? raw.replace(/[[\]]/g, " ").split(",")
1022
+ : [raw.split(",")[0]];
1023
+ for (const part of parts) {
1024
+ const name = part.trim().split(":")[0].trim();
1025
+ if (/^[\w$]+$/.test(name) && !tainted.has(name)) {
1026
+ tainted.add(name);
1027
+ hit = true;
1028
+ }
1029
+ }
1030
+ }
1031
+ }
1032
+ return hit;
1033
+ };
1034
+ // 根形态:`Object.keys(doc).forEach(...)` —— root 正则只吃到开括号,
1035
+ // 这里补 `[^()]*\)` 吃掉实参列表与闭括号(`Object.keys` 的实参无嵌套)。
1036
+ bindFromCallback(`(?:${root})[^()]*\\)`);
1037
+ // ── 有界不动点(≤3 轮):塑形传播 / 聚合迭代 / 单跳赋值 ──
1038
+ // C4c:三轮里的第二轮才是关键 —— `const ks = Object.keys(o); for (const k of ks)`
1039
+ // 的被迭代对象是**已被污染的变量**,不再是根表达式。此前 enumBind 只按 root
1040
+ // 匹配,这种「先收集再迭代」的写法一根都收不到(fr-016 的 gather* 系列如此)。
1041
+ for (let depth = 0; depth < 3; depth++) {
1042
+ let grew = taintedViaShaper(text, tainted, shapers);
1043
+ if (tainted.size > 0) {
1044
+ const names = [...tainted].map(escapeRe).join("|");
1045
+ if (bindFromEnum(`\\b(?:${names})\\b`))
1046
+ grew = true;
1047
+ // C4d 变量形态:`const ks = Object.keys(doc); ks.forEach(k => …)`
1048
+ // C4d-b:接收者允许中间隔着成员 —— `doc.sections.forEach((s) => …)`。
1049
+ // doc/ks 已污时,它的成员同样是污点数据的组成部分,枚举出来的元素也是。
1050
+ // 末尾仍必须是四个枚举方法之一,`doc.length` 之类不会触发。
1051
+ if (bindFromCallback(`(?:^|[^\\w$.])(?:${names})(?:\\s*\\.\\s*[\\w$]+)*`))
1052
+ grew = true;
1053
+ const hop = new RegExp(`(?:const|let|var)\\s+([\\w$]+)\\s*=\\s*\\b(?:${names})\\b`, "g");
1054
+ let hm;
1055
+ while ((hm = hop.exec(text)) !== null) {
1056
+ if (!tainted.has(hm[1])) {
1057
+ tainted.add(hm[1]);
1058
+ grew = true;
1059
+ }
1060
+ }
1061
+ }
1062
+ if (!grew)
669
1063
  break;
670
1064
  }
671
1065
  return tainted;
@@ -678,7 +1072,13 @@ function collectTaintedNames(text) {
678
1072
  * `normalized = path.normalize(p)` → `savePath = path.join(normalized, f)`
679
1073
  * 这类链式构造能接上(fr-012 下载侧守卫块就是这个形状)。
680
1074
  */
681
- function taintedViaShaper(text, tainted) {
1075
+ function taintedViaShaper(text, tainted, shapers) {
1076
+ // C4b:项目自有「纯塑形」helper 的调用也算子(如 getFileNamePath(dir, name, ext))。
1077
+ // 见 pureShaperFunctionNames —— 只认三条判据全部成立的函数,认不出就不传播。
1078
+ const localShaperRe = shapers && shapers.size > 0
1079
+ ? new RegExp(`(?:^|[^\\w$.])(?:${[...shapers].map(escapeRe).join("|")})\\s*\\(`)
1080
+ : null;
1081
+ let grewAny = false;
682
1082
  for (let depth = 0; depth < 3; depth++) {
683
1083
  // 注意:这里**不能**以 `tainted.size === 0` 提前退出 —— 种子是根模式
684
1084
  // (taintPattern 恒定并入 UNTRUSTED_ROOT_SRC)。本轮这 4 条 known-gap
@@ -686,7 +1086,7 @@ function taintedViaShaper(text, tainted) {
686
1086
  // 加这道闸门等于整条 C4 不生效(2026-09-19 实测踩到)。
687
1087
  const taintRe = taintPattern(tainted);
688
1088
  if (!taintRe)
689
- return;
1089
+ return grewAny;
690
1090
  let added = false;
691
1091
  for (const line of text.split("\n")) {
692
1092
  const m = /^\s*(?:const|let|var)?\s*([\w$]+)\s*=\s*(?:await\s+)?(.+)$/.exec(line);
@@ -696,14 +1096,19 @@ function taintedViaShaper(text, tainted) {
696
1096
  const rhs = m[2];
697
1097
  if (tainted.has(name))
698
1098
  continue;
699
- if (taintRe.test(rhs) && (TS_PATH_SHAPER_RE.test(rhs) || TS_CONCAT_RE.test(rhs))) {
1099
+ const shaped = TS_PATH_SHAPER_RE.test(rhs) ||
1100
+ TS_CONCAT_RE.test(rhs) ||
1101
+ (localShaperRe !== null && localShaperRe.test(rhs));
1102
+ if (taintRe.test(rhs) && shaped) {
700
1103
  tainted.add(name);
701
1104
  added = true;
702
1105
  }
703
1106
  }
704
1107
  if (!added)
705
- return;
1108
+ return grewAny;
1109
+ grewAny = true;
706
1110
  }
1111
+ return grewAny;
707
1112
  }
708
1113
  function taintPattern(tainted) {
709
1114
  const parts = [UNTRUSTED_ROOT_SRC];
@@ -712,16 +1117,33 @@ function taintPattern(tainted) {
712
1117
  }
713
1118
  return new RegExp(parts.join("|"));
714
1119
  }
715
- /** 本函数体内:文件 sink 的实参窗口含 request 污点 → true */
716
- function hasTaintedSinkCall(text, tainted) {
1120
+ /**
1121
+ * 本函数体内:文件 sink 的实参窗口含 request 污点 → true。
1122
+ *
1123
+ * `sanitizedRe` 非空时(G2):实参里被净化过的表达式不再算污点 ——
1124
+ * 逐实参判定,只要还有一个实参含【未被净化】的污点就照旧标记。
1125
+ */
1126
+ function hasTaintedSinkCall(text, tainted, sanitizedRe) {
717
1127
  const taint = taintPattern(tainted);
718
1128
  if (!taint)
719
1129
  return false;
720
1130
  const sinkRe = tsSinkCallRegex();
721
1131
  let m;
722
1132
  while ((m = sinkRe.exec(text)) !== null) {
723
- if (taint.test(m[2] || ""))
724
- return true;
1133
+ const win = m[2] || "";
1134
+ if (!taint.test(win))
1135
+ continue;
1136
+ if (sanitizedRe) {
1137
+ const args = splitArgWindow(win);
1138
+ if (args.length > 0) {
1139
+ if (!args.some((a) => taint.test(a) && !sanitizedRe.test(a)))
1140
+ continue;
1141
+ }
1142
+ else if (sanitizedRe.test(win)) {
1143
+ continue;
1144
+ }
1145
+ }
1146
+ return true;
725
1147
  }
726
1148
  return false;
727
1149
  }
@@ -749,6 +1171,7 @@ function splitArgWindow(win) {
749
1171
  /** 项目方法名 → 方法体内流入文件 sink 的形参下标集合 */
750
1172
  function methodSinkParamMap(project, absRoot) {
751
1173
  const map = new Map();
1174
+ const bodies = [];
752
1175
  const register = (keyName, fullName, text, relPath, params) => {
753
1176
  const sinkRe = tsSinkCallRegex();
754
1177
  let sm;
@@ -780,7 +1203,9 @@ function methodSinkParamMap(project, absRoot) {
780
1203
  const params = m.getParameters().map((p) => p.getName());
781
1204
  if (params.length === 0)
782
1205
  continue;
783
- register(m.getName(), `${cn}.${m.getName()}`, m.getText(), relPath, params);
1206
+ const body = { keyName: m.getName(), fullName: `${cn}.${m.getName()}`, relPath, params, text: m.getText() };
1207
+ bodies.push(body);
1208
+ register(body.keyName, body.fullName, body.text, relPath, params);
784
1209
  }
785
1210
  }
786
1211
  // ── 污点数据流试点(2026-09-18)──
@@ -795,9 +1220,64 @@ function methodSinkParamMap(project, absRoot) {
795
1220
  const params = fn.getParameters().map((p) => p.getName());
796
1221
  if (params.length === 0)
797
1222
  continue;
798
- register(fnName, fnName, fn.getText(), relPath, params);
1223
+ const body = { keyName: fnName, fullName: fnName, relPath, params, text: fn.getText() };
1224
+ bodies.push(body);
1225
+ register(body.keyName, body.fullName, body.text, relPath, params);
799
1226
  }
800
1227
  }
1228
+ // ── sink 形参继承(2026-09-19,fr-016)──
1229
+ // 真实工程落盘几乎必过一层自有封装,只做「形参 → 本函数体内 fs sink」的
1230
+ // 一跳登记,等于把整层 wrapper 排除在外:
1231
+ // writeToFileByExtension(data, filePath) → writeJson/writeYaml(d, filePath) → fs.writeFileSync(filePath)
1232
+ // 于是即使上游 taint 已识别,`writeToFileByExtension(data, taintedPath)`
1233
+ // 也查不到任何记录 —— fr-016 的流量断了 sink 侧这一环,与来源侧的根缺口
1234
+ // 是**两个独立缺陷**,必须两个都补才能让真实语料动起来。
1235
+ //
1236
+ // 规矩与既有 register 完全一致、只是再推一层:自己的形参被传进已登记函数的
1237
+ // sink 位置 ⇒ 继承该 sink 位置。有界迭代(≤3 轮)保证收敛。
1238
+ //
1239
+ // 注意这不是 C4b。C4b 是**值侧**( helper 会不会改变污点性质);
1240
+ // 这里是** sink 侧**( wrapper 会不会真的写到文件)。两侧对称,缺一不可。
1241
+ // 注意用 new RegExp(String) 而非正则字面量:本文件同类表达式(directCallRe 等)
1242
+ // 都按这套写法,写成字面量时 `\\w` 会被当成「反斜杠 + w」,一个调用都匹配不到。
1243
+ const wrapperCallRe = new RegExp(`(?:^|[^\\w$.])([\\w$]+)\\s*\\(([\\s\\S]{0,250}?)\\)`, "g");
1244
+ for (let round = 0; round < 3; round++) {
1245
+ let grew = false;
1246
+ for (const b of bodies) {
1247
+ wrapperCallRe.lastIndex = 0;
1248
+ let cm;
1249
+ while ((cm = wrapperCallRe.exec(b.text)) !== null) {
1250
+ const callee = map.get(cm[1]);
1251
+ if (!callee || callee.idxs.size === 0)
1252
+ continue;
1253
+ const args = splitArgWindow(cm[2] || "");
1254
+ if (args.length === 0)
1255
+ continue;
1256
+ b.params.forEach((pname, idx) => {
1257
+ if (!pname)
1258
+ return;
1259
+ const pre = new RegExp(`\\b${escapeRe(pname)}\\b`);
1260
+ // 形参出现在被调用方的【sink 形参位】上,才算继承了 sink
1261
+ const inherited = [...callee.idxs].some((i) => i < args.length && pre.test(args[i]));
1262
+ if (!inherited)
1263
+ return;
1264
+ if (!map.has(b.keyName)) {
1265
+ map.set(b.keyName, { idxs: new Set(), entries: [] });
1266
+ }
1267
+ const rec = map.get(b.keyName);
1268
+ if (rec.idxs.has(idx))
1269
+ return;
1270
+ rec.idxs.add(idx);
1271
+ grew = true;
1272
+ if (!rec.entries.some((e) => e.name === b.fullName)) {
1273
+ rec.entries.push({ name: b.fullName, file: b.relPath });
1274
+ }
1275
+ });
1276
+ }
1277
+ }
1278
+ if (!grew)
1279
+ break;
1280
+ }
801
1281
  return map;
802
1282
  }
803
1283
  // ═══════════════════════════════════════════════════════════════
@@ -833,15 +1313,19 @@ function collectUrlParamNames(params) {
833
1313
  * 项目方法体内的文件 sink(跨函数一跳,onMethodHit 回调登记方法条目)
834
1314
  * - SSRF:URL 形参/request 污点 → HTTP fetch sink,无守卫词汇
835
1315
  */
836
- function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit, guardFns) {
1316
+ function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit, guardFns, directGuardFns, shapers) {
837
1317
  const markers = [];
838
1318
  // ── 路径穿越 ──
839
1319
  // G1(2026-09-19):有流还不够,必须【没有校验证据】才标记——
840
1320
  // 与 SSRF 侧「无 SSRF_GUARD_EVIDENCE 才标记」对齐。
841
1321
  // 未传 guardFns(旧调用点/单测)时按「不做校验识别」的旧语义处理。
842
1322
  if (hasRequestRootedExpr(text)) {
843
- const tainted = collectTaintedNames(text);
1323
+ const tainted = collectTaintedNames(text, shapers);
844
1324
  const selfGuarded = guardFns ? hasPathGuardEvidence(text) !== null : false;
1325
+ // G2:调用点抑制 —— 被传进「自身含校验证据的函数」的表达式视为已净化。
1326
+ // 与 selfGuarded(函数级、按词形)互补:这条按【被调用方的实际证据】定案,
1327
+ // 因此认得出 assertTemplateName 这类不含路径语义后缀的自定义校验函数。
1328
+ const sanitizedRe = collectSanitizedExprs(text, directGuardFns);
845
1329
  // C5(2026-09-19):不可信根可以直接写在 sink 实参里,不需要中间变量。
846
1330
  // 此前外层要求 collectTaintedNames 非空,于是
847
1331
  // `fs.readFileSync("/data/" + req.params.name)` —— 真实工程里最常见的形态
@@ -849,7 +1333,7 @@ function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit, guardFns)
849
1333
  // SSRF 侧从来不是这样(它把 UNTRUSTED_ROOT_SRC 并进 taint 模式去匹配实参窗口),
850
1334
  // 这是同一条数据流上的又一处口径不一致。
851
1335
  if (!selfGuarded) {
852
- if (hasTaintedSinkCall(text, tainted)) {
1336
+ if (hasTaintedSinkCall(text, tainted, sanitizedRe)) {
853
1337
  markers.push("__progmune_path_traversal__");
854
1338
  }
855
1339
  else {
@@ -872,7 +1356,7 @@ function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit, guardFns)
872
1356
  if (guardFns && guardFns.has(m[1]))
873
1357
  continue;
874
1358
  const args = splitArgWindow(m[2] || "");
875
- const hit = args.some((arg, i) => rec.idxs.has(i) && taint.test(arg));
1359
+ const hit = args.some((arg, i) => rec.idxs.has(i) && taint.test(arg) && !(sanitizedRe && sanitizedRe.test(arg)));
876
1360
  if (hit) {
877
1361
  markers.push("__progmune_path_traversal__");
878
1362
  onMethodHit(rec);
@@ -887,7 +1371,7 @@ function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit, guardFns)
887
1371
  TS_HTTP_FETCH_SINK.lastIndex = 0;
888
1372
  if (TS_HTTP_FETCH_SINK.test(text) && !SSRF_GUARD_EVIDENCE.test(text)) {
889
1373
  const urlParams = paramNames.filter((n) => URL_PARAM_NAME.test(n));
890
- const reqTainted = collectTaintedNames(text);
1374
+ const reqTainted = collectTaintedNames(text, shapers);
891
1375
  const taintParts = [];
892
1376
  if (urlParams.length > 0)
893
1377
  taintParts.push(`\\b(?:${urlParams.join("|")})\\b`);
@@ -990,7 +1474,14 @@ function _extractSingleProject(absRoot, tsconfigPath) {
990
1474
  // pendingMethodMarks = 跨函数命中待标记的方法条目(主循环后统一应用)
991
1475
  const sinkParams = methodSinkParamMap(project, absRoot);
992
1476
  // G1(2026-09-19):路径校验识别。自身含校验证据的函数名 + 向调用方传播一跳。
993
- const guardFns = pathGuardFunctionNames(project);
1477
+ // direct = tier-0(自身含校验证据);all = 再向调用方传播一跳后的集合。
1478
+ // G2 只用 direct —— 传播得到的名字是「被推断为守卫」,拿它做抑制会把
1479
+ // 推断误差直接放大成误报消除。
1480
+ const { all: guardFns, direct: directGuardFns } = pathGuardFunctionNames(project);
1481
+ // C4b:项目自有「纯塑形」helper —— 让污点能穿过 getFileNamePath(...) 这类
1482
+ // 自建封装。与 guardFns 方向相反:那边是压掉流,这边是接通流,
1483
+ // 所以它的判据更严(三条全中才认),认不出就留着漏报。
1484
+ const shapers = pureShaperFunctionNames(project);
994
1485
  const pendingMethodMarks = new Set();
995
1486
  const onMethodHit = (rec) => {
996
1487
  for (const me of rec.entries)
@@ -1008,7 +1499,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
1008
1499
  const fParams = f.getParameters();
1009
1500
  const fText = f.getText();
1010
1501
  const fCalls = extractDirectCalls(f, fText);
1011
- fCalls.push(...computeMarkerCalls(fText, fParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns));
1502
+ fCalls.push(...computeMarkerCalls(fText, fParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns, shapers));
1012
1503
  funcs.push({
1013
1504
  name,
1014
1505
  params: fParams.map(p => ({
@@ -1038,7 +1529,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
1038
1529
  const initParams = init.getParameters();
1039
1530
  const initText = init.getText();
1040
1531
  const initCalls = extractDirectCalls(init, initText);
1041
- initCalls.push(...computeMarkerCalls(initText, initParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns));
1532
+ initCalls.push(...computeMarkerCalls(initText, initParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns, shapers));
1042
1533
  funcs.push({
1043
1534
  name,
1044
1535
  params: initParams.map(p => ({
@@ -1064,7 +1555,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
1064
1555
  const argParams = arg.getParameters();
1065
1556
  const argText = arg.getText();
1066
1557
  const argCalls = extractDirectCalls(arg, argText);
1067
- argCalls.push(...computeMarkerCalls(argText, argParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns));
1558
+ argCalls.push(...computeMarkerCalls(argText, argParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns, shapers));
1068
1559
  funcs.push({
1069
1560
  name,
1070
1561
  params: argParams.map(p => ({
@@ -1098,7 +1589,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
1098
1589
  continue;
1099
1590
  const mParams = m.getParameters();
1100
1591
  const mText = m.getText();
1101
- const mCalls = computeMarkerCalls(mText, mParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns);
1592
+ const mCalls = computeMarkerCalls(mText, mParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns, shapers);
1102
1593
  funcs.push({
1103
1594
  name: `${cn}.${mn}`,
1104
1595
  params: mParams.map((p) => ({ name: p.getName(), type: getParamType(p), typeDetail: getParamTypeDetail(p) })),