hackable 1.0.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.
- hackable/__init__.py +3 -0
- hackable/__main__.py +4 -0
- hackable/checks/__init__.py +22 -0
- hackable/checks/cookies.py +65 -0
- hackable/checks/cors.py +68 -0
- hackable/checks/debug.py +43 -0
- hackable/checks/disclosure.py +36 -0
- hackable/checks/exposed_files.py +122 -0
- hackable/checks/headers.py +70 -0
- hackable/checks/methods.py +28 -0
- hackable/checks/open_redirect.py +57 -0
- hackable/checks/rate_limit.py +53 -0
- hackable/checks/robots.py +31 -0
- hackable/checks/securitytxt.py +21 -0
- hackable/checks/sqli.py +152 -0
- hackable/checks/tls.py +89 -0
- hackable/checks/xss.py +83 -0
- hackable/cli.py +117 -0
- hackable/crawler.py +98 -0
- hackable/findings.py +43 -0
- hackable/polite.py +47 -0
- hackable/report.py +244 -0
- hackable/scanner.py +60 -0
- hackable-1.0.0.dist-info/METADATA +104 -0
- hackable-1.0.0.dist-info/RECORD +29 -0
- hackable-1.0.0.dist-info/WHEEL +5 -0
- hackable-1.0.0.dist-info/entry_points.txt +2 -0
- hackable-1.0.0.dist-info/licenses/LICENSE +21 -0
- hackable-1.0.0.dist-info/top_level.txt +1 -0
hackable/checks/sqli.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""SQL injection probes (safe: benign payloads, GET only).
|
|
2
|
+
|
|
3
|
+
Two techniques, both harmless:
|
|
4
|
+
- error-based: a single quote that makes the database complain out loud
|
|
5
|
+
- boolean differential: AND 1=1 vs AND 1=2 must not change the page; when it
|
|
6
|
+
does, input is very likely reaching the query unsanitized (blind SQLi)
|
|
7
|
+
|
|
8
|
+
We never send destructive payloads, stacked queries, or time-based probes.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ..findings import Finding
|
|
12
|
+
|
|
13
|
+
# (path, param) pairs, most likely first. Used when the crawler found no
|
|
14
|
+
# real inputs, so the request budget is spent across endpoints instead of
|
|
15
|
+
# being eaten by one page's params.
|
|
16
|
+
CANDIDATES = [
|
|
17
|
+
("/search", "q"),
|
|
18
|
+
("/search", "search"),
|
|
19
|
+
("/search", "query"),
|
|
20
|
+
("", "q"),
|
|
21
|
+
("", "id"),
|
|
22
|
+
("", "search"),
|
|
23
|
+
("/product", "id"),
|
|
24
|
+
("/user", "id"),
|
|
25
|
+
("/item", "id"),
|
|
26
|
+
("/page", "id"),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
ERROR_SIGS = [
|
|
30
|
+
"you have an error in your sql syntax",
|
|
31
|
+
"warning: mysql",
|
|
32
|
+
"unclosed quotation mark",
|
|
33
|
+
"quoted string not properly terminated",
|
|
34
|
+
"pg_query()",
|
|
35
|
+
"postgresql",
|
|
36
|
+
"sqlite3",
|
|
37
|
+
"ora-",
|
|
38
|
+
"odbc",
|
|
39
|
+
"sqlstate",
|
|
40
|
+
"jdbc",
|
|
41
|
+
"database error",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _looks_like_sql_error(text):
|
|
46
|
+
t = text.lower()
|
|
47
|
+
return any(sig in t for sig in ERROR_SIGS)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _pairs(base, targets):
|
|
51
|
+
"""Discovered (url, params) first, then guesses. All absolute URLs."""
|
|
52
|
+
pairs, seen = [], set()
|
|
53
|
+
for url, params in (targets or [])[:8]:
|
|
54
|
+
for param in params[:3]:
|
|
55
|
+
key = (url, param)
|
|
56
|
+
if key not in seen:
|
|
57
|
+
seen.add(key)
|
|
58
|
+
pairs.append(key)
|
|
59
|
+
for path, param in CANDIDATES[:7]:
|
|
60
|
+
key = (base + path, param)
|
|
61
|
+
if key not in seen:
|
|
62
|
+
seen.add(key)
|
|
63
|
+
pairs.append(key)
|
|
64
|
+
return pairs[:14]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _error_based(base, http, pairs):
|
|
68
|
+
for url, param in pairs:
|
|
69
|
+
baseline = http.get(url, params={param: "1"})
|
|
70
|
+
if baseline is None:
|
|
71
|
+
continue
|
|
72
|
+
probe = http.get(url, params={param: "1'"})
|
|
73
|
+
if probe is None:
|
|
74
|
+
continue
|
|
75
|
+
full_url = url + "?%s=1'" % param
|
|
76
|
+
if _looks_like_sql_error(probe.text):
|
|
77
|
+
return [Finding(
|
|
78
|
+
check="sqli",
|
|
79
|
+
severity="critical",
|
|
80
|
+
title="SQL injection in %s" % url.replace(base, "") or "/",
|
|
81
|
+
meaning="Your database complained out loud when I typed a single "
|
|
82
|
+
"quote into the '%s' field. That means an attacker can talk "
|
|
83
|
+
"directly to your database: read every table, and often take "
|
|
84
|
+
"over the server." % param,
|
|
85
|
+
fix="Never build database queries by gluing strings together. "
|
|
86
|
+
"Use parameterized queries / prepared statements in your "
|
|
87
|
+
"framework, and validate input.",
|
|
88
|
+
evidence=probe.text.strip().replace("\n", " ")[:160],
|
|
89
|
+
url=full_url,
|
|
90
|
+
)]
|
|
91
|
+
if baseline.status_code < 500 <= probe.status_code:
|
|
92
|
+
return [Finding(
|
|
93
|
+
check="sqli",
|
|
94
|
+
severity="medium",
|
|
95
|
+
title="Possible SQL injection in %s" % url.replace(base, "") or "/",
|
|
96
|
+
meaning="The page works fine normally but crashes with a server "
|
|
97
|
+
"error when I type a quote into '%s'. That pattern often means "
|
|
98
|
+
"user input reaches the database unsanitized." % param,
|
|
99
|
+
fix="Have a developer check how the '%s' parameter is used in "
|
|
100
|
+
"database queries, and switch to parameterized queries." % param,
|
|
101
|
+
evidence="HTTP %s -> HTTP %s on quote payload"
|
|
102
|
+
% (baseline.status_code, probe.status_code),
|
|
103
|
+
url=full_url,
|
|
104
|
+
)]
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _boolean_based(base, http, pairs):
|
|
109
|
+
"""Differential check: the page must not change between AND 1=1 / 1=2.
|
|
110
|
+
|
|
111
|
+
Conservative: the two baselines must agree first, the true-condition must
|
|
112
|
+
match the baseline closely, and the false-condition must differ clearly.
|
|
113
|
+
"""
|
|
114
|
+
for url, param in pairs[:3]:
|
|
115
|
+
b1 = http.get(url, params={param: "1"})
|
|
116
|
+
b2 = http.get(url, params={param: "1"})
|
|
117
|
+
if b1 is None or b2 is None or len(b1.text) != len(b2.text):
|
|
118
|
+
continue
|
|
119
|
+
n = len(b1.text)
|
|
120
|
+
if n == 0:
|
|
121
|
+
continue
|
|
122
|
+
true = http.get(url, params={param: "1 AND 1=1"})
|
|
123
|
+
false = http.get(url, params={param: "1 AND 1=2"})
|
|
124
|
+
if true is None or false is None:
|
|
125
|
+
continue
|
|
126
|
+
true_diff = abs(len(true.text) - n)
|
|
127
|
+
false_diff = abs(len(false.text) - n)
|
|
128
|
+
if true_diff < 0.05 * n and false_diff > max(100, 0.3 * n):
|
|
129
|
+
return [Finding(
|
|
130
|
+
check="sqli",
|
|
131
|
+
severity="high",
|
|
132
|
+
title="Likely blind SQL injection in %s" % url.replace(base, "") or "/",
|
|
133
|
+
meaning="The page looks identical for a true database condition "
|
|
134
|
+
"but changes clearly for a false one. No error is shown, yet "
|
|
135
|
+
"the '%s' value is very likely reaching your SQL query, which "
|
|
136
|
+
"lets an attacker extract data bit by bit." % param,
|
|
137
|
+
fix="Use parameterized queries / prepared statements for the "
|
|
138
|
+
"'%s' parameter, and validate that it is the expected type." % param,
|
|
139
|
+
evidence="page length %d -> %d on AND 1=2" % (n, len(false.text)),
|
|
140
|
+
url=url + "?%s=1 AND 1=2" % param,
|
|
141
|
+
)]
|
|
142
|
+
return []
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def run(base, http, targets=None):
|
|
146
|
+
pairs = _pairs(base, targets)
|
|
147
|
+
findings = _error_based(base, http, pairs)
|
|
148
|
+
if findings:
|
|
149
|
+
return findings # one confirmed SQLi is enough
|
|
150
|
+
# boolean differential only on real discovered inputs, to limit requests
|
|
151
|
+
discovered = [(u, p) for (u, p) in pairs if targets and u in [t[0] for t in targets]]
|
|
152
|
+
return _boolean_based(base, http, discovered or pairs[:3])
|
hackable/checks/tls.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""TLS / HTTPS posture."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import socket
|
|
5
|
+
import ssl
|
|
6
|
+
|
|
7
|
+
from ..findings import Finding
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _cert_info(host, port):
|
|
11
|
+
ctx = ssl.create_default_context()
|
|
12
|
+
with socket.create_connection((host, port), timeout=8) as sock:
|
|
13
|
+
with ctx.wrap_socket(sock, server_hostname=host) as ssock:
|
|
14
|
+
cert = ssock.getpeercert()
|
|
15
|
+
version = ssock.version()
|
|
16
|
+
return cert, version
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run(base, http):
|
|
20
|
+
if not base.startswith("https://"):
|
|
21
|
+
return [
|
|
22
|
+
Finding(
|
|
23
|
+
check="tls",
|
|
24
|
+
severity="medium",
|
|
25
|
+
title="Site does not use HTTPS",
|
|
26
|
+
meaning="Traffic between your visitors and your server travels in plain "
|
|
27
|
+
"text. Anyone on the same network (coffee shop Wi-Fi, compromised "
|
|
28
|
+
"router) can read passwords and cookies.",
|
|
29
|
+
fix="Put the site behind HTTPS with a free certificate (e.g. Let's "
|
|
30
|
+
"Encrypt via your host or certbot), then redirect all HTTP to HTTPS.",
|
|
31
|
+
url=base + "/",
|
|
32
|
+
)
|
|
33
|
+
]
|
|
34
|
+
host = base.split("://", 1)[1].split("/", 1)[0].split(":")[0]
|
|
35
|
+
try:
|
|
36
|
+
cert, version = _cert_info(host, 443)
|
|
37
|
+
except Exception:
|
|
38
|
+
return [
|
|
39
|
+
Finding(
|
|
40
|
+
check="tls",
|
|
41
|
+
severity="info",
|
|
42
|
+
title="Could not verify TLS certificate",
|
|
43
|
+
meaning="The TLS handshake did not complete from here, so the "
|
|
44
|
+
"certificate could not be checked. This may be a network issue.",
|
|
45
|
+
fix="Check the certificate manually and ensure it is valid.",
|
|
46
|
+
url=base + "/",
|
|
47
|
+
)
|
|
48
|
+
]
|
|
49
|
+
findings = []
|
|
50
|
+
try:
|
|
51
|
+
not_after = cert.get("notAfter")
|
|
52
|
+
exp = datetime.datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z")
|
|
53
|
+
days = (exp - datetime.datetime.utcnow()).days
|
|
54
|
+
if days < 0:
|
|
55
|
+
findings.append(
|
|
56
|
+
Finding(
|
|
57
|
+
check="tls", severity="critical",
|
|
58
|
+
title="TLS certificate has expired",
|
|
59
|
+
meaning="Browsers show visitors a full-page security warning, and "
|
|
60
|
+
"the encryption cannot be trusted.",
|
|
61
|
+
fix="Renew the certificate immediately and automate renewal.",
|
|
62
|
+
evidence="expired %d days ago" % abs(days), url=base + "/",
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
elif days < 30:
|
|
66
|
+
findings.append(
|
|
67
|
+
Finding(
|
|
68
|
+
check="tls", severity="medium",
|
|
69
|
+
title="TLS certificate expires in %d days" % days,
|
|
70
|
+
meaning="When it lapses, visitors get scary browser warnings and "
|
|
71
|
+
"may not come back.",
|
|
72
|
+
fix="Renew soon, and automate renewal so it never lapses.",
|
|
73
|
+
evidence="expires %s" % not_after, url=base + "/",
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
if version and version in ("TLSv1", "TLSv1.1", "SSLv2", "SSLv3"):
|
|
79
|
+
findings.append(
|
|
80
|
+
Finding(
|
|
81
|
+
check="tls", severity="high",
|
|
82
|
+
title="Outdated TLS version negotiated (%s)" % version,
|
|
83
|
+
meaning="Old TLS versions have known attacks that let eavesdroppers "
|
|
84
|
+
"decrypt traffic.",
|
|
85
|
+
fix="Disable everything below TLS 1.2 on your server.",
|
|
86
|
+
evidence="negotiated %s" % version, url=base + "/",
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
return findings
|
hackable/checks/xss.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Reflected cross-site scripting (XSS) probe.
|
|
2
|
+
|
|
3
|
+
We inject a unique, inert marker tag and check whether it comes back
|
|
4
|
+
unescaped. We never execute JavaScript.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import secrets
|
|
8
|
+
import string
|
|
9
|
+
|
|
10
|
+
from ..findings import Finding
|
|
11
|
+
|
|
12
|
+
CANDIDATES = [
|
|
13
|
+
("/search", "q"),
|
|
14
|
+
("/hello", "name"),
|
|
15
|
+
("/greet", "name"),
|
|
16
|
+
("", "q"),
|
|
17
|
+
("", "search"),
|
|
18
|
+
("/user", "name"),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _pairs(base, targets):
|
|
23
|
+
pairs, seen = [], set()
|
|
24
|
+
for url, params in (targets or [])[:8]:
|
|
25
|
+
for param in params[:3]:
|
|
26
|
+
key = (url, param)
|
|
27
|
+
if key not in seen:
|
|
28
|
+
seen.add(key)
|
|
29
|
+
pairs.append(key)
|
|
30
|
+
for path, param in CANDIDATES:
|
|
31
|
+
key = (base + path, param)
|
|
32
|
+
if key not in seen:
|
|
33
|
+
seen.add(key)
|
|
34
|
+
pairs.append(key)
|
|
35
|
+
return pairs[:14]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run(base, http, targets=None):
|
|
39
|
+
findings = []
|
|
40
|
+
token = "hkx" + "".join(
|
|
41
|
+
secrets.choice(string.ascii_lowercase + string.digits) for _ in range(6)
|
|
42
|
+
)
|
|
43
|
+
payload = "<%s>" % token
|
|
44
|
+
for url, param in _pairs(base, targets):
|
|
45
|
+
r = http.get(url, params={param: payload})
|
|
46
|
+
if r is None:
|
|
47
|
+
continue
|
|
48
|
+
body = r.text
|
|
49
|
+
path = url.replace(base, "") or "/"
|
|
50
|
+
if payload in body:
|
|
51
|
+
findings.append(
|
|
52
|
+
Finding(
|
|
53
|
+
check="xss",
|
|
54
|
+
severity="high",
|
|
55
|
+
title="Cross-site scripting in %s" % (path or "/"),
|
|
56
|
+
meaning="Whatever I typed into '%s' came straight back inside the "
|
|
57
|
+
"page, unescaped. An attacker can replace my text with a "
|
|
58
|
+
"script that runs in every visitor's browser: stealing logins, "
|
|
59
|
+
"defacing the page, or redirecting to malware." % param,
|
|
60
|
+
fix="Escape all user input before putting it into HTML "
|
|
61
|
+
"(your template engine usually does this if you let it), and "
|
|
62
|
+
"add a Content-Security-Policy header as a seatbelt.",
|
|
63
|
+
evidence="payload reflected unescaped at %s" % url,
|
|
64
|
+
url=url + "?%s=%s" % (param, payload),
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
return findings
|
|
68
|
+
if token in body:
|
|
69
|
+
findings.append(
|
|
70
|
+
Finding(
|
|
71
|
+
check="xss",
|
|
72
|
+
severity="low",
|
|
73
|
+
title="User input reflected in %s (encoded)" % (path or "/"),
|
|
74
|
+
meaning="Your input appears in the page but is encoded, so it "
|
|
75
|
+
"cannot run as a script today. Still worth a look: one template "
|
|
76
|
+
"change could un-encode it.",
|
|
77
|
+
fix="Keep output encoding on for this field and add automated "
|
|
78
|
+
"XSS tests.",
|
|
79
|
+
url=url,
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
return findings
|
|
83
|
+
return findings
|
hackable/cli.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""CLI: hackable <target>"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import __version__
|
|
7
|
+
from .findings import score_findings
|
|
8
|
+
from .polite import PoliteClient
|
|
9
|
+
from .checks import CHECKS
|
|
10
|
+
from .report import render_html, render_json, render_sarif, render_terminal
|
|
11
|
+
from .scanner import scan
|
|
12
|
+
|
|
13
|
+
CONSENT = """\
|
|
14
|
+
hackable will send ~100 harmless test requests to {target}.
|
|
15
|
+
It never tries to break anything, delete data, or log in as anyone.
|
|
16
|
+
|
|
17
|
+
Only scan apps you own or have explicit permission to test.
|
|
18
|
+
Scanning without permission may be illegal.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_parser():
|
|
23
|
+
p = argparse.ArgumentParser(
|
|
24
|
+
prog="hackable",
|
|
25
|
+
description="Hack yourself before they do. One-command website security "
|
|
26
|
+
"check in plain English.",
|
|
27
|
+
)
|
|
28
|
+
p.add_argument("target", help="URL of your app, e.g. https://myapp.com")
|
|
29
|
+
p.add_argument("--yes", "-y", action="store_true",
|
|
30
|
+
help="skip the permission confirmation")
|
|
31
|
+
p.add_argument("--json", action="store_true", help="print machine-readable JSON")
|
|
32
|
+
p.add_argument("--sarif", metavar="FILE",
|
|
33
|
+
help="write SARIF 2.1.0 output to FILE (GitHub code scanning)")
|
|
34
|
+
p.add_argument("--html", metavar="FILE",
|
|
35
|
+
help="also write a self-contained HTML report to FILE")
|
|
36
|
+
p.add_argument("--only", metavar="CHECKS",
|
|
37
|
+
help="run only these checks, comma-separated "
|
|
38
|
+
"(e.g. sqli,xss). choices: " + ",".join(c[0] for c in CHECKS))
|
|
39
|
+
p.add_argument("--skip", metavar="CHECKS",
|
|
40
|
+
help="skip these checks, comma-separated")
|
|
41
|
+
p.add_argument("--color", choices=["auto", "always", "never"], default="auto")
|
|
42
|
+
p.add_argument("--fail-under", type=int, metavar="SCORE",
|
|
43
|
+
help="exit 1 if the score is below SCORE (for CI)")
|
|
44
|
+
p.add_argument("--max-requests", type=int, default=150)
|
|
45
|
+
p.add_argument("--timeout", type=float, default=8)
|
|
46
|
+
p.add_argument("--delay", type=float, default=0.15,
|
|
47
|
+
help="seconds between requests (be nice)")
|
|
48
|
+
p.add_argument("--version", action="version", version="hackable " + __version__)
|
|
49
|
+
return p
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main(argv=None):
|
|
53
|
+
args = build_parser().parse_args(argv)
|
|
54
|
+
|
|
55
|
+
if not args.yes and sys.stdin.isatty():
|
|
56
|
+
print(CONSENT.format(target=args.target))
|
|
57
|
+
try:
|
|
58
|
+
answer = input("Continue? [y/N] ").strip().lower()
|
|
59
|
+
except (EOFError, KeyboardInterrupt):
|
|
60
|
+
print("\nAborted.")
|
|
61
|
+
return 130
|
|
62
|
+
if answer not in ("y", "yes"):
|
|
63
|
+
print("Aborted. Only ever scan with permission.")
|
|
64
|
+
return 130
|
|
65
|
+
elif not args.yes:
|
|
66
|
+
print("Not a terminal: pass --yes to confirm you have permission to scan.",
|
|
67
|
+
file=sys.stderr)
|
|
68
|
+
return 2
|
|
69
|
+
|
|
70
|
+
http = PoliteClient(delay=args.delay, timeout=args.timeout,
|
|
71
|
+
max_requests=args.max_requests)
|
|
72
|
+
|
|
73
|
+
def split(s):
|
|
74
|
+
return {c.strip() for c in s.split(",") if c.strip()} if s else None
|
|
75
|
+
|
|
76
|
+
include, exclude = split(args.only), split(args.skip)
|
|
77
|
+
known = {c[0] for c in CHECKS}
|
|
78
|
+
for cid in (include or set()) | (exclude or set()):
|
|
79
|
+
if cid not in known:
|
|
80
|
+
print("Unknown check: %s (choices: %s)" % (cid, ",".join(sorted(known))),
|
|
81
|
+
file=sys.stderr)
|
|
82
|
+
return 2
|
|
83
|
+
|
|
84
|
+
def on_check(label):
|
|
85
|
+
if not args.json:
|
|
86
|
+
print(" checking %-28s" % label, flush=True)
|
|
87
|
+
|
|
88
|
+
base, findings, elapsed, req_count = scan(
|
|
89
|
+
args.target, http, on_check=on_check, include=include, exclude=exclude)
|
|
90
|
+
|
|
91
|
+
use_color = (
|
|
92
|
+
args.color == "always"
|
|
93
|
+
or (args.color == "auto" and sys.stdout.isatty())
|
|
94
|
+
)
|
|
95
|
+
if args.json:
|
|
96
|
+
print(render_json(findings, base, elapsed, req_count))
|
|
97
|
+
else:
|
|
98
|
+
print(render_terminal(findings, base, elapsed, req_count, use_color=use_color))
|
|
99
|
+
|
|
100
|
+
if args.sarif:
|
|
101
|
+
with open(args.sarif, "w") as fh:
|
|
102
|
+
fh.write(render_sarif(findings, base, elapsed, req_count))
|
|
103
|
+
print("SARIF report written to %s" % args.sarif)
|
|
104
|
+
|
|
105
|
+
if args.html:
|
|
106
|
+
with open(args.html, "w") as fh:
|
|
107
|
+
fh.write(render_html(findings, base, elapsed, req_count))
|
|
108
|
+
print("HTML report written to %s" % args.html)
|
|
109
|
+
|
|
110
|
+
score, _grade = score_findings(findings)
|
|
111
|
+
if args.fail_under is not None and score < args.fail_under:
|
|
112
|
+
return 1
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
sys.exit(main())
|
hackable/crawler.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Mini-crawler: discover the app's real inputs before probing.
|
|
2
|
+
|
|
3
|
+
Guessing common parameter names (q, id, search...) works, but testing the
|
|
4
|
+
app's actual links and forms is far more effective. The crawler walks
|
|
5
|
+
same-origin pages breadth-first and returns (url, [param names]) pairs that
|
|
6
|
+
the injection checks probe first.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from html.parser import HTMLParser
|
|
10
|
+
from urllib.parse import urljoin, urlparse, parse_qsl
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _PageParser(HTMLParser):
|
|
14
|
+
def __init__(self):
|
|
15
|
+
super().__init__()
|
|
16
|
+
self.links = []
|
|
17
|
+
self.forms = [] # list of (action, method, [input names])
|
|
18
|
+
self._current = None
|
|
19
|
+
|
|
20
|
+
def handle_starttag(self, tag, attrs):
|
|
21
|
+
a = dict(attrs)
|
|
22
|
+
if tag == "a" and a.get("href"):
|
|
23
|
+
self.links.append(a["href"])
|
|
24
|
+
elif tag == "form":
|
|
25
|
+
self._current = (a.get("action", ""),
|
|
26
|
+
a.get("method", "get").lower(), [])
|
|
27
|
+
self.forms.append(self._current)
|
|
28
|
+
elif tag in ("input", "textarea", "select") and self._current is not None:
|
|
29
|
+
if a.get("name"):
|
|
30
|
+
self._current[2].append(a["name"])
|
|
31
|
+
|
|
32
|
+
def handle_endtag(self, tag):
|
|
33
|
+
if tag == "form":
|
|
34
|
+
self._current = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _same_origin(a, b):
|
|
38
|
+
pa, pb = urlparse(a), urlparse(b)
|
|
39
|
+
return (pa.scheme, pa.hostname, pa.port) == (pb.scheme, pb.hostname, pb.port)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _clean(url):
|
|
43
|
+
p = urlparse(url)
|
|
44
|
+
return p.scheme + "://" + p.netloc + p.path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def discover(base, http, max_pages=20):
|
|
48
|
+
"""Crawl same-origin pages, return [(url, [params...]), ...]."""
|
|
49
|
+
seen = set()
|
|
50
|
+
queue = [base + "/"]
|
|
51
|
+
found = []
|
|
52
|
+
seen_pairs = set()
|
|
53
|
+
pages = 0
|
|
54
|
+
|
|
55
|
+
while queue and pages < max_pages:
|
|
56
|
+
url = queue.pop(0)
|
|
57
|
+
if url in seen:
|
|
58
|
+
continue
|
|
59
|
+
seen.add(url)
|
|
60
|
+
r = http.get(url)
|
|
61
|
+
pages += 1
|
|
62
|
+
if r is None or r.status_code != 200:
|
|
63
|
+
continue
|
|
64
|
+
if "html" not in r.headers.get("content-type", "").lower():
|
|
65
|
+
continue
|
|
66
|
+
parser = _PageParser()
|
|
67
|
+
try:
|
|
68
|
+
parser.feed(r.text)
|
|
69
|
+
except Exception:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
for href in parser.links:
|
|
73
|
+
absolute = urljoin(url, href).split("#")[0]
|
|
74
|
+
if not _same_origin(absolute, base):
|
|
75
|
+
continue
|
|
76
|
+
clean = _clean(absolute)
|
|
77
|
+
query = parse_qsl(urlparse(absolute).query)
|
|
78
|
+
if query:
|
|
79
|
+
key = (clean, tuple(sorted(n for n, _ in query)))
|
|
80
|
+
if key not in seen_pairs:
|
|
81
|
+
seen_pairs.add(key)
|
|
82
|
+
found.append((clean, [n for n, _ in query]))
|
|
83
|
+
if clean not in seen and len(queue) < max_pages * 3:
|
|
84
|
+
queue.append(clean)
|
|
85
|
+
|
|
86
|
+
for action, _method, inputs in parser.forms:
|
|
87
|
+
if not inputs:
|
|
88
|
+
continue
|
|
89
|
+
absolute = urljoin(url, action or url).split("#")[0]
|
|
90
|
+
if not _same_origin(absolute, base):
|
|
91
|
+
continue
|
|
92
|
+
clean = _clean(absolute)
|
|
93
|
+
key = (clean, tuple(sorted(inputs)))
|
|
94
|
+
if key not in seen_pairs:
|
|
95
|
+
seen_pairs.add(key)
|
|
96
|
+
found.append((clean, inputs))
|
|
97
|
+
|
|
98
|
+
return found
|
hackable/findings.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Findings: the unit of a hackable report."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
SEVERITIES = ("critical", "high", "medium", "low", "info")
|
|
6
|
+
|
|
7
|
+
SEVERITY_WEIGHT = {
|
|
8
|
+
"critical": 25,
|
|
9
|
+
"high": 15,
|
|
10
|
+
"medium": 8,
|
|
11
|
+
"low": 3,
|
|
12
|
+
"info": 0,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Finding:
|
|
18
|
+
check: str # check id, e.g. "sqli"
|
|
19
|
+
severity: str # one of SEVERITIES
|
|
20
|
+
title: str # short headline
|
|
21
|
+
meaning: str # plain-English: what this means for a non-expert
|
|
22
|
+
fix: str # plain-English: how to fix it
|
|
23
|
+
evidence: str = ""
|
|
24
|
+
url: str = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def score_findings(findings):
|
|
28
|
+
"""Return (score 0-100, grade A-F)."""
|
|
29
|
+
score = 100
|
|
30
|
+
for f in findings:
|
|
31
|
+
score -= SEVERITY_WEIGHT.get(f.severity, 0)
|
|
32
|
+
score = max(0, score)
|
|
33
|
+
if score >= 90:
|
|
34
|
+
grade = "A"
|
|
35
|
+
elif score >= 75:
|
|
36
|
+
grade = "B"
|
|
37
|
+
elif score >= 60:
|
|
38
|
+
grade = "C"
|
|
39
|
+
elif score >= 40:
|
|
40
|
+
grade = "D"
|
|
41
|
+
else:
|
|
42
|
+
grade = "F"
|
|
43
|
+
return score, grade
|
hackable/polite.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""A polite HTTP client: slow, identifiable, bounded.
|
|
2
|
+
|
|
3
|
+
hackable only ever sends harmless probe requests (no destructive payloads,
|
|
4
|
+
no DoS-style flooding). This client enforces that politeness in one place:
|
|
5
|
+
a small delay between requests, a hard cap on total requests, timeouts,
|
|
6
|
+
and an honest User-Agent.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
USER_AGENT = (
|
|
14
|
+
"hackable/1.0.0 (+https://github.com/Shifu34/hackable; "
|
|
15
|
+
"automated security self-check; scans only with owner permission)"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PoliteClient:
|
|
20
|
+
def __init__(self, delay=0.15, timeout=8, max_requests=150):
|
|
21
|
+
self.session = requests.Session()
|
|
22
|
+
self.session.headers["User-Agent"] = USER_AGENT
|
|
23
|
+
self.delay = delay
|
|
24
|
+
self.timeout = timeout
|
|
25
|
+
self.max_requests = max_requests
|
|
26
|
+
self.count = 0
|
|
27
|
+
|
|
28
|
+
def request(self, method, url, **kwargs):
|
|
29
|
+
if self.count >= self.max_requests:
|
|
30
|
+
raise RuntimeError("request budget exceeded")
|
|
31
|
+
if self.count:
|
|
32
|
+
time.sleep(self.delay)
|
|
33
|
+
kwargs.setdefault("timeout", self.timeout)
|
|
34
|
+
self.count += 1
|
|
35
|
+
try:
|
|
36
|
+
return self.session.request(method, url, **kwargs)
|
|
37
|
+
except requests.RequestException:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
def get(self, url, **kwargs):
|
|
41
|
+
return self.request("GET", url, **kwargs)
|
|
42
|
+
|
|
43
|
+
def post(self, url, **kwargs):
|
|
44
|
+
return self.request("POST", url, **kwargs)
|
|
45
|
+
|
|
46
|
+
def options(self, url, **kwargs):
|
|
47
|
+
return self.request("OPTIONS", url, **kwargs)
|