progmune-runtime 3.7.59 → 3.7.60

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,6 +1,27 @@
1
1
  # Changelog
2
2
 
3
- ## [3.7.58] — 2026-10-04(未发布)
3
+ ## [3.7.60] — 2026-10-05(未发布)
4
+
5
+ ### fr-002/003 点亮:令牌交换路径检查完备性(修复回归 10/17)
6
+
7
+ **策略分支漏报根因第一刀**:OAuth 令牌交换路径绕过正常回调路径的检查——
8
+ open-webui 同一函数 `token_exchange` 的两个独立公告:缺角色策略判定
9
+ (fr-002,被角色策略拒绝的用户仍可登录)+ 缺域白名单检查(fr-003)。
10
+
11
+ - 提取器 `tools/extract_ir.py`:`has_token_exchange_missing_guard`——函数名
12
+ token exchange 形态 + 函数体(AST 全量文本)缺角色判定或缺域检查 ⇒
13
+ 注入 `__progmune_token_exchange_unguarded__`。缺席证据来自 AST 全量
14
+ 文本(非 TS 侧 calls 通道的盲判,R75 适用边界明确)
15
+ - 引擎规则 "Token Exchange Missing OAuth Guard"(python-only marker-driven)
16
+ - **三态验证**:fr-002 parent 报(role 缺)/ fr-002 fix 0(两项齐)/
17
+ fr-003 fix 报(该时间点 role 漏洞在场,正确);Python 盲测 90 项目
18
+ 零触发(2533 条逐条一致);定向测试 8/8
19
+ - **fr-corpus 战绩:8 干净 DETECTED + 2 with-boundary = 有效 10/17**
20
+ - **fr-006 判定不可点亮**:SSRF 黑名单条目缺失是「配置/词表完备性」
21
+ 知识问题,静态判据无法穷举「该有的条目」,不硬做。归入漏报根因
22
+ 新类「配置/词表完备性」
23
+
24
+ ### fr-014 点亮 + fr-015 关闭(2026-10-05)
4
25
 
5
26
  ### fr-001 点亮:身份映射子串匹配(修复回归 6/17)
6
27
 
@@ -49,7 +70,7 @@ external-ID)用 JSON 列 `.contains()` 做匹配——contains() 在 JSON 列
49
70
  (闭包晚绑定)」。注:fix_commit cd9db21c 是「重构形式的安全修复」
50
71
  (fr-013 教训的镜像:fix 性质必须以公告摘要核对)。
51
72
 
52
- ## [3.7.57] — 2026-10-04(未发布)
73
+ ## [3.7.57] — 2026-10-04(3.7.57~3.7.59 合并条目)
53
74
 
54
75
  ### §51 held-out 验证 + §52-53 决策门禁 + 修冤枉第一刀(Data Mutation 族移除)
55
76
 
@@ -137,3 +137,55 @@ async def get_oauth_subject_from_claim(claim, db=None):
137
137
  }
138
138
  });
139
139
  });
140
+ (0, vitest_1.describe)("fr-002/003 token exchange missing guard(提取器标记)", () => {
141
+ const TK_MARKER = "__progmune_token_exchange_unguarded__";
142
+ (0, vitest_1.it)("修复前形态:token_exchange 缺角色判定 → 注入标记(fr-002)", () => {
143
+ write("auths.py", `
144
+ async def token_exchange(request, provider, token_data, db=None):
145
+ email = token_data.get('email', '').lower()
146
+ user = await Users.get_user_by_oauth_sub(provider, token_data.get('sub'), db)
147
+ return await create_session_response(request, user, db, source='oauth')
148
+ `);
149
+ const ir = (0, extract_ir_python_1.extractIRPython)(dir);
150
+ const fn = ir.find((f) => f.name === "token_exchange");
151
+ (0, vitest_1.expect)(fn).toBeDefined();
152
+ (0, vitest_1.expect)(fn.calls).toContain(TK_MARKER);
153
+ });
154
+ (0, vitest_1.it)("修复前形态:token_exchange 缺域检查 → 注入标记(fr-003)", () => {
155
+ write("auths.py", `
156
+ async def token_exchange(request, provider, token_data, db=None):
157
+ email = token_data.get('email', '').lower()
158
+ user = await Users.get_user_by_oauth_sub(provider, token_data.get('sub'), db)
159
+ role = await oauth_manager.get_user_role(user, user_data)
160
+ return await create_session_response(request, user, db, source='oauth')
161
+ `);
162
+ const ir = (0, extract_ir_python_1.extractIRPython)(dir);
163
+ const fn = ir.find((f) => f.name === "token_exchange");
164
+ (0, vitest_1.expect)(fn.calls).toContain(TK_MARKER);
165
+ });
166
+ (0, vitest_1.it)("修复后形态:角色判定 + 域检查都有 → 无标记", () => {
167
+ write("auths.py", `
168
+ async def token_exchange(request, provider, token_data, db=None):
169
+ email = token_data.get('email', '').lower()
170
+ if '*' not in auth_manager_config.OAUTH_ALLOWED_DOMAINS and email.split('@')[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS:
171
+ raise HTTPException(status_code=403)
172
+ user = await Users.get_user_by_oauth_sub(provider, token_data.get('sub'), db)
173
+ user = await oauth_manager.update_user_from_oauth(request=request, user=user, user_data=user_data, provider=provider, token=token_data, db=db)
174
+ return await create_session_response(request, user, db, source='oauth')
175
+ `);
176
+ const ir = (0, extract_ir_python_1.extractIRPython)(dir);
177
+ const fn = ir.find((f) => f.name === "token_exchange");
178
+ (0, vitest_1.expect)(fn).toBeDefined();
179
+ (0, vitest_1.expect)(fn.calls).not.toContain(TK_MARKER);
180
+ });
181
+ (0, vitest_1.it)("负例:非 token exchange 函数 → 无标记", () => {
182
+ write("utils.py", `
183
+ async def refresh_token(request, db=None):
184
+ return await create_session_response(request, None, db)
185
+ `);
186
+ const ir = (0, extract_ir_python_1.extractIRPython)(dir);
187
+ for (const f of ir) {
188
+ (0, vitest_1.expect)(f.calls).not.toContain(TK_MARKER);
189
+ }
190
+ });
191
+ });
@@ -862,6 +862,22 @@ const SAFEGUARD_RULES = [
862
862
  conceptMissing: ["ExactIdentityMatch", "SubscriptExtraction"],
863
863
  conceptExpected: ["oauth[provider]['sub'] == sub", "scim[provider]['external_id'] == external_id"],
864
864
  },
865
+ {
866
+ name: "Token Exchange Missing OAuth Guard",
867
+ category: "auth",
868
+ // 2026-10-05(REALWORLD_FIX_REGRESSION_V1 fr-002/fr-003):OAuth 令牌
869
+ // 交换路径绕过正常回调路径的检查——open-webui 的 token_exchange 函数
870
+ // 缺角色策略判定(fr-002)与域白名单检查(fr-003)两个独立公告。
871
+ // Python 提取器在「函数名 token exchange 形态 + 函数体(AST 全量)
872
+ // 缺角色判定或缺域检查」时注入标记。缺席证据来自 AST 全量文本,
873
+ // 非 TS 侧 calls 通道(R75 的适用范围:calls 有损的盲判不适用)。
874
+ languages: ["python"],
875
+ trigger: /\b(__progmune_token_exchange_unguarded__)\b/,
876
+ safeguards: [],
877
+ violationMessage: "OAuth token exchange endpoint is missing the authorization checks the normal OAuth callback enforces — role policy (get_user_role / OAUTH_ALLOWED_ROLES) and/or email domain allowlist (OAUTH_ALLOWED_DOMAINS). Users rejected by role policy or outside the domain allowlist can still sign in through the token exchange path.",
878
+ conceptMissing: ["RolePolicyOnTokenExchange", "DomainAllowlistOnTokenExchange"],
879
+ conceptExpected: ["get_user_role before session creation", "OAUTH_ALLOWED_DOMAINS check"],
880
+ },
865
881
  {
866
882
  name: "XSS (Unsafe Template Rendering)",
867
883
  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.59";
23
+ exports.RUNTIME_VERSION = "3.7.60";
24
24
  function verify(filePath) {
25
25
  const cert = (0, certify_1.certify)(filePath);
26
26
  const kb = (0, protocol_knowledge_1.buildKnowledgeBase)();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "progmune-runtime",
3
- "version": "3.7.59",
3
+ "version": "3.7.60",
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/",
@@ -782,6 +782,32 @@ def has_identity_substring_match(node):
782
782
  return False
783
783
 
784
784
 
785
+ TOKEN_EXCHANGE_UNGUARDED_MARKER = "__progmune_token_exchange_unguarded__"
786
+
787
+
788
+ def has_token_exchange_missing_guard(node):
789
+ """OAuth 令牌交换路径绕过正常回调路径的检查(REALWORLD_FIX_REGRESSION_V1
790
+ fr-002/fr-003 open-webui——同一函数 token_exchange 的两个独立公告):
791
+ fr-002 角色策略(OAUTH_ALLOWED_ROLES/ADMIN_ROLES)未在令牌交换路径执行,
792
+ 被角色策略拒绝的用户仍可登录;fr-003 域白名单(OAUTH_ALLOWED_DOMAINS)
793
+ 同样只在正常 callback 检查、token_exchange 跳过。
794
+ 判据(语义定义,正向证据):函数名是 token exchange 形态,且函数体
795
+ (AST 全量文本)缺角色判定或缺域检查——Python 提取器有 AST 全量函数体,
796
+ 缺席证据不是 TS 侧「calls 通道有损」的盲判(R75 的适用范围说明)。"""
797
+ try:
798
+ name = getattr(node, "name", "")
799
+ if not re.search(r"token[_\s]?exchange", name, re.I):
800
+ return False
801
+ text = ast.unparse(node)
802
+ except Exception:
803
+ return False
804
+ has_role = bool(re.search(r"get_user_role|update_user_from_oauth|determined_role", text))
805
+ has_domain = bool(
806
+ re.search(r"OAUTH_ALLOWED_DOMAINS|ALLOWED_DOMAINS|email\.split\([^)]*@", text)
807
+ )
808
+ return not has_role or not has_domain
809
+
810
+
785
811
  def has_cross_user_resource_write(node):
786
812
  """跨用户资源写入(REALWORLD_FIX_REGRESSION_V1 fr-005 open-webui):
787
813
  函数把请求 payload 里的外来资源 id(folder_id 等)连同持久化调用
@@ -995,6 +1021,8 @@ def extract_calls(node, unsafe_vars=None, imports=None, module_constants=None, g
995
1021
  calls.append(CROSS_USER_WRITE_MARKER)
996
1022
  if has_identity_substring_match(node) and IDENTITY_SUBSTRING_MARKER not in calls:
997
1023
  calls.append(IDENTITY_SUBSTRING_MARKER)
1024
+ if has_token_exchange_missing_guard(node) and TOKEN_EXCHANGE_UNGUARDED_MARKER not in calls:
1025
+ calls.append(TOKEN_EXCHANGE_UNGUARDED_MARKER)
998
1026
  if has_command_taint_flow(node) and CMD_FLOW_MARKER not in calls:
999
1027
  calls.append(CMD_FLOW_MARKER)
1000
1028
  if has_csrf_exempt(node) and CSRF_MARKER not in calls: