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/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)
|
authztrace/templating.py
ADDED
|
@@ -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
|