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 +39 -0
- overstep/__main__.py +4 -0
- overstep/auth.py +135 -0
- overstep/classifier.py +254 -0
- overstep/cli.py +272 -0
- overstep/drift.py +98 -0
- overstep/executor.py +186 -0
- overstep/expressions.py +94 -0
- overstep/fixtures.py +155 -0
- overstep/interpolation.py +58 -0
- overstep/jsonpath.py +25 -0
- overstep/loaders/__init__.py +1 -0
- overstep/loaders/har.py +66 -0
- overstep/loaders/openapi.py +215 -0
- overstep/matching.py +58 -0
- overstep/matrix.py +138 -0
- overstep/models.py +327 -0
- overstep/pipeline.py +113 -0
- overstep/planner.py +218 -0
- overstep/py.typed +0 -0
- overstep/report/__init__.py +19 -0
- overstep/report/base.py +58 -0
- overstep/report/html_report.py +96 -0
- overstep/report/json_report.py +31 -0
- overstep/report/junit.py +43 -0
- overstep/report/sarif.py +100 -0
- overstep/repro.py +64 -0
- overstep/taxonomy.py +92 -0
- overstep/templating.py +25 -0
- overstep/waivers.py +115 -0
- overstep-0.10.0.dist-info/METADATA +512 -0
- overstep-0.10.0.dist-info/RECORD +37 -0
- overstep-0.10.0.dist-info/WHEEL +5 -0
- overstep-0.10.0.dist-info/entry_points.txt +2 -0
- overstep-0.10.0.dist-info/licenses/LICENSE +201 -0
- overstep-0.10.0.dist-info/licenses/NOTICE +4 -0
- overstep-0.10.0.dist-info/top_level.txt +1 -0
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
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
|