progmune-runtime 3.7.27 → 3.7.29

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,35 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.7.29] — 2026-09-14
4
+
5
+ ### 失败语料沉淀接通(知识网络入口修复)
6
+
7
+ - **断点**:信任引擎(trust CLI/agent/patrol/MCP)检出的违规从不写入失败语料库——此前只有 agent-loop 的 planner 失败会入 `.progmune_corpus`,引擎找到的真东西不进库,知识网络永远不喂新数据
8
+ - **修复**(`src/trust/engine.ts` `writeTrustFailuresToCorpus`):每次 evaluateTrust 的违规(cap 50/扫描)经 `recordFailure` 沉淀——intent=`trust-scan:<project>`、severity→SVL 阶梯映射(critical→SVL-4…low→SVL-1)、项目函数名快照(cap 300,语料挖掘词段门控输入)
9
+ - **去重**:同日同(项目+规则+函数)sidecar 去重——反复扫描不重复沉淀
10
+ - **噪声排除**:`PROTOCOL_CROSS_DOMAIN`/`PLAINTEXT_AUTH_WITHOUT_TLS`(已知高 FP、LLM 域映射抖动的 specific-check)不入语料;真实检出面(PATH_TRAVERSAL/SSRF/AUTHZ_CROSS_USER_WRITE/框架层/SSG)照常沉淀
11
+ - **测试隔离**:VITEST/NODE_ENV=test 跳过——单测不污染语料
12
+ - **首批入库验证**:本周 10 条真实检出全部入库存(openhop PATH_TRAVERSAL×4 + FASTIFY×1、open-webui SSRF×3 + AUTHZ_CROSS_USER_WRITE×1、mcp-from-openapi SSRF×1)——其中 open-webui 3 条 SSRF 命中的正是 fr-009 修复补丁加固的 web 加载器函数族,交叉验证 A3 扩展
13
+ - 语料经 immune-reporter 既有管线脱敏上传 hub(默认脱敏口径不变)
14
+
15
+ ### 回归
16
+
17
+ - build ✓ / 引擎测试 21 green ✓ / 语料写入全部 best-effort(永不打断扫描)
18
+
19
+ ## [3.7.28] — 2026-09-14
20
+
21
+ ### SSRF 检测 TS 化 + Python 形参污点扩展(A3,修复回归 fr-009/010/011 驱动)
22
+
23
+ - **TS 提取器 SSRF 标记**(`extract-ir.ts`):URL 形参/request 污点 → HTTP fetch sink(fetch/axios.*/http.request/ky.*/undici.request/nodeFetch/got),函数内无 SSRF 守卫词汇(private-IP/loopback/denylist/hostname 校验)→ `__progmune_ssrf_user_url__`
24
+ - **Python 形参污点扩展**(`extract_ir.py`):URL 形参(url/target/endpoint/spec_url…)作为污点源(库形态,如 from_url(url));守卫抑制词汇(validate_url/safe_http/is_blocked_hostname 等;**不含裸 "ssrf"**——PyGoat 实验模板名 ssrf_lab2.html 会被误抑制);**is_request_rooted 收紧**:裸名 "request" 仅当它是函数形参才算请求根——此前局部变量命名为 request 会被误判(skyvern _fetch_discovery FP 根因)
25
+ - **引擎 IR 层消费**:SSRF 违规直接报出(同 PATH_TRAVERSAL/AUTHZ_CROSS_USER_WRITE 模式)——Python 侧 SSRF 标记此前在引擎管线中是死的,本版点亮
26
+ - **修复回归战绩 4/11**:fr-011(mcp-from-openapi TS,引擎级:修复前 SSRF ×1 @ fromURL 真值位置/修复后 0)✅、fr-010(unstructured Python,提取器级:修复前 2 标记命中 partition_md/file_and_type_from_url/修复后 0;整仓 tarball 网络截断,mini 语料验证如实记录)✅、PyGoat ssrf_lab2 引擎级 TP 恢复 ✅;fr-009(open-webui DNS 重绑定)为「守卫存在但不足」类,标记模型边界如实不覆盖
27
+ - **skyvern 精化**:_fetch_discovery FP 消除;_download_screenshot 为弱 TP(scheme 校验≠SSRF 防护,如实标注)
28
+
29
+ ### 回归
30
+
31
+ - TS 795 零漂移(3086 LOST 0/ADDED 0)✓ / Python 盲测 64/64 ✓ / Python 源级金标 SSRF 0 命中 ✓ / open-seo 10 无漂移 ✓ / 引擎测试 73 green ✓ / build ✓ / check 免疫正常 ✓
32
+
3
33
  ## [3.7.27] — 2026-09-13
4
34
 
5
35
  ### 归属校验 Python 化(A2,修复回归 fr-005 驱动)
@@ -598,6 +598,96 @@ function augmentPathTraversalMarkers(project, funcs, absRoot) {
598
598
  });
599
599
  }
600
600
  }
601
+ // ═══════════════════════════════════════════════════════════════
602
+ // SSRF marker(2026-09-14,REALWORLD_FIX_REGRESSION fr-011/fr-010)
603
+ // URL 形参/request 污点 → HTTP fetch sink,函数内无 SSRF 守卫证据
604
+ // (private-IP/loopback/denylist/hostname 校验词汇)→ 注入
605
+ // __progmune_ssrf_user_url__。库形态(mcp-from-openapi fromURL)与
606
+ // Web 形态(req.params)共用同一标记;引擎 IR 层消费。
607
+ // ═══════════════════════════════════════════════════════════════
608
+ const TS_HTTP_FETCH_SINK = /\b(fetch|axios\.(?:get|post|put|delete|head|patch)|http\.request|https\.request|ky\.(?:get|post|put|delete|head|patch)|undici\.request|nodeFetch|got)\s*\(/;
609
+ const SSRF_GUARD_EVIDENCE = /127\.0\.0\.1|0\.0\.0\.0|169\.254\.|::1|localhost|isPrivate|isLoopback|hostname|denylist|blocklist|ssrf|validateUrl|isSafeUrl|getAddresses|ipaddress|forbidden_host|private_ip/i;
610
+ const URL_PARAM_NAME = /^(url|target|endpoint|link|href|webUrl|web_url|sourceUrl|source_url|remoteUrl|remote_url|fetchUrl|fetch_url|specUrl|spec_url|apiUrl|api_url|origin|baseUrl|base_url)$/i;
611
+ function collectUrlParamNames(params) {
612
+ return params.map((p) => p.name).filter((n) => URL_PARAM_NAME.test(n));
613
+ }
614
+ function augmentSsfrMarkers(project, funcs, absRoot) {
615
+ const sinkRe = TS_HTTP_FETCH_SINK;
616
+ for (const sf of project.getSourceFiles()) {
617
+ if (sf.getFilePath().includes("node_modules"))
618
+ continue;
619
+ const relPath = path.relative(absRoot, sf.getFilePath());
620
+ sf.forEachDescendant((node) => {
621
+ if (!ts_morph_1.Node.isFunctionDeclaration(node) &&
622
+ !ts_morph_1.Node.isArrowFunction(node) &&
623
+ !ts_morph_1.Node.isMethodDeclaration(node)) {
624
+ return;
625
+ }
626
+ const text = node.getText();
627
+ if (text.length > 12000)
628
+ return; // 巨型函数跳过(性能 + 噪声)
629
+ // 预过滤:必须有 fetch sink
630
+ sinkRe.lastIndex = 0;
631
+ if (!sinkRe.test(text))
632
+ return;
633
+ // 守卫证据:函数内有 SSRF 防护词汇 → 抑制
634
+ if (SSRF_GUARD_EVIDENCE.test(text))
635
+ return;
636
+ // URL 形参污点
637
+ const urlParams = ts_morph_1.Node.isArrowFunction(node)
638
+ ? []
639
+ : collectUrlParamNames(node.getParameters().map((p) => ({ name: p.getName() })));
640
+ // request 污点(Web 形态)
641
+ const reqTainted = collectTaintedNames(text);
642
+ const taintParts = [];
643
+ if (urlParams.length > 0)
644
+ taintParts.push(`\\b(?:${urlParams.join("|")})\\b`);
645
+ if (reqTainted.size > 0)
646
+ taintParts.push(`\\b(?:${[...reqTainted].join("|")})\\b`);
647
+ taintParts.push(/\b(?:req|request)\.(?:params|query|body|headers|cookies)\b[.[]?/.source);
648
+ const taint = new RegExp(taintParts.join("|"));
649
+ if (taintParts.length <= 1) {
650
+ // 只有 request 根模式而无实际污点名——仍需检查 sink 实参
651
+ }
652
+ // sink 实参窗口含污点 → 标记
653
+ let flagged = false;
654
+ sinkRe.lastIndex = 0;
655
+ let m;
656
+ while ((m = sinkRe.exec(text)) !== null) {
657
+ // 取调用后 300 字符窗口(URL 是首个实参)
658
+ const after = text.slice(m.index + m[0].length, m.index + m[0].length + 300);
659
+ if (taint.test(after)) {
660
+ flagged = true;
661
+ break;
662
+ }
663
+ }
664
+ if (!flagged)
665
+ return;
666
+ const nodeName = ts_morph_1.Node.isArrowFunction(node) ? undefined : node.getName();
667
+ if (ts_morph_1.Node.isFunctionDeclaration(node) && nodeName) {
668
+ const entry = funcs.find((x) => x.name === nodeName && x.file === relPath);
669
+ if (entry) {
670
+ entry.calls = entry.calls || [];
671
+ if (!entry.calls.includes("__progmune_ssrf_user_url__")) {
672
+ entry.calls.push("__progmune_ssrf_user_url__");
673
+ }
674
+ }
675
+ }
676
+ else if (ts_morph_1.Node.isMethodDeclaration(node) && nodeName) {
677
+ const cls = node.getParent();
678
+ if (ts_morph_1.Node.isClassDeclaration(cls) && cls.getName()) {
679
+ const entry = funcs.find((x) => x.name === `${cls.getName()}.${nodeName}` && x.file === relPath);
680
+ if (entry) {
681
+ entry.calls = entry.calls || [];
682
+ if (!entry.calls.includes("__progmune_ssrf_user_url__")) {
683
+ entry.calls.push("__progmune_ssrf_user_url__");
684
+ }
685
+ }
686
+ }
687
+ }
688
+ });
689
+ }
690
+ }
601
691
  /**
602
692
  * 从 TypeScript 项目提取 IR(函数签名、参数、返回值、协议注解)。
603
693
  * @protocol namespace=dev_pipeline pre_states=[] post_states=["IR_EXTRACTED"] invalidate=["IR_STALE"]
@@ -784,6 +874,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
784
874
  // 与 protocol-detector 同名规则(source-level benchmark 路径)。
785
875
  // ═══════════════════════════════════════════════════════════════
786
876
  augmentPathTraversalMarkers(project, funcs, absRoot);
877
+ augmentSsfrMarkers(project, funcs, absRoot);
787
878
  // ═══════════════════════════════════════════════════════════════
788
879
  // Phase 5: Dynamic external function resolution
789
880
  // Replaces hardcoded knownExternals
@@ -658,12 +658,17 @@ const SAFEGUARD_RULES = [
658
658
  {
659
659
  name: "SSRF (User-Controlled URL Fetch)",
660
660
  category: "ssrf",
661
- languages: ["python"],
662
- // Source-level detection: the Python extractor emits a synthetic marker
663
- // call when an HTTP fetch (requests.*/urllib.*/httpx.*/aiohttp.*/urlopen)
664
- // receives a URL tainted by request-derived user input (directly or via
665
- // single-hop assignment). No satisfier possible — the marker IS the
666
- // violation evidence.
661
+ // 2026-09-14(A3 fr-011/fr-010):TS 提取器(extract-ir.ts)自 2026-09-14
662
+ // 起同样注入该标记(URL 形参/request 污点 → HTTP fetch sink,无 SSRF
663
+ // 守卫),Python 侧同版扩展形参污点——语言面扩展。
664
+ languages: ["typescript", "javascript", "python"],
665
+ // Source-level detection: the language extractors emit a synthetic marker
666
+ // call when an HTTP fetch (requests.*/urllib.*/httpx.*/aiohttp.*/urlopen;
667
+ // TS: fetch/axios.*/http.request/ky.*/got/undici.request) receives a URL
668
+ // tainted by request-derived user input or a URL-shaped function parameter
669
+ // (directly or via single-hop assignment) with no SSRF guard in the
670
+ // function (private-IP/loopback/denylist/hostname validation evidence).
671
+ // No satisfier possible — the marker IS the violation evidence.
667
672
  trigger: /\b(__progmune_ssrf_user_url__)\b/,
668
673
  safeguards: [],
669
674
  violationMessage: "HTTP fetch whose URL derives from user-controlled request input — server-side request forgery.",
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.27";
23
+ exports.RUNTIME_VERSION = "3.7.29";
24
24
  function verify(filePath) {
25
25
  const cert = (0, certify_1.certify)(filePath);
26
26
  const kb = (0, protocol_knowledge_1.buildKnowledgeBase)();
@@ -140,6 +140,11 @@ async function evaluateTrust(ctx) {
140
140
  ...fiberResult.violations,
141
141
  ];
142
142
  // ═══════════════════════════════════════
143
+ // ── 失败语料沉淀(2026-09-14):信任引擎检出的违规写入本地语料库 ——
144
+ // 知识网络的入口。此前只有 agent-loop 的 planner 失败会入语料,
145
+ // trust 扫描(CLI/agent/patrol/MCP)检出的真违规不沉淀。
146
+ // 测试环境跳过;同日同(项目+规则+函数)去重;语料写入永不打断扫描。
147
+ writeTrustFailuresToCorpus(ctx, allViolations);
143
148
  // PHASE 3: SCORE
144
149
  // ═══════════════════════════════════════
145
150
  const policyResult = (0, score_calculator_1.scorePolicyCompliance)(allViolations);
@@ -1282,6 +1287,20 @@ async function collectProtocolViolations(ctx, callGraph) {
1282
1287
  policy_ref: "protocol-safety.specific",
1283
1288
  });
1284
1289
  }
1290
+ if (calls.includes("__progmune_ssrf_user_url__")) {
1291
+ violations.push({
1292
+ severity: "medium",
1293
+ rule_id: "SSRF",
1294
+ file: f.file,
1295
+ function: f.name || "unknown",
1296
+ message: "HTTP fetch whose URL derives from user-controlled input or a URL-shaped function parameter with no SSRF guard — server-side request forgery.",
1297
+ evidence: "__progmune_ssrf_user_url__",
1298
+ why: "Extractor-verified: fetch sink receives request-tainted or URL-parameter input " +
1299
+ "with no private-IP/loopback/denylist/hostname validation evidence in the function.",
1300
+ fix: "Validate the URL (scheme allowlist, private-IP/loopback block, DNS pinning) before fetching.",
1301
+ policy_ref: "protocol-safety.specific",
1302
+ });
1303
+ }
1285
1304
  if (calls.includes("__progmune_cross_user_write__")) {
1286
1305
  violations.push({
1287
1306
  severity: "medium",
@@ -1800,6 +1819,97 @@ function mapRiskSeverity(severity) {
1800
1819
  default: return "low";
1801
1820
  }
1802
1821
  }
1822
+ // ═══════════════════════════════════════════════════════════════
1823
+ // 失败语料沉淀(2026-09-14):信任引擎违规 → .progmune_corpus
1824
+ // ═══════════════════════════════════════════════════════════════
1825
+ const SEVERITY_TO_SVL = {
1826
+ critical: "SVL-4",
1827
+ high: "SVL-3",
1828
+ medium: "SVL-2",
1829
+ low: "SVL-1",
1830
+ };
1831
+ /** 与 failure-corpus.ts 相同的语料目录解析(模块加载时定死 cwd 的语义) */
1832
+ function trustCorpusDir() {
1833
+ if (process.env.PROGMUNE_CORPUS_DIR)
1834
+ return process.env.PROGMUNE_CORPUS_DIR;
1835
+ const projectDir = process.env.PROGMUNE_PROJECT_DIR || process.cwd();
1836
+ return path.join(projectDir, ".progmune_corpus");
1837
+ }
1838
+ /**
1839
+ * 把本次扫描检出的违规写入失败语料库(知识网络的入口)。
1840
+ * - 测试环境(VITEST/NODE_ENV=test)跳过——单测不得污染语料
1841
+ * - 同日同(项目+规则+函数)去重——反复扫描不重复沉淀
1842
+ * - 全部 best-effort:语料写入失败绝不打断扫描主流程
1843
+ */
1844
+ function writeTrustFailuresToCorpus(ctx, violations) {
1845
+ if (process.env.VITEST === "true" || process.env.NODE_ENV === "test")
1846
+ return;
1847
+ if (violations.length === 0)
1848
+ return;
1849
+ try {
1850
+ const { recordFailure } = require("../failure-corpus");
1851
+ const fs = require("fs");
1852
+ const corpusDir = trustCorpusDir();
1853
+ const date = new Date().toISOString().slice(0, 10);
1854
+ const dedupFile = path.join(corpusDir, `.trust-dedup-${date}.json`);
1855
+ let seen = {};
1856
+ try {
1857
+ seen = JSON.parse(fs.readFileSync(dedupFile, "utf-8"));
1858
+ }
1859
+ catch { /* fresh day */ }
1860
+ // 项目函数名快照(语料挖掘的词段门控输入),cap 300
1861
+ let projectFunctions = [];
1862
+ try {
1863
+ const irPath = path.join(ctx.projectPath, "ir.json");
1864
+ if (fs.existsSync(irPath)) {
1865
+ const ir = JSON.parse(fs.readFileSync(irPath, "utf-8"));
1866
+ const funcs = Array.isArray(ir) ? ir : (ir.functions || []);
1867
+ projectFunctions = funcs
1868
+ .map((f) => String(f.name))
1869
+ .filter((n) => n && !n.startsWith("__progmune_"))
1870
+ .slice(0, 300);
1871
+ }
1872
+ }
1873
+ catch { /* best-effort */ }
1874
+ let added = 0;
1875
+ for (const v of violations.slice(0, 50)) {
1876
+ // 已知高 FP 的 specific-check 规则不入语料——它们以 LLM 域映射
1877
+ // 抖动为主(open-webui 单次扫描 36 条 CROSS_DOMAIN),对语料挖掘
1878
+ // 是纯噪声。真实检出面(PATH_TRAVERSAL/SSRF/CROSS_USER_WRITE/
1879
+ // 框架层/SSG)照常沉淀。
1880
+ if (v.rule_id === "PROTOCOL_CROSS_DOMAIN" || v.rule_id === "PLAINTEXT_AUTH_WITHOUT_TLS")
1881
+ continue;
1882
+ const key = `${ctx.projectName}:${v.rule_id}:${v.function || ""}`;
1883
+ if (seen[key])
1884
+ continue;
1885
+ recordFailure({
1886
+ intent: `trust-scan:${ctx.projectName}`,
1887
+ projectFunctions,
1888
+ violatedSVL: (SEVERITY_TO_SVL[v.severity] || "SVL-2"),
1889
+ constraintType: v.rule_id,
1890
+ actionSequence: [
1891
+ {
1892
+ kind: "violation",
1893
+ function: v.function || "unknown",
1894
+ file: v.file,
1895
+ rule: v.rule_id,
1896
+ },
1897
+ ],
1898
+ errorDetail: (v.message || v.why || "").slice(0, 500),
1899
+ });
1900
+ seen[key] = true;
1901
+ added++;
1902
+ }
1903
+ if (added > 0) {
1904
+ try {
1905
+ fs.mkdirSync(corpusDir, { recursive: true });
1906
+ fs.writeFileSync(dedupFile, JSON.stringify(seen, null, 2));
1907
+ }
1908
+ catch { /* best-effort */ }
1909
+ }
1910
+ }
1911
+ catch { /* corpus write must never crash the scan */ }
1912
+ }
1803
1913
  function extractFunctionName(text) {
1804
1914
  if (!text)
1805
1915
  return "unknown";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "progmune-runtime",
3
- "version": "3.7.27",
3
+ "version": "3.7.29",
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/",
@@ -105,40 +105,50 @@ SSRF_MARKER = "__progmune_ssrf_user_url__"
105
105
  HTTP_FETCH_RECEIVERS = ("requests", "httpx", "urllib", "urllib2", "aiohttp", "http")
106
106
 
107
107
 
108
- def is_request_rooted(node):
108
+ def is_request_rooted(node, request_params=None):
109
109
  """True if the expression derives from the request object
110
- (request.POST.get(...), request.GET['x'], request.body ...)."""
110
+ (request.POST.get(...), request.GET['x'], request.body ...).
111
+ 2026-09-14 收紧:裸名 "request" 仅当它是函数形参(Web 处理器签名)
112
+ 时才算 request 根——此前任何局部变量命名为 request 都被误判
113
+ (skyvern _fetch_discovery:request = urllib.request.Request(url),
114
+ 局部变量撞词 → SSRF 误报)。request_params=None 保持旧行为。"""
111
115
  if isinstance(node, ast.Name):
112
- return node.id == "request"
116
+ if node.id != "request":
117
+ return False
118
+ return request_params is None or "request" in request_params
113
119
  if isinstance(node, ast.Attribute):
114
- return is_request_rooted(node.value)
120
+ return is_request_rooted(node.value, request_params)
115
121
  if isinstance(node, ast.Subscript):
116
- return is_request_rooted(node.value)
122
+ return is_request_rooted(node.value, request_params)
117
123
  if isinstance(node, ast.Call):
118
- return (is_request_rooted(node.func)
119
- or any(is_request_rooted(a) for a in node.args)
120
- or any(is_request_rooted(k.value) for k in node.keywords))
124
+ return (is_request_rooted(node.func, request_params)
125
+ or any(is_request_rooted(a, request_params) for a in node.args)
126
+ or any(is_request_rooted(k.value, request_params) for k in node.keywords))
121
127
  return False
122
128
 
123
129
 
124
- def is_tainted(node, assigns, depth=0):
130
+ def _func_param_names(node):
131
+ return {a.arg for a in node.args.args + node.args.posonlyargs + node.args.kwonlyargs}
132
+
133
+
134
+ def is_tainted(node, assigns, depth=0, request_params=None):
125
135
  """Single-hop taint: request-rooted, variable assigned from a tainted
126
136
  value, or dynamic formatting containing tainted parts."""
127
137
  if node is None or depth > 2:
128
138
  return False
129
- if is_request_rooted(node):
139
+ if is_request_rooted(node, request_params):
130
140
  return True
131
141
  if isinstance(node, ast.Name):
132
- return is_tainted(assigns.get(node.id), assigns, depth + 1)
142
+ return is_tainted(assigns.get(node.id), assigns, depth + 1, request_params)
133
143
  if isinstance(node, ast.JoinedStr):
134
- return any(is_tainted(v.value, assigns, depth + 1)
144
+ return any(is_tainted(v.value, assigns, depth + 1, request_params)
135
145
  for v in node.values if isinstance(v, ast.FormattedValue))
136
146
  if isinstance(node, ast.BinOp):
137
- return (is_tainted(node.left, assigns, depth)
138
- or is_tainted(node.right, assigns, depth))
147
+ return (is_tainted(node.left, assigns, depth, request_params)
148
+ or is_tainted(node.right, assigns, depth, request_params))
139
149
  if isinstance(node, ast.Call):
140
- return (any(is_tainted(a, assigns, depth) for a in node.args)
141
- or any(is_tainted(k.value, assigns, depth) for k in node.keywords))
150
+ return (any(is_tainted(a, assigns, depth, request_params) for a in node.args)
151
+ or any(is_tainted(k.value, assigns, depth, request_params) for k in node.keywords))
142
152
  return False
143
153
 
144
154
 
@@ -162,15 +172,57 @@ def is_http_fetch_call(node):
162
172
  return False
163
173
 
164
174
 
175
+ URL_PARAM_NAMES = re.compile(
176
+ r"^(url|target|endpoint|link|href|web_url|source_url|remote_url|"
177
+ r"fetch_url|spec_url|api_url|base_url|origin)$",
178
+ re.I,
179
+ )
180
+
181
+ SSRF_GUARD_EVIDENCE = re.compile(
182
+ r"127\.0\.0\.1|0\.0\.0\.0|169\.254\.|::1|localhost|is_private|"
183
+ r"is_loopback|hostname|deny_?list|block_?list|validate_url|"
184
+ r"is_safe_url|get_addresses|ipaddress|forbidden_host|private_ip|"
185
+ r"safe_http|get_ssrf_safe|assert_url_safe|is_blocked_hostname|"
186
+ r"is_blocked_address|blocked_hostnames",
187
+ re.I,
188
+ )
189
+
190
+
165
191
  def has_ssrf(node):
166
192
  """SSRF check: an HTTP fetch call whose URL argument is tainted by
167
- request-derived user input (directly or via single-hop assignment)."""
193
+ request-derived user input (directly or via single-hop assignment)
194
+ or references a URL-shaped function parameter (library form, e.g.
195
+ from_url(url)) — with no SSRF guard evidence in the function
196
+ (private-IP/loopback/denylist/hostname validation vocabulary).
197
+ 2026-09-14 扩展(fr-010/fr-011):形参污点 + 守卫抑制。"""
198
+ # 守卫证据:函数体内有 SSRF 防护词汇 → 不报(fr-009 证明
199
+ # 「守卫存在但不足」的 DNS 重绑定类是标记模型的边界,如实不覆盖)
200
+ try:
201
+ text = ast.unparse(node)
202
+ except Exception:
203
+ text = ""
204
+ if text and SSRF_GUARD_EVIDENCE.search(text):
205
+ return False
168
206
  assigns = collect_assignments(node)
207
+ request_params = _func_param_names(node)
208
+ url_params = {
209
+ a for a in request_params if URL_PARAM_NAMES.match(a or "")
210
+ }
211
+
212
+ def refs_url_param(n):
213
+ if isinstance(n, ast.Name):
214
+ return n.id in url_params
215
+ if isinstance(n, (ast.Attribute, ast.Subscript)):
216
+ return refs_url_param(n.value)
217
+ return False
218
+
169
219
  for child in ast.walk(node):
170
220
  if isinstance(child, ast.Call) and is_http_fetch_call(child):
171
- if any(is_tainted(a, assigns) for a in child.args):
221
+ if any(is_tainted(a, assigns, request_params=request_params) for a in child.args):
222
+ return True
223
+ if any(is_tainted(k.value, assigns, request_params=request_params) for k in child.keywords):
172
224
  return True
173
- if any(is_tainted(k.value, assigns) for k in child.keywords):
225
+ if any(refs_url_param(a) for a in child.args):
174
226
  return True
175
227
  return False
176
228
 
@@ -827,12 +879,13 @@ def has_path_traversal(node):
827
879
  request-derived user input (directly or via single-hop assignment —
828
880
  os.path.join chains resolve through assignment tracking)."""
829
881
  assigns = collect_assignments(node)
882
+ request_params = _func_param_names(node)
830
883
  for child in ast.walk(node):
831
884
  if not isinstance(child, ast.Call) or not is_file_sink_call(child):
832
885
  continue
833
- if any(is_tainted(a, assigns) for a in child.args):
886
+ if any(is_tainted(a, assigns, request_params=request_params) for a in child.args):
834
887
  return True
835
- if any(is_tainted(k.value, assigns) for k in child.keywords):
888
+ if any(is_tainted(k.value, assigns, request_params=request_params) for k in child.keywords):
836
889
  return True
837
890
  # Path(...).read_text(): the tainted path lives in the receiver —
838
891
  # either the direct call or a variable assigned from a Path(...) call.