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 ADDED
@@ -0,0 +1,3 @@
1
+ """hackable: hack yourself before they do."""
2
+
3
+ __version__ = "1.0.0"
hackable/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,22 @@
1
+ """Security checks. Each exposes run(base, http) -> list[Finding].
2
+ Injection checks (sqli, xss, open_redirect) also accept an optional third
3
+ argument: discovered (url, [params]) pairs from the crawler."""
4
+
5
+ from . import cookies, cors, debug, disclosure, exposed_files, headers, methods, open_redirect, rate_limit, robots, securitytxt, sqli, tls, xss
6
+
7
+ CHECKS = [
8
+ ("headers", "security headers", headers.run),
9
+ ("robots", "robots.txt", robots.run),
10
+ ("disclosure", "version disclosure", disclosure.run),
11
+ ("exposed_files", "exposed sensitive files", exposed_files.run),
12
+ ("debug", "debug mode / stack traces", debug.run),
13
+ ("cors", "CORS misconfiguration", cors.run),
14
+ ("methods", "dangerous HTTP methods", methods.run),
15
+ ("cookies", "cookie security flags", cookies.run),
16
+ ("sqli", "SQL injection", sqli.run),
17
+ ("xss", "cross-site scripting", xss.run),
18
+ ("open_redirect", "open redirects", open_redirect.run),
19
+ ("rate_limit", "login rate limiting", rate_limit.run),
20
+ ("tls", "TLS / HTTPS", tls.run),
21
+ ("securitytxt", "security.txt", securitytxt.run),
22
+ ]
@@ -0,0 +1,65 @@
1
+ """Check that cookies, especially session cookies, carry security flags."""
2
+
3
+ from ..findings import Finding
4
+
5
+ FLAG_ADVICE = {
6
+ "secure": ("medium", "Cookie without the Secure flag",
7
+ "This cookie can be sent over plain HTTP, where anyone on the "
8
+ "network can read or steal it.",
9
+ "Fix: set the Secure attribute so the cookie is only ever "
10
+ "sent over HTTPS."),
11
+ "httponly": ("low", "Cookie without the HttpOnly flag",
12
+ "JavaScript running in the page can read this cookie, which "
13
+ "makes session theft via XSS much easier.",
14
+ "Fix: set the HttpOnly attribute so JavaScript cannot read "
15
+ "the cookie. This blunts XSS-based session theft."),
16
+ "samesite": ("low", "Cookie without the SameSite attribute",
17
+ "The browser sends this cookie on cross-site requests, which "
18
+ "enables cross-site request forgery attacks.",
19
+ "Fix: set SameSite=Lax (or Strict) to block the cookie on "
20
+ "cross-site requests and reduce CSRF risk."),
21
+ }
22
+
23
+
24
+ def _raw_cookies(response):
25
+ try:
26
+ return response.raw.headers.getlist("set-cookie")
27
+ except Exception:
28
+ pass
29
+ single = response.headers.get("set-cookie")
30
+ return [single] if single else []
31
+
32
+
33
+ def run(base, http):
34
+ r = http.get(base + "/")
35
+ if r is None:
36
+ return []
37
+ raw = _raw_cookies(r)
38
+ if not raw:
39
+ return []
40
+
41
+ is_https = base.startswith("https://")
42
+ missing = {"secure": [], "httponly": [], "samesite": []}
43
+ for cookie in raw:
44
+ parts = [p.strip() for p in cookie.split(";")]
45
+ name = parts[0].split("=", 1)[0] or "(unnamed)"
46
+ flags = {p.split("=", 1)[0].lower() for p in parts[1:]}
47
+ for flag in missing:
48
+ if flag == "secure" and not is_https:
49
+ continue # Secure only makes sense over HTTPS
50
+ if flag not in flags:
51
+ missing[flag].append(name)
52
+
53
+ findings = []
54
+ for flag, names in missing.items():
55
+ if not names:
56
+ continue
57
+ severity, title, meaning, fix = FLAG_ADVICE[flag]
58
+ findings.append(Finding(
59
+ check="cookies", severity=severity,
60
+ title=f"{title}: {', '.join(names[:3])}",
61
+ meaning=meaning,
62
+ url=base + "/", evidence=f"Set-Cookie flags seen: {flag} missing",
63
+ fix=fix,
64
+ ))
65
+ return findings
@@ -0,0 +1,68 @@
1
+ """CORS misconfiguration."""
2
+
3
+ from ..findings import Finding
4
+
5
+ EVIL = "https://evil-hackable-test.example.com"
6
+
7
+
8
+ def _check_response(r):
9
+ acao = r.headers.get("Access-Control-Allow-Origin", "")
10
+ acac = r.headers.get("Access-Control-Allow-Credentials", "").lower() == "true"
11
+ if acao == EVIL and acac:
12
+ return "critical"
13
+ if acao == EVIL or (acao == "*" and acac):
14
+ return "high"
15
+ if acao == "*":
16
+ return "info"
17
+ return None
18
+
19
+
20
+ def run(base, http):
21
+ r = http.options(base + "/", headers={"Origin": EVIL})
22
+ verdict = _check_response(r) if r is not None else None
23
+ if verdict is None:
24
+ r = http.get(base + "/", headers={"Origin": EVIL})
25
+ verdict = _check_response(r) if r is not None else None
26
+ if verdict is None:
27
+ return []
28
+ if verdict == "critical":
29
+ return [
30
+ Finding(
31
+ check="cors",
32
+ severity="critical",
33
+ title="Any website can make authenticated requests as your users",
34
+ meaning="Your server tells browsers that evil-hackable-test.example.com "
35
+ "may read responses AND send the victim's cookies along. A malicious "
36
+ "site can silently act as your logged-in users.",
37
+ fix="Never reflect arbitrary origins with Access-Control-Allow-Credentials. "
38
+ "Use an explicit allowlist of your own domains.",
39
+ evidence="Access-Control-Allow-Origin: %s + Allow-Credentials: true" % EVIL,
40
+ url=base + "/",
41
+ )
42
+ ]
43
+ if verdict == "high":
44
+ return [
45
+ Finding(
46
+ check="cors",
47
+ severity="high",
48
+ title="CORS trusts arbitrary websites",
49
+ meaning="Your server accepts cross-origin requests from any site I "
50
+ "named. Depending on your auth model, a malicious page could read "
51
+ "private API responses.",
52
+ fix="Replace the wildcard/reflected origin with an explicit allowlist "
53
+ "of domains you own.",
54
+ url=base + "/",
55
+ )
56
+ ]
57
+ return [
58
+ Finding(
59
+ check="cors",
60
+ severity="info",
61
+ title="CORS allows any origin (no credentials)",
62
+ meaning="Any website can read your public API responses. That is fine for "
63
+ "public data, but double-check nothing private is served this way.",
64
+ fix="If all responses are public, no action needed. Otherwise restrict "
65
+ "Access-Control-Allow-Origin to your own domains.",
66
+ url=base + "/",
67
+ )
68
+ ]
@@ -0,0 +1,43 @@
1
+ """Debug mode / stack traces exposed on error pages."""
2
+
3
+ import secrets
4
+
5
+ from ..findings import Finding
6
+
7
+ MARKERS = [
8
+ "traceback (most recent call last)",
9
+ "django.core.exceptions",
10
+ "werkzeug.debug",
11
+ "raise valueerror",
12
+ 'at ".rb:',
13
+ ".java:",
14
+ "system.nullreferenceexception",
15
+ "stack trace:",
16
+ "#0 ",
17
+ "fatal error:",
18
+ ]
19
+
20
+
21
+ def run(base, http):
22
+ probe = "/hackable-probe-" + secrets.token_hex(4)
23
+ r = http.get(base + probe)
24
+ if r is None:
25
+ return []
26
+ body = r.text.lower()
27
+ hits = [m for m in MARKERS if m in body]
28
+ if not hits:
29
+ return []
30
+ return [
31
+ Finding(
32
+ check="debug",
33
+ severity="high",
34
+ title="Error pages leak stack traces",
35
+ meaning="Your 404 page shows internal error details (matched: %s). Stack "
36
+ "traces reveal file paths, library versions and code structure, which "
37
+ "is exactly what an attacker needs to aim." % ", ".join(hits[:3]),
38
+ fix="Turn off debug mode in production and serve a generic error page "
39
+ "that reveals nothing internal. Log the details server-side instead.",
40
+ evidence=r.text.strip().replace("\n", " ")[:160],
41
+ url=base + probe,
42
+ )
43
+ ]
@@ -0,0 +1,36 @@
1
+ """Software version disclosure in response headers."""
2
+
3
+ import re
4
+
5
+ from ..findings import Finding
6
+
7
+ VERSION_RE = re.compile(r"\d+\.\d+")
8
+
9
+ INTERESTING = ("server", "x-powered-by", "x-aspnet-version", "x-generator")
10
+
11
+
12
+ def run(base, http):
13
+ r = http.get(base + "/")
14
+ if r is None:
15
+ return []
16
+ leaked = []
17
+ for name in INTERESTING:
18
+ value = r.headers.get(name)
19
+ if value and VERSION_RE.search(value):
20
+ leaked.append("%s: %s" % (name, value.strip()))
21
+ if not leaked:
22
+ return []
23
+ return [
24
+ Finding(
25
+ check="disclosure",
26
+ severity="low",
27
+ title="Server software versions are public",
28
+ meaning="Your server announces exactly what it runs (%s). Attackers search "
29
+ "for old versions with known holes, so this tells them where to aim."
30
+ % "; ".join(leaked),
31
+ fix="Hide or genericize the Server and X-Powered-By headers in your web "
32
+ "server config. More importantly, keep the software itself updated.",
33
+ evidence="; ".join(leaked),
34
+ url=base + "/",
35
+ )
36
+ ]
@@ -0,0 +1,122 @@
1
+ """Sensitive files accidentally left public."""
2
+
3
+ from ..findings import Finding
4
+
5
+
6
+ def _looks_like_env(text):
7
+ t = text.strip()
8
+ if "<html" in t.lower()[:500]:
9
+ return False
10
+ return "=" in t and any(
11
+ k in t for k in ("KEY", "SECRET", "PASSWORD", "TOKEN", "DATABASE_URL")
12
+ )
13
+
14
+
15
+ def _looks_like_git_head(text):
16
+ return text.strip().startswith("ref:")
17
+
18
+
19
+ def _looks_like_git_config(text):
20
+ return "[core]" in text and "repositoryformatversion" in text
21
+
22
+
23
+ def _looks_like_apache_status(text):
24
+ return "Apache Status" in text
25
+
26
+
27
+ def _looks_like_wp_config(text):
28
+ return "DB_PASSWORD" in text
29
+
30
+
31
+ def _looks_like_ds_store(raw):
32
+ return raw[:8].startswith(b"\x00\x01Bud") or raw[:4] == b"Bud1"
33
+
34
+
35
+ TARGETS = [
36
+ (
37
+ "/.env",
38
+ "critical",
39
+ lambda text, raw: _looks_like_env(text),
40
+ "Your /.env file is public",
41
+ "This file usually holds database passwords, API keys and app secrets. "
42
+ "Anyone on the internet can now read them and log in as you.",
43
+ "Block /.env (and /.env.*) in your web server config immediately, then "
44
+ "rotate every secret that was inside it. They are all compromised.",
45
+ ),
46
+ (
47
+ "/.env.bak",
48
+ "critical",
49
+ lambda text, raw: _looks_like_env(text),
50
+ "A backup of your .env file is public",
51
+ "Same as a public /.env: every secret in it is now in attacker hands.",
52
+ "Delete the backup file from the server and rotate every secret in it.",
53
+ ),
54
+ (
55
+ "/.git/HEAD",
56
+ "critical",
57
+ lambda text, raw: _looks_like_git_head(text),
58
+ "Your .git directory is public",
59
+ "Attackers can download your entire source code, including old commits "
60
+ "that may contain secrets you later deleted.",
61
+ "Block /.git/ in your web server config or stop deploying the .git "
62
+ "directory to production.",
63
+ ),
64
+ (
65
+ "/.git/config",
66
+ "high",
67
+ lambda text, raw: _looks_like_git_config(text),
68
+ "Your git config is public",
69
+ "This confirms your repository internals are exposed and often leads to "
70
+ "full source disclosure.",
71
+ "Block /.git/ in your web server config.",
72
+ ),
73
+ (
74
+ "/server-status",
75
+ "high",
76
+ lambda text, raw: _looks_like_apache_status(text),
77
+ "Apache server-status page is public",
78
+ "It shows live requests, client IPs and server internals. Useful for you, "
79
+ "more useful for an attacker mapping your app.",
80
+ "Restrict /server-status to localhost in your Apache config.",
81
+ ),
82
+ (
83
+ "/wp-config.php.bak",
84
+ "critical",
85
+ lambda text, raw: _looks_like_wp_config(text),
86
+ "A WordPress config backup is public",
87
+ "It contains your database username and password in plain text.",
88
+ "Delete the backup file and change the database password.",
89
+ ),
90
+ (
91
+ "/.DS_Store",
92
+ "low",
93
+ lambda text, raw: _looks_like_ds_store(raw),
94
+ ".DS_Store file is public",
95
+ "A macOS metadata file. It can reveal file and folder names on your server.",
96
+ "Stop deploying .DS_Store files; add them to .gitignore.",
97
+ ),
98
+ ]
99
+
100
+
101
+ def run(base, http):
102
+ findings = []
103
+ for path, severity, signature, title, meaning, fix in TARGETS:
104
+ r = http.get(base + path)
105
+ if r is None or r.status_code != 200:
106
+ continue
107
+ try:
108
+ if signature(r.text, r.content):
109
+ findings.append(
110
+ Finding(
111
+ check="exposed_files",
112
+ severity=severity,
113
+ title=title,
114
+ meaning=meaning,
115
+ fix=fix,
116
+ evidence=r.text.strip().replace("\n", " ")[:120],
117
+ url=base + path,
118
+ )
119
+ )
120
+ except Exception:
121
+ continue
122
+ return findings
@@ -0,0 +1,70 @@
1
+ """Missing security headers."""
2
+
3
+ from ..findings import Finding
4
+
5
+ WANTED = [
6
+ (
7
+ "strict-transport-security",
8
+ "medium",
9
+ "No HSTS header",
10
+ "Without HSTS, a visitor on public Wi-Fi can be silently downgraded from "
11
+ "HTTPS to plain HTTP, and everything they type can be read.",
12
+ "Send Strict-Transport-Security: max-age=31536000 on every HTTPS response.",
13
+ ),
14
+ (
15
+ "content-security-policy",
16
+ "low",
17
+ "No Content-Security-Policy header",
18
+ "CSP is a seatbelt against cross-site scripting: it tells browsers which "
19
+ "scripts are allowed to run. Without it, one injected script runs anywhere.",
20
+ "Add a Content-Security-Policy header that only allows scripts from your own domain.",
21
+ ),
22
+ (
23
+ "x-frame-options",
24
+ "low",
25
+ "No X-Frame-Options header",
26
+ "Without it, an attacker can embed your site invisibly inside theirs and "
27
+ "trick visitors into clicking things they cannot see (clickjacking).",
28
+ "Send X-Frame-Options: DENY, or use frame-ancestors in your CSP.",
29
+ ),
30
+ (
31
+ "x-content-type-options",
32
+ "low",
33
+ "No X-Content-Type-Options header",
34
+ "Browsers may guess a file's type and execute it. An uploaded 'image' could "
35
+ "be run as a script.",
36
+ "Send X-Content-Type-Options: nosniff on every response.",
37
+ ),
38
+ (
39
+ "referrer-policy",
40
+ "info",
41
+ "No Referrer-Policy header",
42
+ "Browsers may leak full page URLs (sometimes containing tokens or private "
43
+ "IDs) to third-party sites when visitors click links.",
44
+ "Send Referrer-Policy: strict-origin-when-cross-origin or stricter.",
45
+ ),
46
+ ]
47
+
48
+
49
+ def run(base, http):
50
+ findings = []
51
+ r = http.get(base + "/")
52
+ if r is None:
53
+ return findings
54
+ present = {k.lower() for k in r.headers}
55
+ is_https = base.startswith("https://")
56
+ for header, severity, title, meaning, fix in WANTED:
57
+ if header == "strict-transport-security" and not is_https:
58
+ continue # covered by the TLS check instead
59
+ if header not in present:
60
+ findings.append(
61
+ Finding(
62
+ check="headers",
63
+ severity=severity,
64
+ title=title,
65
+ meaning=meaning,
66
+ fix=fix,
67
+ url=base + "/",
68
+ )
69
+ )
70
+ return findings
@@ -0,0 +1,28 @@
1
+ """Dangerous HTTP methods (TRACE/TRACK)."""
2
+
3
+ from ..findings import Finding
4
+
5
+
6
+ def run(base, http):
7
+ r = http.options(base + "/")
8
+ if r is None:
9
+ return []
10
+ allow = r.headers.get("allow", "")
11
+ methods = {m.strip().upper() for m in allow.split(",") if m.strip()}
12
+ bad = sorted(m for m in ("TRACE", "TRACK") if m in methods)
13
+ if not bad:
14
+ return []
15
+ return [
16
+ Finding(
17
+ check="methods",
18
+ severity="medium",
19
+ title="Risky HTTP methods enabled (%s)" % ", ".join(bad),
20
+ meaning="The TRACE method lets an attacker bounce requests off your server "
21
+ "to steal cookies and auth headers (cross-site tracing), bypassing the "
22
+ "protections browsers put on JavaScript.",
23
+ fix="Disable TRACE/TRACK in your web server config. You almost certainly "
24
+ "do not use them.",
25
+ evidence="Allow: " + allow,
26
+ url=base + "/",
27
+ )
28
+ ]
@@ -0,0 +1,57 @@
1
+ """Open redirect probes."""
2
+
3
+ from ..findings import Finding
4
+
5
+ EVIL = "https://evil-hackable-test.example.com/"
6
+
7
+ CANDIDATES = [
8
+ ("/goto", "next"),
9
+ ("/redirect", "next"),
10
+ ("/login", "next"),
11
+ ("", "next"),
12
+ ("/goto", "redirect"),
13
+ ("/go", "url"),
14
+ ("", "redirect"),
15
+ ("/callback", "returnUrl"),
16
+ ]
17
+
18
+
19
+ def _pairs(base, targets):
20
+ pairs, seen = [], set()
21
+ for url, params in (targets or [])[:8]:
22
+ for param in params[:3]:
23
+ key = (url, param)
24
+ if key not in seen:
25
+ seen.add(key)
26
+ pairs.append(key)
27
+ for path, param in CANDIDATES:
28
+ key = (base + path, param)
29
+ if key not in seen:
30
+ seen.add(key)
31
+ pairs.append(key)
32
+ return pairs[:14]
33
+
34
+
35
+ def run(base, http, targets=None):
36
+ for url, param in _pairs(base, targets):
37
+ path = url.replace(base, "") or "/"
38
+ r = http.get(url, params={param: EVIL}, allow_redirects=False)
39
+ if r is None:
40
+ continue
41
+ location = r.headers.get("location", "")
42
+ if r.status_code in (301, 302, 303, 307, 308) and "evil-hackable-test" in location:
43
+ return [
44
+ Finding(
45
+ check="open_redirect",
46
+ severity="high",
47
+ title="Open redirect in %s" % (path or "/"),
48
+ meaning="Your site happily redirects visitors to any address I "
49
+ "name, including an attacker's. Phishing emails love this: the "
50
+ "link shows YOUR domain, but lands on THEIR fake login page.",
51
+ fix="Only redirect to relative paths or an allowlist of your "
52
+ "own domains. Reject anything starting with http or //.",
53
+ evidence="Location: %s" % location[:120],
54
+ url=url + "?%s=%s" % (param, EVIL),
55
+ )
56
+ ]
57
+ return []
@@ -0,0 +1,53 @@
1
+ """Login rate-limiting probe (gentle: 10 attempts, wrong creds)."""
2
+
3
+ from ..findings import Finding
4
+
5
+ ENDPOINTS = ["/login", "/signin", "/auth/login", "/api/login", "/admin/login", "/users/sign_in"]
6
+
7
+
8
+ def run(base, http):
9
+ login_url = None
10
+ for path in ENDPOINTS:
11
+ r = http.get(base + path)
12
+ if r is not None and r.status_code in (200, 401, 403, 405):
13
+ login_url = base + path
14
+ break
15
+ if login_url is None:
16
+ return []
17
+ blocked = 0
18
+ attempts = 10
19
+ for _ in range(attempts):
20
+ r = http.post(
21
+ login_url,
22
+ data={"username": "hackable-probe", "password": "wrong-password"},
23
+ )
24
+ if r is None:
25
+ break
26
+ if r.status_code in (429, 403):
27
+ blocked += 1
28
+ if blocked:
29
+ return [
30
+ Finding(
31
+ check="rate_limit",
32
+ severity="info",
33
+ title="Login rate limiting appears active",
34
+ meaning="After a few wrong passwords the server pushed back (%d of %d "
35
+ "attempts blocked). That is what you want." % (blocked, attempts),
36
+ fix="No action needed. Keep it.",
37
+ url=login_url,
38
+ )
39
+ ]
40
+ return [
41
+ Finding(
42
+ check="rate_limit",
43
+ severity="medium",
44
+ title="No rate limiting on the login page",
45
+ meaning="I tried 10 wrong passwords as fast as I could and nobody stopped "
46
+ "me. An attacker can try millions: common passwords, leaked passwords, "
47
+ "until one works.",
48
+ fix="Throttle login attempts (a few tries, then growing delays or a "
49
+ "temporary lockout), add a CAPTCHA after failures, and alert on bursts.",
50
+ evidence="%d rapid attempts, none blocked" % attempts,
51
+ url=login_url,
52
+ )
53
+ ]
@@ -0,0 +1,31 @@
1
+ """Interesting paths leaked via robots.txt."""
2
+
3
+ from ..findings import Finding
4
+
5
+
6
+ def run(base, http):
7
+ r = http.get(base + "/robots.txt")
8
+ if r is None or r.status_code != 200:
9
+ return []
10
+ lines = [
11
+ line.split(":", 1)[1].strip()
12
+ for line in r.text.splitlines()
13
+ if line.lower().startswith("disallow:") and line.split(":", 1)[1].strip()
14
+ ]
15
+ if not lines:
16
+ return []
17
+ shown = ", ".join(lines[:5])
18
+ return [
19
+ Finding(
20
+ check="robots",
21
+ severity="low",
22
+ title="robots.txt lists hidden paths",
23
+ meaning="Your robots.txt names %d path(s) you asked search engines to ignore "
24
+ "(%s). Attackers read this file too: it is a map of where you keep the "
25
+ "interesting things." % (len(lines), shown),
26
+ fix="Assume every path in robots.txt is public. If something must stay "
27
+ "hidden, protect it with a login instead of relying on robots.txt.",
28
+ evidence=shown,
29
+ url=base + "/robots.txt",
30
+ )
31
+ ]
@@ -0,0 +1,21 @@
1
+ """Check for a security.txt file so researchers can report vulnerabilities."""
2
+
3
+ from ..findings import Finding
4
+
5
+
6
+ def run(base, http):
7
+ for path in ("/.well-known/security.txt", "/security.txt"):
8
+ r = http.get(base + path)
9
+ if r is not None and r.status_code == 200 and "contact" in r.text.lower():
10
+ return []
11
+ return [Finding(
12
+ check="securitytxt", severity="info",
13
+ title="No security.txt file",
14
+ meaning="There is no standard way for a security researcher who finds "
15
+ "a vulnerability to contact you, so reports may go public "
16
+ "instead of reaching you first.",
17
+ url=base + "/.well-known/security.txt",
18
+ evidence="no file with a Contact: line found",
19
+ fix="Publish /.well-known/security.txt with a Contact: line so "
20
+ "security researchers can reach you. See securitytxt.org.",
21
+ )]