progmune-runtime 3.7.59 → 3.7.61
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 +67 -2
- package/dist/extract-ir-request-anchored-identity.test.js +223 -0
- package/dist/extract-ir.js +41 -0
- package/dist/protocol-detector-identity-substring.test.js +52 -0
- package/dist/protocol-detector.js +33 -0
- package/dist/sdk.js +1 -1
- package/package.json +1 -1
- package/tools/extract_ir.py +28 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,71 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [3.7.
|
|
3
|
+
## [3.7.61] — 2026-10-06(未发布)
|
|
4
|
+
|
|
5
|
+
### fr-017 点亮:授权身份锚点取自请求输入(修复回归 11/17)
|
|
6
|
+
|
|
7
|
+
**归属语义漏报根因第一刀**:tinacms 的 `isAuthorized` 把「token 属于哪个
|
|
8
|
+
app/client」的身份锚点从 `req.query.clientID` 读取去问身份服务——攻击者
|
|
9
|
+
注册自己的 app 拿合法 token,即通过任意站点的后端授权(confused-deputy
|
|
10
|
+
变体,GHSA-g74q-6g2f-874x)。修复 = 锚点改从服务端配置取
|
|
11
|
+
(`expectedClientID ?? process.env.NEXT_PUBLIC_TINA_CLIENT_ID`)+ 取不到
|
|
12
|
+
fail-closed。
|
|
13
|
+
|
|
14
|
+
- 提取器 `src/extract-ir.ts`:`hasRequestAnchoredIdentity`——判据三元组
|
|
15
|
+
① 从请求取 token(req.headers.authorization / headers().get('authorization'))
|
|
16
|
+
② 从请求输入取 app/client 身份锚点(query/body/params 或 searchParams)
|
|
17
|
+
③ 无服务端锚点证据(process.env. / getConfig( / config.)
|
|
18
|
+
⇒ 注入 `__progmune_request_anchored_identity__`
|
|
19
|
+
- 规则 "Authorization Identity Anchor from Request"(ts/js,marker-driven,
|
|
20
|
+
进 safeguard 告警流,与 fr-001/002/003 同族)
|
|
21
|
+
- 边界(写死在判据里):锚点名不含 tenant(多租户从请求解析租户锚点是
|
|
22
|
+
合法形态);OAuth token 端点(req.body.client_id + client_secret,无
|
|
23
|
+
authorization header)不触发
|
|
24
|
+
- **三态验证**:修复前 2/2 真值函数命中(@tinacms/auth 与
|
|
25
|
+
next-tinacms-azure 两个变体)/ 修复后 0/0;TS 盲测 119 项目逐函数
|
|
26
|
+
零漂移(LOST 0/ADDED 0);fp-pool 真实语料 9 切片 1186 函数 0 命中;
|
|
27
|
+
定向测试 8/8。快照入库 `blind-benchmark/fr-corpus/fr-017-tinacms/`
|
|
28
|
+
|
|
29
|
+
### fr-008 / fr-013 判定关闭(修复回归语料全量定性收官)
|
|
30
|
+
|
|
31
|
+
- **fr-008**(SimpleWebAuthn,密码学协议语义):证书链 walk 在第一个自签名
|
|
32
|
+
证书处 break,永不检查链尾拼上的配置信任锚点——「链内部自洽」冒充
|
|
33
|
+
「链终止于信任锚点」(修复 = break 条件改为 `issuer.equal(anchor)`)。
|
|
34
|
+
检出需要建模证书链验证协议语义,任何标记/污点/缺席判据都够不着,
|
|
35
|
+
归入 V1 七类根因「密码学协议语义」,不做。
|
|
36
|
+
- **fr-013**(gitlab-mcp,五子问题全部分类):F1(a) GraphQL 逗号前导形态
|
|
37
|
+
= 字符串解析语义;F1(b) execute_graphql 缺项目范围检查 = 缺席型判据
|
|
38
|
+
**唯一可点亮方向**,但判据是 MCP 工具处理器专属词汇,需 MCP 类公告
|
|
39
|
+
语料族(≥2-3 条)支撑,单条按三要件不做;F2/F3 配置通道路由(启动校验
|
|
40
|
+
门缺凭证形态 / SSE 默认无认证 + DNS rebinding);F4 会话耗尽 DoS;
|
|
41
|
+
F5 内容级提示注入防御。constant-time 比较属加固建议类不单做。
|
|
42
|
+
- **fr-corpus 17 条全部定性收官:有效 11/17(9 干净 DETECTED + 2
|
|
43
|
+
with-boundary),6 条判定关闭**(fr-004 闭包晚绑定 / fr-006 黑名单
|
|
44
|
+
完备性 / fr-008 密码学协议语义 / fr-009 守卫不足 / fr-013 五子问题
|
|
45
|
+
全出检出面 / fr-015 corpus_invalid+守卫不足)。
|
|
46
|
+
|
|
47
|
+
## [3.7.60] — 2026-10-05
|
|
48
|
+
|
|
49
|
+
### fr-002/003 点亮:令牌交换路径检查完备性(修复回归 10/17)
|
|
50
|
+
|
|
51
|
+
**策略分支漏报根因第一刀**:OAuth 令牌交换路径绕过正常回调路径的检查——
|
|
52
|
+
open-webui 同一函数 `token_exchange` 的两个独立公告:缺角色策略判定
|
|
53
|
+
(fr-002,被角色策略拒绝的用户仍可登录)+ 缺域白名单检查(fr-003)。
|
|
54
|
+
|
|
55
|
+
- 提取器 `tools/extract_ir.py`:`has_token_exchange_missing_guard`——函数名
|
|
56
|
+
token exchange 形态 + 函数体(AST 全量文本)缺角色判定或缺域检查 ⇒
|
|
57
|
+
注入 `__progmune_token_exchange_unguarded__`。缺席证据来自 AST 全量
|
|
58
|
+
文本(非 TS 侧 calls 通道的盲判,R75 适用边界明确)
|
|
59
|
+
- 引擎规则 "Token Exchange Missing OAuth Guard"(python-only marker-driven)
|
|
60
|
+
- **三态验证**:fr-002 parent 报(role 缺)/ fr-002 fix 0(两项齐)/
|
|
61
|
+
fr-003 fix 报(该时间点 role 漏洞在场,正确);Python 盲测 90 项目
|
|
62
|
+
零触发(2533 条逐条一致);定向测试 8/8
|
|
63
|
+
- **fr-corpus 战绩:8 干净 DETECTED + 2 with-boundary = 有效 10/17**
|
|
64
|
+
- **fr-006 判定不可点亮**:SSRF 黑名单条目缺失是「配置/词表完备性」
|
|
65
|
+
知识问题,静态判据无法穷举「该有的条目」,不硬做。归入漏报根因
|
|
66
|
+
新类「配置/词表完备性」
|
|
67
|
+
|
|
68
|
+
### fr-014 点亮 + fr-015 关闭(2026-10-05)
|
|
4
69
|
|
|
5
70
|
### fr-001 点亮:身份映射子串匹配(修复回归 6/17)
|
|
6
71
|
|
|
@@ -49,7 +114,7 @@ external-ID)用 JSON 列 `.contains()` 做匹配——contains() 在 JSON 列
|
|
|
49
114
|
(闭包晚绑定)」。注:fix_commit cd9db21c 是「重构形式的安全修复」
|
|
50
115
|
(fr-013 教训的镜像:fix 性质必须以公告摘要核对)。
|
|
51
116
|
|
|
52
|
-
## [3.7.57] — 2026-10-04
|
|
117
|
+
## [3.7.57] — 2026-10-04(3.7.57~3.7.59 合并条目)
|
|
53
118
|
|
|
54
119
|
### §51 held-out 验证 + §52-53 决策门禁 + 修冤枉第一刀(Data Mutation 族移除)
|
|
55
120
|
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* fr-017 定向回归:授权身份锚点取自请求输入
|
|
4
|
+
* (REALWORLD_FIX_REGRESSION fr-017,tinacms GHSA-g74q-6g2f-874x)
|
|
5
|
+
*
|
|
6
|
+
* 判据在 extract-ir.ts 的 hasRequestAnchoredIdentity(三元组):
|
|
7
|
+
* ① 从请求取 token(req.headers.authorization / headers().get('authorization'))
|
|
8
|
+
* ② 从请求输入取身份锚点(req.query|body|params.<clientID|clientId|appId…>
|
|
9
|
+
* 或 searchParams.get('<锚点名>'))
|
|
10
|
+
* ③ 无服务端锚点证据(process.env. / getConfig( / config.)
|
|
11
|
+
* 三条件同时成立才注入 __progmune_request_anchored_identity__ 标记。
|
|
12
|
+
*/
|
|
13
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
14
|
+
if (k2 === undefined) k2 = k;
|
|
15
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
16
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
17
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
18
|
+
}
|
|
19
|
+
Object.defineProperty(o, k2, desc);
|
|
20
|
+
}) : (function(o, m, k, k2) {
|
|
21
|
+
if (k2 === undefined) k2 = k;
|
|
22
|
+
o[k2] = m[k];
|
|
23
|
+
}));
|
|
24
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
25
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
26
|
+
}) : function(o, v) {
|
|
27
|
+
o["default"] = v;
|
|
28
|
+
});
|
|
29
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
30
|
+
var ownKeys = function(o) {
|
|
31
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
32
|
+
var ar = [];
|
|
33
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
34
|
+
return ar;
|
|
35
|
+
};
|
|
36
|
+
return ownKeys(o);
|
|
37
|
+
};
|
|
38
|
+
return function (mod) {
|
|
39
|
+
if (mod && mod.__esModule) return mod;
|
|
40
|
+
var result = {};
|
|
41
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
42
|
+
__setModuleDefault(result, mod);
|
|
43
|
+
return result;
|
|
44
|
+
};
|
|
45
|
+
})();
|
|
46
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
|
+
const vitest_1 = require("vitest");
|
|
48
|
+
const fs = __importStar(require("fs"));
|
|
49
|
+
const os = __importStar(require("os"));
|
|
50
|
+
const path = __importStar(require("path"));
|
|
51
|
+
const extract_ir_1 = require("./extract-ir");
|
|
52
|
+
const protocol_detector_1 = require("./protocol-detector");
|
|
53
|
+
const TSCONFIG = JSON.stringify({
|
|
54
|
+
compilerOptions: {
|
|
55
|
+
target: "ES2020",
|
|
56
|
+
module: "commonjs",
|
|
57
|
+
moduleResolution: "node",
|
|
58
|
+
strict: false,
|
|
59
|
+
skipLibCheck: true,
|
|
60
|
+
noEmit: true,
|
|
61
|
+
},
|
|
62
|
+
include: ["**/*.ts"],
|
|
63
|
+
});
|
|
64
|
+
function makeProject(files) {
|
|
65
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pm-fr017-"));
|
|
66
|
+
fs.writeFileSync(path.join(dir, "tsconfig.json"), TSCONFIG);
|
|
67
|
+
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "fixture", version: "0.0.0", private: true }));
|
|
68
|
+
for (const [rel, body] of Object.entries(files)) {
|
|
69
|
+
const abs = path.join(dir, rel);
|
|
70
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
71
|
+
fs.writeFileSync(abs, body);
|
|
72
|
+
}
|
|
73
|
+
return dir;
|
|
74
|
+
}
|
|
75
|
+
const MARKER = "__progmune_request_anchored_identity__";
|
|
76
|
+
(0, vitest_1.describe)("fr-017 request-anchored identity(提取器标记)", () => {
|
|
77
|
+
(0, vitest_1.it)("修复前形态:req.query.clientID 锚点 + authorization header → 注入标记", () => {
|
|
78
|
+
const dir = makeProject({
|
|
79
|
+
"index.ts": `
|
|
80
|
+
export const isAuthorized = async (req: any): Promise<any> => {
|
|
81
|
+
const clientID = req.query.clientID;
|
|
82
|
+
const token = req.headers.authorization;
|
|
83
|
+
if (typeof clientID === 'string' && typeof token === 'string') {
|
|
84
|
+
return await isUserAuthorized({ clientID, token });
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
async function isUserAuthorized(args: { clientID: string; token: string }) {
|
|
90
|
+
return fetch("https://identity.example/verify", {
|
|
91
|
+
headers: { authorization: args.token },
|
|
92
|
+
body: JSON.stringify({ clientID: args.clientID }),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
`,
|
|
96
|
+
});
|
|
97
|
+
const ir = (0, extract_ir_1.extractIR)(dir);
|
|
98
|
+
const fn = ir.find((f) => f.name === "isAuthorized");
|
|
99
|
+
const callee = ir.find((f) => f.name === "isUserAuthorized");
|
|
100
|
+
(0, vitest_1.expect)(fn).toBeDefined();
|
|
101
|
+
(0, vitest_1.expect)(fn.calls).toContain(MARKER);
|
|
102
|
+
// 被调用方没有 req 形态,不误标
|
|
103
|
+
(0, vitest_1.expect)(callee).toBeDefined();
|
|
104
|
+
(0, vitest_1.expect)(callee.calls).not.toContain(MARKER);
|
|
105
|
+
});
|
|
106
|
+
(0, vitest_1.it)("修复前形态(NextRequest):searchParams.get('clientID') → 注入标记", () => {
|
|
107
|
+
const dir = makeProject({
|
|
108
|
+
"auth.ts": `
|
|
109
|
+
export const isAuthorized = async (req: Request): Promise<any> => {
|
|
110
|
+
const clientID = new URL(req.url).searchParams.get('clientID');
|
|
111
|
+
const token = (await headers()).get('authorization');
|
|
112
|
+
if (typeof clientID === 'string' && typeof token === 'string') {
|
|
113
|
+
return await isUserAuthorized({ clientID, token });
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
async function isUserAuthorized(args: { clientID: string; token: string }) {
|
|
119
|
+
return fetch("https://identity.example/verify");
|
|
120
|
+
}
|
|
121
|
+
`,
|
|
122
|
+
});
|
|
123
|
+
const ir = (0, extract_ir_1.extractIR)(dir);
|
|
124
|
+
const fn = ir.find((f) => f.name === "isAuthorized");
|
|
125
|
+
(0, vitest_1.expect)(fn).toBeDefined();
|
|
126
|
+
(0, vitest_1.expect)(fn.calls).toContain(MARKER);
|
|
127
|
+
});
|
|
128
|
+
(0, vitest_1.it)("修复后形态:expectedClientID ?? process.env → 无标记", () => {
|
|
129
|
+
const dir = makeProject({
|
|
130
|
+
"index.ts": `
|
|
131
|
+
export const isAuthorized = async (req: any, expectedClientID?: string): Promise<any> => {
|
|
132
|
+
const token = req.headers.authorization;
|
|
133
|
+
const clientID = (expectedClientID ?? process.env.NEXT_PUBLIC_TINA_CLIENT_ID)?.trim();
|
|
134
|
+
if (typeof clientID !== 'string' || clientID.length === 0) {
|
|
135
|
+
console.error("isAuthorized could not resolve this site's clientID. Refusing to authorize.");
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
if (typeof token !== 'string') return undefined;
|
|
139
|
+
return await isUserAuthorized({ clientID, token });
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
async function isUserAuthorized(args: { clientID: string; token: string }) {
|
|
143
|
+
return fetch("https://identity.example/verify");
|
|
144
|
+
}
|
|
145
|
+
`,
|
|
146
|
+
});
|
|
147
|
+
const ir = (0, extract_ir_1.extractIR)(dir);
|
|
148
|
+
for (const f of ir) {
|
|
149
|
+
(0, vitest_1.expect)(f.calls).not.toContain(MARKER);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
(0, vitest_1.it)("负例:OAuth token 端点(req.body.client_id,无 authorization header)→ 无标记", () => {
|
|
153
|
+
const dir = makeProject({
|
|
154
|
+
"token.ts": `
|
|
155
|
+
export const tokenEndpoint = async (req: any) => {
|
|
156
|
+
const clientId = req.body.client_id;
|
|
157
|
+
const clientSecret = req.body.client_secret;
|
|
158
|
+
return await exchangeToken({ clientId, clientSecret });
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
async function exchangeToken(args: { clientId: string; clientSecret: string }) {
|
|
162
|
+
return fetch("https://identity.example/token");
|
|
163
|
+
}
|
|
164
|
+
`,
|
|
165
|
+
});
|
|
166
|
+
const ir = (0, extract_ir_1.extractIR)(dir);
|
|
167
|
+
for (const f of ir) {
|
|
168
|
+
(0, vitest_1.expect)(f.calls).not.toContain(MARKER);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
(0, vitest_1.it)("负例:锚点有服务端绑定(getConfig)→ 无标记", () => {
|
|
172
|
+
const dir = makeProject({
|
|
173
|
+
"index.ts": `
|
|
174
|
+
export const isAuthorized = async (req: any): Promise<any> => {
|
|
175
|
+
const token = req.headers.authorization;
|
|
176
|
+
const clientID = req.query.clientID;
|
|
177
|
+
const expected = getConfig("client-id", "CLIENT_ID");
|
|
178
|
+
if (clientID !== expected) return undefined;
|
|
179
|
+
return await isUserAuthorized({ clientID: expected, token });
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
async function isUserAuthorized(args: { clientID: string; token: string }) {
|
|
183
|
+
return fetch("https://identity.example/verify");
|
|
184
|
+
}
|
|
185
|
+
`,
|
|
186
|
+
});
|
|
187
|
+
const ir = (0, extract_ir_1.extractIR)(dir);
|
|
188
|
+
for (const f of ir) {
|
|
189
|
+
(0, vitest_1.expect)(f.calls).not.toContain(MARKER);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
(0, vitest_1.it)("负例:tenantId 从请求解析(多租户合法形态)→ 无标记", () => {
|
|
193
|
+
const dir = makeProject({
|
|
194
|
+
"index.ts": `
|
|
195
|
+
export const isAuthorized = async (req: any): Promise<any> => {
|
|
196
|
+
const tenantId = req.query.tenantId;
|
|
197
|
+
const token = req.headers.authorization;
|
|
198
|
+
return await verifyTenantToken({ tenantId, token });
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
async function verifyTenantToken(args: { tenantId: string; token: string }) {
|
|
202
|
+
return fetch("https://identity.example/verify");
|
|
203
|
+
}
|
|
204
|
+
`,
|
|
205
|
+
});
|
|
206
|
+
const ir = (0, extract_ir_1.extractIR)(dir);
|
|
207
|
+
for (const f of ir) {
|
|
208
|
+
(0, vitest_1.expect)(f.calls).not.toContain(MARKER);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
(0, vitest_1.describe)("fr-017 request-anchored identity(规则触发)", () => {
|
|
213
|
+
(0, vitest_1.it)("TS 语言:标记命中 Authorization Identity Anchor from Request", () => {
|
|
214
|
+
const svs = (0, protocol_detector_1.detectSafeguardViolations)([MARKER], "isAuthorized", "typescript", []);
|
|
215
|
+
const hit = svs.find((v) => v.rule === "Authorization Identity Anchor from Request");
|
|
216
|
+
(0, vitest_1.expect)(hit).toBeDefined();
|
|
217
|
+
});
|
|
218
|
+
(0, vitest_1.it)("python 语言:该规则不触发(languages 门)", () => {
|
|
219
|
+
const svs = (0, protocol_detector_1.detectSafeguardViolations)([MARKER], "is_authorized", "python", []);
|
|
220
|
+
const hit = svs.find((v) => v.rule === "Authorization Identity Anchor from Request");
|
|
221
|
+
(0, vitest_1.expect)(hit).toBeUndefined();
|
|
222
|
+
});
|
|
223
|
+
});
|
package/dist/extract-ir.js
CHANGED
|
@@ -2891,6 +2891,41 @@ const URL_PARAM_NAME = /^(url|target|endpoint|link|href|webUrl|web_url|sourceUrl
|
|
|
2891
2891
|
function collectUrlParamNames(params) {
|
|
2892
2892
|
return params.map((p) => p.name).filter((n) => URL_PARAM_NAME.test(n));
|
|
2893
2893
|
}
|
|
2894
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2895
|
+
// 请求锚定身份标记(2026-10-06,fr-017 tinacms GHSA-g74q-6g2f-874x)
|
|
2896
|
+
//
|
|
2897
|
+
// 身份校验把「token 属于哪个 app/client」这一身份锚点从**请求输入**读取
|
|
2898
|
+
// (req.query.clientID / searchParams.get('clientID'))。攻击者注册自己的
|
|
2899
|
+
// app 拿到合法 token,再对受害者后端指定自己的 clientID——后端问身份服务
|
|
2900
|
+
// 「这 token 是不是该 app 的」→ 是 → 授权通过。修复形态 = 锚点改从服务端
|
|
2901
|
+
// 配置取(expectedClientID ?? process.env.NEXT_PUBLIC_TINA_CLIENT_ID),
|
|
2902
|
+
// 且取不到 fail-closed。
|
|
2903
|
+
//
|
|
2904
|
+
// 判据三元组(与 fr-002/003 的缺席判据同族):
|
|
2905
|
+
// ① 从请求取 token(req.headers.authorization / headers().get('authorization'))
|
|
2906
|
+
// ② 从请求输入取身份锚点(req.query|body|params.<锚点名> 或 searchParams.get)
|
|
2907
|
+
// ③ 无服务端锚点证据(process.env. / getConfig( / config.)
|
|
2908
|
+
// 锚点名不含 tenant——多租户从请求解析租户锚点是合法形态,只有 app/client
|
|
2909
|
+
// 身份才是「服务器该知道自己的身份」的一侧。
|
|
2910
|
+
// 消费方:protocol-detector 的 "Authorization Identity Anchor from Request"。
|
|
2911
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2912
|
+
const REQUEST_ANCHORED_IDENTITY_MARKER = "__progmune_request_anchored_identity__";
|
|
2913
|
+
const IDENTITY_ANCHOR_NAMES = /clientID|clientId|client_id|appId|appID|app_id|applicationId|applicationID|application_id/;
|
|
2914
|
+
/** ① 从请求取 token(NextApiRequest 与 NextRequest 两种形态) */
|
|
2915
|
+
const REQ_TOKEN_SRC = /req\.headers\.authorization|\.get\(\s*["']authorization["']\s*\)/i;
|
|
2916
|
+
/** ② 从请求输入取身份锚点(query/body/params 成员或 searchParams.get) */
|
|
2917
|
+
const REQ_ANCHOR_SRC = new RegExp(`(?:req\\.(?:query|body|params)\\.(?:${IDENTITY_ANCHOR_NAMES.source})\\b|searchParams\\.get\\(\\s*["'](?:${IDENTITY_ANCHOR_NAMES.source})["']\\s*\\))`);
|
|
2918
|
+
/** ③ 服务端锚点证据:出现即视为锚点有服务端绑定(配置/环境/配置对象),不标记 */
|
|
2919
|
+
const SERVER_ANCHOR_EVIDENCE = /process\.env\.|\bgetConfig\s*\(|\bconfig\./i;
|
|
2920
|
+
function hasRequestAnchoredIdentity(text) {
|
|
2921
|
+
if (!REQ_TOKEN_SRC.test(text))
|
|
2922
|
+
return false;
|
|
2923
|
+
if (!REQ_ANCHOR_SRC.test(text))
|
|
2924
|
+
return false;
|
|
2925
|
+
if (SERVER_ANCHOR_EVIDENCE.test(text))
|
|
2926
|
+
return false;
|
|
2927
|
+
return true;
|
|
2928
|
+
}
|
|
2894
2929
|
/**
|
|
2895
2930
|
* 标记增强的内联实现(2026-09-15 性能重构):消费主循环已获取的
|
|
2896
2931
|
* 函数文本与形参名,输出该函数应附加的合成标记。
|
|
@@ -2974,6 +3009,12 @@ function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit, guardFns,
|
|
|
2974
3009
|
}
|
|
2975
3010
|
}
|
|
2976
3011
|
}
|
|
3012
|
+
// ── 请求锚定身份(2026-10-06,fr-017)──
|
|
3013
|
+
// 纯函数体文本判据,无需函数名——isUserAuthorized(被调用方)体内没有
|
|
3014
|
+
// req.headers.authorization 形态,不会误标;只有面向请求的校验入口会命中。
|
|
3015
|
+
if (hasRequestAnchoredIdentity(text)) {
|
|
3016
|
+
markers.push(REQUEST_ANCHORED_IDENTITY_MARKER);
|
|
3017
|
+
}
|
|
2977
3018
|
return markers;
|
|
2978
3019
|
}
|
|
2979
3020
|
/**
|
|
@@ -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,39 @@ 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
|
+
},
|
|
881
|
+
{
|
|
882
|
+
name: "Authorization Identity Anchor from Request",
|
|
883
|
+
category: "auth",
|
|
884
|
+
// 2026-10-06(REALWORLD_FIX_REGRESSION fr-017):身份校验的锚点
|
|
885
|
+
// (token 属于哪个 app/client)从请求输入读取——tinacms 的 isAuthorized
|
|
886
|
+
// 从 req.query.clientID 取锚点去问身份服务「这 token 是不是该 app 的」,
|
|
887
|
+
// 攻击者注册自己的 app 拿合法 token 即通过任意站点的后端授权
|
|
888
|
+
// (GHSA-g74q-6g2f-874x)。修复 = 锚点改从服务端配置/环境取 +
|
|
889
|
+
// 取不到 fail-closed。判据在提取器侧 hasRequestAnchoredIdentity
|
|
890
|
+
// (三元组:请求取 token + 请求取 app/client 锚点 + 无服务端锚点证据)。
|
|
891
|
+
languages: ["typescript", "javascript"],
|
|
892
|
+
trigger: /\b(__progmune_request_anchored_identity__)\b/,
|
|
893
|
+
safeguards: [],
|
|
894
|
+
violationMessage: "Authorization validates a token against an app/client identity read from the request (query/body/searchParams) rather than server configuration — an attacker registers their own app identity, supplies a token that IS valid for it, and passes authorization on any endpoint using this function. Anchor the identity in server config/env and fail closed when it cannot be resolved.",
|
|
895
|
+
conceptMissing: ["ServerConfiguredIdentityAnchor"],
|
|
896
|
+
conceptExpected: ["expectedClientID from config/env", "fail-closed when anchor unresolved"],
|
|
897
|
+
},
|
|
865
898
|
{
|
|
866
899
|
name: "XSS (Unsafe Template Rendering)",
|
|
867
900
|
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.
|
|
23
|
+
exports.RUNTIME_VERSION = "3.7.61";
|
|
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.
|
|
3
|
+
"version": "3.7.61",
|
|
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/",
|
package/tools/extract_ir.py
CHANGED
|
@@ -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:
|