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/auditor.py
ADDED
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Safety-first API audit orchestration.
|
|
3
|
+
|
|
4
|
+
Read-only BOLA checks run by default. State-changing mass-assignment checks
|
|
5
|
+
require an explicit opt-in and an independent readback mapping. Confirmed
|
|
6
|
+
changes are rolled back and the rollback is verified.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import copy
|
|
12
|
+
import ipaddress
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import ssl
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import urllib.request
|
|
21
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
22
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
from core import __version__
|
|
25
|
+
from core.config_validator import ConfigValidator
|
|
26
|
+
from core.diff import ResponseDiffEngine
|
|
27
|
+
from core.evidence import body_sha256, sanitize_body, sanitize_evidence
|
|
28
|
+
from core.transactions import PatchSnapshot, get_path, prepare_patch
|
|
29
|
+
from core.generator import SmartDataGenerator
|
|
30
|
+
from core.models import HTTPResult, Verdict, json_values_equal, parse_operation_key, strict_json_loads
|
|
31
|
+
from core.parser import OpenAPIParser
|
|
32
|
+
from core.parameters import ParameterSerializationError, serialize_path_parameter, serialize_query_parameter
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
36
|
+
"""Return 30x to the detector instead of forwarding credentials."""
|
|
37
|
+
|
|
38
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class APISentinelAuditor:
|
|
43
|
+
# PATCH has reversible partial-update semantics. POST and PUT require an
|
|
44
|
+
# operation-specific compensation/full-snapshot design that this release
|
|
45
|
+
# deliberately does not attempt to guess.
|
|
46
|
+
WRITE_METHODS = {"PATCH"}
|
|
47
|
+
AUTH_DENIAL_STATUSES = {401, 403}
|
|
48
|
+
INVALID_TEST_STATUSES = {400, 404, 405, 409, 415, 422}
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
spec_path: str,
|
|
53
|
+
target_base_url: str,
|
|
54
|
+
max_workers: int = 4,
|
|
55
|
+
request_delay: float = 0.05,
|
|
56
|
+
identities_config: Optional[Dict[str, Any]] = None,
|
|
57
|
+
insecure_ssl: bool = False,
|
|
58
|
+
allow_http: bool = False,
|
|
59
|
+
allow_write_tests: bool = False,
|
|
60
|
+
readback_config: Optional[Dict[str, Any]] = None,
|
|
61
|
+
parameter_values: Optional[Dict[str, Any]] = None,
|
|
62
|
+
write_allowlist: Optional[Iterable[str]] = None,
|
|
63
|
+
request_timeout: float = 6.0,
|
|
64
|
+
max_response_bytes: int = 1024 * 1024,
|
|
65
|
+
include_sensitive_evidence: bool = False,
|
|
66
|
+
bola_config: Optional[Dict[str, Any]] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self._validate_target(target_base_url, allow_http)
|
|
69
|
+
self.parser = OpenAPIParser(spec_path)
|
|
70
|
+
self.target_base_url = target_base_url.rstrip("/")
|
|
71
|
+
self.max_workers = max(1, int(max_workers))
|
|
72
|
+
self.request_delay = max(0.0, float(request_delay))
|
|
73
|
+
self.request_timeout = max(0.1, float(request_timeout))
|
|
74
|
+
self.max_response_bytes = max(1024, int(max_response_bytes))
|
|
75
|
+
self.insecure_ssl = bool(insecure_ssl)
|
|
76
|
+
self.allow_write_tests = bool(allow_write_tests)
|
|
77
|
+
self.include_sensitive_evidence = bool(include_sensitive_evidence)
|
|
78
|
+
def _canonical_op_key(k: Any) -> str:
|
|
79
|
+
if not isinstance(k, str):
|
|
80
|
+
return str(k)
|
|
81
|
+
parts = k.strip().split()
|
|
82
|
+
if len(parts) == 2:
|
|
83
|
+
return f"{parts[0]} {parts[1]}"
|
|
84
|
+
return k.strip()
|
|
85
|
+
|
|
86
|
+
self.readback_config = {
|
|
87
|
+
_canonical_op_key(k): v
|
|
88
|
+
for k, v in (readback_config or {}).items()
|
|
89
|
+
}
|
|
90
|
+
self.parameter_values = {
|
|
91
|
+
_canonical_op_key(k): v
|
|
92
|
+
for k, v in (parameter_values or {}).items()
|
|
93
|
+
}
|
|
94
|
+
self.bola_config = {
|
|
95
|
+
_canonical_op_key(k): v
|
|
96
|
+
for k, v in (bola_config or {}).items()
|
|
97
|
+
}
|
|
98
|
+
self.write_allowlist = set()
|
|
99
|
+
for item in write_allowlist or []:
|
|
100
|
+
c_key = _canonical_op_key(item)
|
|
101
|
+
is_valid, method, path, canonical, err_msg = parse_operation_key(c_key)
|
|
102
|
+
if not is_valid or method != "PATCH":
|
|
103
|
+
raise ValueError("write_allowlist entries must be 'PATCH /case-sensitive-path'")
|
|
104
|
+
self.write_allowlist.add(canonical)
|
|
105
|
+
self._writes_halted_reason = None
|
|
106
|
+
|
|
107
|
+
context = ssl._create_unverified_context() if insecure_ssl else ssl.create_default_context()
|
|
108
|
+
self._opener = urllib.request.build_opener(
|
|
109
|
+
_NoRedirectHandler(), urllib.request.HTTPSHandler(context=context)
|
|
110
|
+
)
|
|
111
|
+
self._rate_lock = threading.Lock()
|
|
112
|
+
self._next_request_at = 0.0
|
|
113
|
+
self._result_lock = threading.Lock()
|
|
114
|
+
self._resource_locks: Dict[str, threading.Lock] = {}
|
|
115
|
+
self._resource_locks_guard = threading.Lock()
|
|
116
|
+
|
|
117
|
+
self.identities = identities_config or {
|
|
118
|
+
"owner": {
|
|
119
|
+
"id": "1001",
|
|
120
|
+
"token": "Bearer TOKEN_ALICE_OWNER_1001",
|
|
121
|
+
"parameters": {"user_id": "1001"},
|
|
122
|
+
},
|
|
123
|
+
"visitor": {
|
|
124
|
+
"id": "1002",
|
|
125
|
+
"token": "Bearer TOKEN_BOB_VISITOR_1002",
|
|
126
|
+
"parameters": {"user_id": "1002"},
|
|
127
|
+
},
|
|
128
|
+
"anonymous": {"id": None, "token": None, "parameters": {}},
|
|
129
|
+
}
|
|
130
|
+
self._validate_identities()
|
|
131
|
+
self._secret_values = set()
|
|
132
|
+
for identity_name in self.identities:
|
|
133
|
+
for key, value in self._identity_headers(identity_name).items():
|
|
134
|
+
if self._is_auth_header(key):
|
|
135
|
+
self._secret_values.add(value)
|
|
136
|
+
if key.lower() == "authorization" and " " in value:
|
|
137
|
+
self._secret_values.add(value.split(" ", 1)[1])
|
|
138
|
+
|
|
139
|
+
self.findings: List[Dict[str, Any]] = []
|
|
140
|
+
self.results: List[Dict[str, Any]] = []
|
|
141
|
+
self.stats: Dict[str, Any] = {
|
|
142
|
+
"total_endpoints": 0,
|
|
143
|
+
"total_checks": 0,
|
|
144
|
+
"audited_count": 0,
|
|
145
|
+
"conclusive_count": 0,
|
|
146
|
+
"confirmed": 0,
|
|
147
|
+
"bola_confirmed": 0,
|
|
148
|
+
"mass_assignment_confirmed": 0,
|
|
149
|
+
"public_endpoints": 0,
|
|
150
|
+
"authorized_endpoints": 0,
|
|
151
|
+
"secure_endpoints": 0,
|
|
152
|
+
"suspicious_count": 0,
|
|
153
|
+
"inconclusive_count": 0,
|
|
154
|
+
"skipped_count": 0,
|
|
155
|
+
"error_count": 0,
|
|
156
|
+
"raw_filtered_count": 0,
|
|
157
|
+
"coverage_pct": 0.0,
|
|
158
|
+
"conclusive_coverage_pct": 0.0,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _validate_target(target: str, allow_http: bool) -> None:
|
|
163
|
+
parsed = urllib.parse.urlsplit(target)
|
|
164
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
165
|
+
raise ValueError("target must be an absolute http:// or https:// URL")
|
|
166
|
+
if parsed.username or parsed.password:
|
|
167
|
+
raise ValueError("credentials must not be embedded in the target URL")
|
|
168
|
+
if parsed.query or parsed.fragment:
|
|
169
|
+
raise ValueError("target must be a base URL without query parameters or fragments")
|
|
170
|
+
if parsed.scheme == "http" and not allow_http:
|
|
171
|
+
hostname = parsed.hostname.lower()
|
|
172
|
+
is_loopback = hostname == "localhost"
|
|
173
|
+
if not is_loopback:
|
|
174
|
+
try:
|
|
175
|
+
is_loopback = ipaddress.ip_address(hostname).is_loopback
|
|
176
|
+
except ValueError:
|
|
177
|
+
is_loopback = False
|
|
178
|
+
if not is_loopback:
|
|
179
|
+
raise ValueError("plain HTTP is allowed only for loopback targets; use --allow-http to opt in")
|
|
180
|
+
|
|
181
|
+
def _is_auth_header(self, name: str) -> bool:
|
|
182
|
+
schemes = dict(self.parser.raw_spec.get("securityDefinitions") or {})
|
|
183
|
+
schemes.update((self.parser.raw_spec.get("components") or {}).get("securitySchemes") or {})
|
|
184
|
+
spec_headers = [
|
|
185
|
+
str(scheme.get("name", ""))
|
|
186
|
+
for scheme in schemes.values()
|
|
187
|
+
if isinstance(scheme, dict) and scheme.get("in") == "header" and scheme.get("name")
|
|
188
|
+
]
|
|
189
|
+
custom_headers = []
|
|
190
|
+
for identity in self.identities.values():
|
|
191
|
+
extra = identity.get("auth_header_names", [])
|
|
192
|
+
if not isinstance(extra, list) or any(not isinstance(key, str) for key in extra):
|
|
193
|
+
raise ValueError("auth_header_names must be a list of header names")
|
|
194
|
+
custom_headers.extend(extra)
|
|
195
|
+
return ConfigValidator.is_auth_header(
|
|
196
|
+
name,
|
|
197
|
+
custom_auth_names=custom_headers,
|
|
198
|
+
spec_auth_names=spec_headers,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _validate_identities(self) -> None:
|
|
202
|
+
missing = {"owner", "visitor", "anonymous"} - set(self.identities)
|
|
203
|
+
if missing:
|
|
204
|
+
raise ValueError(f"identity configuration is missing: {', '.join(sorted(missing))}")
|
|
205
|
+
for name, identity in self.identities.items():
|
|
206
|
+
if not isinstance(identity, dict):
|
|
207
|
+
raise ValueError(f"identity '{name}' must be an object")
|
|
208
|
+
if not isinstance(identity.get("headers", {}), dict):
|
|
209
|
+
raise ValueError(f"identity '{name}' headers must be an object")
|
|
210
|
+
fingerprints = {}
|
|
211
|
+
for name in ("owner", "visitor", "anonymous"):
|
|
212
|
+
material = []
|
|
213
|
+
for key, value in self._identity_headers(name).items():
|
|
214
|
+
if self._is_auth_header(key) and value.strip():
|
|
215
|
+
normalized = value.strip()
|
|
216
|
+
if key.lower() == "authorization" and " " in normalized:
|
|
217
|
+
scheme, credential = normalized.split(None, 1)
|
|
218
|
+
normalized = scheme.lower() + " " + credential.strip()
|
|
219
|
+
material.append((key.lower(), normalized))
|
|
220
|
+
fingerprints[name] = tuple(sorted(material))
|
|
221
|
+
if (not fingerprints["owner"] or not fingerprints["visitor"]
|
|
222
|
+
or fingerprints["owner"] == fingerprints["visitor"]):
|
|
223
|
+
raise ValueError("owner and visitor must use distinct, non-empty effective authentication material")
|
|
224
|
+
if fingerprints["anonymous"]:
|
|
225
|
+
raise ValueError("anonymous identity must not define authentication material")
|
|
226
|
+
|
|
227
|
+
def _throttle(self) -> None:
|
|
228
|
+
if self.request_delay <= 0:
|
|
229
|
+
return
|
|
230
|
+
with self._rate_lock:
|
|
231
|
+
now = time.monotonic()
|
|
232
|
+
wait_for = max(0.0, self._next_request_at - now)
|
|
233
|
+
if wait_for:
|
|
234
|
+
time.sleep(wait_for)
|
|
235
|
+
self._next_request_at = time.monotonic() + self.request_delay
|
|
236
|
+
|
|
237
|
+
def _identity_headers(self, identity_name: str) -> Dict[str, str]:
|
|
238
|
+
identity = self.identities[identity_name]
|
|
239
|
+
headers = {}
|
|
240
|
+
for key, value in identity.get("headers", {}).items():
|
|
241
|
+
normalized = str(key).strip().lower()
|
|
242
|
+
if normalized in headers:
|
|
243
|
+
raise ValueError("duplicate case-insensitive identity header: " + normalized)
|
|
244
|
+
if any(char in str(key) + str(value) for char in "\r\n"):
|
|
245
|
+
raise ValueError("identity headers must not contain line breaks")
|
|
246
|
+
headers[normalized] = str(value).strip()
|
|
247
|
+
token = identity.get("token")
|
|
248
|
+
if token:
|
|
249
|
+
if any(char in str(token) for char in "\r\n"):
|
|
250
|
+
raise ValueError("identity token must not contain line breaks")
|
|
251
|
+
headers["authorization"] = str(token).strip()
|
|
252
|
+
return headers
|
|
253
|
+
|
|
254
|
+
def _read_limited(self, response) -> Tuple[str, bool]: # noqa: ANN001
|
|
255
|
+
raw = response.read(self.max_response_bytes + 1)
|
|
256
|
+
truncated = len(raw) > self.max_response_bytes
|
|
257
|
+
raw = raw[: self.max_response_bytes]
|
|
258
|
+
charset = response.headers.get_content_charset() or "utf-8"
|
|
259
|
+
return raw.decode(charset, errors="replace"), truncated
|
|
260
|
+
|
|
261
|
+
def _http_request(
|
|
262
|
+
self,
|
|
263
|
+
method: str,
|
|
264
|
+
url: str,
|
|
265
|
+
data: Optional[Dict[str, Any]] = None,
|
|
266
|
+
identity_name: str = "anonymous",
|
|
267
|
+
content_type: str = "application/json",
|
|
268
|
+
) -> HTTPResult:
|
|
269
|
+
self._throttle()
|
|
270
|
+
try:
|
|
271
|
+
headers = {
|
|
272
|
+
"accept": "application/json",
|
|
273
|
+
"user-agent": f"GrantTrace/{__version__}",
|
|
274
|
+
**self._identity_headers(identity_name),
|
|
275
|
+
}
|
|
276
|
+
encoded_data = None
|
|
277
|
+
if data is not None:
|
|
278
|
+
headers["content-type"] = content_type
|
|
279
|
+
encoded_data = json.dumps(data, ensure_ascii=False, allow_nan=False).encode("utf-8")
|
|
280
|
+
request = urllib.request.Request(url, data=encoded_data, method=method, headers=headers)
|
|
281
|
+
try:
|
|
282
|
+
response = self._opener.open(request, timeout=self.request_timeout)
|
|
283
|
+
except urllib.error.HTTPError as exc:
|
|
284
|
+
response = exc
|
|
285
|
+
with response:
|
|
286
|
+
body, truncated = self._read_limited(response)
|
|
287
|
+
return HTTPResult(
|
|
288
|
+
status=response.status, body=body, truncated=truncated,
|
|
289
|
+
headers={str(k): str(v) for k, v in response.headers.items()},
|
|
290
|
+
)
|
|
291
|
+
except Exception as exc:
|
|
292
|
+
# Includes malformed encodings and truncated/chunked transport reads.
|
|
293
|
+
# A PATCH may already have committed; the transaction owns recovery.
|
|
294
|
+
return HTTPResult(status=0, body="", error=f"{type(exc).__name__}: {exc}")
|
|
295
|
+
|
|
296
|
+
@staticmethod
|
|
297
|
+
def _operation_key(ep: Dict[str, Any]) -> str:
|
|
298
|
+
return f"{ep['method'].upper()} {ep['path']}"
|
|
299
|
+
|
|
300
|
+
def _operation_parameter_values(self, ep: Dict[str, Any], identity_name: str) -> Dict[str, Any]:
|
|
301
|
+
values: Dict[str, Any] = {}
|
|
302
|
+
configured = self.parameter_values.get(self._operation_key(ep), {})
|
|
303
|
+
if isinstance(configured, dict):
|
|
304
|
+
common = configured.get("common")
|
|
305
|
+
if isinstance(common, dict):
|
|
306
|
+
values.update(common)
|
|
307
|
+
role_values = configured.get(identity_name)
|
|
308
|
+
if isinstance(role_values, dict):
|
|
309
|
+
values.update(role_values)
|
|
310
|
+
for key, value in configured.items():
|
|
311
|
+
if key not in {"common", "owner", "visitor", "anonymous"}:
|
|
312
|
+
values[key] = value
|
|
313
|
+
identity_values = self.identities[identity_name].get("parameters", {})
|
|
314
|
+
if isinstance(identity_values, dict):
|
|
315
|
+
values.update(identity_values)
|
|
316
|
+
return values
|
|
317
|
+
|
|
318
|
+
def _build_url(
|
|
319
|
+
self,
|
|
320
|
+
ep: Dict[str, Any],
|
|
321
|
+
resource_identity: str,
|
|
322
|
+
extra_values: Optional[Dict[str, Any]] = None,
|
|
323
|
+
) -> str:
|
|
324
|
+
raw_path = ep["path"]
|
|
325
|
+
parameters = ep.get("parameters", [])
|
|
326
|
+
if (not isinstance(raw_path, str) or not raw_path.startswith("/")
|
|
327
|
+
or any(character in raw_path for character in "?#\r\n")):
|
|
328
|
+
raise ParameterSerializationError("Operation path must be an absolute path without a query/fragment; no request was sent")
|
|
329
|
+
if not isinstance(parameters, list) or any(not isinstance(item, dict) for item in parameters):
|
|
330
|
+
raise ParameterSerializationError("Operation parameters must be an array of metadata objects; no request was sent")
|
|
331
|
+
swagger2 = str(ep.get("spec_version", self.parser.raw_spec.get("swagger", ""))).startswith("2.")
|
|
332
|
+
supported = []
|
|
333
|
+
for metadata in parameters:
|
|
334
|
+
location, name = metadata.get("in"), metadata.get("name")
|
|
335
|
+
if location == "body" and swagger2:
|
|
336
|
+
continue # Swagger request bodies have their own payload encoder.
|
|
337
|
+
if location == "header" and isinstance(name, str):
|
|
338
|
+
normalized = name.lower()
|
|
339
|
+
if not swagger2 and normalized in {"authorization", "accept", "content-type"}:
|
|
340
|
+
continue # OAS 3 requires these Parameter Objects to be ignored.
|
|
341
|
+
if (self._is_auth_header(name)
|
|
342
|
+
and all(self._identity_headers(role).get(normalized) for role in ("owner", "visitor"))):
|
|
343
|
+
continue # Authentication comes only from the actual requester.
|
|
344
|
+
if location not in {"path", "query"}:
|
|
345
|
+
raise ParameterSerializationError("Header/cookie/formData parameter serialization is unsupported; configure authentication in identity headers; no request was sent")
|
|
346
|
+
supported.append(metadata)
|
|
347
|
+
parameters = supported
|
|
348
|
+
values = self._operation_parameter_values(ep, resource_identity)
|
|
349
|
+
if extra_values:
|
|
350
|
+
values.update(extra_values)
|
|
351
|
+
candidate_id = self.identities[resource_identity].get("id")
|
|
352
|
+
test_path = raw_path
|
|
353
|
+
|
|
354
|
+
for param_name in re.findall(r"\{([^}]+)\}", raw_path):
|
|
355
|
+
metadata = next(
|
|
356
|
+
(
|
|
357
|
+
item
|
|
358
|
+
for item in parameters
|
|
359
|
+
if item.get("name") == param_name and item.get("in", "path") == "path"
|
|
360
|
+
),
|
|
361
|
+
{"name": param_name, "in": "path", "schema": {"type": "string"}},
|
|
362
|
+
)
|
|
363
|
+
value = values.get(param_name) if param_name in values else SmartDataGenerator.generate_raw_value_for_param(metadata, candidate_id=candidate_id)
|
|
364
|
+
test_path = test_path.replace("{" + param_name + "}", serialize_path_parameter(metadata, value, swagger2=swagger2))
|
|
365
|
+
|
|
366
|
+
if "{" in test_path or "}" in test_path:
|
|
367
|
+
raise ParameterSerializationError("Unresolved or malformed path template; no request was sent")
|
|
368
|
+
template_names = set(re.findall(r"\{([^}]+)\}", raw_path))
|
|
369
|
+
if any(item.get("in") == "path" and item.get("name") not in template_names for item in parameters):
|
|
370
|
+
raise ParameterSerializationError("Declared path parameter is absent from the operation path; no request was sent")
|
|
371
|
+
|
|
372
|
+
query_pairs: List[Tuple[str, str]] = []
|
|
373
|
+
emitted_names: Dict[str, int] = {}
|
|
374
|
+
for index, metadata in enumerate(parameters):
|
|
375
|
+
if metadata.get("in") != "query":
|
|
376
|
+
continue
|
|
377
|
+
name = metadata.get("name")
|
|
378
|
+
value = values.get(name) if name in values else SmartDataGenerator.generate_raw_value_for_param(metadata, candidate_id=candidate_id)
|
|
379
|
+
pairs = serialize_query_parameter(metadata, value, swagger2=swagger2)
|
|
380
|
+
for encoded_name, _ in pairs:
|
|
381
|
+
if encoded_name in emitted_names and emitted_names[encoded_name] != index:
|
|
382
|
+
raise ParameterSerializationError("Different query parameters serialize to the same name; no request was sent")
|
|
383
|
+
emitted_names[encoded_name] = index
|
|
384
|
+
query_pairs.extend(pairs)
|
|
385
|
+
|
|
386
|
+
url = f"{self.target_base_url}{test_path}"
|
|
387
|
+
if query_pairs:
|
|
388
|
+
url += "?" + "&".join(name + "=" + value for name, value in query_pairs)
|
|
389
|
+
return url
|
|
390
|
+
|
|
391
|
+
def _safe_body(self, result: HTTPResult) -> Dict[str, Any]:
|
|
392
|
+
return {
|
|
393
|
+
"status": result.status,
|
|
394
|
+
"body": sanitize_body(
|
|
395
|
+
result.body,
|
|
396
|
+
include_sensitive=self.include_sensitive_evidence,
|
|
397
|
+
max_chars=min(self.max_response_bytes, 4096),
|
|
398
|
+
secret_values=self._secret_values,
|
|
399
|
+
),
|
|
400
|
+
"sha256": body_sha256(result.body),
|
|
401
|
+
"truncated": result.truncated,
|
|
402
|
+
"error": result.error,
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
def _record_result(
|
|
406
|
+
self,
|
|
407
|
+
ep: Dict[str, Any],
|
|
408
|
+
check: str,
|
|
409
|
+
verdict: Verdict,
|
|
410
|
+
reason: str,
|
|
411
|
+
evidence: Optional[Dict[str, Any]] = None,
|
|
412
|
+
finding: Optional[Dict[str, Any]] = None,
|
|
413
|
+
) -> Dict[str, Any]:
|
|
414
|
+
result = {
|
|
415
|
+
"endpoint": self._operation_key(ep),
|
|
416
|
+
"operation_id": ep.get("operation_id", ""),
|
|
417
|
+
"check": check,
|
|
418
|
+
"verdict": verdict.value,
|
|
419
|
+
"reason": reason,
|
|
420
|
+
"evidence": evidence or {},
|
|
421
|
+
}
|
|
422
|
+
result = sanitize_evidence(result, self.include_sensitive_evidence, self._secret_values)
|
|
423
|
+
if finding:
|
|
424
|
+
finding = sanitize_evidence(finding, self.include_sensitive_evidence, self._secret_values)
|
|
425
|
+
with self._result_lock:
|
|
426
|
+
self.results.append(result)
|
|
427
|
+
self.stats["total_checks"] += 1
|
|
428
|
+
if verdict not in {Verdict.SKIPPED, Verdict.ERROR}:
|
|
429
|
+
self.stats["audited_count"] += 1
|
|
430
|
+
if verdict in {Verdict.CONFIRMED, Verdict.SECURE, Verdict.PUBLIC, Verdict.AUTHORIZED}:
|
|
431
|
+
self.stats["conclusive_count"] += 1
|
|
432
|
+
if verdict == Verdict.CONFIRMED:
|
|
433
|
+
self.stats["confirmed"] += 1
|
|
434
|
+
elif verdict == Verdict.SECURE:
|
|
435
|
+
self.stats["secure_endpoints"] += 1
|
|
436
|
+
elif verdict == Verdict.PUBLIC:
|
|
437
|
+
self.stats["public_endpoints"] += 1
|
|
438
|
+
elif verdict == Verdict.AUTHORIZED:
|
|
439
|
+
self.stats["authorized_endpoints"] += 1
|
|
440
|
+
elif verdict == Verdict.SUSPICIOUS:
|
|
441
|
+
self.stats["suspicious_count"] += 1
|
|
442
|
+
elif verdict == Verdict.INCONCLUSIVE:
|
|
443
|
+
self.stats["inconclusive_count"] += 1
|
|
444
|
+
elif verdict == Verdict.SKIPPED:
|
|
445
|
+
self.stats["skipped_count"] += 1
|
|
446
|
+
elif verdict == Verdict.ERROR:
|
|
447
|
+
self.stats["error_count"] += 1
|
|
448
|
+
if finding:
|
|
449
|
+
self.findings.append(finding)
|
|
450
|
+
return result
|
|
451
|
+
|
|
452
|
+
@staticmethod
|
|
453
|
+
def _map_bola_verdict(raw: str) -> Verdict:
|
|
454
|
+
mapping = {
|
|
455
|
+
"BOLA_CONFIRMED": Verdict.CONFIRMED,
|
|
456
|
+
"CONFIRMED": Verdict.CONFIRMED,
|
|
457
|
+
"PUBLIC_ENDPOINT": Verdict.PUBLIC,
|
|
458
|
+
"PUBLIC": Verdict.PUBLIC,
|
|
459
|
+
"SECURE_ENFORCED": Verdict.SECURE,
|
|
460
|
+
"SECURE": Verdict.SECURE,
|
|
461
|
+
"LOW_SUSPICION": Verdict.SUSPICIOUS,
|
|
462
|
+
"SUSPICIOUS": Verdict.SUSPICIOUS,
|
|
463
|
+
"ERROR": Verdict.ERROR,
|
|
464
|
+
}
|
|
465
|
+
return mapping.get(str(raw).upper(), Verdict.INCONCLUSIVE)
|
|
466
|
+
|
|
467
|
+
def audit_endpoint_bola(self, ep: Dict[str, Any]) -> None:
|
|
468
|
+
path = ep["path"]
|
|
469
|
+
parameters = ep.get("parameters", [])
|
|
470
|
+
has_selector = "{" in path or any(item.get("in") == "query" for item in parameters)
|
|
471
|
+
if not has_selector:
|
|
472
|
+
self._record_result(ep, "BOLA", Verdict.SKIPPED, "No object selector was found")
|
|
473
|
+
return
|
|
474
|
+
|
|
475
|
+
try:
|
|
476
|
+
owner_url = self._build_url(ep, "owner")
|
|
477
|
+
visitor_self_url = self._build_url(ep, "visitor")
|
|
478
|
+
except ParameterSerializationError as exc:
|
|
479
|
+
self._record_result(ep, "BOLA", Verdict.INCONCLUSIVE, str(exc))
|
|
480
|
+
return
|
|
481
|
+
owner = self._http_request(ep["method"], owner_url, identity_name="owner")
|
|
482
|
+
visitor_cross = self._http_request(ep["method"], owner_url, identity_name="visitor")
|
|
483
|
+
anonymous = self._http_request(ep["method"], owner_url, identity_name="anonymous")
|
|
484
|
+
visitor_self = None
|
|
485
|
+
if visitor_self_url != owner_url:
|
|
486
|
+
visitor_self = self._http_request(
|
|
487
|
+
ep["method"], visitor_self_url, identity_name="visitor"
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
request_results = {
|
|
491
|
+
"owner": owner,
|
|
492
|
+
"visitor_cross": visitor_cross,
|
|
493
|
+
"anonymous": anonymous,
|
|
494
|
+
}
|
|
495
|
+
if visitor_self is not None:
|
|
496
|
+
request_results["visitor_self"] = visitor_self
|
|
497
|
+
transport_errors = [f"{name}: {item.error}" for name, item in request_results.items() if item.error]
|
|
498
|
+
evidence = {
|
|
499
|
+
"target_url": owner_url,
|
|
500
|
+
"visitor_self_url": visitor_self_url if visitor_self is not None else None,
|
|
501
|
+
**{name: self._safe_body(item) for name, item in request_results.items()},
|
|
502
|
+
}
|
|
503
|
+
if any(item.truncated for item in request_results.values()):
|
|
504
|
+
self._record_result(ep, "BOLA", Verdict.INCONCLUSIVE,
|
|
505
|
+
"A response was truncated; authorization comparison is incomplete", evidence=evidence)
|
|
506
|
+
return
|
|
507
|
+
if transport_errors:
|
|
508
|
+
self._record_result(ep, "BOLA", Verdict.ERROR, "; ".join(transport_errors), evidence=evidence)
|
|
509
|
+
return
|
|
510
|
+
|
|
511
|
+
policy = self.bola_config.get(self._operation_key(ep), {})
|
|
512
|
+
if not isinstance(policy, dict):
|
|
513
|
+
raise ValueError("BOLA operation policy must be an object")
|
|
514
|
+
expected_access = policy.get("expected_visitor_access", "deny")
|
|
515
|
+
if expected_access not in ("allow", "deny"):
|
|
516
|
+
raise ValueError("expected_visitor_access must be 'allow' or 'deny'")
|
|
517
|
+
security = ep.get("security", [])
|
|
518
|
+
requires_auth = bool(security) and {} not in security
|
|
519
|
+
expected_public = policy.get("expected_public") is True or (
|
|
520
|
+
ep.get("security_declared", False) and security == []
|
|
521
|
+
)
|
|
522
|
+
evaluation = ResponseDiffEngine.evaluate_bola(
|
|
523
|
+
owner.as_tuple(),
|
|
524
|
+
visitor_cross.as_tuple(),
|
|
525
|
+
anonymous.as_tuple(),
|
|
526
|
+
visitor_self=visitor_self.as_tuple() if visitor_self is not None else None,
|
|
527
|
+
expected_public=expected_public,
|
|
528
|
+
requires_auth=requires_auth,
|
|
529
|
+
resource_id_paths=policy.get("resource_id_paths"),
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
verdict = self._map_bola_verdict(evaluation.get("verdict", "INCONCLUSIVE"))
|
|
533
|
+
reason = evaluation.get("reason", "No reason supplied")
|
|
534
|
+
# OpenAPI cannot establish business authorization. Apply ONLY the explicit
|
|
535
|
+
# expectation for this selected identity/resource pair after the existing
|
|
536
|
+
# data, self-baseline and anonymous-denial evidence checks have succeeded.
|
|
537
|
+
if expected_access == "allow" and verdict == Verdict.CONFIRMED:
|
|
538
|
+
verdict = Verdict.AUTHORIZED
|
|
539
|
+
reason = "Configured Visitor is authorized to read this selected Owner resource; observed access matches this pair's explicit policy"
|
|
540
|
+
elif expected_access == "allow" and verdict == Verdict.SECURE:
|
|
541
|
+
verdict = Verdict.INCONCLUSIVE
|
|
542
|
+
reason = "Configured Visitor should have access to this selected Owner resource, but was denied; verify the identity and sharing/admin policy"
|
|
543
|
+
evidence["metrics"] = {
|
|
544
|
+
key: value
|
|
545
|
+
for key, value in evaluation.items()
|
|
546
|
+
if key not in {"verdict", "reason"} and isinstance(value, (str, int, float, bool, type(None)))
|
|
547
|
+
}
|
|
548
|
+
evidence["decision_evidence"] = evaluation.get("evidence", {})
|
|
549
|
+
evidence["decision_evidence"]["expected_visitor_access"] = expected_access
|
|
550
|
+
evidence["decision_evidence"]["policy_scope"] = "Configured Owner/Visitor identities and selected resource pair only"
|
|
551
|
+
finding = None
|
|
552
|
+
if verdict == Verdict.CONFIRMED:
|
|
553
|
+
finding = {
|
|
554
|
+
"type": "BOLA (Broken Object Level Authorization)",
|
|
555
|
+
"cwe": "CWE-639",
|
|
556
|
+
"severity": "High",
|
|
557
|
+
"endpoint": self._operation_key(ep),
|
|
558
|
+
"target_url": owner_url,
|
|
559
|
+
"details": evaluation.get("reason", "Cross-identity access was confirmed"),
|
|
560
|
+
"evidence": evidence,
|
|
561
|
+
}
|
|
562
|
+
with self._result_lock:
|
|
563
|
+
self.stats["bola_confirmed"] += 1
|
|
564
|
+
self._record_result(
|
|
565
|
+
ep,
|
|
566
|
+
"BOLA",
|
|
567
|
+
verdict,
|
|
568
|
+
reason,
|
|
569
|
+
evidence=evidence,
|
|
570
|
+
finding=finding,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
@staticmethod
|
|
574
|
+
def _json_object(result: HTTPResult) -> Optional[Dict[str, Any]]:
|
|
575
|
+
if not result.ok or result.truncated:
|
|
576
|
+
return None
|
|
577
|
+
try:
|
|
578
|
+
value = strict_json_loads(result.body)
|
|
579
|
+
except (ValueError, RecursionError):
|
|
580
|
+
return None
|
|
581
|
+
if not isinstance(value, dict) or not value:
|
|
582
|
+
return None
|
|
583
|
+
assessment = ResponseDiffEngine.classify_response(result.status, result.body)
|
|
584
|
+
return value if assessment.get("kind") == ResponseDiffEngine.RESPONSE_VALID else None
|
|
585
|
+
|
|
586
|
+
@staticmethod
|
|
587
|
+
def _path_parts(path: Any) -> Tuple[Any, ...]:
|
|
588
|
+
if isinstance(path, (tuple, list)):
|
|
589
|
+
return tuple(path)
|
|
590
|
+
tokens: List[Any] = []
|
|
591
|
+
for name, index in re.findall(r"(?:^|\.)([^.\[]+)|\[(\d+)\]", str(path)):
|
|
592
|
+
tokens.append(int(index) if index else name)
|
|
593
|
+
return tuple(tokens)
|
|
594
|
+
|
|
595
|
+
@classmethod
|
|
596
|
+
def _get_path(cls, value: Any, path: Any) -> Tuple[bool, Any]:
|
|
597
|
+
current = value
|
|
598
|
+
if not path:
|
|
599
|
+
return True, current
|
|
600
|
+
for part in cls._path_parts(path):
|
|
601
|
+
if isinstance(part, str) and isinstance(current, dict) and part in current:
|
|
602
|
+
current = current[part]
|
|
603
|
+
elif isinstance(part, int) and isinstance(current, list) and part < len(current):
|
|
604
|
+
current = current[part]
|
|
605
|
+
else:
|
|
606
|
+
return False, None
|
|
607
|
+
return True, current
|
|
608
|
+
|
|
609
|
+
@classmethod
|
|
610
|
+
def _set_path(cls, payload: Dict[str, Any], path: Any, value: Any) -> None:
|
|
611
|
+
parts = cls._path_parts(path)
|
|
612
|
+
if not parts:
|
|
613
|
+
raise ValueError("mutation path must not be empty")
|
|
614
|
+
cursor: Any = payload
|
|
615
|
+
for position, part in enumerate(parts):
|
|
616
|
+
last = position == len(parts) - 1
|
|
617
|
+
next_part = None if last else parts[position + 1]
|
|
618
|
+
if isinstance(part, str):
|
|
619
|
+
if not isinstance(cursor, dict):
|
|
620
|
+
raise ValueError(f"cannot place object field '{part}' inside a non-object")
|
|
621
|
+
if last:
|
|
622
|
+
cursor[part] = value
|
|
623
|
+
return
|
|
624
|
+
expected = [] if isinstance(next_part, int) else {}
|
|
625
|
+
if not isinstance(cursor.get(part), type(expected)):
|
|
626
|
+
cursor[part] = expected
|
|
627
|
+
cursor = cursor[part]
|
|
628
|
+
|
|
629
|
+
else:
|
|
630
|
+
if not isinstance(cursor, list):
|
|
631
|
+
raise ValueError(f"cannot place array index {part} inside a non-array")
|
|
632
|
+
while len(cursor) <= part:
|
|
633
|
+
cursor.append([] if isinstance(next_part, int) else {})
|
|
634
|
+
if last:
|
|
635
|
+
cursor[part] = value
|
|
636
|
+
return
|
|
637
|
+
expected = [] if isinstance(next_part, int) else {}
|
|
638
|
+
if not isinstance(cursor[part], type(expected)):
|
|
639
|
+
cursor[part] = expected
|
|
640
|
+
cursor = cursor[part]
|
|
641
|
+
|
|
642
|
+
@classmethod
|
|
643
|
+
def _merge_payloads(cls, base: Dict[str, Any], overlay: Dict[str, Any]) -> Dict[str, Any]:
|
|
644
|
+
"""Recursively overlay configured valid fields without discarding generated ones."""
|
|
645
|
+
merged = copy.deepcopy(base)
|
|
646
|
+
for key, value in overlay.items():
|
|
647
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
648
|
+
merged[key] = cls._merge_payloads(merged[key], value)
|
|
649
|
+
else:
|
|
650
|
+
merged[key] = copy.deepcopy(value)
|
|
651
|
+
return merged
|
|
652
|
+
|
|
653
|
+
def _normalise_mutation_cases(self, schema: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
654
|
+
cases: List[Dict[str, Any]] = []
|
|
655
|
+
builder = getattr(SmartDataGenerator, "build_mass_assignment_cases", None)
|
|
656
|
+
if callable(builder):
|
|
657
|
+
generated = builder(schema)
|
|
658
|
+
for item in generated or []:
|
|
659
|
+
if not isinstance(item, dict):
|
|
660
|
+
continue
|
|
661
|
+
if "field_path" in item and "value" in item:
|
|
662
|
+
cases.append(
|
|
663
|
+
{
|
|
664
|
+
"field_path": str(item["field_path"]),
|
|
665
|
+
"path": item.get("path", item["field_path"]),
|
|
666
|
+
"value": item["value"],
|
|
667
|
+
"payload": copy.deepcopy(item.get("payload"))
|
|
668
|
+
if isinstance(item.get("payload"), dict)
|
|
669
|
+
else {},
|
|
670
|
+
}
|
|
671
|
+
)
|
|
672
|
+
continue
|
|
673
|
+
if "path" in item and "value" in item:
|
|
674
|
+
cases.append({
|
|
675
|
+
"field_path": str(item["path"]),
|
|
676
|
+
"path": item["path"],
|
|
677
|
+
"value": item["value"],
|
|
678
|
+
"payload": copy.deepcopy(item.get("payload"))
|
|
679
|
+
if isinstance(item.get("payload"), dict)
|
|
680
|
+
else {},
|
|
681
|
+
})
|
|
682
|
+
continue
|
|
683
|
+
payload = item.get("payload") if isinstance(item.get("payload"), dict) else item
|
|
684
|
+
for field_path, value in payload.items():
|
|
685
|
+
cases.append({
|
|
686
|
+
"field_path": str(field_path),
|
|
687
|
+
"path": field_path,
|
|
688
|
+
"value": value,
|
|
689
|
+
"payload": copy.deepcopy(payload),
|
|
690
|
+
})
|
|
691
|
+
if not cases:
|
|
692
|
+
payload = SmartDataGenerator.build_mass_assignment_payload(schema)
|
|
693
|
+
cases = [
|
|
694
|
+
{
|
|
695
|
+
"field_path": str(key),
|
|
696
|
+
"path": key,
|
|
697
|
+
"value": value,
|
|
698
|
+
"payload": {key: copy.deepcopy(value)},
|
|
699
|
+
}
|
|
700
|
+
for key, value in payload.items()
|
|
701
|
+
]
|
|
702
|
+
|
|
703
|
+
unique: Dict[str, Dict[str, Any]] = {}
|
|
704
|
+
for case in cases:
|
|
705
|
+
unique.setdefault(case["field_path"], case)
|
|
706
|
+
return list(unique.values())
|
|
707
|
+
|
|
708
|
+
def _readback_for(self, ep: Dict[str, Any], all_endpoints: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
709
|
+
configured = self.readback_config.get(self._operation_key(ep))
|
|
710
|
+
if isinstance(configured, dict):
|
|
711
|
+
return configured
|
|
712
|
+
return None
|
|
713
|
+
|
|
714
|
+
def _readback_result(
|
|
715
|
+
self,
|
|
716
|
+
ep: Dict[str, Any],
|
|
717
|
+
mapping: Dict[str, Any],
|
|
718
|
+
identity_name: str,
|
|
719
|
+
) -> Tuple[str, HTTPResult]:
|
|
720
|
+
documented = next((candidate for candidate in self.parser.get_endpoints()
|
|
721
|
+
if candidate["method"] == str(mapping.get("method", "GET")).upper()
|
|
722
|
+
and candidate["path"] == str(mapping["path"])), None)
|
|
723
|
+
read_ep = {
|
|
724
|
+
"method": str(mapping.get("method", "GET")).upper(),
|
|
725
|
+
"path": str(mapping["path"]),
|
|
726
|
+
"parameters": mapping.get("parameters", (documented or ep).get("parameters", [])),
|
|
727
|
+
"spec_version": (documented or ep).get("spec_version", self.parser.raw_spec.get("openapi", self.parser.raw_spec.get("swagger", ""))),
|
|
728
|
+
"operation_id": f"readback_{ep.get('operation_id', '')}",
|
|
729
|
+
}
|
|
730
|
+
url = self._build_url(read_ep, identity_name, mapping.get("parameter_values"))
|
|
731
|
+
return url, self._http_request(read_ep["method"], url, identity_name=identity_name)
|
|
732
|
+
|
|
733
|
+
def _resource_lock(self, key: str) -> threading.Lock:
|
|
734
|
+
with self._resource_locks_guard:
|
|
735
|
+
return self._resource_locks.setdefault(key, threading.Lock())
|
|
736
|
+
|
|
737
|
+
@staticmethod
|
|
738
|
+
def _poll_settings(readback: Dict[str, Any]) -> Tuple[int, float]:
|
|
739
|
+
try:
|
|
740
|
+
attempts = min(10, max(1, int(readback.get("readback_attempts", 3))))
|
|
741
|
+
delay = min(5.0, max(0.0, float(readback.get("readback_delay", 0.1))))
|
|
742
|
+
return attempts, delay
|
|
743
|
+
except (TypeError, ValueError, OverflowError):
|
|
744
|
+
return 3, 0.1
|
|
745
|
+
|
|
746
|
+
def _write_request(self, ep: Dict[str, Any], url: str, payload: Dict[str, Any]) -> HTTPResult:
|
|
747
|
+
content_type = str(ep.get("request_content_type") or "application/json")
|
|
748
|
+
if content_type.lower() == "application/json":
|
|
749
|
+
return self._http_request(ep["method"], url, data=payload, identity_name="visitor")
|
|
750
|
+
return self._http_request(ep["method"], url, data=payload, identity_name="visitor",
|
|
751
|
+
content_type=content_type)
|
|
752
|
+
|
|
753
|
+
def _rollback_and_verify(
|
|
754
|
+
self, ep: Dict[str, Any], mutation_url: str,
|
|
755
|
+
readback: Dict[str, Any], snapshot: PatchSnapshot,
|
|
756
|
+
) -> Tuple[bool, HTTPResult, HTTPResult]:
|
|
757
|
+
"""Restore every written container and verify the entire snapshot, not just the probe."""
|
|
758
|
+
try:
|
|
759
|
+
rollback_http = self._write_request(ep, mutation_url, snapshot.restore)
|
|
760
|
+
except Exception as exc:
|
|
761
|
+
rollback_http = HTTPResult(0, "", error=f"{type(exc).__name__}: {exc}")
|
|
762
|
+
attempts, delay = self._poll_settings(readback)
|
|
763
|
+
restored_http = HTTPResult(0, "", error="rollback readback was not attempted")
|
|
764
|
+
verified = False
|
|
765
|
+
for attempt in range(attempts):
|
|
766
|
+
try:
|
|
767
|
+
_, restored_http = self._readback_result(ep, readback, "visitor")
|
|
768
|
+
verified = snapshot.matches(self._json_object(restored_http))
|
|
769
|
+
except Exception as exc:
|
|
770
|
+
restored_http = HTTPResult(0, "", error=f"{type(exc).__name__}: {exc}")
|
|
771
|
+
# A response that cannot be decoded, or async restoration, is not a safe success.
|
|
772
|
+
if verified and rollback_http.ok and rollback_http.status != 202:
|
|
773
|
+
return True, rollback_http, restored_http
|
|
774
|
+
if attempt + 1 < attempts:
|
|
775
|
+
time.sleep(delay)
|
|
776
|
+
return False, rollback_http, restored_http
|
|
777
|
+
|
|
778
|
+
def _execute_patch_case(
|
|
779
|
+
self, ep: Dict[str, Any], mutation_url: str, readback: Dict[str, Any],
|
|
780
|
+
snapshot: PatchSnapshot,
|
|
781
|
+
) -> Dict[str, Any]:
|
|
782
|
+
"""Every possibly-sent mutation passes through finally-based recovery."""
|
|
783
|
+
mutation = HTTPResult(0, "", error="mutation was not completed")
|
|
784
|
+
after_http = HTTPResult(0, "", error="read-after-write was not completed")
|
|
785
|
+
after = None
|
|
786
|
+
recovery_needed = True
|
|
787
|
+
rollback_ok = False
|
|
788
|
+
rollback_http = HTTPResult(0, "", error="not attempted")
|
|
789
|
+
restored_http = HTTPResult(0, "", error="not attempted")
|
|
790
|
+
failure = None
|
|
791
|
+
try:
|
|
792
|
+
mutation = self._write_request(ep, mutation_url, snapshot.mutation)
|
|
793
|
+
attempts, delay = self._poll_settings(readback)
|
|
794
|
+
for attempt in range(attempts):
|
|
795
|
+
_, after_http = self._readback_result(ep, readback, "visitor")
|
|
796
|
+
after = self._json_object(after_http)
|
|
797
|
+
if (after is not None and not snapshot.matches(after)) or attempt + 1 == attempts:
|
|
798
|
+
break
|
|
799
|
+
time.sleep(delay)
|
|
800
|
+
# Only an explicitly strongly consistent, complete observation allows
|
|
801
|
+
# skipping a redundant restore. Weak/stale reads must not bypass recovery.
|
|
802
|
+
recovery_needed = not (
|
|
803
|
+
readback.get("consistency") == "strong"
|
|
804
|
+
and snapshot.matches(after)
|
|
805
|
+
and not mutation.error
|
|
806
|
+
and mutation.status != 202
|
|
807
|
+
and (mutation.ok or mutation.status in self.AUTH_DENIAL_STATUSES)
|
|
808
|
+
)
|
|
809
|
+
if not recovery_needed:
|
|
810
|
+
rollback_ok = True
|
|
811
|
+
restored_http = after_http
|
|
812
|
+
except Exception as exc:
|
|
813
|
+
failure = f"{type(exc).__name__}: {exc}"
|
|
814
|
+
finally:
|
|
815
|
+
# Fail closed BEFORE invoking recovery: recovery code can itself fail.
|
|
816
|
+
previous_halt = self._writes_halted_reason
|
|
817
|
+
self._writes_halted_reason = (
|
|
818
|
+
"Recovery could not be guaranteed; later write tests stopped. "
|
|
819
|
+
"Inspect the dedicated test resource before retrying."
|
|
820
|
+
)
|
|
821
|
+
try:
|
|
822
|
+
if recovery_needed:
|
|
823
|
+
rollback_ok, rollback_http, restored_http = self._rollback_and_verify(
|
|
824
|
+
ep, mutation_url, readback, snapshot
|
|
825
|
+
)
|
|
826
|
+
except Exception as exc:
|
|
827
|
+
rollback_ok = False
|
|
828
|
+
failure = f"Recovery raised {type(exc).__name__}: {exc}"
|
|
829
|
+
rollback_http = HTTPResult(0, "", error=failure)
|
|
830
|
+
if rollback_ok and mutation.status != 202 and rollback_http.status != 202:
|
|
831
|
+
self._writes_halted_reason = previous_halt
|
|
832
|
+
return {
|
|
833
|
+
"mutation": mutation, "after_http": after_http, "after": after,
|
|
834
|
+
"rollback": rollback_http, "restored": restored_http,
|
|
835
|
+
"rollback_verified": rollback_ok, "recovery_sent": recovery_needed,
|
|
836
|
+
"failure": failure,
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
def audit_endpoint_mass_assignment(
|
|
840
|
+
self, ep: Dict[str, Any], all_endpoints: List[Dict[str, Any]]
|
|
841
|
+
) -> None:
|
|
842
|
+
operation_key = self._operation_key(ep)
|
|
843
|
+
if self._writes_halted_reason:
|
|
844
|
+
self._record_result(ep, "MASS_ASSIGNMENT", Verdict.SKIPPED, self._writes_halted_reason)
|
|
845
|
+
return
|
|
846
|
+
if not self.allow_write_tests:
|
|
847
|
+
self._record_result(ep, "MASS_ASSIGNMENT", Verdict.SKIPPED,
|
|
848
|
+
"State-changing checks are disabled; pass --allow-write-tests to opt in")
|
|
849
|
+
return
|
|
850
|
+
if operation_key not in self.write_allowlist:
|
|
851
|
+
self._record_result(ep, "MASS_ASSIGNMENT", Verdict.SKIPPED,
|
|
852
|
+
"Endpoint is not in the explicit, case-sensitive write-test allowlist")
|
|
853
|
+
return
|
|
854
|
+
schema = ep.get("request_schema")
|
|
855
|
+
if not isinstance(schema, dict) or not schema:
|
|
856
|
+
self._record_result(ep, "MASS_ASSIGNMENT", Verdict.SKIPPED,
|
|
857
|
+
"No request schema is available; no write was sent")
|
|
858
|
+
return
|
|
859
|
+
content_type = str(ep.get("request_content_type") or "").split(";", 1)[0].lower()
|
|
860
|
+
if content_type not in {"application/json", "application/merge-patch+json"}:
|
|
861
|
+
self._record_result(ep, "MASS_ASSIGNMENT", Verdict.SKIPPED,
|
|
862
|
+
"Only object application/json and application/merge-patch+json are supported; "
|
|
863
|
+
"JSON Patch operation arrays and arbitrary vendor formats are not implemented")
|
|
864
|
+
return
|
|
865
|
+
readback = self._readback_for(ep, all_endpoints)
|
|
866
|
+
if (not readback or not readback.get("path")
|
|
867
|
+
or str(readback.get("method", "GET")).upper() != "GET"):
|
|
868
|
+
self._record_result(ep, "MASS_ASSIGNMENT", Verdict.INCONCLUSIVE,
|
|
869
|
+
"An independent GET readback mapping is required; no write was sent")
|
|
870
|
+
return
|
|
871
|
+
field_map = readback.get("field_map")
|
|
872
|
+
if not isinstance(field_map, dict) or not field_map:
|
|
873
|
+
self._record_result(ep, "MASS_ASSIGNMENT", Verdict.INCONCLUSIVE,
|
|
874
|
+
"An explicit non-empty field_map is required; no write was sent")
|
|
875
|
+
return
|
|
876
|
+
try:
|
|
877
|
+
generated = self._normalise_mutation_cases(schema)
|
|
878
|
+
except ValueError as exc:
|
|
879
|
+
self._record_result(ep, "MASS_ASSIGNMENT", Verdict.INCONCLUSIVE,
|
|
880
|
+
f"A valid baseline could not be generated: {exc}; no write was sent")
|
|
881
|
+
return
|
|
882
|
+
cases = [case for case in generated if case["field_path"] in field_map]
|
|
883
|
+
generated_paths = {case["field_path"] for case in cases}
|
|
884
|
+
unverified = [f"{name}: no mutation case was generated" for name in field_map if name not in generated_paths]
|
|
885
|
+
verified_safe: List[str] = []
|
|
886
|
+
tested: List[str] = []
|
|
887
|
+
case_summaries: List[Dict[str, Any]] = []
|
|
888
|
+
try:
|
|
889
|
+
mutation_url = self._build_url(ep, "visitor")
|
|
890
|
+
except ParameterSerializationError as exc:
|
|
891
|
+
self._record_result(ep, "MASS_ASSIGNMENT", Verdict.INCONCLUSIVE, str(exc))
|
|
892
|
+
return
|
|
893
|
+
|
|
894
|
+
with self._resource_lock(mutation_url):
|
|
895
|
+
for case in cases:
|
|
896
|
+
field_path = case["field_path"]
|
|
897
|
+
try:
|
|
898
|
+
read_url, before_http = self._readback_result(ep, readback, "visitor")
|
|
899
|
+
except ParameterSerializationError as exc:
|
|
900
|
+
self._record_result(ep, "MASS_ASSIGNMENT", Verdict.INCONCLUSIVE, str(exc))
|
|
901
|
+
return
|
|
902
|
+
if before_http.error:
|
|
903
|
+
self._record_result(ep, "MASS_ASSIGNMENT", Verdict.ERROR,
|
|
904
|
+
f"Read-before-write failed: {before_http.error}",
|
|
905
|
+
evidence={"readback_url": read_url, "before": self._safe_body(before_http)})
|
|
906
|
+
return
|
|
907
|
+
before = self._json_object(before_http)
|
|
908
|
+
if before is None:
|
|
909
|
+
unverified.append(f"{field_path}: readback was not a complete successful JSON object")
|
|
910
|
+
continue
|
|
911
|
+
try:
|
|
912
|
+
snapshot = prepare_patch(before, case, readback)
|
|
913
|
+
if not snapshot.matches(before):
|
|
914
|
+
raise ValueError("original readback snapshot cannot be verified")
|
|
915
|
+
# Merge Patch null members remove fields; they cannot restore an
|
|
916
|
+
# explicitly present null from an object snapshot.
|
|
917
|
+
def has_null_member(value):
|
|
918
|
+
if isinstance(value, dict):
|
|
919
|
+
return any(item is None or has_null_member(item) for item in value.values())
|
|
920
|
+
return False # Arrays replace as complete values.
|
|
921
|
+
if content_type == "application/merge-patch+json" and has_null_member(snapshot.restore):
|
|
922
|
+
raise ValueError("Merge Patch cannot restore present null object members")
|
|
923
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
924
|
+
unverified.append(f"{field_path}: {exc}; no write was sent")
|
|
925
|
+
continue
|
|
926
|
+
|
|
927
|
+
tested.append(field_path)
|
|
928
|
+
transaction = self._execute_patch_case(ep, mutation_url, readback, snapshot)
|
|
929
|
+
mutation = transaction["mutation"]
|
|
930
|
+
after = transaction["after"]
|
|
931
|
+
exists, value = get_path(after or {}, snapshot.read_path)
|
|
932
|
+
persisted = exists and json_values_equal(value, snapshot.target)
|
|
933
|
+
evidence = {
|
|
934
|
+
"field": field_path, "readback_field": field_map[field_path],
|
|
935
|
+
"target_url": mutation_url,
|
|
936
|
+
"readback_url": read_url,
|
|
937
|
+
"snapshot_fields": list(snapshot.restore),
|
|
938
|
+
"verification_scope": "Full readback document except explicitly ignored volatile paths",
|
|
939
|
+
"ignored_readback_paths": readback.get("ignore_readback_paths", []),
|
|
940
|
+
"mutation": self._safe_body(mutation),
|
|
941
|
+
"after": self._safe_body(transaction["after_http"]),
|
|
942
|
+
"rollback": self._safe_body(transaction["rollback"]),
|
|
943
|
+
"restored": self._safe_body(transaction["restored"]),
|
|
944
|
+
"rollback_verified": transaction["rollback_verified"],
|
|
945
|
+
"recovery_sent": transaction["recovery_sent"],
|
|
946
|
+
"tested_fields": list(tested),
|
|
947
|
+
"untested_fields": [name for name in field_map if name not in tested],
|
|
948
|
+
"readback_consistency": readback.get("consistency", "unspecified"),
|
|
949
|
+
}
|
|
950
|
+
case_summaries.append({
|
|
951
|
+
"field": field_path, "readback_field": field_map[field_path],
|
|
952
|
+
"persisted": persisted,
|
|
953
|
+
"rollback_verified": transaction["rollback_verified"],
|
|
954
|
+
"recovery_sent": transaction["recovery_sent"],
|
|
955
|
+
})
|
|
956
|
+
if persisted:
|
|
957
|
+
finding = {
|
|
958
|
+
"type": "Mass Assignment (independent readback confirmed)",
|
|
959
|
+
"cwe": "CWE-915", "severity": "Critical", "endpoint": operation_key,
|
|
960
|
+
"target_url": mutation_url,
|
|
961
|
+
"details": f"Privilege field '{field_path}' persisted after mutation",
|
|
962
|
+
"injected_payload": {field_path: snapshot.target},
|
|
963
|
+
"evidence": evidence,
|
|
964
|
+
}
|
|
965
|
+
with self._result_lock:
|
|
966
|
+
self.stats["mass_assignment_confirmed"] += 1
|
|
967
|
+
self._record_result(ep, "MASS_ASSIGNMENT", Verdict.CONFIRMED,
|
|
968
|
+
finding["details"], evidence, finding)
|
|
969
|
+
if self._writes_halted_reason:
|
|
970
|
+
self._record_result(ep, "ROLLBACK", Verdict.ERROR,
|
|
971
|
+
self._writes_halted_reason, evidence)
|
|
972
|
+
return
|
|
973
|
+
if self._writes_halted_reason:
|
|
974
|
+
self._record_result(ep, "MASS_ASSIGNMENT", Verdict.ERROR,
|
|
975
|
+
self._writes_halted_reason, evidence)
|
|
976
|
+
return
|
|
977
|
+
if transaction["failure"] or mutation.error or mutation.status == 0 or mutation.status >= 500:
|
|
978
|
+
self._record_result(ep, "MASS_ASSIGNMENT", Verdict.ERROR,
|
|
979
|
+
"Mutation outcome was ambiguous; recovery was verified. "
|
|
980
|
+
+ str(transaction["failure"] or mutation.error or mutation.status), evidence)
|
|
981
|
+
return
|
|
982
|
+
if after is None:
|
|
983
|
+
unverified.append(f"{field_path}: invalid read-after-write; original snapshot restored")
|
|
984
|
+
continue
|
|
985
|
+
if not snapshot.matches(after):
|
|
986
|
+
unverified.append(f"{field_path}: unexpected state change; original snapshot restored")
|
|
987
|
+
continue
|
|
988
|
+
if readback.get("consistency") != "strong":
|
|
989
|
+
unverified.append(f"{field_path}: unchanged state is not conclusive without strongly consistent readback")
|
|
990
|
+
continue
|
|
991
|
+
if mutation.status in self.AUTH_DENIAL_STATUSES:
|
|
992
|
+
verified_safe.append(field_path)
|
|
993
|
+
continue
|
|
994
|
+
assessment = (ResponseDiffEngine.classify_response(mutation.status, mutation.body)
|
|
995
|
+
if mutation.status != 204 else {"kind": ResponseDiffEngine.RESPONSE_VALID})
|
|
996
|
+
if mutation.ok and assessment.get("kind") == ResponseDiffEngine.RESPONSE_VALID:
|
|
997
|
+
verified_safe.append(field_path)
|
|
998
|
+
else:
|
|
999
|
+
unverified.append(f"{field_path}: mutation was rejected or returned a business error")
|
|
1000
|
+
evidence = {"tested_fields": tested, "safe_fields": verified_safe, "unverified": unverified,
|
|
1001
|
+
"configured_fields": list(field_map), "target_url": mutation_url,
|
|
1002
|
+
"cases": case_summaries,
|
|
1003
|
+
"rollback_verified": all(case["rollback_verified"] is True for case in case_summaries) if case_summaries else None,
|
|
1004
|
+
"recovery_sent": any(case["recovery_sent"] for case in case_summaries),
|
|
1005
|
+
"ignored_readback_paths": readback.get("ignore_readback_paths", [])}
|
|
1006
|
+
if verified_safe and not unverified and len(verified_safe) == len(field_map):
|
|
1007
|
+
with self._result_lock:
|
|
1008
|
+
self.stats["raw_filtered_count"] += 1
|
|
1009
|
+
self._record_result(ep, "MASS_ASSIGNMENT", Verdict.SECURE,
|
|
1010
|
+
"Strongly consistent readback showed no persistence for all configured fields", evidence)
|
|
1011
|
+
else:
|
|
1012
|
+
self._record_result(ep, "MASS_ASSIGNMENT", Verdict.INCONCLUSIVE,
|
|
1013
|
+
"; ".join(unverified) or "No configured mutation case could be executed safely", evidence)
|
|
1014
|
+
|
|
1015
|
+
def _record_task_error(self, ep: Dict[str, Any], check: str, exc: BaseException) -> None:
|
|
1016
|
+
self._record_result(ep, check, Verdict.ERROR, f"Unhandled {type(exc).__name__}: {exc}")
|
|
1017
|
+
|
|
1018
|
+
def _finalise_coverage(self) -> None:
|
|
1019
|
+
total = max(1, int(self.stats["total_endpoints"]))
|
|
1020
|
+
endpoint_results = [item for item in self.results if item["check"] != "ROLLBACK"]
|
|
1021
|
+
attempted = sum(1 for item in endpoint_results if item["verdict"] != Verdict.SKIPPED.value)
|
|
1022
|
+
conclusive = sum(
|
|
1023
|
+
1
|
|
1024
|
+
for item in endpoint_results
|
|
1025
|
+
if item["verdict"] in {Verdict.CONFIRMED.value, Verdict.SECURE.value, Verdict.PUBLIC.value, Verdict.AUTHORIZED.value}
|
|
1026
|
+
)
|
|
1027
|
+
self.stats["coverage_pct"] = round(attempted * 100.0 / total, 1)
|
|
1028
|
+
self.stats["conclusive_coverage_pct"] = round(conclusive * 100.0 / total, 1)
|
|
1029
|
+
|
|
1030
|
+
def build_plan(self) -> Dict[str, Any]:
|
|
1031
|
+
"""Inspect local specification/configuration only; this never makes requests."""
|
|
1032
|
+
entries = []
|
|
1033
|
+
for ep in self.parser.get_endpoints():
|
|
1034
|
+
key = self._operation_key(ep)
|
|
1035
|
+
readback = self.readback_config.get(key, {})
|
|
1036
|
+
entry = {"endpoint": key, "check": "UNSUPPORTED", "writes_enabled": False}
|
|
1037
|
+
if ep["method"] == "GET":
|
|
1038
|
+
entry["check"] = "BOLA"
|
|
1039
|
+
elif ep["method"] == "PATCH":
|
|
1040
|
+
entry.update({
|
|
1041
|
+
"check": "MASS_ASSIGNMENT",
|
|
1042
|
+
"writes_enabled": self.allow_write_tests and key in self.write_allowlist,
|
|
1043
|
+
"content_type": ep.get("request_content_type"),
|
|
1044
|
+
"readback_path": readback.get("path"),
|
|
1045
|
+
"configured_fields": list(readback.get("field_map", {})),
|
|
1046
|
+
"readback_consistency": readback.get("consistency", "unspecified"),
|
|
1047
|
+
})
|
|
1048
|
+
entries.append(entry)
|
|
1049
|
+
return sanitize_evidence({"mode": "DRY_RUN", "requests_sent": 0,
|
|
1050
|
+
"note": "Local plan only; not a successful scan or a recovery guarantee",
|
|
1051
|
+
"target": self.target_base_url, "operations": entries},
|
|
1052
|
+
self.include_sensitive_evidence, self._secret_values)
|
|
1053
|
+
|
|
1054
|
+
def run(self) -> None:
|
|
1055
|
+
endpoints = self.parser.get_endpoints()
|
|
1056
|
+
self.stats["total_endpoints"] = len(endpoints)
|
|
1057
|
+
print("=" * 68)
|
|
1058
|
+
print(f"GrantTrace {__version__} safety-first audit")
|
|
1059
|
+
print(f"Specification: {self.parser.version} | endpoints: {len(endpoints)}")
|
|
1060
|
+
print(
|
|
1061
|
+
"Write tests: "
|
|
1062
|
+
+ ("ENABLED with rollback verification" if self.allow_write_tests else "DISABLED (read-only mode)")
|
|
1063
|
+
)
|
|
1064
|
+
print("=" * 68)
|
|
1065
|
+
|
|
1066
|
+
get_endpoints = [ep for ep in endpoints if ep["method"] == "GET"]
|
|
1067
|
+
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
|
1068
|
+
future_to_ep = {executor.submit(self.audit_endpoint_bola, ep): ep for ep in get_endpoints}
|
|
1069
|
+
for future in as_completed(future_to_ep):
|
|
1070
|
+
ep = future_to_ep[future]
|
|
1071
|
+
try:
|
|
1072
|
+
future.result()
|
|
1073
|
+
except Exception as exc: # A task must always become a reportable error.
|
|
1074
|
+
self._record_task_error(ep, "BOLA", exc)
|
|
1075
|
+
|
|
1076
|
+
# State-changing checks are deliberately serial to prevent readback
|
|
1077
|
+
# attribution races between endpoints that share a resource.
|
|
1078
|
+
for ep in endpoints:
|
|
1079
|
+
try:
|
|
1080
|
+
if ep["method"] in self.WRITE_METHODS:
|
|
1081
|
+
self.audit_endpoint_mass_assignment(ep, endpoints)
|
|
1082
|
+
elif ep["method"] != "GET":
|
|
1083
|
+
self._record_result(
|
|
1084
|
+
ep,
|
|
1085
|
+
"UNSUPPORTED",
|
|
1086
|
+
Verdict.SKIPPED,
|
|
1087
|
+
f"No detector is implemented for {ep['method']}",
|
|
1088
|
+
)
|
|
1089
|
+
except Exception as exc:
|
|
1090
|
+
self._record_task_error(ep, "MASS_ASSIGNMENT", exc)
|
|
1091
|
+
|
|
1092
|
+
self._finalise_coverage()
|
|
1093
|
+
print("\n" + "=" * 68)
|
|
1094
|
+
print(f"Confirmed vulnerabilities: {self.stats['confirmed']}")
|
|
1095
|
+
print(f"Errors: {self.stats['error_count']} | inconclusive: {self.stats['inconclusive_count']}")
|
|
1096
|
+
print(
|
|
1097
|
+
f"Attempted coverage: {self.stats['coverage_pct']:.1f}% | "
|
|
1098
|
+
f"conclusive coverage: {self.stats['conclusive_coverage_pct']:.1f}%"
|
|
1099
|
+
)
|
|
1100
|
+
print("=" * 68)
|
|
1101
|
+
|