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.
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
+