granttrace 2.4.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.
core/models.py ADDED
@@ -0,0 +1,104 @@
1
+ """Small shared data models for transport and scan accounting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from dataclasses import dataclass, field
8
+ from enum import Enum
9
+ from typing import Any, Dict, Optional, Tuple
10
+
11
+
12
+ def strict_json_loads(text: str) -> Any:
13
+ """Decode JSON without non-standard constants or overflowing floats."""
14
+ def reject_constant(value: str) -> None:
15
+ raise ValueError(f"Non-standard JSON number is not permitted: {value}")
16
+
17
+ def finite_float(value: str) -> float:
18
+ number = float(value)
19
+ if not math.isfinite(number):
20
+ raise ValueError("JSON number exceeds the supported finite range")
21
+ return number
22
+
23
+ return json.loads(text, parse_constant=reject_constant, parse_float=finite_float)
24
+
25
+
26
+ def json_values_equal(left, right) -> bool:
27
+ """Compare JSON without Python's True == 1 / False == 0 coercion."""
28
+ try:
29
+ return json.dumps(left, sort_keys=True, allow_nan=False) == json.dumps(
30
+ right, sort_keys=True, allow_nan=False
31
+ )
32
+ except (TypeError, ValueError, RecursionError):
33
+ return False
34
+
35
+
36
+ class Verdict(str, Enum):
37
+ CONFIRMED = "CONFIRMED"
38
+ SECURE = "SECURE"
39
+ PUBLIC = "PUBLIC"
40
+ AUTHORIZED = "AUTHORIZED"
41
+ SUSPICIOUS = "SUSPICIOUS"
42
+ INCONCLUSIVE = "INCONCLUSIVE"
43
+ SKIPPED = "SKIPPED"
44
+ ERROR = "ERROR"
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class HTTPResult:
49
+ status: int
50
+ body: str
51
+ error: Optional[str] = None
52
+ truncated: bool = False
53
+ headers: Dict[str, str] = field(default_factory=dict)
54
+
55
+ def as_tuple(self) -> Tuple[int, str]:
56
+ return self.status, self.body
57
+
58
+ @property
59
+ def ok(self) -> bool:
60
+ return self.error is None and 200 <= self.status < 300
61
+
62
+
63
+ STANDARD_HTTP_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "TRACE")
64
+
65
+
66
+ def parse_operation_key(key: Any) -> Tuple[bool, str, str, str, Optional[str]]:
67
+ """Parse and validate an operation key such as 'PATCH /api/users/{id}'.
68
+
69
+ Returns:
70
+ (is_valid, method, path, canonical_key, error_message)
71
+ """
72
+ if not isinstance(key, str):
73
+ return False, "", "", "", f"operation key must be a string, got {type(key).__name__}"
74
+
75
+ if key != key.strip():
76
+ return False, "", "", "", "operation key must not contain leading or trailing whitespace"
77
+
78
+ parts = key.split(" ")
79
+ if len(parts) != 2:
80
+ return False, "", "", "", "operation key must be '<METHOD> /<path>' separated by a single space"
81
+
82
+ raw_method, path = parts[0], parts[1]
83
+ if not raw_method:
84
+ return False, "", "", "", "HTTP method cannot be empty"
85
+ # Enforce uppercase method in configuration
86
+ if raw_method != raw_method.upper():
87
+ return False, "", "", "", f"HTTP method must be uppercase, got '{raw_method}'"
88
+
89
+ method = raw_method.upper()
90
+ if method not in STANDARD_HTTP_METHODS:
91
+ return False, "", "", "", f"unsupported HTTP method '{method}', expected one of {list(STANDARD_HTTP_METHODS)}"
92
+
93
+ if not path.startswith("/"):
94
+ return False, "", "", "", f"operation path must start with '/', got '{path}'"
95
+
96
+ if "?" in path or "#" in path:
97
+ return False, "", "", "", "operation path must not contain query parameters or fragments"
98
+
99
+ if any(c in path for c in "\r\n\t "):
100
+ return False, "", "", "", "operation path must not contain whitespace or line breaks"
101
+
102
+ canonical_key = f"{method} {path}"
103
+ return True, method, path, canonical_key, None
104
+
core/parameters.py ADDED
@@ -0,0 +1,198 @@
1
+ """Strict path/query parameter serialization shared by scans and readbacks.
2
+
3
+ Supported contracts follow the OpenAPI Parameter Object rules:
4
+ https://spec.openapis.org/oas/v3.0.4.html#parameter-object
5
+ https://spec.openapis.org/oas/v2.0.html#parameter-object
6
+
7
+ Values are encoded individually before joining, so data cannot introduce URL
8
+ syntax. Unsupported or ambiguous formats fail before any request is sent.
9
+ Header/cookie parameters are deliberately outside this URL serializer; identity
10
+ authentication headers are handled by the auditor's authentication context.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ from typing import Any, Dict, List, Tuple
17
+ from urllib.parse import quote
18
+
19
+
20
+ class ParameterSerializationError(ValueError):
21
+ """A parameter cannot be represented safely using its declared contract."""
22
+
23
+
24
+ def _fail(message: str) -> None:
25
+ # Diagnostics intentionally omit parameter values, which may be credentials.
26
+ raise ParameterSerializationError(message + "; no request was sent")
27
+
28
+
29
+ def _scalar(value: Any) -> str:
30
+ if isinstance(value, bool):
31
+ return "true" if value else "false"
32
+ if isinstance(value, str):
33
+ return value
34
+ if isinstance(value, int):
35
+ return str(value)
36
+ if isinstance(value, float) and math.isfinite(value):
37
+ return str(value)
38
+ _fail("Only finite scalar parameter values are supported; null and nested values are unsupported")
39
+
40
+
41
+ def _encoded(value: Any, *, label: bool = False) -> str:
42
+ text = _scalar(value)
43
+ encoded = quote(text, safe="")
44
+ # quote always leaves unreserved dots alone. A standalone dot segment or
45
+ # an exploded label's dot delimiter must not change the selected resource.
46
+ if label or text in {".", ".."}:
47
+ encoded = encoded.replace(".", "%2E")
48
+ return encoded
49
+
50
+
51
+ def _kind(value: Any) -> str:
52
+ if isinstance(value, list):
53
+ if not value:
54
+ _fail("Empty array parameter serialization is unsupported")
55
+ for item in value:
56
+ _scalar(item)
57
+ return "array"
58
+ if isinstance(value, dict):
59
+ if not value:
60
+ _fail("Empty object parameter serialization is unsupported")
61
+ if any(not isinstance(key, str) for key in value):
62
+ _fail("Object parameter property names must be strings")
63
+ for item in value.values():
64
+ _scalar(item)
65
+ return "object"
66
+ _scalar(value)
67
+ return "primitive"
68
+
69
+
70
+ def _settings(metadata: Dict[str, Any], value: Any, swagger2: bool) -> Tuple[str, str, str, bool]:
71
+ if not isinstance(metadata, dict):
72
+ _fail("Parameter metadata must be an object")
73
+ location = metadata.get("in")
74
+ name = metadata.get("name")
75
+ if not isinstance(location, str) or location not in {"path", "query"}:
76
+ _fail("Only path/query parameter serialization is supported; header/cookie/formData parameters are unsupported")
77
+ if not isinstance(name, str) or not name or any(ord(char) < 32 for char in name):
78
+ _fail("Parameter name must be a non-empty string without control characters")
79
+ if "$ref" in metadata:
80
+ _fail("Unresolved parameter references are unsupported")
81
+ if "content" in metadata:
82
+ _fail("Content-based parameter serialization is unsupported; use a supported schema/style contract")
83
+ if "allowReserved" in metadata:
84
+ if not isinstance(metadata["allowReserved"], bool):
85
+ _fail("allowReserved must be a boolean")
86
+ if metadata["allowReserved"]:
87
+ _fail("allowReserved=true parameter serialization is unsupported")
88
+ kind = _kind(value)
89
+ if location == "path" and kind == "primitive" and _scalar(value) == "":
90
+ _fail("Empty path parameter values are unsupported because they can select a different resource")
91
+ schema = metadata.get("schema", metadata if swagger2 else {})
92
+ if isinstance(schema, dict):
93
+ declared = schema.get("type")
94
+ if isinstance(declared, list):
95
+ declared = next((item for item in declared if item != "null"), None) if len(declared) <= 2 else None
96
+ if isinstance(declared, str) and declared in {"array", "object"} and kind != declared:
97
+ _fail("Configured parameter value must retain its declared array/object structure")
98
+ if isinstance(declared, str) and declared in {"string", "integer", "number", "boolean"} and kind != "primitive":
99
+ _fail("Configured parameter value conflicts with its declared scalar type")
100
+ if declared == "file":
101
+ _fail("File parameter serialization is unsupported")
102
+ if swagger2:
103
+ if "style" in metadata or "explode" in metadata:
104
+ _fail("Swagger 2 parameters use collectionFormat; style/explode are unsupported")
105
+ if kind == "object":
106
+ _fail("Swagger 2 non-body object parameters are unsupported")
107
+ collection = metadata.get("collectionFormat", "csv")
108
+ if not isinstance(collection, str) or collection not in {"csv", "ssv", "tsv", "pipes", "multi"}:
109
+ _fail("Unsupported Swagger 2 collectionFormat")
110
+ if "collectionFormat" in metadata and kind != "array":
111
+ _fail("Swagger 2 collectionFormat requires an array parameter")
112
+ if collection == "multi" and location != "query":
113
+ _fail("Swagger 2 collectionFormat=multi is supported only for query parameters")
114
+ return location, name, collection, collection == "multi"
115
+ if "collectionFormat" in metadata:
116
+ _fail("OpenAPI 3 parameters use style/explode; collectionFormat is unsupported")
117
+ style = metadata.get("style", "form" if location == "query" else "simple")
118
+ explode = metadata.get("explode", style == "form")
119
+ if not isinstance(explode, bool):
120
+ _fail("explode must be a boolean")
121
+ allowed = {"simple", "label", "matrix"} if location == "path" else {
122
+ "form", "spaceDelimited", "pipeDelimited", "deepObject"
123
+ }
124
+ if not isinstance(style, str) or style not in allowed:
125
+ _fail("Unsupported parameter style for its location")
126
+ if style in {"spaceDelimited", "pipeDelimited"}:
127
+ if kind == "primitive" or explode:
128
+ _fail("spaceDelimited/pipeDelimited require a flat array/object and explode=false")
129
+ if style == "deepObject" and (kind != "object" or not explode):
130
+ _fail("deepObject requires a flat object and explicit explode=true")
131
+ return location, name, style, explode
132
+
133
+
134
+ def _tokens(value: Any, *, label: bool = False) -> List[str]:
135
+ if isinstance(value, dict):
136
+ return [part for key, item in value.items() for part in (_encoded(key, label=label), _encoded(item, label=label))]
137
+ if isinstance(value, list):
138
+ return [_encoded(item, label=label) for item in value]
139
+ return [_encoded(value, label=label)]
140
+
141
+
142
+ def _delimited(value: Any, style: str) -> str:
143
+ delimiter = {"csv": ",", "ssv": " ", "tsv": "\t", "pipes": "|",
144
+ "spaceDelimited": " ", "pipeDelimited": "|"}[style]
145
+ # Space and pipe delimiters are themselves percent-encoded. Contracts must
146
+ # define an extra escape convention for delimiter characters in data; we
147
+ # cannot infer one, so reject those ambiguous inputs instead of guessing.
148
+ if delimiter != ",":
149
+ raw = (list(value.keys()) + list(value.values())) if isinstance(value, dict) else value
150
+ if any(delimiter in _scalar(item) for item in raw):
151
+ _fail("Delimited parameter data contains its delimiter; an API-specific escape convention is unsupported")
152
+ encoded_delimiter = "," if delimiter == "," else quote(delimiter, safe="")
153
+ return encoded_delimiter.join(_tokens(value))
154
+
155
+
156
+ def serialize_path_parameter(metadata: Dict[str, Any], value: Any, *, swagger2: bool = False) -> str:
157
+ location, name, style, explode = _settings(metadata, value, swagger2)
158
+ if location != "path":
159
+ _fail("Path serialization requires in=path")
160
+ if swagger2:
161
+ return _delimited(value, style) if isinstance(value, list) else _encoded(value)
162
+ if isinstance(value, dict) and explode:
163
+ delimiter = ";" if style == "matrix" else "." if style == "label" else ","
164
+ body = delimiter.join(_encoded(key, label=style == "label") + "=" +
165
+ _encoded(item, label=style == "label") for key, item in value.items())
166
+ return (";" if style == "matrix" else "." if style == "label" else "") + body
167
+ if style == "matrix":
168
+ encoded_name = _encoded(name)
169
+ if isinstance(value, list) and explode:
170
+ return "".join(";" + encoded_name + "=" + _encoded(item) for item in value)
171
+ return ";" + encoded_name + "=" + ",".join(_tokens(value))
172
+ separator = "." if style == "label" and explode else ","
173
+ return ("." if style == "label" else "") + separator.join(_tokens(value, label=style == "label"))
174
+
175
+
176
+ def serialize_query_parameter(metadata: Dict[str, Any], value: Any, *, swagger2: bool = False) -> List[Tuple[str, str]]:
177
+ """Return pairs whose names and values are already percent-encoded."""
178
+ location, name, style, explode = _settings(metadata, value, swagger2)
179
+ if location != "query":
180
+ _fail("Query serialization requires in=query")
181
+ encoded_name = _encoded(name)
182
+ if swagger2:
183
+ if isinstance(value, list):
184
+ if style == "multi":
185
+ return [(encoded_name, _encoded(item)) for item in value]
186
+ return [(encoded_name, _delimited(value, style))]
187
+ return [(encoded_name, _encoded(value))]
188
+ if style == "deepObject":
189
+ if "[" in name or "]" in name or any("[" in key or "]" in key for key in value):
190
+ _fail("deepObject property/parameter names containing brackets are ambiguous and unsupported")
191
+ return [(_encoded(name + "[" + key + "]"), _encoded(item)) for key, item in value.items()]
192
+ if style in {"spaceDelimited", "pipeDelimited"}:
193
+ return [(encoded_name, _delimited(value, style))]
194
+ if explode and isinstance(value, dict):
195
+ return [(_encoded(key), _encoded(item)) for key, item in value.items()]
196
+ if explode and isinstance(value, list):
197
+ return [(encoded_name, _encoded(item)) for item in value]
198
+ return [(encoded_name, ",".join(_tokens(value)))]