progmune-runtime 3.7.37 → 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.
@@ -622,6 +622,372 @@ function collectSanitizedExprs(text, directGuardFns) {
622
622
  // 但 `args.name` 能匹配 `path.join(DIR, args.name)` 里的 args.name。
623
623
  return new RegExp([...exprs].map((e) => `(?:^|[^\\w$.])${escapeRe(e)}\\b`).join("|"));
624
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
+ /** 按顶层逗号切分实参串(跳过字符串与已配平的括号,后者由调用方保证不含嵌套) */
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
+ }
886
+ /** return 表达式列表:块体取 return 语句,箭头简洁体把整个体当作返回值 */
887
+ function returnExprsOf(body) {
888
+ if (!ts_morph_1.Node.isBlock(body))
889
+ return [body.getText()]; // 箭头简洁体
890
+ const rets = [];
891
+ for (const rs of body.getDescendantsOfKind(ts.SyntaxKind.ReturnStatement)) {
892
+ const e = rs.getExpression();
893
+ if (e)
894
+ rets.push(e.getText());
895
+ }
896
+ return rets;
897
+ }
898
+ /** 扫描全项目,收集「纯塑形」helper 的名字 + 返回值依赖(有界两轮不动点) */
899
+ function pureShaperFunctionNames(project) {
900
+ const sinkRe = tsSinkCallRegex();
901
+ const cands = [];
902
+ /** 登记一个候选:三条判据 —— 有形参 / 有返回值 / 体内无 sink */
903
+ const consider = (name, paramNodes, declText, bodyNode) => {
904
+ const paramList = paramNodes.filter(Boolean);
905
+ const params = new Set(paramList);
906
+ if (params.size === 0)
907
+ return;
908
+ sinkRe.lastIndex = 0;
909
+ if (sinkRe.test(declText))
910
+ return; // ②
911
+ // 用 AST 取 return 表达式,而不是正则 —— 模板字面量里的 `${}` 会
912
+ // 让「遇到花括号就停」的朴素正则在 `path.join(a,b) + \`.${c}\`` 上截断。
913
+ const rets = returnExprsOf(bodyNode);
914
+ if (rets.length === 0)
915
+ return; // ①
916
+ if (cands.some((c) => c.name === name))
917
+ return; // 同名重复登记:先到先得
918
+ cands.push({ name, params, paramList, rets });
919
+ };
920
+ for (const sf of project.getSourceFiles()) {
921
+ // (a) 函数声明
922
+ for (const fn of sf.getFunctions()) {
923
+ const name = fn.getName();
924
+ if (!name)
925
+ continue;
926
+ const body = fn.getBody();
927
+ if (!body)
928
+ continue; // 重载签名 / declare 只有签名
929
+ consider(name, fn.getParameters().map((p) => p.getName()), fn.getText(), body);
930
+ }
931
+ // (b) 变量承载的箭头 / 函数表达式 —— 现代 TS 里 helper 的主力写法
932
+ // `export const toPath = (n: string) => n + ".md";`
933
+ // sf.getFunctions() 收不到这些,2026-09-20 实测整片漏。
934
+ for (const vd of sf.getVariableDeclarations()) {
935
+ const init = vd.getInitializer();
936
+ if (!init)
937
+ continue;
938
+ const isArrow = ts_morph_1.Node.isArrowFunction(init);
939
+ const isFnExpr = ts_morph_1.Node.isFunctionExpression(init);
940
+ if (!isArrow && !isFnExpr)
941
+ continue;
942
+ const fnLike = init;
943
+ const body = fnLike.getBody();
944
+ if (!body)
945
+ continue;
946
+ consider(vd.getName(), fnLike.getParameters().map((p) => p.getName()), init.getText(), body);
947
+ }
948
+ }
949
+ // 形参之外允许出现的自由标识符 = 模块级字面量常量 ∪ 确证的 path 模块别名
950
+ const extraIdents = moduleLiteralConstNames(project);
951
+ for (const a of pathModuleAliases(project))
952
+ extraIdents.add(a);
953
+ const names = new Set();
954
+ const info = new Map();
955
+ for (let round = 0; round < 2; round++) {
956
+ let added = false;
957
+ for (const c of cands) {
958
+ if (names.has(c.name))
959
+ continue;
960
+ const ok = c.rets.every((e) => isPureShaperExpr(e, c.params, names, extraIdents));
961
+ if (!ok)
962
+ continue;
963
+ names.add(c.name);
964
+ info.set(c.name, { params: c.paramList, deps: returnDeps(c.rets, c.paramList, info) });
965
+ added = true;
966
+ }
967
+ if (!added)
968
+ break;
969
+ }
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 };
990
+ }
625
991
  const TS_FILE_SINK_NAMES = [
626
992
  "readFile", "readFileSync", "writeFile", "writeFileSync",
627
993
  "appendFile", "appendFileSync", "unlink", "unlinkSync", "rm", "rmSync",
@@ -653,6 +1019,29 @@ const UNTRUSTED_ROOTS = [
653
1019
  expr: String.raw `\bparams\.arguments\b[.[]?`,
654
1020
  why: "MCP CallToolRequest.params.arguments —— 工具调用实参,调用方可控(fr-012)",
655
1021
  },
1022
+ {
1023
+ id: "document_parse",
1024
+ 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*\(`,
1025
+ why: "外部文档的解析产物:值的**内容**由被解析的输入决定,而非由代码写死(fr-016 的 OpenAPI/AsyncAPI 描述文件经 parseYaml 进入后再 split 落盘)",
1026
+ },
1027
+ // ── 一类性质不同的根(务必读懂再改)──
1028
+ // 上面两条是**传输面**:值跨进程边界进来,来源性质无歧义。这一条不是。
1029
+ // `Object.keys/values/entries` 是语言运算,声明的是一件更弱的事:
1030
+ // 「这些名字/值不是字面量,而是运行时数据结构的产物」。
1031
+ // 之所以仍然列为根,理由与取舍:
1032
+ // - 根的职责是【如实描述值的来源】,不是做是否有害的价值判断。
1033
+ // 键名能否落到 fs sink、以及有没有被校验,那是前缀修剪与守卫证据的事
1034
+ // (G1/G2)。压掉来源的代价是不可见的漏报,而留下来的误报是可见的。
1035
+ // - 它是 fr-016 唯一本地可观测的入口:文档本体已由**上游**解析好作为形参
1036
+ // 传入(`channels: Record<string, any>`),函数体内没有任何解析调用,
1037
+ // 看得见的只有这次枚举。
1038
+ // 代价:键名来自内部固定字典(如 `for (const l of Object.keys(locales))`)
1039
+ // 也会被标记。若工具体现为噪声过大,应优先收窄**这里**,不要在守卫侧放宽。
1040
+ {
1041
+ id: "runtime_key_enum",
1042
+ expr: String.raw `\bObject\s*\.\s*(?:keys|values|entries)\s*\(`,
1043
+ why: "运行时枚举产物:键名/键值由数据结构内容决定,不是字面量(fr-016 的 Object.keys(channels) → channelName)",
1044
+ },
656
1045
  ];
657
1046
  /** 所有根的来源片段,供 **无标志** 的布尔探测正则使用 */
658
1047
  const UNTRUSTED_ROOT_SRC = UNTRUSTED_ROOTS.map((r) => `(?:${r.expr})`).join("|");
@@ -686,7 +1075,7 @@ const TS_PATH_SHAPER_RE = /(?:path\s*\.\s*(?:posix|win32)\s*\.\s*|path\s*\.\s*)?
686
1075
  /** 拼接形态:`+` 或反引号(模板字面量)—— 同样只是塑形 */
687
1076
  const TS_CONCAT_RE = /\+|`/;
688
1077
  /** 函数体内被不可信根污染的局部名(含解构、单跳赋值与 C4 塑形传播,深度 ≤3) */
689
- function collectTaintedNames(text) {
1078
+ function collectTaintedNames(text, shapers) {
690
1079
  const tainted = new Set();
691
1080
  let m;
692
1081
  const root = UNTRUSTED_ROOT_SRC;
@@ -704,21 +1093,108 @@ function collectTaintedNames(text) {
704
1093
  const bareAssign = new RegExp(`(?:^|[^\\w$.])([\\w$]+)\\s*=\\s*(?:await\\s+)?(?:${root})`, "g");
705
1094
  while ((m = bareAssign.exec(text)) !== null)
706
1095
  tainted.add(m[1]);
707
- taintedViaShaper(text, tainted);
708
- // 单跳传播(深度 ≤2)
709
- for (let depth = 0; depth < 2; depth++) {
710
- if (tainted.size === 0)
711
- break;
712
- const names = [...tainted].join("|");
713
- const hop = new RegExp(`(?:const|let|var)\\s+([\\w$]+)\\s*=\\s*\\b(?:${names})\\b`, "g");
714
- let added = false;
715
- while ((m = hop.exec(text)) !== null) {
716
- if (!tainted.has(m[1])) {
717
- tainted.add(m[1]);
718
- added = true;
1096
+ // 枚举绑定:`for (const k of Object.keys(doc))` / `for (const [k, v] of Object.entries(doc))`
1097
+ // fr-016 的污点就是这样进来的 —— 文档本体已由上游解析好作为形参传入,
1098
+ // 函数体内没有赋值语句,只有这次枚举。少了这条,iterate-* 系列函数的
1099
+ // 局部变量一个都收不到(此前 MISS 的一半原因在此)。
1100
+ const bindFromEnum = (pattern) => {
1101
+ const enumBind = new RegExp(`for\\s*\\(\\s*(?:const|let|var)\\s+([\\w$\\[\\],\\s]+?)\\s+(?:of|in)\\s+(?:await\\s+)?(?:${pattern})`, "g");
1102
+ let hit = false;
1103
+ let em;
1104
+ while ((em = enumBind.exec(text)) !== null) {
1105
+ const binding = em[1].replace(/[[\]]/g, " ");
1106
+ for (const part of binding.split(",")) {
1107
+ const name = part.trim().split(":")[0].trim();
1108
+ if (/^[\w$]+$/.test(name) && !tainted.has(name)) {
1109
+ tainted.add(name);
1110
+ hit = true;
1111
+ }
719
1112
  }
720
1113
  }
721
- if (!added)
1114
+ return hit;
1115
+ };
1116
+ bindFromEnum(root);
1117
+ // ── C4d(2026-09-20):高阶枚举方法的回调形参 ──
1118
+ // `Object.keys(doc).forEach(k => …)` / `ks.map(k => …)` 与 for-of 语义等价
1119
+ // (形参 = 被枚举到的元素),但此前只绑 for-of/for-in,这类整片漏。
1120
+ // 实测(2026-09-20 探针):Object.keys/entries 后接 forEach/map 的 4 种
1121
+ // 写法全部 MISS,而 for-in 其实一直是通的 —— 缺口在回调,不在 for-in。
1122
+ //
1123
+ // 方法表的取舍(务必读懂再扩):只收【枚举全部元素、且形参 = 元素本身】的
1124
+ // forEach/map/flatMap/filter 四个。不收:
1125
+ // - find/some/every —— 谓词,形参虽是元素但语义是「判定」,且常配合
1126
+ // 白名单做守卫,盲目绑进去反而给守卫侧喂误报;
1127
+ // - reduce —— 第一个形参是累加器不是元素,语义错位。
1128
+ // 认不出就不传播,代价是漏报,不是误报。
1129
+ //
1130
+ // ── C4d-b(2026-09-20,同日第二次修正)──
1131
+ // 初版正则以 `[,)]` 收尾,带来两件事:
1132
+ // ① `forEach(handleOne)` —— 回调是【函数引用】而非内联函数,`handleOne` 被
1133
+ // 当成形参绑进污点集合。探针 `Object.keys(doc).forEach(handle); … = handle`
1134
+ // 实测误标:这是把回调名当元素,语义错误;
1135
+ // ② 只认 `(k) =>` 带括号的箭头,`k =>` / `function (k) {}` 两种常见写法漏。
1136
+ // 改法:分两条 —— 箭头分支形参后必须见 `=>`(括号可有可无),ES5 分支必须见
1137
+ // `function` 关键字。两者都要求【回调体是内联的】,函数引用形态自然被排除。
1138
+ const CALLBACK_ENUM_METHODS = "forEach|map|flatMap|filter";
1139
+ const bindFromCallback = (receiver) => {
1140
+ const callbackRe = (paramsTail) => new RegExp(`${receiver}\\s*\\.\\s*(?:${CALLBACK_ENUM_METHODS})\\s*\\(\\s*(?:async\\s+)?${paramsTail}`, "g");
1141
+ // 形参字符集要容得下 TS 的**类型标注**(`(s: any) =>` 是 TS 工程里的常态,
1142
+ // 漏了 `:` 会让整环不匹配 —— 2026-09-20 实测踩到),因此放行 `:.|<>`
1143
+ // (联合类型与泛型形参);`=>` 与 `{` 仍不在集合内,函数体不会被吃进来。
1144
+ const params = String.raw `\(?\s*([\w$\[\],.:\s<>|]+?)\s*\)?`;
1145
+ // 箭头回调:`(k) =>` / `k =>` / `async (k) =>` / `([k, v]) =>`
1146
+ const arrow = callbackRe(`${params}\\s*=>`);
1147
+ // ES5 回调:`function (k) { … }` —— 不是箭头,但形参同样是元素
1148
+ const fnCb = callbackRe(String.raw `function\s*\(?\s*([\w$\[\],.:\s<>|]+?)\s*\)`);
1149
+ let hit = false;
1150
+ for (const re of [arrow, fnCb]) {
1151
+ let cm;
1152
+ while ((cm = re.exec(text)) !== null) {
1153
+ const raw = cm[1].trim();
1154
+ // `[k, v]` 解构:两个都是元素,都绑;`k, i` 只取第一个(第二个是索引)
1155
+ const parts = raw.startsWith("[")
1156
+ ? raw.replace(/[[\]]/g, " ").split(",")
1157
+ : [raw.split(",")[0]];
1158
+ for (const part of parts) {
1159
+ const name = part.trim().split(":")[0].trim();
1160
+ if (/^[\w$]+$/.test(name) && !tainted.has(name)) {
1161
+ tainted.add(name);
1162
+ hit = true;
1163
+ }
1164
+ }
1165
+ }
1166
+ }
1167
+ return hit;
1168
+ };
1169
+ // 根形态:`Object.keys(doc).forEach(...)` —— root 正则只吃到开括号,
1170
+ // 这里补 `[^()]*\)` 吃掉实参列表与闭括号(`Object.keys` 的实参无嵌套)。
1171
+ bindFromCallback(`(?:${root})[^()]*\\)`);
1172
+ // ── 有界不动点(≤3 轮):塑形传播 / 聚合迭代 / 单跳赋值 ──
1173
+ // C4c:三轮里的第二轮才是关键 —— `const ks = Object.keys(o); for (const k of ks)`
1174
+ // 的被迭代对象是**已被污染的变量**,不再是根表达式。此前 enumBind 只按 root
1175
+ // 匹配,这种「先收集再迭代」的写法一根都收不到(fr-016 的 gather* 系列如此)。
1176
+ for (let depth = 0; depth < 3; depth++) {
1177
+ let grew = taintedViaShaper(text, tainted, shapers);
1178
+ if (tainted.size > 0) {
1179
+ const names = [...tainted].map(escapeRe).join("|");
1180
+ if (bindFromEnum(`\\b(?:${names})\\b`))
1181
+ grew = true;
1182
+ // C4d 变量形态:`const ks = Object.keys(doc); ks.forEach(k => …)`
1183
+ // C4d-b:接收者允许中间隔着成员 —— `doc.sections.forEach((s) => …)`。
1184
+ // doc/ks 已污时,它的成员同样是污点数据的组成部分,枚举出来的元素也是。
1185
+ // 末尾仍必须是四个枚举方法之一,`doc.length` 之类不会触发。
1186
+ if (bindFromCallback(`(?:^|[^\\w$.])(?:${names})(?:\\s*\\.\\s*[\\w$]+)*`))
1187
+ grew = true;
1188
+ const hop = new RegExp(`(?:const|let|var)\\s+([\\w$]+)\\s*=\\s*\\b(?:${names})\\b`, "g");
1189
+ let hm;
1190
+ while ((hm = hop.exec(text)) !== null) {
1191
+ if (!tainted.has(hm[1])) {
1192
+ tainted.add(hm[1]);
1193
+ grew = true;
1194
+ }
1195
+ }
1196
+ }
1197
+ if (!grew)
722
1198
  break;
723
1199
  }
724
1200
  return tainted;
@@ -731,7 +1207,15 @@ function collectTaintedNames(text) {
731
1207
  * `normalized = path.normalize(p)` → `savePath = path.join(normalized, f)`
732
1208
  * 这类链式构造能接上(fr-012 下载侧守卫块就是这个形状)。
733
1209
  */
734
- function taintedViaShaper(text, tainted) {
1210
+ function taintedViaShaper(text, tainted, shapers) {
1211
+ // C4b:项目自有「纯塑形」helper 的调用也算子(如 getFileNamePath(dir, name, ext))。
1212
+ // 见 pureShaperFunctionNames —— 只认三条判据全部成立的函数,认不出就不传播。
1213
+ const localShaperRe = shapers && shapers.names.size > 0
1214
+ ? new RegExp(`(?:^|[^\\w$.])(?:${[...shapers.names].map(escapeRe).join("|")})\\s*\\(`)
1215
+ : null;
1216
+ // C4f:helper 调用里【返回值不依赖的实参】不构成污点证据。
1217
+ const shaperInfo = shapers?.info;
1218
+ let grewAny = false;
735
1219
  for (let depth = 0; depth < 3; depth++) {
736
1220
  // 注意:这里**不能**以 `tainted.size === 0` 提前退出 —— 种子是根模式
737
1221
  // (taintPattern 恒定并入 UNTRUSTED_ROOT_SRC)。本轮这 4 条 known-gap
@@ -739,7 +1223,7 @@ function taintedViaShaper(text, tainted) {
739
1223
  // 加这道闸门等于整条 C4 不生效(2026-09-19 实测踩到)。
740
1224
  const taintRe = taintPattern(tainted);
741
1225
  if (!taintRe)
742
- return;
1226
+ return grewAny;
743
1227
  let added = false;
744
1228
  for (const line of text.split("\n")) {
745
1229
  const m = /^\s*(?:const|let|var)?\s*([\w$]+)\s*=\s*(?:await\s+)?(.+)$/.exec(line);
@@ -749,14 +1233,23 @@ function taintedViaShaper(text, tainted) {
749
1233
  const rhs = m[2];
750
1234
  if (tainted.has(name))
751
1235
  continue;
752
- if (taintRe.test(rhs) && (TS_PATH_SHAPER_RE.test(rhs) || TS_CONCAT_RE.test(rhs))) {
1236
+ const shaped = TS_PATH_SHAPER_RE.test(rhs) ||
1237
+ TS_CONCAT_RE.test(rhs) ||
1238
+ (localShaperRe !== null && localShaperRe.test(rhs));
1239
+ // 证据看的是【抹掉不被依赖的实参之后】的 rhs:`discard(k)` 里 k 不流出,
1240
+ // 那条支路就不该算证据。`shaped` 仍看原式 —— helper 是不是塑形,与它的
1241
+ // 返回值依不依赖实参是两件事。
1242
+ const evidence = shaperInfo ? maskNonDependentArgs(rhs, shaperInfo) : rhs;
1243
+ if (taintRe.test(evidence) && shaped) {
753
1244
  tainted.add(name);
754
1245
  added = true;
755
1246
  }
756
1247
  }
757
1248
  if (!added)
758
- return;
1249
+ return grewAny;
1250
+ grewAny = true;
759
1251
  }
1252
+ return grewAny;
760
1253
  }
761
1254
  function taintPattern(tainted) {
762
1255
  const parts = [UNTRUSTED_ROOT_SRC];
@@ -819,6 +1312,7 @@ function splitArgWindow(win) {
819
1312
  /** 项目方法名 → 方法体内流入文件 sink 的形参下标集合 */
820
1313
  function methodSinkParamMap(project, absRoot) {
821
1314
  const map = new Map();
1315
+ const bodies = [];
822
1316
  const register = (keyName, fullName, text, relPath, params) => {
823
1317
  const sinkRe = tsSinkCallRegex();
824
1318
  let sm;
@@ -850,7 +1344,9 @@ function methodSinkParamMap(project, absRoot) {
850
1344
  const params = m.getParameters().map((p) => p.getName());
851
1345
  if (params.length === 0)
852
1346
  continue;
853
- register(m.getName(), `${cn}.${m.getName()}`, m.getText(), relPath, params);
1347
+ const body = { keyName: m.getName(), fullName: `${cn}.${m.getName()}`, relPath, params, text: m.getText() };
1348
+ bodies.push(body);
1349
+ register(body.keyName, body.fullName, body.text, relPath, params);
854
1350
  }
855
1351
  }
856
1352
  // ── 污点数据流试点(2026-09-18)──
@@ -865,9 +1361,64 @@ function methodSinkParamMap(project, absRoot) {
865
1361
  const params = fn.getParameters().map((p) => p.getName());
866
1362
  if (params.length === 0)
867
1363
  continue;
868
- register(fnName, fnName, fn.getText(), relPath, params);
1364
+ const body = { keyName: fnName, fullName: fnName, relPath, params, text: fn.getText() };
1365
+ bodies.push(body);
1366
+ register(body.keyName, body.fullName, body.text, relPath, params);
869
1367
  }
870
1368
  }
1369
+ // ── sink 形参继承(2026-09-19,fr-016)──
1370
+ // 真实工程落盘几乎必过一层自有封装,只做「形参 → 本函数体内 fs sink」的
1371
+ // 一跳登记,等于把整层 wrapper 排除在外:
1372
+ // writeToFileByExtension(data, filePath) → writeJson/writeYaml(d, filePath) → fs.writeFileSync(filePath)
1373
+ // 于是即使上游 taint 已识别,`writeToFileByExtension(data, taintedPath)`
1374
+ // 也查不到任何记录 —— fr-016 的流量断了 sink 侧这一环,与来源侧的根缺口
1375
+ // 是**两个独立缺陷**,必须两个都补才能让真实语料动起来。
1376
+ //
1377
+ // 规矩与既有 register 完全一致、只是再推一层:自己的形参被传进已登记函数的
1378
+ // sink 位置 ⇒ 继承该 sink 位置。有界迭代(≤3 轮)保证收敛。
1379
+ //
1380
+ // 注意这不是 C4b。C4b 是**值侧**( helper 会不会改变污点性质);
1381
+ // 这里是** sink 侧**( wrapper 会不会真的写到文件)。两侧对称,缺一不可。
1382
+ // 注意用 new RegExp(String) 而非正则字面量:本文件同类表达式(directCallRe 等)
1383
+ // 都按这套写法,写成字面量时 `\\w` 会被当成「反斜杠 + w」,一个调用都匹配不到。
1384
+ const wrapperCallRe = new RegExp(`(?:^|[^\\w$.])([\\w$]+)\\s*\\(([\\s\\S]{0,250}?)\\)`, "g");
1385
+ for (let round = 0; round < 3; round++) {
1386
+ let grew = false;
1387
+ for (const b of bodies) {
1388
+ wrapperCallRe.lastIndex = 0;
1389
+ let cm;
1390
+ while ((cm = wrapperCallRe.exec(b.text)) !== null) {
1391
+ const callee = map.get(cm[1]);
1392
+ if (!callee || callee.idxs.size === 0)
1393
+ continue;
1394
+ const args = splitArgWindow(cm[2] || "");
1395
+ if (args.length === 0)
1396
+ continue;
1397
+ b.params.forEach((pname, idx) => {
1398
+ if (!pname)
1399
+ return;
1400
+ const pre = new RegExp(`\\b${escapeRe(pname)}\\b`);
1401
+ // 形参出现在被调用方的【sink 形参位】上,才算继承了 sink
1402
+ const inherited = [...callee.idxs].some((i) => i < args.length && pre.test(args[i]));
1403
+ if (!inherited)
1404
+ return;
1405
+ if (!map.has(b.keyName)) {
1406
+ map.set(b.keyName, { idxs: new Set(), entries: [] });
1407
+ }
1408
+ const rec = map.get(b.keyName);
1409
+ if (rec.idxs.has(idx))
1410
+ return;
1411
+ rec.idxs.add(idx);
1412
+ grew = true;
1413
+ if (!rec.entries.some((e) => e.name === b.fullName)) {
1414
+ rec.entries.push({ name: b.fullName, file: b.relPath });
1415
+ }
1416
+ });
1417
+ }
1418
+ }
1419
+ if (!grew)
1420
+ break;
1421
+ }
871
1422
  return map;
872
1423
  }
873
1424
  // ═══════════════════════════════════════════════════════════════
@@ -903,14 +1454,14 @@ function collectUrlParamNames(params) {
903
1454
  * 项目方法体内的文件 sink(跨函数一跳,onMethodHit 回调登记方法条目)
904
1455
  * - SSRF:URL 形参/request 污点 → HTTP fetch sink,无守卫词汇
905
1456
  */
906
- function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit, guardFns, directGuardFns) {
1457
+ function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit, guardFns, directGuardFns, shapers) {
907
1458
  const markers = [];
908
1459
  // ── 路径穿越 ──
909
1460
  // G1(2026-09-19):有流还不够,必须【没有校验证据】才标记——
910
1461
  // 与 SSRF 侧「无 SSRF_GUARD_EVIDENCE 才标记」对齐。
911
1462
  // 未传 guardFns(旧调用点/单测)时按「不做校验识别」的旧语义处理。
912
1463
  if (hasRequestRootedExpr(text)) {
913
- const tainted = collectTaintedNames(text);
1464
+ const tainted = collectTaintedNames(text, shapers);
914
1465
  const selfGuarded = guardFns ? hasPathGuardEvidence(text) !== null : false;
915
1466
  // G2:调用点抑制 —— 被传进「自身含校验证据的函数」的表达式视为已净化。
916
1467
  // 与 selfGuarded(函数级、按词形)互补:这条按【被调用方的实际证据】定案,
@@ -961,7 +1512,7 @@ function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit, guardFns,
961
1512
  TS_HTTP_FETCH_SINK.lastIndex = 0;
962
1513
  if (TS_HTTP_FETCH_SINK.test(text) && !SSRF_GUARD_EVIDENCE.test(text)) {
963
1514
  const urlParams = paramNames.filter((n) => URL_PARAM_NAME.test(n));
964
- const reqTainted = collectTaintedNames(text);
1515
+ const reqTainted = collectTaintedNames(text, shapers);
965
1516
  const taintParts = [];
966
1517
  if (urlParams.length > 0)
967
1518
  taintParts.push(`\\b(?:${urlParams.join("|")})\\b`);
@@ -1068,6 +1619,10 @@ function _extractSingleProject(absRoot, tsconfigPath) {
1068
1619
  // G2 只用 direct —— 传播得到的名字是「被推断为守卫」,拿它做抑制会把
1069
1620
  // 推断误差直接放大成误报消除。
1070
1621
  const { all: guardFns, direct: directGuardFns } = pathGuardFunctionNames(project);
1622
+ // C4b:项目自有「纯塑形」helper —— 让污点能穿过 getFileNamePath(...) 这类
1623
+ // 自建封装。与 guardFns 方向相反:那边是压掉流,这边是接通流,
1624
+ // 所以它的判据更严(三条全中才认),认不出就留着漏报。
1625
+ const shapers = pureShaperFunctionNames(project);
1071
1626
  const pendingMethodMarks = new Set();
1072
1627
  const onMethodHit = (rec) => {
1073
1628
  for (const me of rec.entries)
@@ -1085,7 +1640,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
1085
1640
  const fParams = f.getParameters();
1086
1641
  const fText = f.getText();
1087
1642
  const fCalls = extractDirectCalls(f, fText);
1088
- fCalls.push(...computeMarkerCalls(fText, fParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns));
1643
+ fCalls.push(...computeMarkerCalls(fText, fParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns, shapers));
1089
1644
  funcs.push({
1090
1645
  name,
1091
1646
  params: fParams.map(p => ({
@@ -1115,7 +1670,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
1115
1670
  const initParams = init.getParameters();
1116
1671
  const initText = init.getText();
1117
1672
  const initCalls = extractDirectCalls(init, initText);
1118
- initCalls.push(...computeMarkerCalls(initText, initParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns));
1673
+ initCalls.push(...computeMarkerCalls(initText, initParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns, shapers));
1119
1674
  funcs.push({
1120
1675
  name,
1121
1676
  params: initParams.map(p => ({
@@ -1141,7 +1696,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
1141
1696
  const argParams = arg.getParameters();
1142
1697
  const argText = arg.getText();
1143
1698
  const argCalls = extractDirectCalls(arg, argText);
1144
- argCalls.push(...computeMarkerCalls(argText, argParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns));
1699
+ argCalls.push(...computeMarkerCalls(argText, argParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns, shapers));
1145
1700
  funcs.push({
1146
1701
  name,
1147
1702
  params: argParams.map(p => ({
@@ -1175,7 +1730,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
1175
1730
  continue;
1176
1731
  const mParams = m.getParameters();
1177
1732
  const mText = m.getText();
1178
- const mCalls = computeMarkerCalls(mText, mParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns);
1733
+ const mCalls = computeMarkerCalls(mText, mParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns, shapers);
1179
1734
  funcs.push({
1180
1735
  name: `${cn}.${mn}`,
1181
1736
  params: mParams.map((p) => ({ name: p.getName(), type: getParamType(p), typeDetail: getParamTypeDetail(p) })),