authztrace 0.3.1__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.
- authztrace/__init__.py +3 -0
- authztrace/cli.py +130 -0
- authztrace/config.py +224 -0
- authztrace/engine.py +284 -0
- authztrace/matrix.py +81 -0
- authztrace/models.py +98 -0
- authztrace/openapi.py +135 -0
- authztrace/py.typed +1 -0
- authztrace/report.py +246 -0
- authztrace/templating.py +22 -0
- authztrace-0.3.1.dist-info/METADATA +327 -0
- authztrace-0.3.1.dist-info/RECORD +16 -0
- authztrace-0.3.1.dist-info/WHEEL +5 -0
- authztrace-0.3.1.dist-info/entry_points.txt +2 -0
- authztrace-0.3.1.dist-info/licenses/LICENSE +21 -0
- authztrace-0.3.1.dist-info/top_level.txt +1 -0
authztrace/__init__.py
ADDED
authztrace/cli.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Command-line entry point: authztrace run -c authztrace.yaml"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .config import load_contract
|
|
9
|
+
from .engine import run as run_checks
|
|
10
|
+
from .matrix import generate
|
|
11
|
+
from .openapi import generate_contract, write_contract
|
|
12
|
+
from .report import counts, to_json, to_junit, to_sarif, to_terminal
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _exit_code(results, strict: bool = False) -> int:
|
|
16
|
+
c = counts(results)
|
|
17
|
+
if any(result.outcome == "error" and result.category == "setup" for result in results):
|
|
18
|
+
return 2
|
|
19
|
+
if c["fail"] or (strict and c["warn"]):
|
|
20
|
+
return 1
|
|
21
|
+
return 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _run(args) -> int:
|
|
25
|
+
try:
|
|
26
|
+
contract = load_contract(args.config)
|
|
27
|
+
except (OSError, ValueError) as exc:
|
|
28
|
+
print(f"authztrace: cannot load contract: {exc}", file=sys.stderr)
|
|
29
|
+
return 2
|
|
30
|
+
|
|
31
|
+
if args.base_url:
|
|
32
|
+
contract.base_url = args.base_url.rstrip("/")
|
|
33
|
+
|
|
34
|
+
checks = generate(contract)
|
|
35
|
+
results = run_checks(
|
|
36
|
+
contract,
|
|
37
|
+
checks,
|
|
38
|
+
timeout=args.timeout,
|
|
39
|
+
include_unsafe=args.include_unsafe,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
print(to_terminal(results, color=not args.no_color))
|
|
43
|
+
|
|
44
|
+
if args.sarif:
|
|
45
|
+
with open(args.sarif, "w", encoding="utf-8") as f:
|
|
46
|
+
f.write(to_sarif(results, artifact_uri=args.config))
|
|
47
|
+
print(f"\nSARIF written to {args.sarif}")
|
|
48
|
+
|
|
49
|
+
if args.json:
|
|
50
|
+
with open(args.json, "w", encoding="utf-8") as f:
|
|
51
|
+
f.write(to_json(results))
|
|
52
|
+
print(f"\nJSON written to {args.json}")
|
|
53
|
+
|
|
54
|
+
if args.junit:
|
|
55
|
+
with open(args.junit, "w", encoding="utf-8") as f:
|
|
56
|
+
f.write(to_junit(results))
|
|
57
|
+
print(f"\nJUnit written to {args.junit}")
|
|
58
|
+
|
|
59
|
+
return _exit_code(results, strict=args.strict)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _init(args) -> int:
|
|
63
|
+
try:
|
|
64
|
+
contract = generate_contract(args.from_file, base_url=args.base_url)
|
|
65
|
+
write_contract(contract, args.output, force=args.force)
|
|
66
|
+
except (OSError, ValueError, FileExistsError) as exc:
|
|
67
|
+
print(f"authztrace: cannot generate contract: {exc}", file=sys.stderr)
|
|
68
|
+
return 2
|
|
69
|
+
|
|
70
|
+
print(f"AuthzTrace contract written to {args.output}")
|
|
71
|
+
print("Set the generated *_ID and *_TOKEN environment variables before running it.")
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main(argv=None) -> int:
|
|
76
|
+
parser = argparse.ArgumentParser(
|
|
77
|
+
prog="authztrace",
|
|
78
|
+
description="Authorization contract testing for IDOR/BOLA — "
|
|
79
|
+
"prove user A cannot touch user B's objects, in CI.",
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument("--version", action="version", version=f"authztrace {__version__}")
|
|
82
|
+
sub = parser.add_subparsers(dest="command")
|
|
83
|
+
|
|
84
|
+
run_p = sub.add_parser("run", help="run an authorization contract against a live API")
|
|
85
|
+
run_p.add_argument(
|
|
86
|
+
"-c", "--config", default="authztrace.yaml",
|
|
87
|
+
help="path to the authorization contract (default: authztrace.yaml)",
|
|
88
|
+
)
|
|
89
|
+
run_p.add_argument("--sarif", metavar="FILE", help="also write SARIF results to FILE")
|
|
90
|
+
run_p.add_argument("--json", metavar="FILE", help="also write machine-readable JSON to FILE")
|
|
91
|
+
run_p.add_argument("--junit", metavar="FILE", help="also write JUnit XML to FILE")
|
|
92
|
+
run_p.add_argument("--base-url", help="override base_url from the contract")
|
|
93
|
+
run_p.add_argument("--timeout", type=float, default=10.0, help="per-request timeout in seconds")
|
|
94
|
+
run_p.add_argument("--strict", action="store_true", help="return non-zero when warnings occur")
|
|
95
|
+
run_p.add_argument(
|
|
96
|
+
"--include-unsafe",
|
|
97
|
+
action="store_true",
|
|
98
|
+
help="execute non-read-only endpoints such as POST, PUT, PATCH, and DELETE",
|
|
99
|
+
)
|
|
100
|
+
run_p.add_argument("--no-color", action="store_true", help="disable ANSI colors")
|
|
101
|
+
|
|
102
|
+
init_p = sub.add_parser("init", help="scaffold a contract from an OpenAPI spec")
|
|
103
|
+
init_p.add_argument(
|
|
104
|
+
"--from",
|
|
105
|
+
dest="from_file",
|
|
106
|
+
required=True,
|
|
107
|
+
help="path to an OpenAPI JSON/YAML file",
|
|
108
|
+
)
|
|
109
|
+
init_p.add_argument(
|
|
110
|
+
"-o",
|
|
111
|
+
"--output",
|
|
112
|
+
default="authztrace.yaml",
|
|
113
|
+
help="contract path to write (default: authztrace.yaml)",
|
|
114
|
+
)
|
|
115
|
+
init_p.add_argument("--base-url", help="override server URL from the OpenAPI spec")
|
|
116
|
+
init_p.add_argument("--force", action="store_true", help="overwrite output if it exists")
|
|
117
|
+
|
|
118
|
+
args = parser.parse_args(argv)
|
|
119
|
+
|
|
120
|
+
if args.command == "run":
|
|
121
|
+
return _run(args)
|
|
122
|
+
if args.command == "init":
|
|
123
|
+
return _init(args)
|
|
124
|
+
|
|
125
|
+
parser.print_help()
|
|
126
|
+
return 2
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
sys.exit(main())
|
authztrace/config.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Load and validate an authztrace.yaml contract, expanding ${ENV_VARS}."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from .models import Actor, Check, Contract, Endpoint, Policy, Resource, effective_safe
|
|
11
|
+
from .templating import render
|
|
12
|
+
|
|
13
|
+
_ENV = re.compile(r"\$\{([A-Z0-9_]+)\}")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _expand(value):
|
|
17
|
+
"""Recursively replace ${VAR} with the environment value (empty if unset)."""
|
|
18
|
+
if isinstance(value, str):
|
|
19
|
+
return _ENV.sub(lambda m: os.environ.get(m.group(1), ""), value)
|
|
20
|
+
if isinstance(value, dict):
|
|
21
|
+
return {k: _expand(v) for k, v in value.items()}
|
|
22
|
+
if isinstance(value, list):
|
|
23
|
+
return [_expand(v) for v in value]
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _as_list(value: Any, default: list[str] | None = None) -> list[str]:
|
|
28
|
+
if value is None:
|
|
29
|
+
return list(default or [])
|
|
30
|
+
if isinstance(value, list):
|
|
31
|
+
return [str(item) for item in value]
|
|
32
|
+
return [str(value)]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _split_request(value: str) -> tuple[str, str]:
|
|
36
|
+
parts = value.strip().split(None, 1)
|
|
37
|
+
if len(parts) != 2:
|
|
38
|
+
raise ValueError(f"invalid request (expected 'METHOD /path'): {value!r}")
|
|
39
|
+
method, path = parts
|
|
40
|
+
return method.upper(), path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _endpoint(raw: Any, name: str) -> Endpoint:
|
|
44
|
+
"""Parse either ``GET /path`` or a structured endpoint object."""
|
|
45
|
+
if isinstance(raw, str):
|
|
46
|
+
method, path = _split_request(raw)
|
|
47
|
+
return Endpoint(name=name, method=method, path=path)
|
|
48
|
+
|
|
49
|
+
if not isinstance(raw, dict):
|
|
50
|
+
raise ValueError(f"endpoint {name!r} must be a string or object")
|
|
51
|
+
|
|
52
|
+
spec = dict(raw)
|
|
53
|
+
request = spec.pop("request", None)
|
|
54
|
+
if request:
|
|
55
|
+
if not isinstance(request, str):
|
|
56
|
+
raise ValueError(f"endpoint {name!r} request must be 'METHOD /path'")
|
|
57
|
+
method, path = _split_request(request)
|
|
58
|
+
else:
|
|
59
|
+
method = str(spec.pop("method", "")).upper()
|
|
60
|
+
path = str(spec.pop("path", ""))
|
|
61
|
+
|
|
62
|
+
if not method or not path:
|
|
63
|
+
raise ValueError(f"endpoint {name!r} must define method/path")
|
|
64
|
+
|
|
65
|
+
safe = spec.pop("safe", None)
|
|
66
|
+
if safe is not None and not isinstance(safe, bool):
|
|
67
|
+
raise ValueError(f"endpoint {name!r} safe must be true or false")
|
|
68
|
+
|
|
69
|
+
return Endpoint(
|
|
70
|
+
name=str(spec.pop("name", name)),
|
|
71
|
+
method=method,
|
|
72
|
+
path=path,
|
|
73
|
+
query=spec.pop("query", {}) or spec.pop("params", {}) or {},
|
|
74
|
+
headers=spec.pop("headers", {}) or {},
|
|
75
|
+
json=spec.pop("json", None),
|
|
76
|
+
data=spec.pop("data", None),
|
|
77
|
+
allow=_as_list(spec.pop("allow", None), ["owner"]),
|
|
78
|
+
assertions=spec.pop("assertions", {}) or {},
|
|
79
|
+
safe=safe,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _context(
|
|
84
|
+
resource: Resource | None,
|
|
85
|
+
actor: str,
|
|
86
|
+
owner: str,
|
|
87
|
+
object_id: Any,
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
marker = ""
|
|
90
|
+
if resource and owner:
|
|
91
|
+
marker = resource.markers.get(owner, "")
|
|
92
|
+
return {
|
|
93
|
+
"actor": actor,
|
|
94
|
+
"owner": owner,
|
|
95
|
+
"id": object_id,
|
|
96
|
+
"object_id": object_id,
|
|
97
|
+
"resource": resource.name if resource else "",
|
|
98
|
+
"marker": marker,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _explicit_check(spec: dict[str, Any], index: int, resources: dict[str, Resource]) -> Check:
|
|
103
|
+
name = str(spec.get("name") or f"contract-{index}")
|
|
104
|
+
actor = str(spec.get("as") or spec.get("actor") or "")
|
|
105
|
+
if not actor:
|
|
106
|
+
raise ValueError(f"contract {name!r} must define 'as' or 'actor'")
|
|
107
|
+
|
|
108
|
+
resource_name = str(spec.get("resource") or "")
|
|
109
|
+
resource = resources.get(resource_name) if resource_name else None
|
|
110
|
+
owner = str(spec.get("target_owner") or spec.get("owner") or spec.get("target") or "")
|
|
111
|
+
|
|
112
|
+
object_id = spec.get("id", "")
|
|
113
|
+
if object_id == "" and resource and owner:
|
|
114
|
+
object_id = resource.ids.get(owner, "")
|
|
115
|
+
|
|
116
|
+
params = spec.get("params") or {}
|
|
117
|
+
ctx = _context(resource, actor, owner, object_id)
|
|
118
|
+
ctx.update(params)
|
|
119
|
+
|
|
120
|
+
request = spec.get("request")
|
|
121
|
+
if isinstance(request, str):
|
|
122
|
+
endpoint_spec = {
|
|
123
|
+
"request": request,
|
|
124
|
+
"query": spec.get("query") or spec.get("params") or {},
|
|
125
|
+
"headers": spec.get("headers") or {},
|
|
126
|
+
"json": spec.get("json", None),
|
|
127
|
+
"data": spec.get("data", None),
|
|
128
|
+
"assertions": spec.get("assertions") or {},
|
|
129
|
+
"safe": spec.get("safe", None),
|
|
130
|
+
}
|
|
131
|
+
endpoint = _endpoint(endpoint_spec, name)
|
|
132
|
+
elif isinstance(request, dict):
|
|
133
|
+
endpoint_spec = {
|
|
134
|
+
"query": spec.get("query") or spec.get("params") or {},
|
|
135
|
+
"headers": spec.get("headers") or {},
|
|
136
|
+
"json": spec.get("json", None),
|
|
137
|
+
"data": spec.get("data", None),
|
|
138
|
+
"assertions": spec.get("assertions") or {},
|
|
139
|
+
"safe": spec.get("safe", None),
|
|
140
|
+
}
|
|
141
|
+
endpoint_spec.update(request)
|
|
142
|
+
endpoint = _endpoint(endpoint_spec, name)
|
|
143
|
+
else:
|
|
144
|
+
endpoint = _endpoint(spec, name)
|
|
145
|
+
|
|
146
|
+
return Check(
|
|
147
|
+
name=name,
|
|
148
|
+
resource=resource_name,
|
|
149
|
+
actor=actor,
|
|
150
|
+
method=endpoint.method,
|
|
151
|
+
path=render(endpoint.path, ctx),
|
|
152
|
+
path_template=endpoint.path,
|
|
153
|
+
endpoint_name=endpoint.name,
|
|
154
|
+
query=render(endpoint.query, ctx),
|
|
155
|
+
headers=render(endpoint.headers, ctx),
|
|
156
|
+
json=render(endpoint.json, ctx),
|
|
157
|
+
data=render(endpoint.data, ctx),
|
|
158
|
+
target_owner=owner,
|
|
159
|
+
object_id=str(object_id),
|
|
160
|
+
expect=str(spec.get("expect") or "deny"),
|
|
161
|
+
assertions=render(endpoint.assertions | (spec.get("assertions") or {}), ctx),
|
|
162
|
+
safe=effective_safe(endpoint.method, endpoint.safe),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def load_contract(path: str) -> Contract:
|
|
167
|
+
with open(path, encoding="utf-8") as f:
|
|
168
|
+
raw = _expand(yaml.safe_load(f))
|
|
169
|
+
|
|
170
|
+
if not raw or "base_url" not in raw:
|
|
171
|
+
raise ValueError("contract must define 'base_url'")
|
|
172
|
+
|
|
173
|
+
actors: dict[str, Actor] = {}
|
|
174
|
+
for name, spec in (raw.get("actors") or {}).items():
|
|
175
|
+
auth = (spec or {}).get("auth") or {"type": "none"}
|
|
176
|
+
actors[name] = Actor(name=name, auth=auth)
|
|
177
|
+
if not actors:
|
|
178
|
+
raise ValueError("contract must define at least one actor")
|
|
179
|
+
|
|
180
|
+
resources: dict[str, Resource] = {}
|
|
181
|
+
for name, spec in (raw.get("resources") or {}).items():
|
|
182
|
+
spec = spec or {}
|
|
183
|
+
endpoint_specs = spec.get("endpoints") or []
|
|
184
|
+
if not endpoint_specs:
|
|
185
|
+
raise ValueError(f"resource {name!r} must define at least one endpoint")
|
|
186
|
+
ids = spec.get("ids") or {}
|
|
187
|
+
if not isinstance(ids, dict) or not ids:
|
|
188
|
+
raise ValueError(f"resource {name!r} must define ids by owner")
|
|
189
|
+
resources[name] = Resource(
|
|
190
|
+
name=name,
|
|
191
|
+
endpoints=[
|
|
192
|
+
_endpoint(endpoint_spec, f"{name}.{idx}")
|
|
193
|
+
for idx, endpoint_spec in enumerate(endpoint_specs, start=1)
|
|
194
|
+
],
|
|
195
|
+
ids=ids,
|
|
196
|
+
markers=spec.get("markers", {}) or {},
|
|
197
|
+
)
|
|
198
|
+
if not resources:
|
|
199
|
+
raise ValueError("contract must define at least one resource")
|
|
200
|
+
|
|
201
|
+
pol = raw.get("policy") or {}
|
|
202
|
+
policy = Policy(
|
|
203
|
+
default=pol.get("default", "owner-only"),
|
|
204
|
+
deny_status=pol.get("deny_status", [401, 403, 404]),
|
|
205
|
+
allow_status=pol.get("allow_status", []),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
checks = [
|
|
209
|
+
_explicit_check(spec or {}, idx, resources)
|
|
210
|
+
for idx, spec in enumerate(raw.get("contracts") or raw.get("checks") or [], start=1)
|
|
211
|
+
]
|
|
212
|
+
for chk in checks:
|
|
213
|
+
if chk.actor not in actors:
|
|
214
|
+
raise ValueError(f"contract {chk.name!r} references unknown actor {chk.actor!r}")
|
|
215
|
+
if chk.resource and chk.resource not in resources:
|
|
216
|
+
raise ValueError(f"contract {chk.name!r} references unknown resource {chk.resource!r}")
|
|
217
|
+
|
|
218
|
+
return Contract(
|
|
219
|
+
base_url=raw["base_url"].rstrip("/"),
|
|
220
|
+
actors=actors,
|
|
221
|
+
resources=resources,
|
|
222
|
+
policy=policy,
|
|
223
|
+
checks=checks,
|
|
224
|
+
)
|
authztrace/engine.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Replay every check against the live API and grade it against the contract."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from .models import Check, Contract, Result
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _headers(auth: dict) -> dict:
|
|
14
|
+
kind = (auth or {}).get("type", "none")
|
|
15
|
+
if kind == "bearer":
|
|
16
|
+
return {"Authorization": f"Bearer {auth.get('token', '')}"}
|
|
17
|
+
if kind == "header":
|
|
18
|
+
return {auth.get("name", "X-API-Key"): auth.get("value", "")}
|
|
19
|
+
if kind == "cookie":
|
|
20
|
+
return {"Cookie": f"{auth.get('name', 'session')}={auth.get('value', '')}"}
|
|
21
|
+
if kind == "basic":
|
|
22
|
+
raw = f"{auth.get('username', '')}:{auth.get('password', '')}".encode()
|
|
23
|
+
return {"Authorization": "Basic " + base64.b64encode(raw).decode("ascii")}
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_allowed_status(contract: Contract, status: int) -> bool:
|
|
28
|
+
if contract.policy.allow_status:
|
|
29
|
+
return status in contract.policy.allow_status
|
|
30
|
+
return 200 <= status < 300
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _field_exists(value: Any, path: str) -> bool:
|
|
34
|
+
current = value
|
|
35
|
+
for part in path.split("."):
|
|
36
|
+
if isinstance(current, dict) and part in current:
|
|
37
|
+
current = current[part]
|
|
38
|
+
continue
|
|
39
|
+
if isinstance(current, list) and part.isdigit() and int(part) < len(current):
|
|
40
|
+
current = current[int(part)]
|
|
41
|
+
continue
|
|
42
|
+
return False
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _no_field_failures(resp: requests.Response, check: Check) -> list[str]:
|
|
47
|
+
failures: list[str] = []
|
|
48
|
+
assertions = check.assertions or {}
|
|
49
|
+
|
|
50
|
+
no_fields = assertions.get("no_fields") or []
|
|
51
|
+
if isinstance(no_fields, str):
|
|
52
|
+
no_fields = [no_fields]
|
|
53
|
+
if no_fields:
|
|
54
|
+
try:
|
|
55
|
+
body = resp.json()
|
|
56
|
+
except ValueError:
|
|
57
|
+
body = None
|
|
58
|
+
if body is not None:
|
|
59
|
+
for field_path in no_fields:
|
|
60
|
+
if _field_exists(body, str(field_path)):
|
|
61
|
+
failures.append(f"response contains forbidden JSON field {field_path!r}")
|
|
62
|
+
|
|
63
|
+
return failures
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _allow_assertions(resp: requests.Response, check: Check) -> list[str]:
|
|
67
|
+
failures: list[str] = []
|
|
68
|
+
assertions = check.assertions or {}
|
|
69
|
+
|
|
70
|
+
allowed_markers = assertions.get("allow_contains") or []
|
|
71
|
+
if isinstance(allowed_markers, str):
|
|
72
|
+
allowed_markers = [allowed_markers]
|
|
73
|
+
for marker in allowed_markers:
|
|
74
|
+
marker = str(marker)
|
|
75
|
+
if marker and marker not in resp.text:
|
|
76
|
+
failures.append(f"allowed response missed required marker {marker!r}")
|
|
77
|
+
|
|
78
|
+
failures.extend(_no_field_failures(resp, check))
|
|
79
|
+
return failures
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _deny_assertions(resp: requests.Response, check: Check) -> list[str]:
|
|
83
|
+
failures: list[str] = []
|
|
84
|
+
assertions = check.assertions or {}
|
|
85
|
+
|
|
86
|
+
denied_markers = assertions.get("deny_not_contains") or assertions.get("not_contains") or []
|
|
87
|
+
if isinstance(denied_markers, str):
|
|
88
|
+
denied_markers = [denied_markers]
|
|
89
|
+
for marker in denied_markers:
|
|
90
|
+
marker = str(marker)
|
|
91
|
+
if marker and marker in resp.text:
|
|
92
|
+
failures.append(f"denied response leaked forbidden marker {marker!r}")
|
|
93
|
+
|
|
94
|
+
failures.extend(_no_field_failures(resp, check))
|
|
95
|
+
return failures
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _setup_target(check: Check) -> str:
|
|
99
|
+
target = check.resource or "resource"
|
|
100
|
+
if check.object_id:
|
|
101
|
+
target = f"{target} ({check.object_id})"
|
|
102
|
+
if check.target_owner:
|
|
103
|
+
target = f"{check.target_owner}'s {target}"
|
|
104
|
+
return target
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _grade_allow(
|
|
108
|
+
contract: Contract,
|
|
109
|
+
check: Check,
|
|
110
|
+
resp: requests.Response,
|
|
111
|
+
elapsed_ms: int,
|
|
112
|
+
) -> Result:
|
|
113
|
+
status = resp.status_code
|
|
114
|
+
allowed = _is_allowed_status(contract, status)
|
|
115
|
+
if not allowed:
|
|
116
|
+
return Result(
|
|
117
|
+
check=check,
|
|
118
|
+
status=status,
|
|
119
|
+
outcome="error",
|
|
120
|
+
category="setup",
|
|
121
|
+
note=(
|
|
122
|
+
f"setup: expected '{check.actor}' to access {_setup_target(check)} "
|
|
123
|
+
f"but got HTTP {status}; authorization fixtures or credentials are not trustworthy"
|
|
124
|
+
),
|
|
125
|
+
elapsed_ms=elapsed_ms,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
assertion_failures = _allow_assertions(resp, check)
|
|
129
|
+
if assertion_failures:
|
|
130
|
+
return Result(
|
|
131
|
+
check=check,
|
|
132
|
+
status=status,
|
|
133
|
+
outcome="error",
|
|
134
|
+
category="setup",
|
|
135
|
+
note="setup: " + "; ".join(assertion_failures),
|
|
136
|
+
elapsed_ms=elapsed_ms,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return Result(
|
|
140
|
+
check=check,
|
|
141
|
+
status=status,
|
|
142
|
+
outcome="pass",
|
|
143
|
+
category="ok",
|
|
144
|
+
elapsed_ms=elapsed_ms,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _grade_deny(
|
|
149
|
+
contract: Contract,
|
|
150
|
+
check: Check,
|
|
151
|
+
resp: requests.Response,
|
|
152
|
+
elapsed_ms: int,
|
|
153
|
+
) -> Result:
|
|
154
|
+
status = resp.status_code
|
|
155
|
+
allowed = _is_allowed_status(contract, status)
|
|
156
|
+
|
|
157
|
+
if allowed:
|
|
158
|
+
outcome = "fail"
|
|
159
|
+
category = "bola"
|
|
160
|
+
note = (
|
|
161
|
+
f"BOLA: '{check.actor}' accessed {check.target_owner}'s "
|
|
162
|
+
f"{check.resource} ({check.object_id}) - HTTP {status}"
|
|
163
|
+
)
|
|
164
|
+
elif status in contract.policy.deny_status:
|
|
165
|
+
outcome, category, note = "pass", "ok", ""
|
|
166
|
+
else:
|
|
167
|
+
outcome = "warn"
|
|
168
|
+
category = "over_restrictive"
|
|
169
|
+
note = f"denied with {status}, not in deny_status {contract.policy.deny_status}"
|
|
170
|
+
|
|
171
|
+
assertion_failures = _deny_assertions(resp, check)
|
|
172
|
+
if assertion_failures:
|
|
173
|
+
details = "; ".join(assertion_failures)
|
|
174
|
+
note = f"{note}; {details}" if note else details
|
|
175
|
+
if outcome != "fail":
|
|
176
|
+
outcome = "fail"
|
|
177
|
+
category = "leak"
|
|
178
|
+
|
|
179
|
+
return Result(
|
|
180
|
+
check=check,
|
|
181
|
+
status=status,
|
|
182
|
+
outcome=outcome,
|
|
183
|
+
category=category,
|
|
184
|
+
note=note,
|
|
185
|
+
elapsed_ms=elapsed_ms,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _execute_check(
|
|
190
|
+
session: requests.Session,
|
|
191
|
+
contract: Contract,
|
|
192
|
+
check: Check,
|
|
193
|
+
timeout: float,
|
|
194
|
+
) -> Result:
|
|
195
|
+
actor = contract.actors[check.actor]
|
|
196
|
+
url = contract.base_url + check.path
|
|
197
|
+
headers = _headers(actor.auth)
|
|
198
|
+
headers.update(check.headers)
|
|
199
|
+
started = time.monotonic()
|
|
200
|
+
try:
|
|
201
|
+
resp = session.request(
|
|
202
|
+
check.method,
|
|
203
|
+
url,
|
|
204
|
+
params=check.query or None,
|
|
205
|
+
headers=headers,
|
|
206
|
+
json=check.json,
|
|
207
|
+
data=check.data,
|
|
208
|
+
timeout=timeout,
|
|
209
|
+
)
|
|
210
|
+
elapsed_ms = int((time.monotonic() - started) * 1000)
|
|
211
|
+
except requests.RequestException as exc:
|
|
212
|
+
elapsed_ms = int((time.monotonic() - started) * 1000)
|
|
213
|
+
return Result(
|
|
214
|
+
check=check,
|
|
215
|
+
status=None,
|
|
216
|
+
outcome="error",
|
|
217
|
+
category="setup",
|
|
218
|
+
note=f"setup: request failed: {exc}",
|
|
219
|
+
elapsed_ms=elapsed_ms,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if check.expect == "allow":
|
|
223
|
+
return _grade_allow(contract, check, resp, elapsed_ms)
|
|
224
|
+
return _grade_deny(contract, check, resp, elapsed_ms)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _unsafe_skipped(check: Check) -> Result:
|
|
228
|
+
return Result(
|
|
229
|
+
check=check,
|
|
230
|
+
status=None,
|
|
231
|
+
outcome="skipped",
|
|
232
|
+
category="unsafe_skipped",
|
|
233
|
+
note=(
|
|
234
|
+
f"unsafe {check.method} skipped in read-only mode; mark the endpoint "
|
|
235
|
+
"safe: true or pass --include-unsafe to execute it"
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _setup_skipped(check: Check) -> Result:
|
|
241
|
+
return Result(
|
|
242
|
+
check=check,
|
|
243
|
+
status=None,
|
|
244
|
+
outcome="skipped",
|
|
245
|
+
category="setup",
|
|
246
|
+
note="skipped because preflight failed; fix setup errors before trusting attack rows",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def run(
|
|
251
|
+
contract: Contract,
|
|
252
|
+
checks: list[Check],
|
|
253
|
+
timeout: float = 10.0,
|
|
254
|
+
include_unsafe: bool = False,
|
|
255
|
+
) -> list[Result]:
|
|
256
|
+
results_by_index: dict[int, Result] = {}
|
|
257
|
+
executable_indices: list[int] = []
|
|
258
|
+
session = requests.Session()
|
|
259
|
+
|
|
260
|
+
for idx, check in enumerate(checks):
|
|
261
|
+
if not include_unsafe and not check.safe:
|
|
262
|
+
results_by_index[idx] = _unsafe_skipped(check)
|
|
263
|
+
else:
|
|
264
|
+
executable_indices.append(idx)
|
|
265
|
+
|
|
266
|
+
preflight_indices = [idx for idx in executable_indices if checks[idx].expect == "allow"]
|
|
267
|
+
for idx in preflight_indices:
|
|
268
|
+
results_by_index[idx] = _execute_check(session, contract, checks[idx], timeout)
|
|
269
|
+
|
|
270
|
+
preflight_failed = any(
|
|
271
|
+
result.outcome == "error" and result.category == "setup"
|
|
272
|
+
for result in results_by_index.values()
|
|
273
|
+
)
|
|
274
|
+
if preflight_failed:
|
|
275
|
+
for idx in executable_indices:
|
|
276
|
+
if idx not in results_by_index:
|
|
277
|
+
results_by_index[idx] = _setup_skipped(checks[idx])
|
|
278
|
+
return [results_by_index[idx] for idx in range(len(checks))]
|
|
279
|
+
|
|
280
|
+
for idx in executable_indices:
|
|
281
|
+
if idx not in results_by_index:
|
|
282
|
+
results_by_index[idx] = _execute_check(session, contract, checks[idx], timeout)
|
|
283
|
+
|
|
284
|
+
return [results_by_index[idx] for idx in range(len(checks))]
|