overstep 0.10.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.
overstep/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """overstep — a matrix-driven authorization testing tool for HTTP APIs.
2
+
3
+ overstep takes a declarative *authorization matrix* (who is allowed to do what)
4
+ and turns it into concrete positive and negative HTTP tests. Negative tests that
5
+ unexpectedly succeed are reported as authorization vulnerabilities and classified
6
+ as BOLA, BFLA or privilege escalation. Results can be snapshotted so that CI can
7
+ fail on *authorization drift* between releases.
8
+
9
+ The public API mirrors the pipeline stages, so an embedding application can do::
10
+
11
+ from overstep import load_matrix, run_pipeline, write_reports
12
+
13
+ matrix = load_matrix("matrix.yaml")
14
+ result = run_pipeline(matrix)
15
+ write_reports(result, "out")
16
+ if result.vulnerabilities:
17
+ raise SystemExit(1)
18
+ """
19
+
20
+ __version__ = "0.10.0"
21
+
22
+ from overstep.auth import authenticate
23
+ from overstep.matrix import Matrix, load_matrix
24
+ from overstep.models import Finding, RunResult, VulnClass
25
+ from overstep.pipeline import run_pipeline, write_reports
26
+ from overstep.planner import plan
27
+
28
+ __all__ = [
29
+ "__version__",
30
+ "Matrix",
31
+ "load_matrix",
32
+ "plan",
33
+ "authenticate",
34
+ "run_pipeline",
35
+ "write_reports",
36
+ "RunResult",
37
+ "Finding",
38
+ "VulnClass",
39
+ ]
overstep/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from overstep.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
overstep/auth.py ADDED
@@ -0,0 +1,135 @@
1
+ """Dynamic authentication: obtain subject tokens before a run.
2
+
3
+ Real APIs don't accept a JWT pasted into a config file — it expires, and it
4
+ shouldn't be committed anyway. A subject instead points at an auth provider and
5
+ supplies its credentials via ``auth.vars``; before the run we perform the login,
6
+ extract the token and set it on the subject as a header. Everything here happens
7
+ once, up front, over a short-lived synchronous client kept separate from the
8
+ async test executor.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Dict, List, Optional
13
+ from urllib.parse import urljoin
14
+
15
+ import httpx
16
+
17
+ from overstep.jsonpath import extract
18
+ from overstep.matrix import Matrix
19
+ from overstep.models import AuthProvider, Subject
20
+ from overstep.templating import render
21
+
22
+
23
+ class AuthError(RuntimeError):
24
+ """Raised when a subject's login fails or no token can be extracted."""
25
+
26
+
27
+ def extract_token(path: str, data: Any) -> Optional[str]:
28
+ """Pull a token string out of a JSON response by a dotted path."""
29
+ value = extract(path, data)
30
+ return value if value is None or isinstance(value, str) else str(value)
31
+
32
+
33
+ def _login_call(provider: AuthProvider, variables: Dict[str, str], base_url: Optional[str]):
34
+ """Build (method, url, kwargs) for a provider's login request."""
35
+ provider_base = provider.base_url or base_url or ""
36
+
37
+ if provider.type == "http":
38
+ if provider.request is None:
39
+ raise AuthError(f"auth provider '{provider.name}' (http) needs a request")
40
+ req = provider.request
41
+ url = urljoin(_slash(provider_base), req.path.lstrip("/"))
42
+ kwargs: Dict[str, Any] = {
43
+ "params": render(req.query, variables) or None,
44
+ "json": render(req.body, variables),
45
+ "headers": render(req.headers, variables) or None,
46
+ }
47
+ return req.method, url, kwargs
48
+
49
+ # OAuth2 token endpoints: standard form-encoded body.
50
+ if not provider.token_url:
51
+ raise AuthError(f"auth provider '{provider.name}' needs a token_url")
52
+ url = urljoin(_slash(provider_base), provider.token_url.lstrip("/"))
53
+ form: Dict[str, str] = {}
54
+ if provider.type == "oauth2_client_credentials":
55
+ form["grant_type"] = "client_credentials"
56
+ elif provider.type == "oauth2_password":
57
+ form["grant_type"] = "password"
58
+ form["username"] = render(provider.username or "", variables)
59
+ form["password"] = render(provider.password or "", variables)
60
+ for key in ("client_id", "client_secret", "scope"):
61
+ val = render(getattr(provider, key) or "", variables)
62
+ if val:
63
+ form[key] = val
64
+ return "POST", url, {"data": form}
65
+
66
+
67
+ def _slash(base: str) -> str:
68
+ return base if base.endswith("/") else base + "/"
69
+
70
+
71
+ def _obtain_token(
72
+ client: httpx.Client,
73
+ provider: AuthProvider,
74
+ variables: Dict[str, str],
75
+ base_url: Optional[str],
76
+ ) -> str:
77
+ method, url, kwargs = _login_call(provider, variables, base_url)
78
+ try:
79
+ resp = client.request(method, url, **kwargs)
80
+ except httpx.HTTPError as exc:
81
+ raise AuthError(f"login via provider '{provider.name}' failed: {exc}") from exc
82
+
83
+ if resp.status_code >= 400:
84
+ raise AuthError(
85
+ f"login via provider '{provider.name}' returned {resp.status_code}"
86
+ )
87
+ try:
88
+ payload = resp.json()
89
+ except ValueError as exc:
90
+ raise AuthError(
91
+ f"login via provider '{provider.name}' did not return JSON"
92
+ ) from exc
93
+
94
+ token = extract_token(provider.token_path, payload)
95
+ if not token:
96
+ raise AuthError(
97
+ f"provider '{provider.name}' response had no token at "
98
+ f"'{provider.token_path}'"
99
+ )
100
+ return token
101
+
102
+
103
+ def authenticate(
104
+ matrix: Matrix,
105
+ *,
106
+ base_url: Optional[str] = None,
107
+ verify_tls: bool = True,
108
+ client: Optional[httpx.Client] = None,
109
+ ) -> None:
110
+ """Resolve every subject that has an ``auth`` block, in place.
111
+
112
+ A no-op when the matrix declares no providers, so runs without dynamic auth
113
+ pay nothing and stay offline.
114
+ """
115
+ providers: Dict[str, AuthProvider] = {p.name: p for p in matrix.auth.providers}
116
+ subjects_with_auth: List[Subject] = [s for s in matrix.subjects if s.auth]
117
+ if not providers or not subjects_with_auth:
118
+ return
119
+
120
+ owns_client = client is None
121
+ client = client or httpx.Client(timeout=15.0, verify=verify_tls, follow_redirects=True)
122
+ try:
123
+ for subject in subjects_with_auth:
124
+ provider = providers.get(subject.auth.provider)
125
+ if provider is None:
126
+ raise AuthError(
127
+ f"subject '{subject.name}' references unknown auth provider "
128
+ f"'{subject.auth.provider}'"
129
+ )
130
+ token = _obtain_token(client, provider, subject.auth.vars, base_url)
131
+ header_value = provider.token_format.format(token=token)
132
+ subject.headers = {**subject.headers, provider.token_header: header_value}
133
+ finally:
134
+ if owns_client:
135
+ client.close()
overstep/classifier.py ADDED
@@ -0,0 +1,254 @@
1
+ """Compare expectations with observations and classify the mismatches.
2
+
3
+ There are two kinds of mismatch:
4
+
5
+ * A negative test (expected deny) that was **allowed** is a real authorization
6
+ weakness. We label it BOLA, BFLA or privilege escalation depending on the
7
+ resource type and the subject's role relative to what the policy requires.
8
+ * A positive test (expected allow) that was **denied** is an over-restriction —
9
+ not a security hole, but a functional regression worth surfacing.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import Dict, List, Set
15
+
16
+ from overstep.matrix import Matrix
17
+ from overstep.models import (
18
+ Effect,
19
+ Finding,
20
+ Observation,
21
+ ResourceType,
22
+ TestCase,
23
+ Variant,
24
+ VulnClass,
25
+ )
26
+ from overstep.repro import request_record, to_curl
27
+
28
+
29
+ def _min_required_rank(matrix: Matrix, case: TestCase) -> int:
30
+ ranks = [matrix.role_rank(r) for r in case.required_roles]
31
+ ranks = [r for r in ranks if r >= 0]
32
+ return min(ranks) if ranks else -1
33
+
34
+
35
+ def _classify_violation(matrix: Matrix, case: TestCase) -> VulnClass:
36
+ """A negative test slipped through — decide which flavour of broken authz."""
37
+ subject_rank = matrix.role_rank(case.role)
38
+ required_rank = _min_required_rank(matrix, case)
39
+
40
+ # Vertical escalation: the subject reached something only a strictly more
41
+ # privileged role should be able to reach.
42
+ if subject_rank >= 0 and required_rank >= 0 and subject_rank < required_rank:
43
+ return VulnClass.PRIVILEGE_ESCALATION
44
+
45
+ if case.resource_type == ResourceType.OBJECT and case.variant == Variant.OTHER:
46
+ return VulnClass.BOLA
47
+ return VulnClass.BFLA
48
+
49
+
50
+ def _json_keys(body: str) -> Set[str]:
51
+ """Every object key that appears anywhere in a JSON body (recursively).
52
+
53
+ Returns an empty set when the body is not valid JSON, so BOPLA checks match
54
+ real property *keys* rather than substrings of arbitrary text.
55
+ """
56
+ try:
57
+ data = json.loads(body)
58
+ except (ValueError, TypeError):
59
+ return set()
60
+
61
+ keys: Set[str] = set()
62
+
63
+ def _walk(node) -> None:
64
+ if isinstance(node, dict):
65
+ for key, value in node.items():
66
+ keys.add(key)
67
+ _walk(value)
68
+ elif isinstance(node, list):
69
+ for item in node:
70
+ _walk(item)
71
+
72
+ _walk(data)
73
+ return keys
74
+
75
+
76
+ def _leaked_fields(resource, obs: Observation) -> Set[str]:
77
+ """Forbidden JSON keys present in an allowed response (BOPLA surface)."""
78
+ if resource is None or not resource.forbidden_fields:
79
+ return set()
80
+ present = _json_keys(obs.body_snippet)
81
+ return {f for f in resource.forbidden_fields if f in present}
82
+
83
+
84
+ def _grade(vuln: VulnClass, case: TestCase, obs: Observation):
85
+ """Assign (severity, confidence) using the content-aware oracle.
86
+
87
+ Only object-level probes (BOLA) can be content-verified: we know the victim's
88
+ marker. When it shows up in the body the leak is *confirmed*; when a marker was
89
+ configured but never appeared the grant is *suspected* (possibly an empty
90
+ result) and downgraded; with no marker at all we fall back to status alone and
91
+ label the finding *unverified*.
92
+ """
93
+ if vuln != VulnClass.BOLA:
94
+ return "high", "confirmed"
95
+ if not case.expect_markers:
96
+ return "high", "unverified"
97
+ if obs.matched_markers:
98
+ return "high", "confirmed"
99
+ return "medium", "suspected"
100
+
101
+
102
+ def _detail(case: TestCase, obs: Observation, vuln: VulnClass, confidence: str = "confirmed") -> str:
103
+ if vuln == VulnClass.BOLA:
104
+ if confidence == "confirmed":
105
+ leaked = ", ".join(obs.matched_markers)
106
+ proof = (
107
+ f" and the response exposed the owner's data ({leaked})"
108
+ if leaked
109
+ else ""
110
+ )
111
+ return (
112
+ f"{case.subject} ({case.role}) read another subject's object via "
113
+ f"{case.method} {case.path} and got {obs.status}{proof}; the matrix "
114
+ f"only allows owners here."
115
+ )
116
+ if confidence == "suspected":
117
+ return (
118
+ f"{case.subject} ({case.role}) was granted {case.method} {case.path} "
119
+ f"(status {obs.status}) on another subject's object, but the expected "
120
+ f"owner data did not appear — suspected BOLA, verify manually."
121
+ )
122
+ return (
123
+ f"{case.subject} ({case.role}) read another subject's object via "
124
+ f"{case.method} {case.path} and got {obs.status}; the matrix only "
125
+ f"allows owners here (no content marker configured to confirm the leak)."
126
+ )
127
+ if vuln == VulnClass.PRIVILEGE_ESCALATION:
128
+ allowed = ", ".join(case.required_roles) or "a higher-privileged role"
129
+ return (
130
+ f"{case.subject} ({case.role}) reached {case.method} {case.path} "
131
+ f"(status {obs.status}) which the matrix reserves for {allowed}."
132
+ )
133
+ return (
134
+ f"{case.subject} ({case.role}) invoked {case.method} {case.path} "
135
+ f"(status {obs.status}) but has no allow rule for it."
136
+ )
137
+
138
+
139
+ def classify(
140
+ matrix: Matrix,
141
+ cases: List[TestCase],
142
+ observations: List[Observation],
143
+ *,
144
+ base_url: str = "",
145
+ ) -> List[Finding]:
146
+ """Produce findings from expectations vs. observations.
147
+
148
+ ``base_url`` (defaulting to the matrix's own) is used to render a
149
+ reproduction (``curl`` + a masked request record) onto every finding.
150
+ """
151
+ by_id: Dict[str, TestCase] = {c.id: c for c in cases}
152
+ by_case: Dict[str, TestCase] = by_id
153
+ subjects = {s.name: s for s in matrix.subjects}
154
+ resources = matrix.resource_map()
155
+ repro_base = base_url or matrix.base_url or ""
156
+ findings: List[Finding] = []
157
+
158
+ for obs in observations:
159
+ case = by_id.get(obs.test_id)
160
+ if case is None:
161
+ continue
162
+ # A deliberately skipped request (read-only) is not evidence either way.
163
+ if obs.skipped:
164
+ continue
165
+
166
+ if case.expected == Effect.ALLOW:
167
+ if obs.effect == Effect.DENY:
168
+ findings.append(
169
+ Finding(
170
+ test_id=case.id,
171
+ vuln_class=VulnClass.UNEXPECTED_DENY,
172
+ severity="low",
173
+ resource=case.resource,
174
+ subject=case.subject,
175
+ role=case.role,
176
+ method=case.method,
177
+ path=case.path,
178
+ expected=case.expected,
179
+ observed=obs.effect,
180
+ status=obs.status,
181
+ variant=case.variant,
182
+ detail=(
183
+ f"{case.subject} ({case.role}) should be allowed "
184
+ f"{case.method} {case.path} but was denied "
185
+ f"(status {obs.status})."
186
+ ),
187
+ evidence=obs,
188
+ )
189
+ )
190
+ elif obs.effect == Effect.ALLOW:
191
+ # BOPLA: an allowed read that over-shares forbidden properties.
192
+ leaked = _leaked_fields(resources.get(case.resource), obs)
193
+ if leaked:
194
+ fields = ", ".join(sorted(leaked))
195
+ findings.append(
196
+ Finding(
197
+ test_id=case.id,
198
+ vuln_class=VulnClass.BOPLA,
199
+ severity="high",
200
+ resource=case.resource,
201
+ subject=case.subject,
202
+ role=case.role,
203
+ method=case.method,
204
+ path=case.path,
205
+ expected=case.expected,
206
+ observed=obs.effect,
207
+ status=obs.status,
208
+ variant=case.variant,
209
+ detail=(
210
+ f"{case.subject} ({case.role}) was allowed "
211
+ f"{case.method} {case.path} but the response exposed "
212
+ f"forbidden field(s): {fields}."
213
+ ),
214
+ evidence=obs,
215
+ )
216
+ )
217
+ continue
218
+
219
+ # Negative test.
220
+ if obs.effect == Effect.ALLOW:
221
+ vuln = _classify_violation(matrix, case)
222
+ severity, confidence = _grade(vuln, case, obs)
223
+ findings.append(
224
+ Finding(
225
+ test_id=case.id,
226
+ vuln_class=vuln,
227
+ severity=severity,
228
+ resource=case.resource,
229
+ subject=case.subject,
230
+ role=case.role,
231
+ method=case.method,
232
+ path=case.path,
233
+ expected=case.expected,
234
+ observed=obs.effect,
235
+ status=obs.status,
236
+ variant=case.variant,
237
+ detail=_detail(case, obs, vuln, confidence),
238
+ evidence=obs,
239
+ confidence=confidence,
240
+ )
241
+ )
242
+
243
+ # Attach a copy-pasteable reproduction to every finding.
244
+ for f in findings:
245
+ case = by_case.get(f.test_id)
246
+ subject = subjects.get(f.subject)
247
+ if case is not None and subject is not None:
248
+ f.curl = to_curl(repro_base, subject, case)
249
+ f.request = request_record(repro_base, subject, case)
250
+
251
+ # Highest severity, then stable by test id, so reports read consistently.
252
+ order = {"high": 0, "medium": 1, "low": 2}
253
+ findings.sort(key=lambda f: (order[f.severity], f.test_id))
254
+ return findings