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.
- summit/__init__.py +3 -0
- summit/authorize.py +137 -0
- summit/captcha.py +55 -0
- summit/cli.py +366 -0
- summit/crawl/__init__.py +0 -0
- summit/crawl/js_endpoints.py +57 -0
- summit/crawl/spider.py +154 -0
- summit/cve.py +120 -0
- summit/fuzz/__init__.py +0 -0
- summit/fuzz/dirbuster.py +87 -0
- summit/graphql.py +62 -0
- summit/http/__init__.py +0 -0
- summit/http/client.py +97 -0
- summit/http/models.py +91 -0
- summit/openapi.py +103 -0
- summit/paramminer.py +99 -0
- summit/proxy/__init__.py +0 -0
- summit/proxy/server.py +168 -0
- summit/ratelimit.py +40 -0
- summit/recon.py +143 -0
- summit/report/__init__.py +0 -0
- summit/report/writer.py +124 -0
- summit/scan/__init__.py +0 -0
- summit/scan/core.py +125 -0
- summit/scan/engine.py +159 -0
- summit/scan/linkcheck.py +224 -0
- summit/scan/modules/__init__.py +28 -0
- summit/scan/modules/cacheable.py +52 -0
- summit/scan/modules/cors_reflect.py +46 -0
- summit/scan/modules/crlf.py +44 -0
- summit/scan/modules/csrf.py +47 -0
- summit/scan/modules/directory_listing.py +49 -0
- summit/scan/modules/enumeration.py +55 -0
- summit/scan/modules/headers_grade.py +58 -0
- summit/scan/modules/host_header.py +51 -0
- summit/scan/modules/open_redirect.py +66 -0
- summit/scan/modules/os_command.py +84 -0
- summit/scan/modules/path_traversal.py +45 -0
- summit/scan/modules/sensitive_files.py +59 -0
- summit/scan/modules/sqli.py +81 -0
- summit/scan/modules/sqli_time.py +84 -0
- summit/scan/modules/ssti.py +57 -0
- summit/scan/modules/waf_bypass.py +64 -0
- summit/scan/modules/xss.py +76 -0
- summit/scan/passive.py +273 -0
- summit/smart.py +45 -0
- summit/takeover.py +65 -0
- summit/tls.py +144 -0
- summit/toolkit.py +71 -0
- summit/updates.py +155 -0
- summit/waf.py +54 -0
- summit_scanner-0.6.7.dist-info/METADATA +332 -0
- summit_scanner-0.6.7.dist-info/RECORD +57 -0
- summit_scanner-0.6.7.dist-info/WHEEL +5 -0
- summit_scanner-0.6.7.dist-info/entry_points.txt +2 -0
- summit_scanner-0.6.7.dist-info/licenses/LICENSE +21 -0
- summit_scanner-0.6.7.dist-info/top_level.txt +1 -0
summit/__init__.py
ADDED
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
|
summit/crawl/__init__.py
ADDED
|
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)
|