progmune-runtime 3.7.26 → 3.7.28

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,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.7.28] — 2026-09-14
4
+
5
+ ### SSRF 检测 TS 化 + Python 形参污点扩展(A3,修复回归 fr-009/010/011 驱动)
6
+
7
+ - **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__`
8
+ - **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 根因)
9
+ - **引擎 IR 层消费**:SSRF 违规直接报出(同 PATH_TRAVERSAL/AUTHZ_CROSS_USER_WRITE 模式)——Python 侧 SSRF 标记此前在引擎管线中是死的,本版点亮
10
+ - **修复回归战绩 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 重绑定)为「守卫存在但不足」类,标记模型边界如实不覆盖
11
+ - **skyvern 精化**:_fetch_discovery FP 消除;_download_screenshot 为弱 TP(scheme 校验≠SSRF 防护,如实标注)
12
+
13
+ ### 回归
14
+
15
+ - TS 795 零漂移(3086 LOST 0/ADDED 0)✓ / Python 盲测 64/64 ✓ / Python 源级金标 SSRF 0 命中 ✓ / open-seo 10 无漂移 ✓ / 引擎测试 73 green ✓ / build ✓ / check 免疫正常 ✓
16
+
17
+ ## [3.7.27] — 2026-09-13
18
+
19
+ ### 归属校验 Python 化(A2,修复回归 fr-005 驱动)
20
+
21
+ - **跨用户资源写入检测**(`tools/extract_ir.py` 新标记 `__progmune_cross_user_write__`):函数把请求 payload 里的外来资源 id(folder_id)带着持久化(insert/add/create)却没有资源归属守卫 → 标记。**严格归属守卫词表**(任一出现即抑制):归属字段比较(user_id/owner_id/user.id)、`has_*/check_*_folder_access` 助手、`get_*_by_id_and_user_id` 限定查询——**不含 `has_permission`**(fr-005 真值函数含特性权限检查,与资源归属无关);`insert_` 前缀持久化原语不标(归属决策在上游路由层)
22
+ - **引擎 IR 层消费**(`src/trust/engine.ts`):`AUTHZ_CROSS_USER_WRITE` 违规直接报出(同 PATH_TRAVERSAL 模式);`protocol-detector.ts` 同名规则供 source-level 路径
23
+ - **fr-005 闭环验收**:修复前 DETECTED(open-webui main.py::chat_completion,真值位置);修复后 0 误报(has_folder_write_access 抑制);chats.py create_new_chat / automations.py(有守卫)均不标
24
+ - **修复回归战绩 2/8**(fr-007 路径穿越 TS 化 + fr-005 归属校验 Python 化)
25
+
26
+ ### 回归
27
+
28
+ - Python 盲测 64/64 零漂移 ✓ / Python 源级金标新规则 0 命中 ✓ / skyvern 24 无漂移 ✓ / 引擎测试 73 green ✓ / build ✓ / check 免疫正常 ✓
29
+
3
30
  ## [3.7.26] — 2026-09-13
4
31
 
5
32
  ### 精度修复批次(AI 生成项目验证 V2 + 真实修复回归驱动的引擎改动)
@@ -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.",
@@ -688,6 +693,20 @@ const SAFEGUARD_RULES = [
688
693
  conceptMissing: ["PathTraversalPrevention", "InputPathValidation"],
689
694
  conceptExpected: ["path allowlist", "basename normalization", "path sanitization"],
690
695
  },
696
+ {
697
+ name: "Authorization (Cross-User Resource Write)",
698
+ category: "authorization",
699
+ // 2026-09-13(REALWORLD_FIX_REGRESSION_V1 fr-005):Python 提取器在
700
+ // 「请求 payload 外来资源 id(folder_id)带着持久化、无归属守卫」时
701
+ // 注入标记。证据语义全部在提取器侧(严格归属守卫词表——不含
702
+ // has_permission 类特性权限检查,见 tools/extract_ir.py)。
703
+ languages: ["python"],
704
+ trigger: /\b(__progmune_cross_user_write__)\b/,
705
+ safeguards: [],
706
+ violationMessage: "Data is persisted with a user-supplied foreign resource id (folder_id) without an ownership guard — any authenticated user can write into another user's resource.",
707
+ conceptMissing: ["ResourceOwnership", "CrossUserReference"],
708
+ conceptExpected: ["folder write-access check", "get_*_by_id_and_user_id query"],
709
+ },
691
710
  {
692
711
  name: "XSS (Unsafe Template Rendering)",
693
712
  category: "xss",
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.26";
23
+ exports.RUNTIME_VERSION = "3.7.28";
24
24
  function verify(filePath) {
25
25
  const cert = (0, certify_1.certify)(filePath);
26
26
  const kb = (0, protocol_knowledge_1.buildKnowledgeBase)();
@@ -1265,6 +1265,7 @@ async function collectProtocolViolations(ctx, callGraph) {
1265
1265
  // 在 IR 层直接消费——call-sequence 构建会过滤 __progmune_ 前缀
1266
1266
  // (P4.5 历史),标记到不了 checkSpecificViolations;此前的 Python
1267
1267
  // 标记因此在引擎管线中一直是死的。此处同时点亮 Python 与 TS 两侧。
1268
+ // 2026-09-13(fr-005):同层消费跨用户资源写入标记。
1268
1269
  for (const f of functions) {
1269
1270
  const calls = f.calls || [];
1270
1271
  if (calls.includes("__progmune_path_traversal__")) {
@@ -1281,6 +1282,34 @@ async function collectProtocolViolations(ctx, callGraph) {
1281
1282
  policy_ref: "protocol-safety.specific",
1282
1283
  });
1283
1284
  }
1285
+ if (calls.includes("__progmune_ssrf_user_url__")) {
1286
+ violations.push({
1287
+ severity: "medium",
1288
+ rule_id: "SSRF",
1289
+ file: f.file,
1290
+ function: f.name || "unknown",
1291
+ message: "HTTP fetch whose URL derives from user-controlled input or a URL-shaped function parameter with no SSRF guard — server-side request forgery.",
1292
+ evidence: "__progmune_ssrf_user_url__",
1293
+ why: "Extractor-verified: fetch sink receives request-tainted or URL-parameter input " +
1294
+ "with no private-IP/loopback/denylist/hostname validation evidence in the function.",
1295
+ fix: "Validate the URL (scheme allowlist, private-IP/loopback block, DNS pinning) before fetching.",
1296
+ policy_ref: "protocol-safety.specific",
1297
+ });
1298
+ }
1299
+ if (calls.includes("__progmune_cross_user_write__")) {
1300
+ violations.push({
1301
+ severity: "medium",
1302
+ rule_id: "AUTHZ_CROSS_USER_WRITE",
1303
+ file: f.file,
1304
+ function: f.name || "unknown",
1305
+ message: "Data is persisted with a user-supplied foreign resource id (folder_id) without an ownership guard — any authenticated user can write into another user's resource.",
1306
+ evidence: "__progmune_cross_user_write__",
1307
+ why: "Extractor-verified: folder_id from request payload is persisted (insert/add/create) " +
1308
+ "with no ownership comparison or folder write-access check in this function.",
1309
+ fix: "Verify folder write access for the acting user (ownership query or access-control helper) before persisting.",
1310
+ policy_ref: "protocol-safety.specific",
1311
+ });
1312
+ }
1284
1313
  }
1285
1314
  for (const f of functions) {
1286
1315
  if (!f.protocol)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "progmune-runtime",
3
- "version": "3.7.26",
3
+ "version": "3.7.28",
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):
172
222
  return True
173
- if any(is_tainted(k.value, assigns) for k in child.keywords):
223
+ if any(is_tainted(k.value, assigns, request_params=request_params) for k in child.keywords):
224
+ return True
225
+ if any(refs_url_param(a) for a in child.args):
174
226
  return True
175
227
  return False
176
228
 
@@ -691,6 +743,47 @@ def has_template_tag_decorator(node):
691
743
  return False
692
744
 
693
745
 
746
+ CROSS_USER_WRITE_MARKER = "__progmune_cross_user_write__"
747
+
748
+
749
+ def has_cross_user_resource_write(node):
750
+ """跨用户资源写入(REALWORLD_FIX_REGRESSION_V1 fr-005 open-webui):
751
+ 函数把请求 payload 里的外来资源 id(folder_id 等)连同持久化调用
752
+ 写入(insert/add/create),却没有资源归属守卫——任意已认证用户可
753
+ 把数据写进其他用户的资源。归属守卫证据(任一出现即抑制):
754
+ ① 归属字段比较 user_id/owner_id/author_id/user.id/folder.user_id
755
+ ② 文件夹写权限助手 has_*/check_*_folder_access / has_folder_access
756
+ ③ 按用户 id 限定的查询 get_*_by_id_and_user_id。
757
+ 注意:不把 has_permission 算作归属证据——fr-005 真值函数含
758
+ features.direct_tool_servers 特性权限检查(user.role != 'admin' 同
759
+ 族,has_ownership_checked 会误放行),与资源归属无关。
760
+ 另:函数名以 insert_/save_/add_ 开头 = 持久化原语层(模型方法),
761
+ 归属决策在上游路由层,不标记。"""
762
+ try:
763
+ name = getattr(node, "name", "")
764
+ if re.match(r"^(insert_|save_|add_)", name):
765
+ return False
766
+ text = ast.unparse(node)
767
+ except Exception:
768
+ return False
769
+ if "folder_id" not in text:
770
+ return False
771
+ if not any(k in text for k in ("metadata", "form_data", "body", "request")):
772
+ return False
773
+ if not re.search(r"insert_new_chat|insert_new\w*|\binsert\(|\.add\(|\.create\(", text):
774
+ return False
775
+ if re.search(r"\b(user_id|owner_id|author_id|user\.id|folder\.user_id)\s*[!=]==?", text):
776
+ return False
777
+ if re.search(
778
+ r"has_folder_write_access|has_folder_access|"
779
+ r"has_\w*_folder_access|check_\w*_folder_access|check_folder\w*|"
780
+ r"get_\w+_by_id_and_user_id",
781
+ text,
782
+ ):
783
+ return False
784
+ return True
785
+
786
+
694
787
  def has_ownership_checked(node):
695
788
  """Inline ownership comparison: an identity-named parameter (user,
696
789
  current_user, profile, author, owner) compared with ==/!= against another
@@ -786,12 +879,13 @@ def has_path_traversal(node):
786
879
  request-derived user input (directly or via single-hop assignment —
787
880
  os.path.join chains resolve through assignment tracking)."""
788
881
  assigns = collect_assignments(node)
882
+ request_params = _func_param_names(node)
789
883
  for child in ast.walk(node):
790
884
  if not isinstance(child, ast.Call) or not is_file_sink_call(child):
791
885
  continue
792
- 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):
793
887
  return True
794
- 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):
795
889
  return True
796
890
  # Path(...).read_text(): the tainted path lives in the receiver —
797
891
  # either the direct call or a variable assigned from a Path(...) call.
@@ -861,6 +955,8 @@ def extract_calls(node, unsafe_vars=None, imports=None, module_constants=None, g
861
955
  calls.append(TEMPLATE_TAG_MARKER)
862
956
  if has_ownership_checked(node) and OWNERSHIP_CHECKED_MARKER not in calls:
863
957
  calls.append(OWNERSHIP_CHECKED_MARKER)
958
+ if has_cross_user_resource_write(node) and CROSS_USER_WRITE_MARKER not in calls:
959
+ calls.append(CROSS_USER_WRITE_MARKER)
864
960
  if has_command_taint_flow(node) and CMD_FLOW_MARKER not in calls:
865
961
  calls.append(CMD_FLOW_MARKER)
866
962
  if has_csrf_exempt(node) and CSRF_MARKER not in calls: