granttrace 2.4.0__py3-none-any.whl
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.
- api_sentinel.py +402 -0
- core/__init__.py +4 -0
- core/auditor.py +1101 -0
- core/config_scaffold.py +254 -0
- core/config_validator.py +1408 -0
- core/diff.py +839 -0
- core/evidence.py +353 -0
- core/generator.py +1048 -0
- core/models.py +104 -0
- core/parameters.py +198 -0
- core/parser.py +495 -0
- core/reporter.py +359 -0
- core/transactions.py +167 -0
- granttrace-2.4.0.dist-info/METADATA +108 -0
- granttrace-2.4.0.dist-info/RECORD +21 -0
- granttrace-2.4.0.dist-info/WHEEL +5 -0
- granttrace-2.4.0.dist-info/entry_points.txt +2 -0
- granttrace-2.4.0.dist-info/licenses/LICENSE +202 -0
- granttrace-2.4.0.dist-info/top_level.txt +3 -0
- mock_server/__init__.py +4 -0
- mock_server/server.py +190 -0
core/diff.py
ADDED
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
语义响应差分与分类判定引擎。
|
|
4
|
+
|
|
5
|
+
状态码和业务拒绝信号必须与响应正文一起检查。错误响应仍可能泄露数据,
|
|
6
|
+
匿名可读也不能证明资源预期公开;确认证据只来自业务值或显式可信标识路径。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from core.models import json_values_equal, strict_json_loads
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any, Dict, Iterable, Optional, Set, Tuple
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ResponseDiffEngine:
|
|
17
|
+
"""Classify API responses and evaluate object-level authorization evidence."""
|
|
18
|
+
|
|
19
|
+
RESPONSE_VALID = "VALID"
|
|
20
|
+
RESPONSE_AUTH_DENIED = "AUTH_DENIED"
|
|
21
|
+
RESPONSE_APPLICATION_ERROR = "APPLICATION_ERROR"
|
|
22
|
+
RESPONSE_TRANSPORT_ERROR = "TRANSPORT_ERROR"
|
|
23
|
+
RESPONSE_HTTP_CLIENT_ERROR = "HTTP_CLIENT_ERROR"
|
|
24
|
+
RESPONSE_SERVER_ERROR = "SERVER_ERROR"
|
|
25
|
+
RESPONSE_HTTP_ERROR = "HTTP_ERROR"
|
|
26
|
+
RESPONSE_EMPTY = "EMPTY_RESPONSE"
|
|
27
|
+
RESPONSE_INVALID = "INVALID_RESPONSE"
|
|
28
|
+
|
|
29
|
+
AUTH_ERROR_CODES = {401, 403, "401", "403"}
|
|
30
|
+
AUTH_ERROR_WORDS = (
|
|
31
|
+
"unauthorized",
|
|
32
|
+
"forbidden",
|
|
33
|
+
"authentication required",
|
|
34
|
+
"not authenticated",
|
|
35
|
+
"permission denied",
|
|
36
|
+
"access denied",
|
|
37
|
+
"not allowed",
|
|
38
|
+
"auth_error",
|
|
39
|
+
"无权访问",
|
|
40
|
+
"无权限",
|
|
41
|
+
"权限不足",
|
|
42
|
+
"拒绝访问",
|
|
43
|
+
"未授权",
|
|
44
|
+
"未登录",
|
|
45
|
+
"需要登录",
|
|
46
|
+
)
|
|
47
|
+
ERROR_STATUS_WORDS = {"error", "fail", "failed", "failure", "denied"}
|
|
48
|
+
ERROR_VALUE_KEYS = {
|
|
49
|
+
"error",
|
|
50
|
+
"errors",
|
|
51
|
+
"exception",
|
|
52
|
+
"error_message",
|
|
53
|
+
"errormessage",
|
|
54
|
+
}
|
|
55
|
+
CODE_KEYS = {
|
|
56
|
+
"code",
|
|
57
|
+
"error_code",
|
|
58
|
+
"errorcode",
|
|
59
|
+
"err_code",
|
|
60
|
+
"errcode",
|
|
61
|
+
"http_status",
|
|
62
|
+
"status_code",
|
|
63
|
+
}
|
|
64
|
+
MESSAGE_KEYS = {
|
|
65
|
+
"error",
|
|
66
|
+
"errors",
|
|
67
|
+
"message",
|
|
68
|
+
"msg",
|
|
69
|
+
"detail",
|
|
70
|
+
"description",
|
|
71
|
+
"reason",
|
|
72
|
+
"status",
|
|
73
|
+
}
|
|
74
|
+
# Wrapper fields do not identify a returned business resource by themselves.
|
|
75
|
+
NON_RESOURCE_VALUE_KEYS = {
|
|
76
|
+
"code",
|
|
77
|
+
"error",
|
|
78
|
+
"errors",
|
|
79
|
+
"message",
|
|
80
|
+
"msg",
|
|
81
|
+
"status",
|
|
82
|
+
"success",
|
|
83
|
+
"timestamp",
|
|
84
|
+
"trace_id",
|
|
85
|
+
"traceid",
|
|
86
|
+
"request_id",
|
|
87
|
+
"requestid",
|
|
88
|
+
"detail",
|
|
89
|
+
"description",
|
|
90
|
+
"reason",
|
|
91
|
+
}
|
|
92
|
+
RESPONSE_WRAPPER_KEYS = {"payload", "result", "response", "data", "error", "errors"}
|
|
93
|
+
METADATA_CONTAINER_KEYS = {"meta", "metadata", "request", "query", "params", "headers", "trace"}
|
|
94
|
+
|
|
95
|
+
# Kept for callers that introspect the old constant. Detection itself is recursive.
|
|
96
|
+
SOFT_ERROR_SIGNATURES = [
|
|
97
|
+
("code", [401, 403, 404, 500, 10001, 10002, -1, "401", "403", "404", "AUTH_ERROR", "FORBIDDEN", "UNAUTHORIZED"]),
|
|
98
|
+
("status", ["error", "fail", "unauthorized", "forbidden"]),
|
|
99
|
+
("success", [False, "false", 0, "0"]),
|
|
100
|
+
("error", None),
|
|
101
|
+
("message", ["unauthorized", "forbidden", "permission denied", "not allowed", "无权访问", "权限不足", "未登录"]),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _normalise_key(key: Any) -> str:
|
|
106
|
+
text = str(key).strip().replace("-", "_")
|
|
107
|
+
text = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", text)
|
|
108
|
+
text = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", text)
|
|
109
|
+
return text.lower()
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def _walk_fields(cls, obj: Any, path: str = "") -> Iterable[Tuple[str, str, Any]]:
|
|
113
|
+
"""Yield every nested mapping field, including fields inside arrays."""
|
|
114
|
+
if isinstance(obj, dict):
|
|
115
|
+
for raw_key, value in obj.items():
|
|
116
|
+
key = cls._normalise_key(raw_key)
|
|
117
|
+
field_path = f"{path}.{raw_key}" if path else str(raw_key)
|
|
118
|
+
yield field_path, key, value
|
|
119
|
+
if isinstance(value, (dict, list)):
|
|
120
|
+
yield from cls._walk_fields(value, field_path)
|
|
121
|
+
elif isinstance(obj, list):
|
|
122
|
+
for index, value in enumerate(obj):
|
|
123
|
+
item_path = f"{path}[{index}]" if path else f"[{index}]"
|
|
124
|
+
if isinstance(value, (dict, list)):
|
|
125
|
+
yield from cls._walk_fields(value, item_path)
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _text_contains(text: Any, words: Iterable[str]) -> Optional[str]:
|
|
129
|
+
if not isinstance(text, str):
|
|
130
|
+
return None
|
|
131
|
+
normalised = text.strip().lower()
|
|
132
|
+
for word in words:
|
|
133
|
+
if word in normalised:
|
|
134
|
+
return word
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def _detect_nested_soft_error(cls, data: Any) -> Optional[Tuple[str, str]]:
|
|
139
|
+
"""Inspect response envelopes, never arbitrary records or historical logs."""
|
|
140
|
+
def envelope_fields(value: Any, prefix: str = ""):
|
|
141
|
+
if isinstance(value, dict):
|
|
142
|
+
for raw_key, child in value.items():
|
|
143
|
+
key = cls._normalise_key(raw_key)
|
|
144
|
+
path = f"{prefix}.{raw_key}" if prefix else str(raw_key)
|
|
145
|
+
yield path, key, child
|
|
146
|
+
if key in cls.RESPONSE_WRAPPER_KEYS and isinstance(child, (dict, list)):
|
|
147
|
+
yield from envelope_fields(child, path)
|
|
148
|
+
elif isinstance(value, list):
|
|
149
|
+
for index, child in enumerate(value):
|
|
150
|
+
if isinstance(child, dict):
|
|
151
|
+
yield from envelope_fields(child, f"{prefix}[{index}]")
|
|
152
|
+
|
|
153
|
+
fields = list(envelope_fields(data))
|
|
154
|
+
|
|
155
|
+
# Authentication evidence wins over a generic error wrapper, even when the
|
|
156
|
+
# generic field is encountered first in the JSON object.
|
|
157
|
+
for path, key, value in fields:
|
|
158
|
+
if key in cls.CODE_KEYS and isinstance(value, (int, str)) and value in cls.AUTH_ERROR_CODES:
|
|
159
|
+
return cls.RESPONSE_AUTH_DENIED, f"业务鉴权错误码: {path}={value}"
|
|
160
|
+
|
|
161
|
+
if key in cls.MESSAGE_KEYS or key in cls.CODE_KEYS:
|
|
162
|
+
if isinstance(value, str) and value.strip().lower() in cls.AUTH_ERROR_WORDS:
|
|
163
|
+
return cls.RESPONSE_AUTH_DENIED, f"业务鉴权拒绝包装: {path}"
|
|
164
|
+
|
|
165
|
+
for path, key, value in fields:
|
|
166
|
+
if key in cls.CODE_KEYS:
|
|
167
|
+
numeric_value = None
|
|
168
|
+
try:
|
|
169
|
+
numeric_value = int(value)
|
|
170
|
+
except (TypeError, ValueError):
|
|
171
|
+
pass
|
|
172
|
+
if numeric_value is not None and (numeric_value < 0 or numeric_value >= 400):
|
|
173
|
+
return cls.RESPONSE_APPLICATION_ERROR, f"业务错误码: {path}={value}"
|
|
174
|
+
|
|
175
|
+
if key == "success" and value in (False, 0, "0"):
|
|
176
|
+
return cls.RESPONSE_APPLICATION_ERROR, f"业务失败标志: {path}={value}"
|
|
177
|
+
if key == "success" and isinstance(value, str) and value.strip().lower() == "false":
|
|
178
|
+
return cls.RESPONSE_APPLICATION_ERROR, f"业务失败标志: {path}={value}"
|
|
179
|
+
|
|
180
|
+
if key == "status" and isinstance(value, str):
|
|
181
|
+
if value.strip().lower() in cls.ERROR_STATUS_WORDS:
|
|
182
|
+
return cls.RESPONSE_APPLICATION_ERROR, f"业务失败状态: {path}={value}"
|
|
183
|
+
|
|
184
|
+
if key in cls.ERROR_VALUE_KEYS:
|
|
185
|
+
if isinstance(value, str):
|
|
186
|
+
if value.strip() and value.strip().lower() not in {"none", "null", "false", "0"}:
|
|
187
|
+
return cls.RESPONSE_APPLICATION_ERROR, f"业务错误字段: {path}={value}"
|
|
188
|
+
elif value:
|
|
189
|
+
return cls.RESPONSE_APPLICATION_ERROR, f"业务错误字段: {path} 非空"
|
|
190
|
+
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def _assess_response(cls, status_code: int, body_str: str) -> Dict[str, Any]:
|
|
195
|
+
"""Classify one response and retain parsed JSON for internal comparison."""
|
|
196
|
+
try:
|
|
197
|
+
status = int(status_code)
|
|
198
|
+
except (TypeError, ValueError):
|
|
199
|
+
return {
|
|
200
|
+
"kind": cls.RESPONSE_TRANSPORT_ERROR,
|
|
201
|
+
"status_code": status_code,
|
|
202
|
+
"is_error": True,
|
|
203
|
+
"is_auth_denial": False,
|
|
204
|
+
"reason": f"无效的传输状态码: {status_code!r}",
|
|
205
|
+
"data": None,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if status <= 0:
|
|
209
|
+
return {
|
|
210
|
+
"kind": cls.RESPONSE_TRANSPORT_ERROR,
|
|
211
|
+
"status_code": status,
|
|
212
|
+
"is_error": True,
|
|
213
|
+
"is_auth_denial": False,
|
|
214
|
+
"reason": "请求未获得 HTTP 响应(网络、TLS、超时或连接错误)",
|
|
215
|
+
"data": None,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
# Error bodies are evidence too: keep JSON even for 401/403 so that an
|
|
219
|
+
# apparent denial cannot conceal business data returned in its body.
|
|
220
|
+
try:
|
|
221
|
+
error_data = strict_json_loads(body_str)
|
|
222
|
+
except (TypeError, ValueError, RecursionError):
|
|
223
|
+
error_data = None
|
|
224
|
+
|
|
225
|
+
if status in (401, 403):
|
|
226
|
+
return {
|
|
227
|
+
"kind": cls.RESPONSE_AUTH_DENIED,
|
|
228
|
+
"status_code": status,
|
|
229
|
+
"is_error": True,
|
|
230
|
+
"is_auth_denial": True,
|
|
231
|
+
"reason": f"HTTP 鉴权拒绝状态码 {status}",
|
|
232
|
+
"data": error_data,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if 400 <= status <= 499:
|
|
236
|
+
return {
|
|
237
|
+
"kind": cls.RESPONSE_HTTP_CLIENT_ERROR,
|
|
238
|
+
"status_code": status,
|
|
239
|
+
"is_error": True,
|
|
240
|
+
"is_auth_denial": False,
|
|
241
|
+
"reason": f"HTTP 客户端错误状态码 {status},不能据此证明访问控制生效",
|
|
242
|
+
"data": error_data,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if 500 <= status <= 599:
|
|
246
|
+
return {
|
|
247
|
+
"kind": cls.RESPONSE_SERVER_ERROR,
|
|
248
|
+
"status_code": status,
|
|
249
|
+
"is_error": True,
|
|
250
|
+
"is_auth_denial": False,
|
|
251
|
+
"reason": f"HTTP 服务端错误状态码 {status}",
|
|
252
|
+
"data": error_data,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if status < 200 or status >= 300:
|
|
256
|
+
return {
|
|
257
|
+
"kind": cls.RESPONSE_HTTP_ERROR,
|
|
258
|
+
"status_code": status,
|
|
259
|
+
"is_error": True,
|
|
260
|
+
"is_auth_denial": False,
|
|
261
|
+
"reason": f"非成功 HTTP 状态码 {status}",
|
|
262
|
+
"data": error_data,
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if body_str is None or not str(body_str).strip():
|
|
266
|
+
return {
|
|
267
|
+
"kind": cls.RESPONSE_EMPTY,
|
|
268
|
+
"status_code": status,
|
|
269
|
+
"is_error": True,
|
|
270
|
+
"is_auth_denial": False,
|
|
271
|
+
"reason": "成功状态码对应的响应体为空,无法建立数据基线",
|
|
272
|
+
"data": None,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
data = strict_json_loads(body_str)
|
|
277
|
+
except (TypeError, ValueError, RecursionError):
|
|
278
|
+
return {
|
|
279
|
+
"kind": cls.RESPONSE_INVALID,
|
|
280
|
+
"status_code": status,
|
|
281
|
+
"is_error": True,
|
|
282
|
+
"is_auth_denial": False,
|
|
283
|
+
"reason": "成功状态码对应的响应体不是有效 JSON",
|
|
284
|
+
"data": None,
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if data is None or data == {} or data == []:
|
|
288
|
+
return {
|
|
289
|
+
"kind": cls.RESPONSE_EMPTY,
|
|
290
|
+
"status_code": status,
|
|
291
|
+
"is_error": True,
|
|
292
|
+
"is_auth_denial": False,
|
|
293
|
+
"reason": "JSON 响应没有可用于比对的数据",
|
|
294
|
+
"data": data,
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
nested_error = cls._detect_nested_soft_error(data)
|
|
298
|
+
if nested_error:
|
|
299
|
+
kind, reason = nested_error
|
|
300
|
+
return {
|
|
301
|
+
"kind": kind,
|
|
302
|
+
"status_code": status,
|
|
303
|
+
"is_error": True,
|
|
304
|
+
"is_auth_denial": kind == cls.RESPONSE_AUTH_DENIED,
|
|
305
|
+
"reason": reason,
|
|
306
|
+
"data": data,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
"kind": cls.RESPONSE_VALID,
|
|
311
|
+
"status_code": status,
|
|
312
|
+
"is_error": False,
|
|
313
|
+
"is_auth_denial": False,
|
|
314
|
+
"reason": "有效 JSON 业务响应",
|
|
315
|
+
"data": data,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
@classmethod
|
|
319
|
+
def classify_response(cls, status_code: int, body_str: str) -> Dict[str, Any]:
|
|
320
|
+
"""Return a serialisable response classification without echoing response data."""
|
|
321
|
+
assessment = cls._assess_response(status_code, body_str)
|
|
322
|
+
return {key: value for key, value in assessment.items() if key != "data"}
|
|
323
|
+
|
|
324
|
+
@classmethod
|
|
325
|
+
def is_soft_error(cls, status_code: int, body_str: str) -> Tuple[bool, str]:
|
|
326
|
+
"""Backward-compatible error predicate used by the existing auditor."""
|
|
327
|
+
assessment = cls._assess_response(status_code, body_str)
|
|
328
|
+
return assessment["is_error"], assessment["reason"] if assessment["is_error"] else ""
|
|
329
|
+
|
|
330
|
+
@classmethod
|
|
331
|
+
def _denial_body_is_clean(cls, assessment: Dict[str, Any], body: str) -> bool:
|
|
332
|
+
"""Accept only a recognizable rejection envelope without business content.
|
|
333
|
+
|
|
334
|
+
Unknown or unparseable content cannot prove that a denial withheld data.
|
|
335
|
+
Metadata allowance is deliberately limited to scalar tracing fields.
|
|
336
|
+
"""
|
|
337
|
+
if body is None or not str(body).strip():
|
|
338
|
+
return True
|
|
339
|
+
data = assessment["data"]
|
|
340
|
+
if data is None:
|
|
341
|
+
return str(body).strip().lower() in cls.AUTH_ERROR_WORDS
|
|
342
|
+
|
|
343
|
+
def clean(value: Any, key: str = "") -> bool:
|
|
344
|
+
if isinstance(value, dict):
|
|
345
|
+
return all(clean(child, cls._normalise_key(raw)) for raw, child in value.items())
|
|
346
|
+
if isinstance(value, list):
|
|
347
|
+
return key in cls.RESPONSE_WRAPPER_KEYS and all(clean(child) for child in value)
|
|
348
|
+
if value is None or value == "":
|
|
349
|
+
return True
|
|
350
|
+
if key in {"timestamp", "request_id", "requestid", "trace_id", "traceid"}:
|
|
351
|
+
return isinstance(value, (str, int, float))
|
|
352
|
+
if key in cls.CODE_KEYS:
|
|
353
|
+
return isinstance(value, (int, str)) and value in cls.AUTH_ERROR_CODES
|
|
354
|
+
if key == "success":
|
|
355
|
+
return value in (False, 0, "0", "false")
|
|
356
|
+
if key in cls.MESSAGE_KEYS or key in cls.ERROR_VALUE_KEYS:
|
|
357
|
+
return isinstance(value, str) and value.strip().lower() in (
|
|
358
|
+
set(cls.AUTH_ERROR_WORDS) | cls.ERROR_STATUS_WORDS
|
|
359
|
+
)
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
return clean(data)
|
|
363
|
+
|
|
364
|
+
@classmethod
|
|
365
|
+
def _extract_keys(cls, obj: Any) -> Set[str]:
|
|
366
|
+
"""Recursively extract structural paths from objects and object arrays."""
|
|
367
|
+
keys: Set[str] = set()
|
|
368
|
+
if isinstance(obj, dict):
|
|
369
|
+
for key, value in obj.items():
|
|
370
|
+
key_text = str(key)
|
|
371
|
+
keys.add(key_text)
|
|
372
|
+
if isinstance(value, dict):
|
|
373
|
+
keys.update(f"{key_text}.{sub}" for sub in cls._extract_keys(value))
|
|
374
|
+
elif isinstance(value, list):
|
|
375
|
+
keys.update(f"{key_text}[].{sub}" for sub in cls._extract_keys(value))
|
|
376
|
+
elif isinstance(obj, list):
|
|
377
|
+
# A few records are enough for a schema-shape hint. Structure never
|
|
378
|
+
# becomes confirmation evidence on its own.
|
|
379
|
+
for item in obj[:3]:
|
|
380
|
+
if isinstance(item, (dict, list)):
|
|
381
|
+
keys.update(cls._extract_keys(item))
|
|
382
|
+
return keys
|
|
383
|
+
|
|
384
|
+
@classmethod
|
|
385
|
+
def calculate_jaccard_similarity(cls, json_str_a: str, json_str_b: str) -> float:
|
|
386
|
+
"""Calculate structural-key Jaccard similarity for compatibility/reporting."""
|
|
387
|
+
try:
|
|
388
|
+
a = strict_json_loads(json_str_a)
|
|
389
|
+
b = strict_json_loads(json_str_b)
|
|
390
|
+
keys_a = cls._extract_keys(a)
|
|
391
|
+
keys_b = cls._extract_keys(b)
|
|
392
|
+
|
|
393
|
+
if not keys_a and not keys_b:
|
|
394
|
+
return 1.0 if a == b else 0.0
|
|
395
|
+
|
|
396
|
+
union = keys_a.union(keys_b)
|
|
397
|
+
return len(keys_a.intersection(keys_b)) / len(union) if union else 0.0
|
|
398
|
+
except (TypeError, ValueError, RecursionError):
|
|
399
|
+
return 0.0
|
|
400
|
+
|
|
401
|
+
@classmethod
|
|
402
|
+
def _meaningful_leaf_pairs(cls, obj: Any, path: str = "") -> Set[Tuple[str, str]]:
|
|
403
|
+
pairs: Set[Tuple[str, str]] = set()
|
|
404
|
+
if isinstance(obj, dict):
|
|
405
|
+
for raw_key, value in obj.items():
|
|
406
|
+
key = cls._normalise_key(raw_key)
|
|
407
|
+
child_path = f"{path}.{key}" if path else key
|
|
408
|
+
if cls._is_request_metadata_key(key):
|
|
409
|
+
continue
|
|
410
|
+
if isinstance(value, (dict, list)):
|
|
411
|
+
pairs.update(cls._meaningful_leaf_pairs(value, child_path))
|
|
412
|
+
elif (
|
|
413
|
+
key not in cls.NON_RESOURCE_VALUE_KEYS
|
|
414
|
+
and not cls._looks_like_identifier_key(key)
|
|
415
|
+
and value is not None
|
|
416
|
+
):
|
|
417
|
+
canonical = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
418
|
+
pairs.add((child_path, canonical))
|
|
419
|
+
elif isinstance(obj, list):
|
|
420
|
+
for value in obj:
|
|
421
|
+
child_path = f"{path}[]" if path else "[]"
|
|
422
|
+
if isinstance(value, (dict, list)):
|
|
423
|
+
pairs.update(cls._meaningful_leaf_pairs(value, child_path))
|
|
424
|
+
elif value is not None:
|
|
425
|
+
canonical = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
426
|
+
pairs.add((child_path, canonical))
|
|
427
|
+
elif obj is not None:
|
|
428
|
+
canonical = json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
429
|
+
pairs.add((path or "$", canonical))
|
|
430
|
+
return pairs
|
|
431
|
+
|
|
432
|
+
@classmethod
|
|
433
|
+
def _is_request_metadata_key(cls, key: str) -> bool:
|
|
434
|
+
return (
|
|
435
|
+
key in cls.METADATA_CONTAINER_KEYS
|
|
436
|
+
or key.startswith(("requested_", "request_", "trace_", "correlation_"))
|
|
437
|
+
or key in {"requested", "requestid", "traceid", "correlationid"}
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
@classmethod
|
|
441
|
+
def _normalise_resource_path(cls, path: str) -> str:
|
|
442
|
+
return ".".join(
|
|
443
|
+
cls._normalise_key(part) for part in re.sub(r"\[\d+\]", "[]", path).split(".")
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
@classmethod
|
|
447
|
+
def _resource_identifier_pairs(
|
|
448
|
+
cls, obj: Any, resource_id_paths: Optional[Set[str]] = None, path: str = ""
|
|
449
|
+
) -> Set[Tuple[str, str]]:
|
|
450
|
+
"""Only operator-approved business paths can establish resource identity."""
|
|
451
|
+
if not resource_id_paths:
|
|
452
|
+
return set()
|
|
453
|
+
identifiers: Set[Tuple[str, str]] = set()
|
|
454
|
+
if isinstance(obj, dict):
|
|
455
|
+
for raw_key, value in obj.items():
|
|
456
|
+
key = cls._normalise_key(raw_key)
|
|
457
|
+
child_path = f"{path}.{key}" if path else key
|
|
458
|
+
if cls._is_request_metadata_key(key):
|
|
459
|
+
continue
|
|
460
|
+
if isinstance(value, (dict, list)):
|
|
461
|
+
identifiers.update(cls._resource_identifier_pairs(value, resource_id_paths, child_path))
|
|
462
|
+
elif value is not None and child_path in resource_id_paths:
|
|
463
|
+
canonical = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
464
|
+
identifiers.add((child_path, canonical))
|
|
465
|
+
elif isinstance(obj, list):
|
|
466
|
+
for value in obj:
|
|
467
|
+
if isinstance(value, (dict, list)):
|
|
468
|
+
identifiers.update(cls._resource_identifier_pairs(
|
|
469
|
+
value, resource_id_paths, f"{path}[]" if path else "[]"
|
|
470
|
+
))
|
|
471
|
+
return identifiers
|
|
472
|
+
|
|
473
|
+
@staticmethod
|
|
474
|
+
def _looks_like_identifier_key(key: str) -> bool:
|
|
475
|
+
return (
|
|
476
|
+
key in {"id", "uuid", "identifier"}
|
|
477
|
+
or key.endswith("_id")
|
|
478
|
+
or key.endswith("_uuid")
|
|
479
|
+
or key.endswith("_identifier")
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
@staticmethod
|
|
483
|
+
def _set_similarity(left: Set[Any], right: Set[Any]) -> float:
|
|
484
|
+
union = left.union(right)
|
|
485
|
+
if not union:
|
|
486
|
+
return 0.0
|
|
487
|
+
return len(left.intersection(right)) / len(union)
|
|
488
|
+
|
|
489
|
+
@classmethod
|
|
490
|
+
def calculate_value_similarity(cls, json_str_a: str, json_str_b: str) -> float:
|
|
491
|
+
"""Calculate Jaccard similarity of concrete leaf path/value pairs."""
|
|
492
|
+
try:
|
|
493
|
+
left = cls._meaningful_leaf_pairs(strict_json_loads(json_str_a))
|
|
494
|
+
right = cls._meaningful_leaf_pairs(strict_json_loads(json_str_b))
|
|
495
|
+
except (TypeError, ValueError, RecursionError):
|
|
496
|
+
return 0.0
|
|
497
|
+
return cls._set_similarity(left, right)
|
|
498
|
+
|
|
499
|
+
@staticmethod
|
|
500
|
+
def _assessment_evidence(assessment: Dict[str, Any]) -> Dict[str, Any]:
|
|
501
|
+
return {
|
|
502
|
+
"kind": assessment["kind"],
|
|
503
|
+
"status_code": assessment["status_code"],
|
|
504
|
+
"is_auth_denial": assessment["is_auth_denial"],
|
|
505
|
+
"reason": assessment["reason"],
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
@classmethod
|
|
509
|
+
def _build_evidence(
|
|
510
|
+
cls,
|
|
511
|
+
owner: Dict[str, Any],
|
|
512
|
+
visitor: Dict[str, Any],
|
|
513
|
+
anonymous: Dict[str, Any],
|
|
514
|
+
visitor_self: Optional[Dict[str, Any]],
|
|
515
|
+
body_owner: str,
|
|
516
|
+
body_visitor: str,
|
|
517
|
+
resource_id_paths: Optional[Set[str]] = None,
|
|
518
|
+
) -> Dict[str, Any]:
|
|
519
|
+
owner_values = cls._meaningful_leaf_pairs(owner["data"])
|
|
520
|
+
visitor_values = cls._meaningful_leaf_pairs(visitor["data"])
|
|
521
|
+
shared_values = owner_values.intersection(visitor_values)
|
|
522
|
+
|
|
523
|
+
owner_ids = cls._resource_identifier_pairs(owner["data"], resource_id_paths)
|
|
524
|
+
visitor_ids = cls._resource_identifier_pairs(visitor["data"], resource_id_paths)
|
|
525
|
+
shared_ids = owner_ids.intersection(visitor_ids)
|
|
526
|
+
|
|
527
|
+
exact_match = bool(owner_values and owner_values == visitor_values)
|
|
528
|
+
structure_similarity = 0.0
|
|
529
|
+
if owner["data"] is not None and visitor["data"] is not None:
|
|
530
|
+
structure_similarity = cls.calculate_jaccard_similarity(body_owner, body_visitor)
|
|
531
|
+
|
|
532
|
+
visitor_self_exact_match = False
|
|
533
|
+
visitor_self_value_similarity = 0.0
|
|
534
|
+
visitor_self_shared_ids: Set[Tuple[str, str]] = set()
|
|
535
|
+
self_values: Set[Tuple[str, str]] = set()
|
|
536
|
+
if visitor_self and visitor_self["kind"] == cls.RESPONSE_VALID:
|
|
537
|
+
self_values = cls._meaningful_leaf_pairs(visitor_self["data"])
|
|
538
|
+
self_ids = cls._resource_identifier_pairs(visitor_self["data"], resource_id_paths)
|
|
539
|
+
visitor_self_exact_match = bool(visitor_values and self_values == visitor_values)
|
|
540
|
+
if visitor_ids and self_ids and visitor_ids != self_ids:
|
|
541
|
+
visitor_self_exact_match = False
|
|
542
|
+
visitor_self_value_similarity = cls._set_similarity(visitor_values, self_values)
|
|
543
|
+
visitor_self_shared_ids = visitor_ids.intersection(self_ids)
|
|
544
|
+
owner_only_shared_ids = shared_ids.difference(visitor_self_shared_ids)
|
|
545
|
+
|
|
546
|
+
return {
|
|
547
|
+
"responses": {
|
|
548
|
+
"owner": cls._assessment_evidence(owner),
|
|
549
|
+
"visitor_cross": cls._assessment_evidence(visitor),
|
|
550
|
+
"anonymous": cls._assessment_evidence(anonymous),
|
|
551
|
+
"visitor_self": cls._assessment_evidence(visitor_self) if visitor_self else None,
|
|
552
|
+
},
|
|
553
|
+
"anonymous_denied": anonymous["kind"] == cls.RESPONSE_AUTH_DENIED,
|
|
554
|
+
"anonymous_exact_match": False,
|
|
555
|
+
"anonymous_value_similarity": 0.0,
|
|
556
|
+
"structure_similarity": round(structure_similarity, 6),
|
|
557
|
+
"value_similarity": round(cls._set_similarity(owner_values, visitor_values), 6),
|
|
558
|
+
"exact_value_match": exact_match,
|
|
559
|
+
"owner_meaningful_value_count": len(owner_values),
|
|
560
|
+
"visitor_meaningful_value_count": len(visitor_values),
|
|
561
|
+
"shared_value_count": len(shared_values),
|
|
562
|
+
"owner_specific_shared_value_count": len(shared_values.difference(self_values)),
|
|
563
|
+
"owner_specific_shared_value_paths": sorted(
|
|
564
|
+
{path for path, _ in shared_values.difference(self_values)}
|
|
565
|
+
),
|
|
566
|
+
"shared_resource_identifier_count": len(shared_ids),
|
|
567
|
+
"shared_resource_identifier_paths": sorted({path for path, _ in shared_ids}),
|
|
568
|
+
"owner_only_shared_resource_identifier_count": len(owner_only_shared_ids),
|
|
569
|
+
"owner_only_shared_resource_identifier_paths": sorted(
|
|
570
|
+
{path for path, _ in owner_only_shared_ids}
|
|
571
|
+
),
|
|
572
|
+
"visitor_self_provided": visitor_self is not None,
|
|
573
|
+
"visitor_self_exact_match": visitor_self_exact_match,
|
|
574
|
+
"visitor_self_value_similarity": round(visitor_self_value_similarity, 6),
|
|
575
|
+
"visitor_self_shared_resource_identifier_count": len(visitor_self_shared_ids),
|
|
576
|
+
"confirmation_signals": [],
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
@staticmethod
|
|
580
|
+
def _result(
|
|
581
|
+
verdict: str,
|
|
582
|
+
confidence: float,
|
|
583
|
+
reason: str,
|
|
584
|
+
evidence: Dict[str, Any],
|
|
585
|
+
) -> Dict[str, Any]:
|
|
586
|
+
"""Create one stable result shape for every decision branch."""
|
|
587
|
+
return {
|
|
588
|
+
"verdict": verdict,
|
|
589
|
+
"confidence": float(confidence),
|
|
590
|
+
"reason": reason,
|
|
591
|
+
# Kept at the top level for existing reporting code.
|
|
592
|
+
"similarity": evidence["structure_similarity"],
|
|
593
|
+
"evidence": evidence,
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
@classmethod
|
|
597
|
+
def evaluate_bola(
|
|
598
|
+
cls,
|
|
599
|
+
resp_owner: Tuple[int, str],
|
|
600
|
+
resp_visitor: Tuple[int, str],
|
|
601
|
+
resp_anon: Tuple[int, str],
|
|
602
|
+
visitor_self: Optional[Tuple[int, str]] = None,
|
|
603
|
+
*,
|
|
604
|
+
expected_public: bool = False,
|
|
605
|
+
requires_auth: bool = False,
|
|
606
|
+
resource_id_paths: Optional[Iterable[str]] = None,
|
|
607
|
+
) -> Dict[str, Any]:
|
|
608
|
+
"""
|
|
609
|
+
Evaluate BOLA using owner, cross-visitor, anonymous and optional self baselines.
|
|
610
|
+
|
|
611
|
+
``visitor_self`` is the visitor identity reading its own resource. Supplying it
|
|
612
|
+
helps detect APIs that ignore the requested object id and always return the
|
|
613
|
+
authenticated user's object. The original three-argument call remains valid.
|
|
614
|
+
|
|
615
|
+
PUBLIC requires explicit ``expected_public=True``, no ``requires_auth``
|
|
616
|
+
requirement, and exact JSON equality across owner/visitor/anonymous.
|
|
617
|
+
``resource_id_paths`` contains trusted dot paths, e.g. ``data.user_id`` or
|
|
618
|
+
``items[].id``. No implicit *_id discovery is used for confirmation.
|
|
619
|
+
Known request/trace metadata is excluded even if configured as an id path.
|
|
620
|
+
"""
|
|
621
|
+
status_owner, body_owner = resp_owner
|
|
622
|
+
status_visitor, body_visitor = resp_visitor
|
|
623
|
+
status_anon, body_anon = resp_anon
|
|
624
|
+
|
|
625
|
+
owner = cls._assess_response(status_owner, body_owner)
|
|
626
|
+
visitor = cls._assess_response(status_visitor, body_visitor)
|
|
627
|
+
anonymous = cls._assess_response(status_anon, body_anon)
|
|
628
|
+
self_assessment = cls._assess_response(*visitor_self) if visitor_self is not None else None
|
|
629
|
+
if isinstance(resource_id_paths, str):
|
|
630
|
+
resource_id_paths = [resource_id_paths]
|
|
631
|
+
trusted_paths = set()
|
|
632
|
+
for path in resource_id_paths or ():
|
|
633
|
+
if not isinstance(path, str) or not path.strip():
|
|
634
|
+
raise ValueError("resource_id_paths 必须包含非空的响应字段路径")
|
|
635
|
+
trusted_paths.add(cls._normalise_resource_path(path.strip()))
|
|
636
|
+
|
|
637
|
+
evidence = cls._build_evidence(
|
|
638
|
+
owner,
|
|
639
|
+
visitor,
|
|
640
|
+
anonymous,
|
|
641
|
+
self_assessment,
|
|
642
|
+
body_owner,
|
|
643
|
+
body_visitor,
|
|
644
|
+
trusted_paths,
|
|
645
|
+
)
|
|
646
|
+
evidence["expected_public"] = bool(expected_public)
|
|
647
|
+
evidence["requires_auth"] = bool(requires_auth)
|
|
648
|
+
evidence["trusted_resource_id_paths"] = sorted(trusted_paths)
|
|
649
|
+
evidence["denial_body_conflict"] = False
|
|
650
|
+
|
|
651
|
+
owner_values = cls._meaningful_leaf_pairs(owner["data"])
|
|
652
|
+
visitor_values = cls._meaningful_leaf_pairs(visitor["data"])
|
|
653
|
+
anon_values = cls._meaningful_leaf_pairs(anonymous["data"])
|
|
654
|
+
evidence["anonymous_exact_match"] = bool(
|
|
655
|
+
anonymous["data"] is not None and json_values_equal(anonymous["data"], visitor["data"])
|
|
656
|
+
)
|
|
657
|
+
evidence["anonymous_value_similarity"] = round(cls._set_similarity(anon_values, visitor_values), 6)
|
|
658
|
+
private_increment = owner_values.intersection(visitor_values).difference(anon_values)
|
|
659
|
+
evidence["owner_shared_nonpublic_value_paths"] = sorted({path for path, _ in private_increment})
|
|
660
|
+
|
|
661
|
+
if owner["kind"] != cls.RESPONSE_VALID:
|
|
662
|
+
return cls._result(
|
|
663
|
+
"INCONCLUSIVE",
|
|
664
|
+
0.0,
|
|
665
|
+
f"合法资源所有者基线无效:{owner['reason']}",
|
|
666
|
+
evidence,
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
if requires_auth and anonymous["kind"] == cls.RESPONSE_VALID:
|
|
670
|
+
return cls._result(
|
|
671
|
+
"LOW_SUSPICION", 0.9,
|
|
672
|
+
"端点要求鉴权,但匿名请求返回成功 JSON;不能将该访问解释为预期公开。",
|
|
673
|
+
evidence,
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
if visitor["kind"] == cls.RESPONSE_AUTH_DENIED:
|
|
677
|
+
if not cls._denial_body_is_clean(visitor, body_visitor):
|
|
678
|
+
evidence["denial_body_conflict"] = True
|
|
679
|
+
return cls._result(
|
|
680
|
+
"LOW_SUSPICION" if visitor_values else "INCONCLUSIVE",
|
|
681
|
+
0.85 if visitor_values else 0.0,
|
|
682
|
+
"访客响应声明鉴权拒绝,但正文含业务数据或未知内容,不能证明访问控制有效。",
|
|
683
|
+
evidence,
|
|
684
|
+
)
|
|
685
|
+
# A visitor rejection proves only that identity's access was denied.
|
|
686
|
+
# Anonymous success, redirects and non-authentication errors cannot
|
|
687
|
+
# establish that the endpoint also withheld the owner's data.
|
|
688
|
+
if anonymous["kind"] != cls.RESPONSE_AUTH_DENIED:
|
|
689
|
+
if anonymous["kind"] == cls.RESPONSE_VALID:
|
|
690
|
+
return cls._result(
|
|
691
|
+
"LOW_SUSPICION", 0.25,
|
|
692
|
+
"访客跨资源请求被拒绝,但匿名请求返回成功 JSON;缺少一致的拒绝证据,不能判为安全。",
|
|
693
|
+
evidence,
|
|
694
|
+
)
|
|
695
|
+
return cls._result(
|
|
696
|
+
"LOW_SUSPICION" if anon_values else "INCONCLUSIVE",
|
|
697
|
+
0.8 if anon_values else 0.0,
|
|
698
|
+
"访客跨资源请求被拒绝,但匿名基线不是明确的鉴权拒绝"
|
|
699
|
+
+ ("且正文含业务数据" if anon_values else "")
|
|
700
|
+
+ f":{anonymous['reason']}",
|
|
701
|
+
evidence,
|
|
702
|
+
)
|
|
703
|
+
if anonymous["kind"] == cls.RESPONSE_AUTH_DENIED and not cls._denial_body_is_clean(anonymous, body_anon):
|
|
704
|
+
evidence["denial_body_conflict"] = True
|
|
705
|
+
return cls._result(
|
|
706
|
+
"LOW_SUSPICION", 0.8,
|
|
707
|
+
"匿名响应声明鉴权拒绝,但正文含业务数据或未知内容,访问控制结果仍有冲突。",
|
|
708
|
+
evidence,
|
|
709
|
+
)
|
|
710
|
+
if self_assessment is None:
|
|
711
|
+
return cls._result(
|
|
712
|
+
"INCONCLUSIVE",
|
|
713
|
+
0.0,
|
|
714
|
+
"访客跨资源请求被拒绝,但缺少访客自有资源基线,无法确认访客凭证有效。",
|
|
715
|
+
evidence,
|
|
716
|
+
)
|
|
717
|
+
if self_assessment["kind"] != cls.RESPONSE_VALID:
|
|
718
|
+
return cls._result(
|
|
719
|
+
"INCONCLUSIVE",
|
|
720
|
+
0.0,
|
|
721
|
+
f"访客跨资源请求被拒绝,但访客自有资源基线无效:{self_assessment['reason']}",
|
|
722
|
+
evidence,
|
|
723
|
+
)
|
|
724
|
+
return cls._result(
|
|
725
|
+
"SECURE_ENFORCED",
|
|
726
|
+
1.0 if int(status_visitor) in (401, 403) else 0.95,
|
|
727
|
+
f"访客跨资源请求与匿名请求均被明确拒绝且正文无业务数据:{visitor['reason']}",
|
|
728
|
+
evidence,
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
if visitor["kind"] != cls.RESPONSE_VALID:
|
|
732
|
+
return cls._result(
|
|
733
|
+
"INCONCLUSIVE",
|
|
734
|
+
0.0,
|
|
735
|
+
f"访客跨资源请求未得到可判定响应:{visitor['reason']}",
|
|
736
|
+
evidence,
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
if anonymous["kind"] == cls.RESPONSE_VALID:
|
|
740
|
+
if (
|
|
741
|
+
expected_public and not requires_auth and visitor_values
|
|
742
|
+
and json_values_equal(anonymous["data"], visitor["data"])
|
|
743
|
+
and json_values_equal(visitor["data"], owner["data"])
|
|
744
|
+
):
|
|
745
|
+
return cls._result(
|
|
746
|
+
"PUBLIC_ENDPOINT",
|
|
747
|
+
1.0,
|
|
748
|
+
"端点显式配置为公开,所有者、访客与匿名响应的完整 JSON 数据完全一致。",
|
|
749
|
+
evidence,
|
|
750
|
+
)
|
|
751
|
+
if private_increment:
|
|
752
|
+
return cls._result(
|
|
753
|
+
"LOW_SUSPICION", 0.8,
|
|
754
|
+
"访客获得匿名响应没有的所有者字段或值;公开摘要不能排除私有数据泄露。",
|
|
755
|
+
evidence,
|
|
756
|
+
)
|
|
757
|
+
return cls._result(
|
|
758
|
+
"LOW_SUSPICION",
|
|
759
|
+
0.25,
|
|
760
|
+
"观察到匿名访问,但缺少明确公开策略或完整数据等价证据,不能判为公开端点。",
|
|
761
|
+
evidence,
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
if anonymous["kind"] != cls.RESPONSE_AUTH_DENIED:
|
|
765
|
+
return cls._result(
|
|
766
|
+
"INCONCLUSIVE",
|
|
767
|
+
0.0,
|
|
768
|
+
f"匿名基线不是明确的鉴权拒绝:{anonymous['reason']}",
|
|
769
|
+
evidence,
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
if not cls._denial_body_is_clean(anonymous, body_anon):
|
|
773
|
+
evidence["denial_body_conflict"] = True
|
|
774
|
+
return cls._result(
|
|
775
|
+
"LOW_SUSPICION", 0.8,
|
|
776
|
+
"匿名响应声明拒绝,但正文含业务数据或未知内容,无法作为未泄露的拒绝基线。",
|
|
777
|
+
evidence,
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
if self_assessment is None:
|
|
781
|
+
return cls._result(
|
|
782
|
+
"INCONCLUSIVE",
|
|
783
|
+
0.0,
|
|
784
|
+
"缺少与所有者资源不同的访客自有资源基线,无法排除接口始终返回当前用户数据。",
|
|
785
|
+
evidence,
|
|
786
|
+
)
|
|
787
|
+
if self_assessment["kind"] != cls.RESPONSE_VALID:
|
|
788
|
+
return cls._result(
|
|
789
|
+
"INCONCLUSIVE",
|
|
790
|
+
0.0,
|
|
791
|
+
f"访客自有资源基线无效:{self_assessment['reason']}",
|
|
792
|
+
evidence,
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
# Confirmation requires concrete values or a trusted object identifier. Structural
|
|
796
|
+
# key similarity remains a diagnostic metric and never contributes a signal.
|
|
797
|
+
if evidence["exact_value_match"] and evidence["owner_specific_shared_value_count"] > 0:
|
|
798
|
+
evidence["confirmation_signals"].append("exact_business_value_match")
|
|
799
|
+
if evidence["owner_only_shared_resource_identifier_count"] > 0:
|
|
800
|
+
evidence["confirmation_signals"].append("matching_resource_identifier")
|
|
801
|
+
if (
|
|
802
|
+
evidence["value_similarity"] >= 0.75
|
|
803
|
+
and evidence["shared_value_count"] >= 2
|
|
804
|
+
and evidence["owner_specific_shared_value_count"] > 0
|
|
805
|
+
and evidence["value_similarity"] > evidence["visitor_self_value_similarity"] + 0.15
|
|
806
|
+
):
|
|
807
|
+
evidence["confirmation_signals"].append("high_business_value_overlap")
|
|
808
|
+
|
|
809
|
+
# If the cross-resource result is also exactly the visitor's own baseline,
|
|
810
|
+
# the API may simply be ignoring the requested id and returning the caller's
|
|
811
|
+
# own record. That is not evidence of reading the owner's object.
|
|
812
|
+
if evidence["visitor_self_exact_match"] or (
|
|
813
|
+
evidence["owner_only_shared_resource_identifier_count"] == 0
|
|
814
|
+
and evidence["visitor_self_value_similarity"] > 0
|
|
815
|
+
and evidence["visitor_self_value_similarity"] >= evidence["value_similarity"]
|
|
816
|
+
):
|
|
817
|
+
evidence["confirmation_signals"] = []
|
|
818
|
+
return cls._result(
|
|
819
|
+
"LOW_SUSPICION",
|
|
820
|
+
0.2,
|
|
821
|
+
"跨资源响应与访客自有资源基线一致或更接近,无法证明返回的是所有者资源。",
|
|
822
|
+
evidence,
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
if evidence["confirmation_signals"]:
|
|
826
|
+
confidence = 0.99 if evidence["exact_value_match"] else 0.95
|
|
827
|
+
return cls._result(
|
|
828
|
+
"BOLA_CONFIRMED",
|
|
829
|
+
confidence,
|
|
830
|
+
"匿名请求被明确拒绝,而未授权访客获得了与所有者资源一致的具体值或资源标识。",
|
|
831
|
+
evidence,
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
return cls._result(
|
|
835
|
+
"LOW_SUSPICION",
|
|
836
|
+
0.3,
|
|
837
|
+
"访客请求成功,但只有结构相似或具体值证据不足,不能确认 BOLA。",
|
|
838
|
+
evidence,
|
|
839
|
+
)
|