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 ADDED
@@ -0,0 +1,402 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Command-line entry point for GrantTrace."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import math
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any, Dict, Optional, Sequence
13
+
14
+ from core import __version__
15
+ from core.auditor import APISentinelAuditor
16
+ from core.config_scaffold import config_needs_input, write_config_scaffold
17
+ from core.config_validator import ConfigValidator
18
+ from core.evidence import sanitize_log_text, sanitize_text
19
+ from core.models import parse_operation_key
20
+ from core.parser import OpenAPIParser
21
+ from core.reporter import SecurityReportGenerator
22
+
23
+
24
+ BANNER = rf"""
25
+ ### ## # ### # # ##### ##### ## # ### ### ####
26
+ # # # # # ## # # # # # # # # #
27
+ # ## ## #### # ## # # ## #### # ###
28
+ # # # # # # # # # # # # # # # #
29
+ ### # # # # # # # # # # # # ### ####
30
+ safety-first API authorization auditing v{__version__}
31
+ """
32
+
33
+
34
+ def build_argument_parser() -> argparse.ArgumentParser:
35
+ parser = argparse.ArgumentParser(
36
+ description="Safety-first OpenAPI authorization and mass-assignment auditor"
37
+ )
38
+ parser.add_argument("--spec", "-s", default=None,
39
+ help="OpenAPI/Swagger JSON or YAML (default: openapi.json)")
40
+ parser.add_argument(
41
+ "--target", "-t", default="http://127.0.0.1:8080", help="Target API base URL"
42
+ )
43
+ parser.add_argument("--config", "-c", default=None, help="Identity and readback JSON config")
44
+ parser.add_argument(
45
+ "--init-config", metavar="CONFIG.json", default=None,
46
+ help="Create an incomplete offline config and companion checklist from --spec; never overwrite files",
47
+ )
48
+ parser.add_argument("--workers", "-w", type=int, default=4, help="Concurrent read-only checks")
49
+ parser.add_argument(
50
+ "--delay",
51
+ "-d",
52
+ type=float,
53
+ default=0.05,
54
+ help="Global minimum delay between requests in seconds",
55
+ )
56
+ parser.add_argument("--timeout", type=float, default=6.0, help="Per-request timeout in seconds")
57
+ parser.add_argument(
58
+ "--max-response-bytes",
59
+ type=int,
60
+ default=1024 * 1024,
61
+ help="Maximum response bytes retained per request",
62
+ )
63
+ parser.add_argument(
64
+ "--output", "-o", default="granttrace_report.html", help="HTML report path (default: granttrace_report.html)"
65
+ )
66
+ parser.add_argument("--export-json", default=None, help="Optional machine-readable JSON report")
67
+ parser.add_argument(
68
+ "--insecure",
69
+ "-k",
70
+ action="store_true",
71
+ default=False,
72
+ help="Disable TLS certificate verification for an isolated test environment",
73
+ )
74
+ parser.add_argument(
75
+ "--allow-http",
76
+ action="store_true",
77
+ help="Allow clear-text HTTP to non-loopback targets",
78
+ )
79
+ parser.add_argument(
80
+ "--allow-write-tests",
81
+ action="store_true",
82
+ help="Enable allowlisted PATCH mass-assignment checks with readback and rollback",
83
+ )
84
+ parser.add_argument(
85
+ "--write-endpoint",
86
+ action="append",
87
+ default=[],
88
+ metavar="'PATCH /path'",
89
+ help="Allow one PATCH operation for active testing; repeat as needed",
90
+ )
91
+ parser.add_argument(
92
+ "--include-sensitive-evidence",
93
+ action="store_true",
94
+ help="Retain sensitive response fields in reports instead of redacting them",
95
+ )
96
+ parser.add_argument(
97
+ "--fail-on-vuln",
98
+ action="store_true",
99
+ help="Exit 1 when a vulnerability is confirmed",
100
+ )
101
+ parser.add_argument(
102
+ "--fail-on-error",
103
+ action="store_true",
104
+ help="Exit 2 on errors, inconclusive results, or no conclusive checks",
105
+ )
106
+ parser.add_argument("--dry-run", action="store_true",
107
+ help="Print/export a local-only plan without sending any target requests")
108
+ parser.add_argument("--min-coverage", type=float, default=0.0, metavar="PERCENT",
109
+ help="Require this percentage of endpoints to have conclusive results; exit 2 otherwise")
110
+ parser.add_argument("--fail-on-suspicious", action="store_true",
111
+ help="Exit 2 if any result remains suspicious")
112
+ parser.add_argument(
113
+ "--validate-config",
114
+ action="store_true",
115
+ help="Validate configuration syntax and semantics offline without network activity",
116
+ )
117
+ parser.add_argument(
118
+ "--version",
119
+ "-v",
120
+ action="version",
121
+ version=f"GrantTrace {__version__}",
122
+ help="Show program version and exit",
123
+ )
124
+ return parser
125
+
126
+
127
+ def _reject_constant(val: str) -> None:
128
+ raise ValueError(f"Non-standard JSON number not allowed: {val}")
129
+
130
+
131
+ def _load_spec(path: str) -> Dict[str, Any]:
132
+ """Use the scanner's local JSON/YAML loader for both preflight modes."""
133
+ try:
134
+ return OpenAPIParser(path).raw_spec
135
+ except Exception as exc:
136
+ raise ValueError(f"Cannot load specification '{path}': {exc}") from exc
137
+
138
+
139
+ def _reject_incomplete_draft(config: Any) -> None:
140
+ if config_needs_input(config):
141
+ raise ValueError(
142
+ "Configuration draft needs user input. Complete the companion checklist, replace all "
143
+ "__GRANTTRACE_INPUT__: placeholders, and remove _granttrace_draft after reviewing identities, "
144
+ "resources, authorization policy, and any write/readback mappings. No requests were sent."
145
+ )
146
+
147
+
148
+ def run_config_validation(config_path_str: str, spec_path: Optional[str] = None) -> int:
149
+ config_file = Path(config_path_str)
150
+ safe_path_str = sanitize_log_text(config_path_str)
151
+ if not config_file.is_file():
152
+ print(f"[ERROR] Configuration file does not exist: {safe_path_str}", file=sys.stderr)
153
+ return 2
154
+ try:
155
+ with config_file.open("r", encoding="utf-8") as handle:
156
+ raw_data = json.load(handle, parse_constant=_reject_constant)
157
+ except (json.JSONDecodeError, ValueError) as exc:
158
+ safe_err = sanitize_log_text(sanitize_text(str(exc)))
159
+ print(f"[ERROR] Failed to parse JSON configuration '{safe_path_str}': {safe_err}", file=sys.stderr)
160
+ return 2
161
+ except UnicodeDecodeError as exc:
162
+ safe_err = sanitize_log_text(str(exc))
163
+ print(f"[ERROR] Configuration file must be valid UTF-8: {safe_err}", file=sys.stderr)
164
+ return 2
165
+ except OSError as exc:
166
+ safe_err = sanitize_log_text(str(exc))
167
+ print(f"[ERROR] Cannot read configuration file '{safe_path_str}': {safe_err}", file=sys.stderr)
168
+ return 2
169
+ except Exception as exc:
170
+ safe_err = sanitize_log_text(f"{type(exc).__name__}: {exc}")
171
+ print(f"[ERROR] Unexpected error inspecting configuration: {safe_err}", file=sys.stderr)
172
+ return 2
173
+
174
+ try:
175
+ _reject_incomplete_draft(raw_data)
176
+ except ValueError as exc:
177
+ print("[ERROR] " + str(exc), file=sys.stderr)
178
+ return 2
179
+
180
+ # Config-only validation remains available without a default specification.
181
+ # An explicitly selected specification must load successfully.
182
+ selected_spec = spec_path if spec_path is not None else (
183
+ "openapi.json" if Path("openapi.json").is_file() else None
184
+ )
185
+ try:
186
+ raw_spec = _load_spec(selected_spec) if selected_spec is not None else None
187
+ except ValueError as exc:
188
+ print("[ERROR] " + sanitize_log_text(sanitize_text(str(exc))), file=sys.stderr)
189
+ return 2
190
+
191
+ result = ConfigValidator.validate(raw_data, raw_spec=raw_spec)
192
+ if not result.is_valid:
193
+ error_word = "error" if result.summary.errors_count == 1 else "errors"
194
+ print(
195
+ f"[ERROR] Configuration validation failed for '{safe_path_str}' "
196
+ f"({result.summary.errors_count} {error_word} found):",
197
+ file=sys.stderr,
198
+ )
199
+ for issue in result.errors:
200
+ print(issue.format(), file=sys.stderr)
201
+ if result.warnings:
202
+ print(f"[WARN] In addition, {result.summary.warnings_count} warning(s) observed:", file=sys.stderr)
203
+ for warning in result.warnings:
204
+ print(warning.format(), file=sys.stderr)
205
+ return 2
206
+
207
+ print(f"[OK] Configuration is valid: {safe_path_str}")
208
+ print(f" - Identities: {result.summary.identities_count} configured (owner, visitor, anonymous)")
209
+ print(f" - Write allowlist: {result.summary.allowlisted_endpoints} endpoint(s)")
210
+ print(f" - Readbacks: {result.summary.readbacks_count} mapped endpoint(s)")
211
+ print(f" - Parameter values: {result.summary.parameter_endpoints} endpoint(s)")
212
+ print(f" - BOLA policies: {result.summary.bola_policies_count} operation(s)")
213
+ if result.warnings:
214
+ for warning in result.warnings:
215
+ print(warning.format())
216
+ return 0
217
+
218
+
219
+ def _load_config(path: Optional[str]) -> Dict[str, Any]:
220
+ if not path:
221
+ return {}
222
+ config_path = Path(path)
223
+ if not config_path.is_file():
224
+ raise ValueError(f"configuration file does not exist: {path}")
225
+ try:
226
+ with config_path.open("r", encoding="utf-8") as handle:
227
+ config = json.load(handle, parse_constant=_reject_constant)
228
+ except UnicodeDecodeError as exc:
229
+ raise ValueError(f"configuration file must be valid UTF-8: {exc}") from exc
230
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
231
+ raise ValueError(f"cannot read configuration '{path}': {exc}") from exc
232
+ if not isinstance(config, dict):
233
+ raise ValueError("configuration root must be a JSON object")
234
+ _reject_incomplete_draft(config)
235
+ return config
236
+
237
+
238
+ def _ensure_parent(path: str) -> None:
239
+ parent = Path(path).expanduser().resolve().parent
240
+ parent.mkdir(parents=True, exist_ok=True)
241
+
242
+
243
+ def main(argv: Optional[Sequence[str]] = None) -> int:
244
+ args = build_argument_parser().parse_args(argv)
245
+ print(BANNER)
246
+ if args.init_config is not None:
247
+ if args.config or args.validate_config or args.dry_run or args.allow_write_tests or args.write_endpoint:
248
+ print("[ERROR] --init-config cannot be combined with --config, --validate-config, "
249
+ "--dry-run, --allow-write-tests, or --write-endpoint", file=sys.stderr)
250
+ return 2
251
+ try:
252
+ output, checklist = write_config_scaffold(
253
+ args.spec if args.spec is not None else "openapi.json", args.init_config
254
+ )
255
+ except Exception as exc:
256
+ print("[ERROR] Cannot create configuration draft: " +
257
+ sanitize_log_text(sanitize_text(str(exc))), file=sys.stderr)
258
+ return 2
259
+ print("[OK] Offline configuration draft: " + sanitize_log_text(str(output)))
260
+ print("[OK] Review checklist: " + sanitize_log_text(str(checklist)))
261
+ print("[REVIEW] Needs user input; validation and scans remain blocked until review is complete. "
262
+ "Write tests are disabled.")
263
+ return 0
264
+ if args.validate_config:
265
+ config_path = args.config or "config.json"
266
+ return run_config_validation(config_path, args.spec)
267
+
268
+ spec_path = args.spec if args.spec is not None else "openapi.json"
269
+ if (args.workers < 1 or args.delay < 0 or args.timeout <= 0 or args.max_response_bytes < 1024
270
+ or not all(math.isfinite(v) for v in (args.delay, args.timeout, args.min_coverage))
271
+ or not 0 <= args.min_coverage <= 100):
272
+ print("[ERROR] Invalid worker, delay, timeout, or response-size option", file=sys.stderr)
273
+ return 2
274
+
275
+ try:
276
+ config = _load_config(args.config)
277
+ raw_spec = _load_spec(spec_path)
278
+ except ValueError as exc:
279
+ safe_err = sanitize_log_text(sanitize_text(str(exc)))
280
+ print(f"[ERROR] {safe_err}", file=sys.stderr)
281
+ return 2
282
+
283
+ # Validate write_endpoint options if provided on CLI
284
+ if args.write_endpoint:
285
+ for item in args.write_endpoint:
286
+ is_valid, method, path, canonical, err_msg = parse_operation_key(item)
287
+ if not is_valid or method != "PATCH":
288
+ print(
289
+ f"[ERROR] Invalid --write-endpoint '{sanitize_log_text(str(item))}': {err_msg or 'must be uppercase PATCH'}",
290
+ file=sys.stderr,
291
+ )
292
+ return 2
293
+
294
+ # If configuration is loaded, run full ConfigValidator semantic validation
295
+ if args.config or args.write_endpoint:
296
+ val_config = dict(config)
297
+ # Explicit CLI selection narrows/replaces, never silently unions scope
298
+ if args.write_endpoint:
299
+ val_config["write_allowlist"] = list(args.write_endpoint)
300
+
301
+ val_result = ConfigValidator.validate(val_config, raw_spec=raw_spec)
302
+ if not val_result.is_valid:
303
+ safe_cfg_path = sanitize_log_text(args.config or "CLI configuration")
304
+ error_word = "error" if val_result.summary.errors_count == 1 else "errors"
305
+ print(
306
+ f"[ERROR] Configuration validation failed for '{safe_cfg_path}' "
307
+ f"({val_result.summary.errors_count} {error_word} found):",
308
+ file=sys.stderr,
309
+ )
310
+ for issue in val_result.errors:
311
+ print(issue.format(), file=sys.stderr)
312
+ return 2
313
+
314
+ try:
315
+ # Explicit CLI selection narrows/replaces, never silently unions scope.
316
+ write_allowlist = list(args.write_endpoint) if args.write_endpoint else config.get("write_allowlist", [])
317
+ if not isinstance(write_allowlist, list):
318
+ raise ValueError("write_allowlist must be a list")
319
+
320
+ auditor = APISentinelAuditor(
321
+ spec_path=spec_path,
322
+ target_base_url=args.target,
323
+ max_workers=args.workers,
324
+ request_delay=args.delay,
325
+ identities_config=config.get("identities"),
326
+ insecure_ssl=args.insecure,
327
+ allow_http=args.allow_http,
328
+ allow_write_tests=args.allow_write_tests,
329
+ readback_config=config.get("readbacks"),
330
+ parameter_values=config.get("parameter_values"),
331
+ write_allowlist=write_allowlist,
332
+ request_timeout=args.timeout,
333
+ max_response_bytes=args.max_response_bytes,
334
+ include_sensitive_evidence=args.include_sensitive_evidence,
335
+ bola_config=config.get("bola"),
336
+ )
337
+ if args.dry_run:
338
+ plan = auditor.build_plan()
339
+ print(json.dumps(plan, indent=2, ensure_ascii=False))
340
+ if args.export_json:
341
+ _ensure_parent(args.export_json)
342
+ with open(args.export_json, "w", encoding="utf-8") as handle:
343
+ json.dump(plan, handle, indent=2, ensure_ascii=False)
344
+ return 0
345
+ started = time.time()
346
+ auditor.run()
347
+ elapsed = time.time() - started
348
+
349
+ _ensure_parent(args.output)
350
+ SecurityReportGenerator.generate(
351
+ stats=auditor.stats,
352
+ findings=auditor.findings,
353
+ results=auditor.results,
354
+ target_url=args.target,
355
+ output_path=args.output,
356
+ )
357
+ print(f"[OK] HTML report: {args.output}")
358
+
359
+ if args.export_json:
360
+ _ensure_parent(args.export_json)
361
+ with open(args.export_json, "w", encoding="utf-8") as handle:
362
+ json.dump(
363
+ {
364
+ "tool_version": __version__,
365
+ "report_schema_version": 2,
366
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
367
+ "elapsed_seconds": round(elapsed, 3),
368
+ "target": args.target,
369
+ "stats": auditor.stats,
370
+ "results": auditor.results,
371
+ "findings": auditor.findings,
372
+ },
373
+ handle,
374
+ indent=2,
375
+ ensure_ascii=False,
376
+ )
377
+ print(f"[OK] JSON report: {args.export_json}")
378
+ except (ValueError, OSError, json.JSONDecodeError) as exc:
379
+ print("[ERROR] " + sanitize_log_text(sanitize_text(str(exc), secret_values=getattr(locals().get("auditor"), "_secret_values", ()))), file=sys.stderr)
380
+ return 2
381
+ except Exception as exc: # The CLI must not emit a healthy report after a fatal error.
382
+ print("[ERROR] Audit aborted: " + sanitize_log_text(sanitize_text(f"{type(exc).__name__}: {exc}", secret_values=getattr(locals().get("auditor"), "_secret_values", ()))), file=sys.stderr)
383
+ return 2
384
+
385
+ if args.fail_on_error and (
386
+ auditor.stats.get("error_count", 0) > 0
387
+ or auditor.stats.get("inconclusive_count", 0) > 0
388
+ or auditor.stats.get("conclusive_count", 0) == 0
389
+ ):
390
+ return 2
391
+ if auditor.stats.get("conclusive_coverage_pct", 0) < args.min_coverage:
392
+ return 2
393
+ if args.fail_on_suspicious and auditor.stats.get("suspicious_count", 0) > 0:
394
+ return 2
395
+ if args.fail_on_vuln and auditor.stats.get("confirmed", 0) > 0:
396
+ return 1
397
+ return 0
398
+
399
+
400
+ if __name__ == "__main__":
401
+ raise SystemExit(main())
402
+
core/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ # -*- coding: utf-8 -*-
2
+ """GrantTrace core package."""
3
+
4
+ __version__ = "2.4.0"