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/matrix.py ADDED
@@ -0,0 +1,81 @@
1
+ """Expand a contract into the full actor x object test matrix.
2
+
3
+ This is the part a human bug-hunter does by hand: take every object, and for
4
+ every *other* identity try to reach it. AuthzTrace generates that whole cross
5
+ product from a few lines of ownership declaration.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from .models import Check, Contract, Endpoint, effective_safe
10
+ from .templating import render
11
+
12
+
13
+ def _is_authenticated(auth: dict) -> bool:
14
+ return (auth or {}).get("type", "none") != "none"
15
+
16
+
17
+ def _is_allowed(contract: Contract, endpoint: Endpoint, actor: str, owner: str) -> bool:
18
+ allow = {item.lower() for item in endpoint.allow}
19
+ if "all" in allow or "*" in allow:
20
+ return True
21
+ if "authenticated" in allow and _is_authenticated(contract.actors[actor].auth):
22
+ return True
23
+ if "anonymous" in allow and not _is_authenticated(contract.actors[actor].auth):
24
+ return True
25
+ if "owner" in allow and actor == owner:
26
+ return True
27
+ return actor.lower() in allow
28
+
29
+
30
+ def _context(resource: str, actor: str, owner: str, object_id: object, marker: object) -> dict:
31
+ return {
32
+ "resource": resource,
33
+ "actor": actor,
34
+ "owner": owner,
35
+ "id": object_id,
36
+ "object_id": object_id,
37
+ "marker": marker,
38
+ }
39
+
40
+
41
+ def generate(contract: Contract) -> list[Check]:
42
+ """For every object and every actor, assert allowed actors pass and others deny."""
43
+ checks: list[Check] = []
44
+ for res in contract.resources.values():
45
+ for endpoint in res.endpoints:
46
+ for owner, object_id in res.ids.items():
47
+ ctx_base = _context(
48
+ resource=res.name,
49
+ actor="",
50
+ owner=owner,
51
+ object_id=object_id,
52
+ marker=res.markers.get(owner, ""),
53
+ )
54
+ for actor_name in contract.actors:
55
+ ctx = dict(ctx_base)
56
+ ctx["actor"] = actor_name
57
+ checks.append(
58
+ Check(
59
+ name=f"{endpoint.name}: {actor_name} -> {owner}",
60
+ resource=res.name,
61
+ actor=actor_name,
62
+ method=endpoint.method,
63
+ path=render(endpoint.path, ctx),
64
+ path_template=endpoint.path,
65
+ endpoint_name=endpoint.name,
66
+ query=render(endpoint.query, ctx),
67
+ headers=render(endpoint.headers, ctx),
68
+ json=render(endpoint.json, ctx),
69
+ data=render(endpoint.data, ctx),
70
+ target_owner=owner,
71
+ object_id=str(object_id),
72
+ expect=(
73
+ "allow"
74
+ if _is_allowed(contract, endpoint, actor_name, owner)
75
+ else "deny"
76
+ ),
77
+ assertions=render(endpoint.assertions, ctx),
78
+ safe=effective_safe(endpoint.method, endpoint.safe),
79
+ )
80
+ )
81
+ return checks + contract.checks
authztrace/models.py ADDED
@@ -0,0 +1,98 @@
1
+ """Core data models for authorization contracts and execution results."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+ SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
8
+
9
+
10
+ def effective_safe(method: str, override: bool | None = None) -> bool:
11
+ """Return whether an operation is safe to execute in read-only mode."""
12
+ if override is not None:
13
+ return override
14
+ return method.upper() in SAFE_METHODS
15
+
16
+
17
+ @dataclass
18
+ class Actor:
19
+ """A test identity (a user, a role, or the anonymous caller)."""
20
+
21
+ name: str
22
+ auth: dict = field(default_factory=lambda: {"type": "none"})
23
+
24
+
25
+ @dataclass
26
+ class Endpoint:
27
+ """A templated API operation that will be expanded across actors and objects."""
28
+
29
+ name: str
30
+ method: str
31
+ path: str
32
+ query: dict[str, Any] = field(default_factory=dict)
33
+ headers: dict[str, Any] = field(default_factory=dict)
34
+ json: Any = None
35
+ data: Any = None
36
+ allow: list[str] = field(default_factory=lambda: ["owner"])
37
+ assertions: dict[str, Any] = field(default_factory=dict)
38
+ safe: bool | None = None
39
+
40
+
41
+ @dataclass
42
+ class Resource:
43
+ """A protected object type and concrete object ids owned by each actor."""
44
+
45
+ name: str
46
+ endpoints: list[Endpoint]
47
+ ids: dict[str, Any]
48
+ markers: dict[str, Any] = field(default_factory=dict)
49
+
50
+
51
+ @dataclass
52
+ class Policy:
53
+ """Default expectations for generated checks."""
54
+
55
+ default: str = "owner-only"
56
+ deny_status: list[int] = field(default_factory=lambda: [401, 403, 404])
57
+ allow_status: list[int] = field(default_factory=list)
58
+
59
+
60
+ @dataclass
61
+ class Contract:
62
+ base_url: str
63
+ actors: dict[str, Actor]
64
+ resources: dict[str, Resource]
65
+ policy: Policy
66
+ checks: list[Check] = field(default_factory=list)
67
+
68
+
69
+ @dataclass
70
+ class Check:
71
+ """A single HTTP probe the engine will execute."""
72
+
73
+ name: str
74
+ resource: str
75
+ actor: str
76
+ method: str
77
+ path: str
78
+ path_template: str = ""
79
+ endpoint_name: str = ""
80
+ query: dict[str, Any] = field(default_factory=dict)
81
+ headers: dict[str, Any] = field(default_factory=dict)
82
+ json: Any = None
83
+ data: Any = None
84
+ object_id: str = ""
85
+ target_owner: str = ""
86
+ expect: str = "deny"
87
+ assertions: dict[str, Any] = field(default_factory=dict)
88
+ safe: bool = True
89
+
90
+
91
+ @dataclass
92
+ class Result:
93
+ check: Check
94
+ status: int | None
95
+ outcome: str
96
+ category: str = "ok"
97
+ note: str = ""
98
+ elapsed_ms: int | None = None
authztrace/openapi.py ADDED
@@ -0,0 +1,135 @@
1
+ """Generate starter AuthzTrace contracts from OpenAPI specs."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+ _METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
12
+ _PATH_PARAM = re.compile(r"\{([^{}]+)\}")
13
+
14
+
15
+ def _read_spec(path: str) -> dict[str, Any]:
16
+ text = Path(path).read_text(encoding="utf-8")
17
+ if path.endswith(".json"):
18
+ return json.loads(text)
19
+ data = yaml.safe_load(text)
20
+ if not isinstance(data, dict):
21
+ raise ValueError("OpenAPI file must contain an object")
22
+ return data
23
+
24
+
25
+ def _resource_name(path: str, param: str) -> str:
26
+ parts = [part for part in path.strip("/").split("/") if part]
27
+ marker = "{" + param + "}"
28
+ if marker in parts:
29
+ idx = parts.index(marker)
30
+ if idx > 0:
31
+ return re.sub(r"(?<!s)s$", "", parts[idx - 1].replace("-", "_")) or "resource"
32
+ return param.removesuffix("_id").removesuffix("Id").replace("-", "_") or "resource"
33
+
34
+
35
+ def _operation_parameters(
36
+ path_item: dict[str, Any],
37
+ operation: dict[str, Any],
38
+ ) -> list[dict[str, Any]]:
39
+ params: list[dict[str, Any]] = []
40
+ for item in path_item.get("parameters") or []:
41
+ if isinstance(item, dict):
42
+ params.append(item)
43
+ for item in operation.get("parameters") or []:
44
+ if isinstance(item, dict):
45
+ params.append(item)
46
+ return params
47
+
48
+
49
+ def _first_server_url(spec: dict[str, Any]) -> str | None:
50
+ servers = spec.get("servers") or []
51
+ if servers and isinstance(servers[0], dict):
52
+ url = servers[0].get("url")
53
+ if isinstance(url, str) and url:
54
+ return url
55
+ return None
56
+
57
+
58
+ def _env_name(resource: str, owner: str) -> str:
59
+ safe = re.sub(r"[^A-Za-z0-9]+", "_", resource).strip("_").upper() or "RESOURCE"
60
+ return f"${{{owner.upper()}_{safe}_ID}}"
61
+
62
+
63
+ def generate_contract(spec_path: str, base_url: str | None = None) -> dict[str, Any]:
64
+ spec = _read_spec(spec_path)
65
+ resources: dict[str, dict[str, Any]] = {}
66
+
67
+ for raw_path, path_item in (spec.get("paths") or {}).items():
68
+ if not isinstance(path_item, dict):
69
+ continue
70
+ for method, operation in path_item.items():
71
+ if method.lower() not in _METHODS or not isinstance(operation, dict):
72
+ continue
73
+
74
+ path_params = _PATH_PARAM.findall(raw_path)
75
+ endpoint: dict[str, Any] | None = None
76
+ resource = ""
77
+
78
+ if len(path_params) == 1:
79
+ param = path_params[0]
80
+ resource = _resource_name(raw_path, param)
81
+ endpoint = {
82
+ "name": operation.get("operationId") or f"{method.upper()} {raw_path}",
83
+ "request": f"{method.upper()} {raw_path.replace('{' + param + '}', '{id}')}",
84
+ }
85
+ else:
86
+ query_id = None
87
+ for param in _operation_parameters(path_item, operation):
88
+ name = str(param.get("name") or "")
89
+ if param.get("in") == "query" and name.lower() in {"id", "object_id"}:
90
+ query_id = name
91
+ break
92
+ if query_id:
93
+ resource = _resource_name(raw_path + "/{" + query_id + "}", query_id)
94
+ endpoint = {
95
+ "name": operation.get("operationId") or f"{method.upper()} {raw_path}",
96
+ "method": method.upper(),
97
+ "path": raw_path,
98
+ "query": {query_id: "{id}"},
99
+ }
100
+
101
+ if not endpoint or not resource:
102
+ continue
103
+
104
+ resource_spec = resources.setdefault(
105
+ resource,
106
+ {
107
+ "ids": {
108
+ "alice": _env_name(resource, "alice"),
109
+ "bob": _env_name(resource, "bob"),
110
+ },
111
+ "endpoints": [],
112
+ },
113
+ )
114
+ resource_spec["endpoints"].append(endpoint)
115
+
116
+ if not resources:
117
+ raise ValueError("no single-object endpoints found in OpenAPI spec")
118
+
119
+ return {
120
+ "base_url": base_url or _first_server_url(spec) or "http://localhost:3000",
121
+ "actors": {
122
+ "alice": {"auth": {"type": "bearer", "token": "${ALICE_TOKEN}"}},
123
+ "bob": {"auth": {"type": "bearer", "token": "${BOB_TOKEN}"}},
124
+ "anon": {"auth": {"type": "none"}},
125
+ },
126
+ "resources": resources,
127
+ "policy": {"default": "owner-only", "deny_status": [401, 403, 404]},
128
+ }
129
+
130
+
131
+ def write_contract(contract: dict[str, Any], output: str, force: bool = False) -> None:
132
+ path = Path(output)
133
+ if path.exists() and not force:
134
+ raise FileExistsError(f"{output} already exists; pass --force to overwrite")
135
+ path.write_text(yaml.safe_dump(contract, sort_keys=False), encoding="utf-8")
authztrace/py.typed ADDED
@@ -0,0 +1 @@
1
+
authztrace/report.py ADDED
@@ -0,0 +1,246 @@
1
+ """Render results as a terminal matrix and as SARIF for GitHub code scanning."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import urllib.parse
7
+ import xml.etree.ElementTree as ET
8
+
9
+ from .models import Result
10
+
11
+ _OUTCOMES = ("pass", "fail", "warn", "error", "skipped")
12
+ _TAG = {"pass": "PASS", "fail": "FAIL", "warn": "WARN", "error": "ERROR", "skipped": "SKIP"}
13
+ _COLOR = {
14
+ "pass": "\033[32m",
15
+ "fail": "\033[31m",
16
+ "warn": "\033[33m",
17
+ "error": "\033[35m",
18
+ "skipped": "\033[36m",
19
+ }
20
+ _RESET = "\033[0m"
21
+ _BOLA_HELP_URI = (
22
+ "https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/"
23
+ )
24
+
25
+
26
+ def counts(results: list[Result]) -> dict:
27
+ out = {outcome: 0 for outcome in _OUTCOMES}
28
+ for r in results:
29
+ out[r.outcome] += 1
30
+ return out
31
+
32
+
33
+ def category_counts(results: list[Result]) -> dict:
34
+ out: dict[str, int] = {}
35
+ for result in results:
36
+ out[result.category] = out.get(result.category, 0) + 1
37
+ return out
38
+
39
+
40
+ def _display_path(result: Result) -> str:
41
+ check = result.check
42
+ if not check.query:
43
+ return check.path
44
+ return check.path + "?" + urllib.parse.urlencode(check.query, doseq=True)
45
+
46
+
47
+ def to_terminal(results: list[Result], color: bool = True) -> str:
48
+ lines = [
49
+ f"{'RESULT':<6} {'ACTOR':<10} {'TARGET':<10} {'EXPECT':<7} {'STATUS':<7} METHOD PATH",
50
+ "-" * 96,
51
+ ]
52
+ for r in results:
53
+ c = r.check
54
+ tag = _TAG[r.outcome]
55
+ plain_tag = tag
56
+ if color:
57
+ tag = f"{_COLOR[r.outcome]}{tag}{_RESET}"
58
+ status = str(r.status) if r.status is not None else "-"
59
+ lines.append(
60
+ f"{tag}{' ' * (6 - len(plain_tag))} {c.actor:<10} {c.target_owner:<10} "
61
+ f"{c.expect:<7} {status:<7} {c.method:<7} {_display_path(r)}"
62
+ )
63
+ if r.note:
64
+ lines.append(f" -> {r.note}")
65
+
66
+ c = counts(results)
67
+ categories = category_counts(results)
68
+ lines.append("-" * 96)
69
+ lines.append(
70
+ f"{c['pass']} passed, {c['fail']} failed, {c['warn']} warnings, "
71
+ f"{c['error']} errors, {c['skipped']} skipped, {len(results)} checks"
72
+ )
73
+ interesting = {
74
+ key: value
75
+ for key, value in categories.items()
76
+ if key != "ok" and value
77
+ }
78
+ if interesting:
79
+ lines.append(
80
+ "categories: "
81
+ + ", ".join(f"{key}={value}" for key, value in sorted(interesting.items()))
82
+ )
83
+ return "\n".join(lines)
84
+
85
+
86
+ def to_json(results: list[Result]) -> str:
87
+ data = {
88
+ "summary": counts(results) | {
89
+ "categories": category_counts(results),
90
+ "total": len(results),
91
+ },
92
+ "results": [
93
+ {
94
+ "name": r.check.name,
95
+ "outcome": r.outcome,
96
+ "category": r.category,
97
+ "note": r.note,
98
+ "status": r.status,
99
+ "elapsed_ms": r.elapsed_ms,
100
+ "actor": r.check.actor,
101
+ "target_owner": r.check.target_owner,
102
+ "resource": r.check.resource,
103
+ "object_id": r.check.object_id,
104
+ "expect": r.check.expect,
105
+ "request": {
106
+ "method": r.check.method,
107
+ "path": r.check.path,
108
+ "path_template": r.check.path_template or r.check.path,
109
+ "query": r.check.query,
110
+ "headers": r.check.headers,
111
+ "json": r.check.json,
112
+ "data": r.check.data,
113
+ },
114
+ }
115
+ for r in results
116
+ ],
117
+ }
118
+ return json.dumps(data, indent=2)
119
+
120
+
121
+ def to_junit(results: list[Result]) -> str:
122
+ summary = counts(results)
123
+ suite = ET.Element(
124
+ "testsuite",
125
+ {
126
+ "name": "authztrace",
127
+ "tests": str(len(results)),
128
+ "failures": str(summary["fail"]),
129
+ "errors": str(summary["error"]),
130
+ "skipped": str(summary["skipped"]),
131
+ },
132
+ )
133
+ for result in results:
134
+ case = ET.SubElement(
135
+ suite,
136
+ "testcase",
137
+ {
138
+ "classname": "authztrace",
139
+ "name": result.check.name,
140
+ "time": str((result.elapsed_ms or 0) / 1000),
141
+ },
142
+ )
143
+ if result.outcome == "fail":
144
+ failure = ET.SubElement(case, "failure", {"message": result.note})
145
+ failure.text = result.note
146
+ elif result.outcome == "error":
147
+ error = ET.SubElement(case, "error", {"message": result.note})
148
+ error.text = result.note
149
+ elif result.outcome == "skipped":
150
+ skipped = ET.SubElement(case, "skipped", {"message": result.note})
151
+ skipped.text = result.note
152
+ elif result.outcome == "warn":
153
+ props = ET.SubElement(case, "properties")
154
+ ET.SubElement(props, "property", {"name": "warning", "value": result.note})
155
+
156
+ return ET.tostring(suite, encoding="unicode")
157
+
158
+
159
+ def _finding_rule_id(result: Result) -> str:
160
+ return "authztrace/leak" if result.category == "leak" else "authztrace/bola"
161
+
162
+
163
+ def _owner_relationship(result: Result) -> str:
164
+ if not result.check.target_owner:
165
+ return "unknown"
166
+ return "self" if result.check.actor == result.check.target_owner else "other"
167
+
168
+
169
+ def _fingerprint(result: Result) -> str:
170
+ check = result.check
171
+ endpoint_id = check.path_template or check.endpoint_name or check.path
172
+ raw = "\n".join(
173
+ [
174
+ _finding_rule_id(result),
175
+ check.method,
176
+ endpoint_id,
177
+ check.resource,
178
+ check.actor,
179
+ _owner_relationship(result),
180
+ ]
181
+ )
182
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
183
+
184
+
185
+ def to_sarif(results: list[Result], artifact_uri: str = "authztrace.yaml") -> str:
186
+ rules = [
187
+ {
188
+ "id": "authztrace/bola",
189
+ "name": "BrokenObjectLevelAuthorization",
190
+ "shortDescription": {"text": "Broken Object Level Authorization (IDOR/BOLA)"},
191
+ "helpUri": _BOLA_HELP_URI,
192
+ },
193
+ {
194
+ "id": "authztrace/leak",
195
+ "name": "AuthorizationResponseLeak",
196
+ "shortDescription": {"text": "Denied response leaked protected object data"},
197
+ "helpUri": _BOLA_HELP_URI,
198
+ },
199
+ ]
200
+ findings = [
201
+ {
202
+ "ruleId": _finding_rule_id(r),
203
+ "level": "warning" if r.category == "leak" else "error",
204
+ "message": {
205
+ "text": (
206
+ f"{r.check.name}: {r.note} "
207
+ f"({r.check.method} {_display_path(r)} as {r.check.actor})"
208
+ )
209
+ },
210
+ "locations": [
211
+ {
212
+ "physicalLocation": {
213
+ "artifactLocation": {"uri": artifact_uri},
214
+ "region": {"startLine": 1, "startColumn": 1},
215
+ },
216
+ "logicalLocations": [
217
+ {
218
+ "fullyQualifiedName": (
219
+ f"{r.check.method} {r.check.path_template or r.check.path}"
220
+ )
221
+ }
222
+ ],
223
+ }
224
+ ],
225
+ "partialFingerprints": {"authztraceFinding/v1": _fingerprint(r)},
226
+ }
227
+ for r in results
228
+ if r.outcome == "fail" and r.category in {"bola", "leak"}
229
+ ]
230
+ doc = {
231
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
232
+ "version": "2.1.0",
233
+ "runs": [
234
+ {
235
+ "tool": {
236
+ "driver": {
237
+ "name": "AuthzTrace",
238
+ "informationUri": "https://github.com/Asttr0/authztrace",
239
+ "rules": rules,
240
+ }
241
+ },
242
+ "results": findings,
243
+ }
244
+ ],
245
+ }
246
+ return json.dumps(doc, indent=2)
@@ -0,0 +1,22 @@
1
+ """Small placeholder renderer used by contracts and generated checks."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from typing import Any
6
+
7
+ _PLACEHOLDER = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
8
+ _EXACT_PLACEHOLDER = re.compile(r"^\{([A-Za-z_][A-Za-z0-9_]*)\}$")
9
+
10
+
11
+ def render(value: Any, context: dict[str, Any]) -> Any:
12
+ """Recursively replace ``{name}`` placeholders with context values."""
13
+ if isinstance(value, str):
14
+ exact = _EXACT_PLACEHOLDER.match(value)
15
+ if exact:
16
+ return context.get(exact.group(1), value)
17
+ return _PLACEHOLDER.sub(lambda m: str(context.get(m.group(1), m.group(0))), value)
18
+ if isinstance(value, dict):
19
+ return {k: render(v, context) for k, v in value.items()}
20
+ if isinstance(value, list):
21
+ return [render(item, context) for item in value]
22
+ return value