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.
- api_sentinel.py +402 -0
- core/__init__.py +4 -0
- core/auditor.py +1101 -0
- core/config_scaffold.py +254 -0
- core/config_validator.py +1408 -0
- core/diff.py +839 -0
- core/evidence.py +353 -0
- core/generator.py +1048 -0
- core/models.py +104 -0
- core/parameters.py +198 -0
- core/parser.py +495 -0
- core/reporter.py +359 -0
- core/transactions.py +167 -0
- granttrace-2.4.0.dist-info/METADATA +108 -0
- granttrace-2.4.0.dist-info/RECORD +21 -0
- granttrace-2.4.0.dist-info/WHEEL +5 -0
- granttrace-2.4.0.dist-info/entry_points.txt +2 -0
- granttrace-2.4.0.dist-info/licenses/LICENSE +202 -0
- granttrace-2.4.0.dist-info/top_level.txt +3 -0
- mock_server/__init__.py +4 -0
- mock_server/server.py +190 -0
core/config_scaffold.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Create deliberately incomplete, offline configuration drafts from OpenAPI.
|
|
2
|
+
|
|
3
|
+
A contract describes request shapes, not resource ownership, authorization
|
|
4
|
+
policy, or read-after-write guarantees. Those decisions remain explicit user
|
|
5
|
+
inputs; a draft must never silently become a runnable scan configuration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Tuple
|
|
14
|
+
|
|
15
|
+
from core.parser import OpenAPIParser
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DRAFT_KEY = "_granttrace_draft"
|
|
19
|
+
INPUT_PREFIX = "__GRANTTRACE_INPUT__:"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _input(label: str) -> str:
|
|
23
|
+
return INPUT_PREFIX + label
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def config_needs_input(config: Any) -> bool:
|
|
27
|
+
"""Reject draft markers and unresolved placeholders without printing values."""
|
|
28
|
+
if isinstance(config, dict) and DRAFT_KEY in config:
|
|
29
|
+
return True
|
|
30
|
+
pending = [config]
|
|
31
|
+
seen = set()
|
|
32
|
+
while pending:
|
|
33
|
+
value = pending.pop()
|
|
34
|
+
if isinstance(value, str) and value.startswith(INPUT_PREFIX):
|
|
35
|
+
return True
|
|
36
|
+
if isinstance(value, (dict, list)):
|
|
37
|
+
if id(value) in seen:
|
|
38
|
+
continue
|
|
39
|
+
seen.add(id(value))
|
|
40
|
+
if isinstance(value, dict):
|
|
41
|
+
pending.extend(value.keys())
|
|
42
|
+
pending.extend(value.values())
|
|
43
|
+
else:
|
|
44
|
+
pending.extend(value)
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parameter_notes(endpoint: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
49
|
+
notes = []
|
|
50
|
+
present_paths = set()
|
|
51
|
+
for parameter in endpoint["parameters"]:
|
|
52
|
+
name, location = parameter.get("name"), parameter.get("in")
|
|
53
|
+
if not isinstance(name, str) or not name:
|
|
54
|
+
continue
|
|
55
|
+
if location == "path":
|
|
56
|
+
present_paths.add(name)
|
|
57
|
+
schema = parameter.get("schema", parameter)
|
|
58
|
+
schema = schema if isinstance(schema, dict) else {}
|
|
59
|
+
item = {
|
|
60
|
+
"name": name,
|
|
61
|
+
"in": location,
|
|
62
|
+
"required": location == "path" or parameter.get("required") is True,
|
|
63
|
+
"type": schema.get("type", "unspecified"),
|
|
64
|
+
"included_in_config": location == "path" or (
|
|
65
|
+
location == "query" and parameter.get("required") is True
|
|
66
|
+
),
|
|
67
|
+
}
|
|
68
|
+
for keyword in ("style", "explode", "collectionFormat", "allowReserved"):
|
|
69
|
+
if keyword in parameter:
|
|
70
|
+
item[keyword] = parameter[keyword]
|
|
71
|
+
if isinstance(schema.get("format"), str):
|
|
72
|
+
item["format"] = schema["format"]
|
|
73
|
+
if isinstance(schema.get("items"), dict):
|
|
74
|
+
item["items_type"] = schema["items"].get("type", "unspecified")
|
|
75
|
+
if "content" in parameter:
|
|
76
|
+
item["content_parameter"] = True
|
|
77
|
+
if location not in {"path", "query", "body"}:
|
|
78
|
+
item["needs_review"] = (
|
|
79
|
+
"Operation header/cookie/form parameters are unsupported by the scanner. "
|
|
80
|
+
"Identity authentication headers must be configured under identities.*.headers; "
|
|
81
|
+
"do not assume an unsupported required parameter will be sent."
|
|
82
|
+
)
|
|
83
|
+
notes.append(item)
|
|
84
|
+
# An undeclared template variable must not fall through to generated data.
|
|
85
|
+
for name in re.findall(r"\{([^{}]+)\}", endpoint["path"]):
|
|
86
|
+
if name not in present_paths:
|
|
87
|
+
notes.append({
|
|
88
|
+
"name": name, "in": "path", "required": True, "type": "unspecified",
|
|
89
|
+
"included_in_config": True,
|
|
90
|
+
"needs_review": "Missing OpenAPI parameter declaration; fix the contract before scanning.",
|
|
91
|
+
})
|
|
92
|
+
return notes
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _security_notes(raw_spec: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
96
|
+
components = raw_spec.get("components", {})
|
|
97
|
+
schemes = components.get("securitySchemes", {}) if isinstance(components, dict) else {}
|
|
98
|
+
if not schemes:
|
|
99
|
+
schemes = raw_spec.get("securityDefinitions", {})
|
|
100
|
+
if not isinstance(schemes, dict):
|
|
101
|
+
return []
|
|
102
|
+
notes = []
|
|
103
|
+
for name, scheme in schemes.items():
|
|
104
|
+
if not isinstance(scheme, dict):
|
|
105
|
+
continue
|
|
106
|
+
item = {"name": str(name)}
|
|
107
|
+
for keyword in ("type", "scheme", "in", "name"):
|
|
108
|
+
if keyword in scheme:
|
|
109
|
+
item["parameter_name" if keyword == "name" else keyword] = scheme[keyword]
|
|
110
|
+
if "$ref" in scheme:
|
|
111
|
+
item["needs_review"] = "Resolve this security scheme locally and configure authentication manually."
|
|
112
|
+
notes.append(item)
|
|
113
|
+
return notes
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_config_scaffold(spec_path: str) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
117
|
+
"""Return a blocked config and a separate human review checklist.
|
|
118
|
+
|
|
119
|
+
Reuse the scanner's local JSON/YAML parser and its safe reference handling.
|
|
120
|
+
Never copy example/default resource IDs, credentials, or inferred policies.
|
|
121
|
+
"""
|
|
122
|
+
parser = OpenAPIParser(spec_path)
|
|
123
|
+
endpoints = parser.get_endpoints()
|
|
124
|
+
draft: Dict[str, Any] = {
|
|
125
|
+
"description": "INCOMPLETE offline draft. Complete the companion checklist before scanning.",
|
|
126
|
+
DRAFT_KEY: {"status": "needs_user_input", "version": 1},
|
|
127
|
+
"identities": {
|
|
128
|
+
name: {"id": _input(name + " test account id"), "token": None,
|
|
129
|
+
"headers": {}, "parameters": {}}
|
|
130
|
+
for name in ("owner", "visitor")
|
|
131
|
+
},
|
|
132
|
+
"parameter_values": {},
|
|
133
|
+
"write_allowlist": [],
|
|
134
|
+
"readbacks": {},
|
|
135
|
+
"bola": {},
|
|
136
|
+
}
|
|
137
|
+
draft["identities"]["anonymous"] = {
|
|
138
|
+
"id": None, "token": None, "headers": {}, "parameters": {},
|
|
139
|
+
}
|
|
140
|
+
checklist: Dict[str, Any] = {
|
|
141
|
+
"status": "needs_user_input",
|
|
142
|
+
"offline": True,
|
|
143
|
+
"limitations": [
|
|
144
|
+
"OpenAPI cannot establish resource ownership or which identities should be allowed access.",
|
|
145
|
+
"OpenAPI cannot prove an independent GET observes committed PATCH changes or strong consistency.",
|
|
146
|
+
"This draft is incomplete and blocked; generating it sends no requests and authorizes no writes.",
|
|
147
|
+
"Schema defaults and examples are not trusted as test resources or credentials and are not copied.",
|
|
148
|
+
],
|
|
149
|
+
"steps": [
|
|
150
|
+
"Use a dedicated authorized test target and disposable resources.",
|
|
151
|
+
"Fill owner and visitor with distinct real test-account IDs and authentication. For Bearer auth, "
|
|
152
|
+
"set identities.*.token to 'Bearer <test credential>'; for custom authentication, use "
|
|
153
|
+
"identities.*.headers and auth_header_names. Keep anonymous credentials empty.",
|
|
154
|
+
"Replace every resource placeholder with an existing resource belonging to the named test "
|
|
155
|
+
"identity. Parameter values are JSON values of the declared type, not Python list/boolean text. "
|
|
156
|
+
"Fill optional query parameters deliberately when needed; do not guess resource ownership.",
|
|
157
|
+
"Confirm permitted and denied access, including same-team sharing and administrator access, "
|
|
158
|
+
"before treating cross-identity access as a finding. Configure bola.expected_public only for "
|
|
159
|
+
"confirmed public operations and resource_id_paths only for confirmed response identifiers.",
|
|
160
|
+
"For each PATCH you intend to test, independently verify the GET readback path, field_map "
|
|
161
|
+
"(written field -> response field), resource parameters, consistency, baseline and rollback. "
|
|
162
|
+
"Remove unused PATCH readback entries; never infer strong consistency from a similar path.",
|
|
163
|
+
"Keep write_allowlist empty for read-only checks. Enabling writes later requires an explicit "
|
|
164
|
+
"reviewed PATCH allowlist and the separate --allow-write-tests flag.",
|
|
165
|
+
"After completing this review, replace all __GRANTTRACE_INPUT__: placeholders and remove "
|
|
166
|
+
"_granttrace_draft. Run --validate-config --config <config> --spec <spec>, then --dry-run with "
|
|
167
|
+
"the same files and review the plan before any scan. Validation does not prove business policy.",
|
|
168
|
+
],
|
|
169
|
+
"security_schemes": _security_notes(parser.raw_spec),
|
|
170
|
+
"identities": [
|
|
171
|
+
{"identity": name, "status": "needs_user_input",
|
|
172
|
+
"missing": ["test-account id", "distinct test authentication"],
|
|
173
|
+
"config_paths": ["identities." + name + ".id", "identities." + name + ".token",
|
|
174
|
+
"identities." + name + ".headers"],
|
|
175
|
+
"instruction": "Use token or explicit authentication headers appropriate to the API; "
|
|
176
|
+
"the account must have the role you intend to test."}
|
|
177
|
+
for name in ("owner", "visitor")
|
|
178
|
+
],
|
|
179
|
+
"operations": [],
|
|
180
|
+
}
|
|
181
|
+
get_paths = sorted({endpoint["path"] for endpoint in endpoints if endpoint["method"] == "GET"})
|
|
182
|
+
checklist["available_get_paths"] = get_paths
|
|
183
|
+
for endpoint in endpoints:
|
|
184
|
+
operation = endpoint["method"] + " " + endpoint["path"]
|
|
185
|
+
supported = endpoint["method"] in {"GET", "PATCH"}
|
|
186
|
+
parameters = _parameter_notes(endpoint)
|
|
187
|
+
entry: Dict[str, Any] = {
|
|
188
|
+
"operation": operation,
|
|
189
|
+
"scan_supported": supported,
|
|
190
|
+
"parameters": parameters,
|
|
191
|
+
"needs_user_input": [
|
|
192
|
+
"Confirm the intended allowed/denied identities and resource ownership; "
|
|
193
|
+
"same-team sharing or administrator access can be legitimate."
|
|
194
|
+
] if supported else ["This operation method is not actively tested by GrantTrace."],
|
|
195
|
+
}
|
|
196
|
+
if supported:
|
|
197
|
+
names = [p["name"] for p in parameters if p["included_in_config"]]
|
|
198
|
+
for parameter in parameters:
|
|
199
|
+
if parameter["included_in_config"]:
|
|
200
|
+
parameter["status"] = "needs_user_input"
|
|
201
|
+
parameter["config_paths"] = [
|
|
202
|
+
"parameter_values." + operation + "." + identity + "." + parameter["name"]
|
|
203
|
+
for identity in ("owner", "visitor")
|
|
204
|
+
]
|
|
205
|
+
if names:
|
|
206
|
+
draft["parameter_values"][operation] = {
|
|
207
|
+
identity: {name: _input(identity + " resource parameter " + name) for name in names}
|
|
208
|
+
for identity in ("owner", "visitor")
|
|
209
|
+
}
|
|
210
|
+
if endpoint["method"] == "PATCH":
|
|
211
|
+
draft["readbacks"][operation] = {
|
|
212
|
+
"method": "GET", "path": _input("verified GET readback path"),
|
|
213
|
+
"consistency": _input("verified strong, weak, or eventual consistency"),
|
|
214
|
+
"field_map": {},
|
|
215
|
+
}
|
|
216
|
+
schema = endpoint.get("request_schema")
|
|
217
|
+
properties = schema.get("properties", {}) if isinstance(schema, dict) else {}
|
|
218
|
+
entry["readback_review"] = {
|
|
219
|
+
"candidate_get_paths": [path for path in get_paths if path == endpoint["path"]],
|
|
220
|
+
"candidate_written_fields": sorted(properties) if isinstance(properties, dict) else [],
|
|
221
|
+
"missing": ["verified GET path", "write-to-response field_map", "consistency guarantee",
|
|
222
|
+
"readback resource parameters", "baseline and successful rollback"],
|
|
223
|
+
"instruction": "Candidates describe contract shapes only; verify each against real test behavior. "
|
|
224
|
+
"Nested fields use dot/bracket paths, for example data.role or members[0].role.",
|
|
225
|
+
}
|
|
226
|
+
checklist["operations"].append(entry)
|
|
227
|
+
return draft, checklist
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def write_config_scaffold(spec_path: str, output_path: str) -> Tuple[Path, Path]:
|
|
231
|
+
"""Write both new files exclusively, refusing to overwrite either one."""
|
|
232
|
+
output = Path(output_path).expanduser()
|
|
233
|
+
if output.suffix.lower() != ".json":
|
|
234
|
+
raise ValueError("--init-config output must be a .json file")
|
|
235
|
+
checklist_path = output.with_suffix(".checklist.json")
|
|
236
|
+
for destination in (output, checklist_path):
|
|
237
|
+
if destination.exists() or destination.is_symlink():
|
|
238
|
+
raise ValueError(f"Refusing to overwrite existing file: {destination}")
|
|
239
|
+
draft, checklist = build_config_scaffold(spec_path)
|
|
240
|
+
contents = [json.dumps(value, indent=2, ensure_ascii=False, allow_nan=False) + "\n"
|
|
241
|
+
for value in (draft, checklist)]
|
|
242
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
created: List[Path] = []
|
|
244
|
+
try:
|
|
245
|
+
for destination, content in zip((output, checklist_path), contents):
|
|
246
|
+
with destination.open("x", encoding="utf-8") as handle:
|
|
247
|
+
created.append(destination)
|
|
248
|
+
handle.write(content)
|
|
249
|
+
except Exception:
|
|
250
|
+
# Only remove files created by this call; preserve any pre-existing peer.
|
|
251
|
+
for destination in reversed(created):
|
|
252
|
+
destination.unlink()
|
|
253
|
+
raise
|
|
254
|
+
return output, checklist_path
|