summit-scanner 0.6.7__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.
Files changed (57) hide show
  1. summit/__init__.py +3 -0
  2. summit/authorize.py +137 -0
  3. summit/captcha.py +55 -0
  4. summit/cli.py +366 -0
  5. summit/crawl/__init__.py +0 -0
  6. summit/crawl/js_endpoints.py +57 -0
  7. summit/crawl/spider.py +154 -0
  8. summit/cve.py +120 -0
  9. summit/fuzz/__init__.py +0 -0
  10. summit/fuzz/dirbuster.py +87 -0
  11. summit/graphql.py +62 -0
  12. summit/http/__init__.py +0 -0
  13. summit/http/client.py +97 -0
  14. summit/http/models.py +91 -0
  15. summit/openapi.py +103 -0
  16. summit/paramminer.py +99 -0
  17. summit/proxy/__init__.py +0 -0
  18. summit/proxy/server.py +168 -0
  19. summit/ratelimit.py +40 -0
  20. summit/recon.py +143 -0
  21. summit/report/__init__.py +0 -0
  22. summit/report/writer.py +124 -0
  23. summit/scan/__init__.py +0 -0
  24. summit/scan/core.py +125 -0
  25. summit/scan/engine.py +159 -0
  26. summit/scan/linkcheck.py +224 -0
  27. summit/scan/modules/__init__.py +28 -0
  28. summit/scan/modules/cacheable.py +52 -0
  29. summit/scan/modules/cors_reflect.py +46 -0
  30. summit/scan/modules/crlf.py +44 -0
  31. summit/scan/modules/csrf.py +47 -0
  32. summit/scan/modules/directory_listing.py +49 -0
  33. summit/scan/modules/enumeration.py +55 -0
  34. summit/scan/modules/headers_grade.py +58 -0
  35. summit/scan/modules/host_header.py +51 -0
  36. summit/scan/modules/open_redirect.py +66 -0
  37. summit/scan/modules/os_command.py +84 -0
  38. summit/scan/modules/path_traversal.py +45 -0
  39. summit/scan/modules/sensitive_files.py +59 -0
  40. summit/scan/modules/sqli.py +81 -0
  41. summit/scan/modules/sqli_time.py +84 -0
  42. summit/scan/modules/ssti.py +57 -0
  43. summit/scan/modules/waf_bypass.py +64 -0
  44. summit/scan/modules/xss.py +76 -0
  45. summit/scan/passive.py +273 -0
  46. summit/smart.py +45 -0
  47. summit/takeover.py +65 -0
  48. summit/tls.py +144 -0
  49. summit/toolkit.py +71 -0
  50. summit/updates.py +155 -0
  51. summit/waf.py +54 -0
  52. summit_scanner-0.6.7.dist-info/METADATA +332 -0
  53. summit_scanner-0.6.7.dist-info/RECORD +57 -0
  54. summit_scanner-0.6.7.dist-info/WHEEL +5 -0
  55. summit_scanner-0.6.7.dist-info/entry_points.txt +2 -0
  56. summit_scanner-0.6.7.dist-info/licenses/LICENSE +21 -0
  57. summit_scanner-0.6.7.dist-info/top_level.txt +1 -0
summit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Summit - open-source web security testing platform."""
2
+
3
+ __version__ = "0.6.0"
summit/authorize.py ADDED
@@ -0,0 +1,137 @@
1
+ """Authorization testing - the Autorize workflow, made scriptable.
2
+
3
+ You provide URLs and two role definitions - typically a privileged
4
+ session and a low-privilege one. Summit requests every URL as every
5
+ role, compares the responses, and flags the case that matters most:
6
+ a low-privilege role receiving the same answer as the privileged one.
7
+ That is broken access control (BOLA/IDOR) - the single most
8
+ consequential bug class in bug bounty.
9
+
10
+ Verdicts per URL:
11
+
12
+ - ``enforced`` - the roles see different responses (working authz)
13
+ - ``broken`` - the low-privilege response matches the privileged one
14
+ - ``partial`` - same status, different body (worth a human look)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ from dataclasses import dataclass
21
+
22
+ from .http.client import HttpClient
23
+ from .http.models import HttpRequest, HttpResponse
24
+ from .scan.core import Finding
25
+
26
+
27
+ @dataclass
28
+ class Role:
29
+ name: str
30
+ headers: dict[str, str]
31
+
32
+
33
+ @dataclass
34
+ class AuthResult:
35
+ url: str
36
+ privileged_role: str
37
+ other_role: str
38
+ privileged: HttpResponse
39
+ other: HttpResponse
40
+ verdict: str
41
+
42
+
43
+ def _signature(response: HttpResponse) -> tuple:
44
+ return (response.status, len(response.body), hashlib.sha256(response.body).hexdigest()[:16])
45
+
46
+
47
+ def compare(privileged: HttpResponse, other: HttpResponse) -> str:
48
+ if _signature(privileged) == _signature(other):
49
+ return "broken"
50
+ if privileged.status == other.status:
51
+ return "partial"
52
+ return "enforced"
53
+
54
+
55
+ def run_authorize(
56
+ client: HttpClient,
57
+ urls: list[str],
58
+ privileged: Role,
59
+ other: Role,
60
+ ) -> list[AuthResult]:
61
+ """Request every URL as both roles and classify the difference."""
62
+ results: list[AuthResult] = []
63
+ for url in urls:
64
+ try:
65
+ privileged_response = client.send(HttpRequest(method="GET", url=url, headers=dict(privileged.headers)))
66
+ other_response = client.send(HttpRequest(method="GET", url=url, headers=dict(other.headers)))
67
+ except Exception:
68
+ continue # unreachable URL - report nothing, the scanner already handles reachability
69
+ results.append(
70
+ AuthResult(
71
+ url=url,
72
+ privileged_role=privileged.name,
73
+ other_role=other.name,
74
+ privileged=privileged_response,
75
+ other=other_response,
76
+ verdict=compare(privileged_response, other_response),
77
+ )
78
+ )
79
+ return results
80
+
81
+
82
+ def findings_from(results: list[AuthResult]) -> list[Finding]:
83
+ findings: list[Finding] = []
84
+ for result in results:
85
+ if result.verdict == "broken":
86
+ # Guard against the classic login-redirect false positive: if the
87
+ # "privileged" response is itself a redirect or an empty shell,
88
+ # both roles are probably bouncing off the same auth wall - there
89
+ # is no privileged content to leak in the first place.
90
+ privileged_usable = (
91
+ result.privileged.status == 200
92
+ and len(result.privileged.body) >= 100
93
+ )
94
+ if not privileged_usable:
95
+ continue
96
+ findings.append(
97
+ Finding(
98
+ check_id="authorize-bypass",
99
+ title="Broken access control (BOLA/IDOR candidate)",
100
+ severity="critical",
101
+ url=result.url,
102
+ confidence="confirmed",
103
+ detail=(
104
+ f"Role '{result.other_role}' received an identical response to privileged role "
105
+ f"'{result.privileged_role}' (same status, same body). The endpoint does not "
106
+ "enforce authorization - verify the object ids are truly equivalent for both roles."
107
+ ),
108
+ evidence=f"both roles: HTTP {result.privileged.status}, {len(result.privileged.body)} bytes",
109
+ remediation="Enforce authorization server-side on every request; deny by default.",
110
+ )
111
+ )
112
+ elif result.verdict == "partial":
113
+ findings.append(
114
+ Finding(
115
+ check_id="authorize-partial",
116
+ title="Authorization response differs only in body",
117
+ severity="medium",
118
+ url=result.url,
119
+ confidence="potential",
120
+ detail=(
121
+ f"Roles '{result.privileged_role}' and '{result.other_role}' get the same status "
122
+ "but different bodies - could be personalization, could be leaking privileged data. Worth a human look."
123
+ ),
124
+ evidence=f"HTTP {result.privileged.status}, privileged {len(result.privileged.body)}B vs other {len(result.other.body)}B",
125
+ remediation="Confirm the response difference is intentional personalization, not data leakage.",
126
+ )
127
+ )
128
+ return findings
129
+
130
+
131
+ def parse_role(spec: str) -> Role:
132
+ """Parse 'name=Header: value' into a Role, e.g. 'admin=Cookie: session=abc'."""
133
+ name, _, rest = spec.partition("=")
134
+ header_name, _, header_value = rest.partition(":")
135
+ if not name.strip() or not header_name.strip():
136
+ raise ValueError(f"role spec must look like 'name=Header: value', got: {spec!r}")
137
+ return Role(name.strip(), {header_name.strip(): header_value.strip()})
summit/captcha.py ADDED
@@ -0,0 +1,55 @@
1
+ """CAPTCHA and anti-bot challenge detection.
2
+
3
+ Summit does not solve CAPTCHAs - that belongs to a human with a browser,
4
+ and automating it would cross lines we will not cross. What the scanner
5
+ does is detect the challenge, tell you exactly which one is in the way,
6
+ and stop burning requests against it. Knowing "this endpoint sits behind
7
+ reCAPTCHA" is actionable; hammering it is not.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .http.models import HttpResponse
13
+ from .scan.core import Finding
14
+
15
+ CHALLENGE_SIGNATURES: list[tuple[str, list[str]]] = [
16
+ ("reCAPTCHA", ["recaptcha/api.js", "g-recaptcha", "grecaptcha.execute"]),
17
+ ("hCaptcha", ["hcaptcha.com/1/api.js", "h-captcha"]),
18
+ ("Cloudflare challenge", ["cf_chl_opt", "checking your browser", "verifying you are human"]),
19
+ ]
20
+
21
+ _TURNSTILE = "challenges.cloudflare.com/turnstile"
22
+ _MAX_BODY = 20_000
23
+
24
+
25
+ def detect_challenge(response: HttpResponse) -> tuple[str, str] | None:
26
+ """Return (challenge name, evidence) when the response contains one."""
27
+ body = response.text[:_MAX_BODY].lower()
28
+ if _TURNSTILE in body:
29
+ return "Cloudflare Turnstile", _TURNSTILE
30
+ for name, markers in CHALLENGE_SIGNATURES:
31
+ for marker in markers:
32
+ if marker in body:
33
+ return name, marker
34
+ return None
35
+
36
+
37
+ def check_challenge(response: HttpResponse) -> list[Finding]:
38
+ detected = detect_challenge(response)
39
+ if not detected:
40
+ return []
41
+ name, evidence = detected
42
+ return [
43
+ Finding(
44
+ check_id="captcha-challenge",
45
+ title=f"CAPTCHA/challenge detected: {name}",
46
+ severity="info",
47
+ url=response.request.url,
48
+ detail=(
49
+ f"This endpoint is protected by {name}. Summit reports the challenge "
50
+ "instead of hammering it - automated CAPTCHA solving is out of scope "
51
+ "by design. Test this surface manually in a browser."
52
+ ),
53
+ evidence=evidence,
54
+ )
55
+ ]
summit/cli.py ADDED
@@ -0,0 +1,366 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from . import __version__
8
+ from .authorize import findings_from, parse_role, run_authorize
9
+ from .crawl.spider import Spider
10
+ from .fuzz.dirbuster import fuzz
11
+ from .http.client import HttpClient
12
+ from .http.models import HttpRequest
13
+ from .openapi import load_spec, spec_pages
14
+ from .paramminer import mine
15
+ from .proxy.server import ProxyServer
16
+ from .recon import missing_tools, render_report, run_recon
17
+ from .report.writer import write_console, write_html, write_json
18
+ from .scan.core import SEVERITIES, filter_by_min_severity, filter_by_severities
19
+ from .scan.engine import ScanEngine
20
+ from .toolkit import decode_text, encode_text
21
+ from .tls import audit as tls_audit
22
+ from .updates import fetch_latest_version, install_tools, is_outdated, self_update
23
+
24
+ BANNER = r"""
25
+ _
26
+ ___ _ _| |_ ___ _ _ ___ _ _ ___ ___
27
+ | _| ' \ _/ -_) '_/ -_) '_\ \ / / -_) summit v{version}
28
+ |___|_||_\__\___|_| \___|_| /_\_\ \___| by Pennacle Security
29
+ authorized testing only
30
+ """
31
+
32
+
33
+ def build_parser() -> argparse.ArgumentParser:
34
+ parser = argparse.ArgumentParser(
35
+ prog="summit",
36
+ description="Summit - open-source web security testing platform (proxy, scanner, fuzzer, link hijack detection).",
37
+ )
38
+ parser.add_argument("--version", action="version", version=f"summit {__version__}")
39
+ parser.add_argument("--check-update", action="store_true", help="compare the installed version against the latest GitHub release")
40
+ parser.add_argument("--auto-update", action="store_true", help="update summit in place (git checkout) or print exact install steps")
41
+ parser.add_argument("--install-tools", action="store_true", help="install recon tools (nmap, httpx, subfinder) via the available package manager")
42
+ sub = parser.add_subparsers(dest="command")
43
+
44
+ scan = sub.add_parser("scan", help="crawl a target and run passive + active checks")
45
+ scan.add_argument("url", help="target URL, e.g. https://testsite.local")
46
+ scan.add_argument("--openapi", dest="openapi_path", help="scan an OpenAPI/JSON spec instead of crawling (paths become targets)")
47
+ scan.add_argument("--depth", type=int, default=3, help="max crawl depth (default 3)")
48
+ scan.add_argument("--max-pages", type=int, default=50, help="max pages to visit (default 50)")
49
+ scan.add_argument("--no-active", action="store_true", help="skip active checks (passive only)")
50
+ scan.add_argument("--skip-linkcheck", action="store_true", help="skip broken link / hijack check")
51
+ scan.add_argument("--workers", type=int, default=8, help="parallel check workers (default 8)")
52
+ scan.add_argument("--rate", type=float, default=None, help="max requests per second across all workers, e.g. --rate 5")
53
+ scan.add_argument("--retries", type=int, default=1, help="retries on connection errors and 429/503 (default 1)")
54
+ scan.add_argument("--delay", type=float, default=0.0, help="seconds between requests (be nice)")
55
+ scan.add_argument("--timeout", type=float, default=10.0, help="request timeout in seconds")
56
+ scan.add_argument("--json", dest="json_path", help="write a JSON report to this path")
57
+ scan.add_argument("--html", dest="html_path", help="write an HTML report to this path")
58
+ scan.add_argument("--severity", default=None, help="only report these severities, comma separated: high,critical")
59
+ scan.add_argument("--min-severity", dest="min_severity", default=None,
60
+ choices=list(SEVERITIES), help="report findings at or above this severity")
61
+
62
+ mine_p = sub.add_parser("mine", help="discover hidden parameters on one URL (Param Miner approach)")
63
+ mine_p.add_argument("url")
64
+ mine_p.add_argument("--workers", type=int, default=8)
65
+ mine_p.add_argument("--timeout", type=float, default=10.0)
66
+ mine_p.add_argument("--json", dest="json_path", help="write findings as JSON")
67
+
68
+ auth = sub.add_parser("authorize", help="compare two roles per URL - broken access control (Autorize workflow)")
69
+ auth.add_argument("urls", nargs="+", help="URLs to test (test each with both roles)")
70
+ auth.add_argument("--role", dest="roles", action="append", required=True,
71
+ help="role spec 'name=Header: value', repeatable; first role = privileged baseline, e.g. --role 'admin=Cookie: session=abc'")
72
+ auth.add_argument("--timeout", type=float, default=10.0)
73
+ auth.add_argument("--json", dest="json_path", help="write findings as JSON")
74
+
75
+ fuzz_p = sub.add_parser("fuzz", help="brute-force paths on a target")
76
+ fuzz_p.add_argument("url")
77
+ fuzz_p.add_argument("-w", "--wordlist", help="wordlist file, one entry per line")
78
+ fuzz_p.add_argument("-t", "--threads", type=int, default=10, help="concurrent requests (default 10)")
79
+ fuzz_p.add_argument("-e", "--extensions", default="", help="comma separated, e.g. php,html,bak")
80
+ fuzz_p.add_argument("--rate", type=float, default=None, help="max requests per second")
81
+ fuzz_p.add_argument("--timeout", type=float, default=10.0)
82
+
83
+ proxy_p = sub.add_parser("proxy", help="run the local intercepting proxy")
84
+ proxy_p.add_argument("--bind", default="127.0.0.1")
85
+ proxy_p.add_argument("--port", type=int, default=8080)
86
+ proxy_p.add_argument("--db", default="summit_sitemap.db", help="sqlite site map path")
87
+
88
+ rep = sub.add_parser("repeater", help="send a raw HTTP request from a file (Burp-style)")
89
+ rep.add_argument("file")
90
+ rep.add_argument("--timeout", type=float, default=10.0)
91
+
92
+ rec = sub.add_parser("recon", help="chain subfinder -> httpx -> nmap on a target")
93
+ rec.add_argument("target", help="domain or URL, e.g. testsite.local")
94
+ rec.add_argument("--timeout", type=float, default=120.0, help="per-tool timeout in seconds")
95
+ rec.add_argument("--json", dest="json_path", help="write the recon report as JSON")
96
+
97
+ enc = sub.add_parser("encode", help="encode text (b64, url, hex)")
98
+ enc.add_argument("value")
99
+ enc.add_argument("-f", "--format", dest="encoding", default="b64", choices=["b64", "url", "hex"])
100
+
101
+ dec = sub.add_parser("decode", help="decode text (b64, url, hex, jwt)")
102
+ dec.add_argument("value")
103
+ dec.add_argument("-f", "--format", dest="encoding", default="b64", choices=["b64", "url", "hex", "jwt"])
104
+
105
+ tls_p = sub.add_parser("tls", help="audit a host's certificate and TLS versions")
106
+ tls_p.add_argument("host", help="hostname or URL")
107
+ tls_p.add_argument("--port", type=int, default=443)
108
+ tls_p.add_argument("--timeout", type=float, default=10.0)
109
+ tls_p.add_argument("--json", dest="json_path", help="write findings as JSON")
110
+
111
+ return parser
112
+
113
+
114
+ def _cmd_check_update() -> int:
115
+ print(BANNER.format(version=__version__))
116
+ latest = fetch_latest_version()
117
+ if latest is None:
118
+ print("[!] could not reach the GitHub release API (offline or rate-limited)")
119
+ return 1
120
+ if is_outdated(__version__, latest):
121
+ print(f"[*] update available: {__version__} -> {latest}")
122
+ print("[*] run: summit --auto-update")
123
+ else:
124
+ print(f"[*] summit {__version__} is up to date (latest release: {latest})")
125
+ return 0
126
+
127
+
128
+ def _cmd_auto_update() -> int:
129
+ print(BANNER.format(version=__version__))
130
+ ok, message = self_update()
131
+ print(f"[*] {message}" if ok else f"[!] {message}")
132
+ return 0 if ok else 1
133
+
134
+
135
+ def _cmd_install_tools() -> int:
136
+ print(BANNER.format(version=__version__))
137
+ install_tools()
138
+ return 0
139
+
140
+
141
+ def _severity_filters(args: argparse.Namespace):
142
+ """Parse --severity / --min-severity into (allowed set, floor name)."""
143
+ allowed = None
144
+ if getattr(args, "severity", None):
145
+ allowed = {token.strip().lower() for token in args.severity.split(",") if token.strip()}
146
+ unknown = allowed - set(SEVERITIES)
147
+ if unknown:
148
+ print(f"[!] unknown severity value(s): {', '.join(sorted(unknown))} - valid: {', '.join(SEVERITIES)}")
149
+ raise SystemExit(1)
150
+ return allowed, getattr(args, "min_severity", None)
151
+
152
+
153
+ def _apply_severity_filters(findings, args: argparse.Namespace):
154
+ allowed, minimum = _severity_filters(args)
155
+ if allowed:
156
+ findings = filter_by_severities(findings, allowed)
157
+ if minimum:
158
+ findings = filter_by_min_severity(findings, minimum)
159
+ return findings
160
+
161
+
162
+ def _cmd_scan(args: argparse.Namespace) -> int:
163
+ print(BANNER.format(version=__version__))
164
+ client = HttpClient(timeout=args.timeout, delay=args.delay, rate=args.rate, retries=args.retries)
165
+ if args.openapi_path:
166
+ spec = load_spec(args.openapi_path)
167
+ result = spec_pages(client, spec)
168
+ print(f"[*] openapi spec loaded: {len(result['pages'])} target(s), {len(result['errors'])} error(s)")
169
+ else:
170
+ spider = Spider(client, max_depth=args.depth, max_pages=args.max_pages, delay=args.delay)
171
+ print(f"[*] crawling {args.url} ...")
172
+ result = spider.crawl(args.url)
173
+ print(
174
+ f"[*] visited {len(result['visited'])} page(s), "
175
+ f"{len(result['forms'])} form(s), {len(result['external_links'])} outbound link(s), "
176
+ f"{len(result['errors'])} request error(s)"
177
+ )
178
+ for error in result["errors"][:5]:
179
+ print(f"[!] {error}")
180
+
181
+ engine = ScanEngine(
182
+ client,
183
+ active=not args.no_active,
184
+ workers=args.workers,
185
+ linkcheck_enabled=not args.skip_linkcheck,
186
+ )
187
+ findings = engine.scan(result)
188
+ findings = _apply_severity_filters(findings, args)
189
+ print(
190
+ f"[*] {client.request_count} request(s) in {engine.elapsed:.1f}s "
191
+ f"(workers={args.workers}, rate={args.rate or 'unlimited'}, retries={args.retries})"
192
+ )
193
+ print(f"[*] smart order: {engine.high_signal} high-signal injection point(s) probed first")
194
+ for error in engine.errors[:5]:
195
+ print(f"[!] check error: {error}")
196
+ print(write_console(findings, args.url))
197
+ if args.json_path:
198
+ write_json(findings, args.json_path)
199
+ print(f"[*] JSON report written to {args.json_path}")
200
+ if args.html_path:
201
+ write_html(findings, args.html_path, args.url)
202
+ print(f"[*] HTML report written to {args.html_path}")
203
+ return 0
204
+
205
+
206
+ def _cmd_fuzz(args: argparse.Namespace) -> int:
207
+ wordlist = None
208
+ if args.wordlist:
209
+ wordlist = [line.strip() for line in Path(args.wordlist).read_text(encoding="utf-8").splitlines() if line.strip()]
210
+ extensions = [ext.strip().lstrip(".") for ext in args.extensions.split(",") if ext.strip()]
211
+ client = HttpClient(timeout=args.timeout, rate=args.rate)
212
+ findings = fuzz(client, args.url, wordlist=wordlist, threads=args.threads, extensions=extensions)
213
+ print(write_console(findings, args.url))
214
+ return 0
215
+
216
+
217
+ def _cmd_proxy(args: argparse.Namespace) -> int:
218
+ server = ProxyServer(host=args.bind, port=args.port, db_path=args.db)
219
+ try:
220
+ server.start()
221
+ except KeyboardInterrupt:
222
+ print("\n[*] shutting down")
223
+ server.stop()
224
+ return 0
225
+
226
+
227
+ def _cmd_repeater(args: argparse.Namespace) -> int:
228
+ raw = Path(args.file).read_text(encoding="utf-8")
229
+ request = HttpRequest.from_raw(raw)
230
+ client = HttpClient(timeout=args.timeout)
231
+ print(f"[*] sending {request.method} {request.url}")
232
+ response = client.send(request)
233
+ print(f"HTTP {response.status} ({response.elapsed:.2f}s)")
234
+ for key, value in response.headers.items():
235
+ print(f"{key}: {value}")
236
+ print()
237
+ print(response.text[:5000])
238
+ return 0
239
+
240
+
241
+ def _cmd_mine(args: argparse.Namespace) -> int:
242
+ print(BANNER.format(version=__version__))
243
+ client = HttpClient(timeout=args.timeout)
244
+ print(f"[*] mining hidden parameters on {args.url} ...")
245
+ findings = mine(client, args.url, workers=args.workers)
246
+ print(write_console(findings, args.url))
247
+ if args.json_path:
248
+ write_json(findings, args.json_path)
249
+ print(f"[*] JSON report written to {args.json_path}")
250
+ return 0
251
+
252
+
253
+ def _cmd_authorize(args: argparse.Namespace) -> int:
254
+ print(BANNER.format(version=__version__))
255
+ client = HttpClient(timeout=args.timeout)
256
+ try:
257
+ roles = [parse_role(spec) for spec in args.roles]
258
+ except ValueError as exc:
259
+ print(f"[!] {exc}")
260
+ return 1
261
+ if len(roles) < 2:
262
+ print("[!] authorize needs at least two roles - first is the privileged baseline")
263
+ return 1
264
+ privileged, other = roles[0], roles[1]
265
+ print(f"[*] testing {len(args.urls)} URL(s): '{privileged.name}' vs '{other.name}'")
266
+ results = run_authorize(client, args.urls, privileged, other)
267
+ for result in results:
268
+ print(f" [{result.verdict.upper()}] {result.url}")
269
+ if result.verdict == "broken":
270
+ print(f" {other.name} sees exactly what {privileged.name} sees (HTTP {result.privileged.status}, {len(result.privileged.body)}B)")
271
+ findings = findings_from(results)
272
+ print(write_console(findings, f"{len(args.urls)} URL(s), {privileged.name} vs {other.name}"))
273
+ if args.json_path:
274
+ write_json(findings, args.json_path)
275
+ print(f"[*] JSON report written to {args.json_path}")
276
+ return 0
277
+
278
+
279
+ def _cmd_recon(args: argparse.Namespace) -> int:
280
+ print(BANNER.format(version=__version__))
281
+ missing = missing_tools()
282
+ if missing:
283
+ print(f"[!] tools not on PATH: {', '.join(missing)} - install them with: summit --install-tools")
284
+ print("[*] continuing with the tools that ARE available ...\n")
285
+ try:
286
+ report = run_recon(args.target, timeout=args.timeout)
287
+ except Exception as exc:
288
+ print(f"[!] recon failed: {exc}")
289
+ return 1
290
+ print(render_report(report))
291
+ if args.json_path:
292
+ Path(args.json_path).write_text(json.dumps(report, indent=2), encoding="utf-8")
293
+ print(f"\n[*] JSON report written to {args.json_path}")
294
+ return 0
295
+
296
+
297
+ def _cmd_tls(args: argparse.Namespace) -> int:
298
+ print(BANNER.format(version=__version__))
299
+ try:
300
+ findings = tls_audit(args.host, port=args.port, timeout=args.timeout)
301
+ except ConnectionError as exc:
302
+ print(f"[!] {exc}")
303
+ return 1
304
+ print(write_console(findings, f"{args.host}:{args.port}"))
305
+ if not findings:
306
+ print("[*] no TLS findings - certificate valid, no legacy protocol versions accepted")
307
+ if args.json_path:
308
+ write_json(findings, args.json_path)
309
+ print(f"[*] JSON report written to {args.json_path}")
310
+ return 0
311
+
312
+
313
+ def _cmd_encode(args: argparse.Namespace) -> int:
314
+ try:
315
+ print(encode_text(args.value, args.encoding))
316
+ except ValueError as exc:
317
+ print(f"[!] {exc}")
318
+ return 1
319
+ return 0
320
+
321
+
322
+ def _cmd_decode(args: argparse.Namespace) -> int:
323
+ try:
324
+ print(decode_text(args.value, args.encoding))
325
+ except ValueError as exc:
326
+ print(f"[!] {exc}")
327
+ return 1
328
+ return 0
329
+
330
+
331
+ def main(argv: list[str] | None = None) -> int:
332
+ parser = build_parser()
333
+ args = parser.parse_args(argv)
334
+ try:
335
+ if args.check_update:
336
+ return _cmd_check_update()
337
+ if args.auto_update:
338
+ return _cmd_auto_update()
339
+ if args.install_tools:
340
+ return _cmd_install_tools()
341
+ if args.command == "scan":
342
+ return _cmd_scan(args)
343
+ if args.command == "fuzz":
344
+ return _cmd_fuzz(args)
345
+ if args.command == "proxy":
346
+ return _cmd_proxy(args)
347
+ if args.command == "repeater":
348
+ return _cmd_repeater(args)
349
+ if args.command == "mine":
350
+ return _cmd_mine(args)
351
+ if args.command == "authorize":
352
+ return _cmd_authorize(args)
353
+ if args.command == "recon":
354
+ return _cmd_recon(args)
355
+ if args.command == "tls":
356
+ return _cmd_tls(args)
357
+ if args.command == "encode":
358
+ return _cmd_encode(args)
359
+ if args.command == "decode":
360
+ return _cmd_decode(args)
361
+ except KeyboardInterrupt:
362
+ print("\n[!] interrupted")
363
+ return 130
364
+ print(f"summit {__version__}")
365
+ parser.print_help()
366
+ return 0
File without changes
@@ -0,0 +1,57 @@
1
+ """JavaScript endpoint extraction - the AJAX-spider idea, stdlib edition.
2
+
3
+ Modern applications hide most of their attack surface inside JavaScript:
4
+ fetch() calls, axios calls, XMLHttpRequest targets. A crawler that only
5
+ follows <a href> sees none of it - a systematic false-negative source.
6
+
7
+ This module regex-extracts endpoint URLs from every fetched page (and
8
+ from .js files it references), resolves them against the page URL, and
9
+ hands same-host matches back to the spider queue.
10
+
11
+ Regexes are anchored on the calling APIs themselves (fetch/axios/XHR),
12
+ not on generic string hunting, which keeps the hit rate honest.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from urllib.parse import urljoin
19
+
20
+ # endpoint-extraction patterns; group 1 must be the endpoint
21
+ JS_PATTERNS = [
22
+ re.compile(r"""fetch\(\s*['"]([^'"]+)['"]"""),
23
+ re.compile(r"""axios(?:\.\w+)?\(\s*['"]([^'"]+)['"]"""),
24
+ re.compile(r"""axios\.\w+\(\s*['"]([^'"]+)['"]"""),
25
+ re.compile(r"""\.open\(\s*['"](?:[A-Z]+)['"],\s*['"]([^'"]+)['"]"""),
26
+ ]
27
+
28
+ JS_FILE_PATTERN = re.compile(r"""(?:src|href)\s*=\s*['"]([^'"]+\.js(?:\?[^'"]*)?)['"]""", re.IGNORECASE)
29
+
30
+
31
+ def extract_endpoints(text: str, base_url: str) -> tuple[list[str], list[str]]:
32
+ """Return (endpoints, js_files) found in one document, resolved absolute."""
33
+ endpoints: list[str] = []
34
+ js_files: list[str] = []
35
+ for pattern in JS_PATTERNS:
36
+ for match in pattern.finditer(text):
37
+ endpoints.append(urljoin(base_url, match.group(1)))
38
+ for match in JS_FILE_PATTERN.finditer(text):
39
+ js_files.append(urljoin(base_url, match.group(1)))
40
+ return endpoints, js_files
41
+
42
+
43
+ def extract_from_page(response_text: str, page_url: str, scope_hosts: set[str]) -> tuple[list[str], list[str]]:
44
+ """Extract endpoints from a page, then from any same-host .js file it references.
45
+
46
+ Returns (same_host_endpoints, external_endpoints). Fetching the .js
47
+ files is the caller's job (it has the HTTP client).
48
+ """
49
+ endpoints, js_files = extract_endpoints(response_text, page_url)
50
+ return endpoints, js_files
51
+
52
+
53
+ def in_scope(url: str, scope_hosts: set[str]) -> bool:
54
+ from urllib.parse import urlsplit
55
+
56
+ host = (urlsplit(url).hostname or "").lower().removeprefix("www.")
57
+ return any(host == candidate or host.endswith("." + candidate) for candidate in scope_hosts)