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.
@@ -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