loginscan 0.1.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.
loginscan/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """loginscan - a lightweight, dependency-free self-audit scanner for login endpoints.
2
+
3
+ Only use it against systems you own or are authorized to test. It does not crack
4
+ passwords and runs low-volume; the goal is to detect issues and suggest fixes.
5
+ """
6
+ from .authorization import AUTHORIZATION_NOTICE, NotAuthorized
7
+ from .models import Finding, ScanConfig, Severity, Status
8
+ from .report import Report
9
+ from .scanner import Scanner
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = [
14
+ "Scanner", "ScanConfig", "Report", "Finding",
15
+ "Severity", "Status", "NotAuthorized", "AUTHORIZATION_NOTICE",
16
+ "__version__",
17
+ ]
@@ -0,0 +1,22 @@
1
+ """Authorization gate. Only scan systems you own or are permitted to test."""
2
+ from __future__ import annotations
3
+
4
+ AUTHORIZATION_NOTICE = (
5
+ "WARNING: loginscan must only be used against systems you own or have written\n"
6
+ "permission to test. Unauthorized scanning is illegal and unethical. By scanning,\n"
7
+ "you assert that you are authorized to test the target."
8
+ )
9
+
10
+
11
+ class NotAuthorized(Exception):
12
+ pass
13
+
14
+
15
+ def ensure_authorized(authorized: bool) -> None:
16
+ if not authorized:
17
+ raise NotAuthorized(
18
+ "Scan refused: authorization not confirmed.\n\n"
19
+ + AUTHORIZATION_NOTICE
20
+ + "\n\nPython API: Scanner(config, authorized=True)\n"
21
+ "CLI: add the --i-own-this flag."
22
+ )
@@ -0,0 +1,11 @@
1
+ """Check modules. Each exposes run(client, cfg) -> List[Finding]."""
2
+ from . import (cookies, csrf, enumeration, headers, jwt, open_redirect,
3
+ ratelimit, session, sqli)
4
+
5
+ # enumeration runs before sqli so sqli's many failed logins don't trip the
6
+ # target's rate limiter and pollute the enumeration measurement.
7
+ ALL_CHECKS = [headers, csrf, open_redirect, enumeration, sqli, cookies,
8
+ session, jwt, ratelimit]
9
+
10
+ __all__ = ["ALL_CHECKS", "sqli", "enumeration", "ratelimit", "cookies", "session",
11
+ "headers", "csrf", "jwt", "open_redirect"]
@@ -0,0 +1,77 @@
1
+ """Shared helpers: build login data, get a baseline failed login, guess success."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import secrets
6
+ from typing import Dict, List, Optional
7
+
8
+ from ..http import HttpClient, Response
9
+ from ..models import ScanConfig
10
+
11
+ SQL_ERROR_SIGNS = [
12
+ "sql syntax", "sqlite3.", "sqlite error", "psycopg2", "pg::",
13
+ "mysql_fetch", "you have an error in your sql",
14
+ "unclosed quotation mark", "quoted string not properly terminated",
15
+ "odbc", "ora-0", "syntax error", "sqlalchemy",
16
+ ]
17
+
18
+ SESSION_COOKIE_HINT = re.compile(r"sess|token|auth|sid|jwt|login", re.I)
19
+
20
+
21
+ def random_username(prefix: str = "nouser") -> str:
22
+ return f"{prefix}_{secrets.token_hex(4)}"
23
+
24
+
25
+ def build_data(cfg: ScanConfig, username: str, password: str) -> Dict[str, str]:
26
+ data = dict(cfg.extra_fields)
27
+ data[cfg.username_field] = username
28
+ data[cfg.password_field] = password
29
+ return data
30
+
31
+
32
+ def submit_login(client: HttpClient, cfg: ScanConfig, username: str, password: str) -> Response:
33
+ return client.request(cfg.method, cfg.url, data=build_data(cfg, username, password),
34
+ content_type=cfg.content_type)
35
+
36
+
37
+ def cookie_names(resp: Response) -> List[str]:
38
+ return [c.split("=", 1)[0].strip() for c in resp.set_cookies if c.split("=", 1)[0].strip()]
39
+
40
+
41
+ def session_cookies(resp: Response) -> List[str]:
42
+ return [n for n in cookie_names(resp) if SESSION_COOKIE_HINT.search(n)]
43
+
44
+
45
+ def baseline_fail(client: HttpClient, cfg: ScanConfig) -> Response:
46
+ return submit_login(client, cfg, random_username(), "wrong_" + secrets.token_hex(3))
47
+
48
+
49
+ def has_sql_error(body: str) -> Optional[str]:
50
+ low = body.lower()
51
+ for sign in SQL_ERROR_SIGNS:
52
+ if sign in low:
53
+ return sign
54
+ return None
55
+
56
+
57
+ def looks_like_success(resp: Response, baseline: Response, cfg: ScanConfig) -> Optional[str]:
58
+ """Return a reason string if resp looks like a successful login, else None.
59
+
60
+ Conservative on purpose to limit false positives; evidence goes in the report.
61
+ """
62
+ for token in cfg.success_indicators:
63
+ if token and token.lower() in resp.body.lower():
64
+ return f"success text seen in response: '{token}'"
65
+
66
+ new_sess = set(session_cookies(resp)) - set(session_cookies(baseline))
67
+ if new_sess:
68
+ return f"session cookie set that the failed baseline did not get: {sorted(new_sess)}"
69
+
70
+ if resp.status < 400 and baseline.status >= 400:
71
+ return f"status upgraded vs failed baseline (HTTP {resp.status} vs {baseline.status})"
72
+
73
+ if 300 <= resp.status < 400 and not (300 <= baseline.status < 400):
74
+ loc = resp.header("location") or "?"
75
+ return f"redirect not present in failed baseline (HTTP {resp.status} -> {loc})"
76
+
77
+ return None
@@ -0,0 +1,61 @@
1
+ """Session cookie flag check: HttpOnly, Secure, SameSite."""
2
+ from __future__ import annotations
3
+
4
+ from typing import List
5
+ from urllib.parse import urlparse
6
+
7
+ from ..http import HttpClient
8
+ from ..models import Finding, ScanConfig, Severity, Status
9
+ from .base import baseline_fail
10
+
11
+ CHECK = "cookies"
12
+
13
+
14
+ def _parse_flags(raw: str) -> dict:
15
+ name = raw.split("=", 1)[0].strip() if raw else "?"
16
+ low = raw.lower()
17
+ return {
18
+ "name": name,
19
+ "httponly": "httponly" in low,
20
+ "secure": "secure" in low,
21
+ "samesite": "samesite" in low,
22
+ }
23
+
24
+
25
+ def run(client: HttpClient, cfg: ScanConfig) -> List[Finding]:
26
+ resp = baseline_fail(client, cfg)
27
+ is_https = urlparse(cfg.url).scheme == "https"
28
+
29
+ if not resp.set_cookies:
30
+ return [Finding(
31
+ check=CHECK, status=Status.SKIPPED, severity=Severity.INFO,
32
+ title="No cookies to inspect",
33
+ detail="No Set-Cookie on this request (session may be set only on successful login).",
34
+ )]
35
+
36
+ findings: List[Finding] = []
37
+ for raw in resp.set_cookies:
38
+ f = _parse_flags(raw)
39
+ missing = []
40
+ if not f["httponly"]:
41
+ missing.append("HttpOnly")
42
+ if is_https and not f["secure"]:
43
+ missing.append("Secure")
44
+ if not f["samesite"]:
45
+ missing.append("SameSite")
46
+
47
+ if missing:
48
+ findings.append(Finding(
49
+ check=CHECK, status=Status.VULNERABLE, severity=Severity.MEDIUM,
50
+ title=f"Cookie missing security flags: {f['name']}",
51
+ detail=f"Missing: {', '.join(missing)}.",
52
+ remediation="Set HttpOnly, Secure and SameSite=Lax/Strict on session cookies.",
53
+ evidence={"cookie": f["name"], "missing": missing},
54
+ ))
55
+ else:
56
+ findings.append(Finding(
57
+ check=CHECK, status=Status.OK, severity=Severity.INFO,
58
+ title=f"Cookie flags complete: {f['name']}",
59
+ detail="HttpOnly / Secure / SameSite present.",
60
+ ))
61
+ return findings
@@ -0,0 +1,44 @@
1
+ """CSRF protection check (passive).
2
+
3
+ Looks at the login page for a defense: a CSRF token field/meta, a CSRF cookie,
4
+ or a SameSite cookie. If none is present, CSRF is likely possible.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from typing import List
10
+
11
+ from ..http import HttpClient
12
+ from ..models import Finding, ScanConfig, Severity, Status
13
+
14
+ CHECK = "csrf"
15
+
16
+ _TOKEN_INPUT = re.compile(
17
+ r'<input[^>]*name=["\']?[^"\'>\s]*(?:csrf|xsrf|authenticity|_token)[^"\'>\s]*', re.I)
18
+ _META_TOKEN = re.compile(r'<meta[^>]+name=["\'](?:csrf|xsrf)[-_]?token["\']', re.I)
19
+ _CSRF_COOKIE = re.compile(r"csrf|xsrf", re.I)
20
+
21
+
22
+ def run(client: HttpClient, cfg: ScanConfig) -> List[Finding]:
23
+ page = client.get(cfg.login_page_url or cfg.url)
24
+ body = page.body
25
+
26
+ has_token = bool(_TOKEN_INPUT.search(body) or _META_TOKEN.search(body))
27
+ has_csrf_cookie = any(_CSRF_COOKIE.search(c.split("=", 1)[0]) for c in page.set_cookies)
28
+ has_samesite = any("samesite" in c.lower() for c in page.set_cookies)
29
+
30
+ if has_token or has_csrf_cookie or has_samesite:
31
+ how = ("CSRF token" if has_token else
32
+ "CSRF cookie" if has_csrf_cookie else "SameSite cookie")
33
+ return [Finding(
34
+ check=CHECK, status=Status.OK, severity=Severity.INFO,
35
+ title="CSRF defense present",
36
+ detail=f"Found a CSRF defense on the login page ({how}).",
37
+ )]
38
+
39
+ return [Finding(
40
+ check=CHECK, status=Status.WARNING, severity=Severity.MEDIUM,
41
+ title="No CSRF protection detected on login",
42
+ detail="No CSRF token, CSRF cookie, or SameSite cookie found; login may be forgeable.",
43
+ remediation="Add a per-session CSRF token to the login form or set SameSite=Lax/Strict on cookies.",
44
+ )]
@@ -0,0 +1,74 @@
1
+ """User enumeration detection: does the server treat existing vs unknown users differently?"""
2
+ from __future__ import annotations
3
+
4
+ from typing import List
5
+
6
+ from ..http import HttpClient
7
+ from ..models import Finding, ScanConfig, Severity, Status
8
+ from .base import random_username, submit_login
9
+
10
+ CHECK = "enumeration"
11
+
12
+ LEN_DIFF_RATIO = 0.15
13
+ TIME_DIFF_SECONDS = 0.5
14
+
15
+
16
+ def _norm_body(text: str) -> str:
17
+ return " ".join(text.split())[:400].lower()
18
+
19
+
20
+ def run(client: HttpClient, cfg: ScanConfig) -> List[Finding]:
21
+ wrong_pw = "wrong-Password-123"
22
+ r_absent = submit_login(client, cfg, random_username("ghost"), wrong_pw)
23
+
24
+ if not cfg.known_username:
25
+ r_absent2 = submit_login(client, cfg, random_username("ghost2"), wrong_pw)
26
+ consistent = _norm_body(r_absent.body) == _norm_body(r_absent2.body)
27
+ return [Finding(
28
+ check=CHECK, status=Status.SKIPPED, severity=Severity.INFO,
29
+ title="Enumeration test limited (no known_username)",
30
+ detail=("Provide a username that really exists (password not needed) for a full test. "
31
+ + ("Two unknown-user responses were consistent."
32
+ if consistent else "Two unknown-user responses differed, which is suspicious.")),
33
+ remediation="Re-run with --user / known_username.",
34
+ )]
35
+
36
+ r_present = submit_login(client, cfg, cfg.known_username, wrong_pw)
37
+
38
+ if 429 in (r_present.status, r_absent.status):
39
+ return [Finding(
40
+ check=CHECK, status=Status.SKIPPED, severity=Severity.INFO,
41
+ title="Enumeration test unreliable due to rate limiting",
42
+ detail="One request returned HTTP 429, so the responses cannot be compared fairly.",
43
+ remediation="Re-run with a higher --delay or after the rate-limit window resets.",
44
+ evidence={"http": f"{r_present.status} vs {r_absent.status}"},
45
+ )]
46
+
47
+ body_diff = _norm_body(r_present.body) != _norm_body(r_absent.body)
48
+ len_a, len_b = len(r_present.body), len(r_absent.body)
49
+ len_diff = abs(len_a - len_b) / max(len_a, len_b, 1) > LEN_DIFF_RATIO
50
+ status_diff = r_present.status != r_absent.status
51
+ time_diff = abs(r_present.elapsed - r_absent.elapsed) > TIME_DIFF_SECONDS
52
+
53
+ if body_diff or len_diff or status_diff:
54
+ return [Finding(
55
+ check=CHECK, status=Status.VULNERABLE, severity=Severity.MEDIUM,
56
+ title="User enumeration possible",
57
+ detail="The server answers existing and unknown users differently; valid usernames leak.",
58
+ remediation="Return the same generic message and HTTP status in both cases.",
59
+ evidence={"body_differs": body_diff, "length": f"{len_a} vs {len_b}",
60
+ "http": f"{r_present.status} vs {r_absent.status}"},
61
+ )]
62
+ if time_diff:
63
+ return [Finding(
64
+ check=CHECK, status=Status.WARNING, severity=Severity.LOW,
65
+ title="Timing difference (possible enumeration)",
66
+ detail="Bodies match but response times differ, hinting password hashing runs only for real users.",
67
+ remediation="Run a dummy hash verification for unknown users to equalize timing.",
68
+ evidence={"time": f"{r_present.elapsed:.3f}s vs {r_absent.elapsed:.3f}s"},
69
+ )]
70
+ return [Finding(
71
+ check=CHECK, status=Status.OK, severity=Severity.INFO,
72
+ title="Responses consistent against enumeration",
73
+ detail="Existing and unknown users were indistinguishable.",
74
+ )]
@@ -0,0 +1,88 @@
1
+ """Transport and security-header checks (HTTPS, HSTS, nosniff, clickjacking, etc.)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import List
5
+ from urllib.parse import urlparse
6
+
7
+ from ..http import HttpClient
8
+ from ..models import Finding, ScanConfig, Severity, Status
9
+
10
+ CHECK = "headers"
11
+
12
+
13
+ def run(client: HttpClient, cfg: ScanConfig) -> List[Finding]:
14
+ findings: List[Finding] = []
15
+ parsed = urlparse(cfg.url)
16
+ is_https = parsed.scheme == "https"
17
+
18
+ if not is_https:
19
+ findings.append(Finding(
20
+ check=CHECK, status=Status.VULNERABLE, severity=Severity.HIGH,
21
+ title="Login is not served over HTTPS",
22
+ detail="Username and password travel unencrypted; anyone on the network can read them.",
23
+ remediation="Serve the whole site over HTTPS and redirect HTTP to HTTPS.",
24
+ evidence={"scheme": parsed.scheme},
25
+ ))
26
+
27
+ probe_url = cfg.login_page_url or cfg.url
28
+ try:
29
+ resp = client.get(probe_url)
30
+ except Exception as e: # noqa: BLE001
31
+ findings.append(Finding(
32
+ check=CHECK, status=Status.SKIPPED, severity=Severity.INFO,
33
+ title="Could not read headers",
34
+ detail=f"GET {probe_url} failed: {e}",
35
+ ))
36
+ return findings
37
+
38
+ def missing(header: str) -> bool:
39
+ return resp.header(header) is None
40
+
41
+ csp = (resp.header("content-security-policy") or "").lower()
42
+
43
+ if is_https and missing("strict-transport-security"):
44
+ findings.append(Finding(
45
+ check=CHECK, status=Status.WARNING, severity=Severity.LOW,
46
+ title="Missing HSTS header",
47
+ detail="No Strict-Transport-Security; a first request may be downgraded to HTTP.",
48
+ remediation="Add Strict-Transport-Security: max-age=31536000; includeSubDomains.",
49
+ ))
50
+ if missing("x-content-type-options"):
51
+ findings.append(Finding(
52
+ check=CHECK, status=Status.WARNING, severity=Severity.LOW,
53
+ title="Missing X-Content-Type-Options",
54
+ detail="No nosniff; the browser may MIME-sniff responses.",
55
+ remediation="Add X-Content-Type-Options: nosniff.",
56
+ ))
57
+ if missing("x-frame-options") and "frame-ancestors" not in csp:
58
+ findings.append(Finding(
59
+ check=CHECK, status=Status.WARNING, severity=Severity.MEDIUM,
60
+ title="No clickjacking protection",
61
+ detail="No X-Frame-Options or CSP frame-ancestors; the login page can be framed.",
62
+ remediation="Add X-Frame-Options: DENY or CSP frame-ancestors 'none'.",
63
+ ))
64
+ if missing("referrer-policy"):
65
+ findings.append(Finding(
66
+ check=CHECK, status=Status.WARNING, severity=Severity.LOW,
67
+ title="Missing Referrer-Policy",
68
+ detail="No Referrer-Policy; sensitive URL data may leak to third parties.",
69
+ remediation="Add Referrer-Policy: no-referrer or strict-origin-when-cross-origin.",
70
+ ))
71
+
72
+ leak = resp.header("server") or resp.header("x-powered-by")
73
+ if leak and any(ch.isdigit() for ch in leak):
74
+ findings.append(Finding(
75
+ check=CHECK, status=Status.WARNING, severity=Severity.LOW,
76
+ title="Server/tech version disclosed",
77
+ detail=f"Response header reveals version info: '{leak}'.",
78
+ remediation="Strip version details from Server / X-Powered-By headers.",
79
+ evidence={"header": leak},
80
+ ))
81
+
82
+ if not any(f.check == CHECK and f.status != Status.VULNERABLE for f in findings):
83
+ findings.append(Finding(
84
+ check=CHECK, status=Status.OK, severity=Severity.INFO,
85
+ title="Core security headers present",
86
+ detail="No gaps found in HSTS/nosniff/frame protection/Referrer-Policy.",
87
+ ))
88
+ return findings
@@ -0,0 +1,81 @@
1
+ """Passive JWT weakness check.
2
+
3
+ If the login flow exposes a JWT (in a cookie or the response body), inspect its
4
+ header/payload without cracking anything: unsigned tokens (alg=none) and missing
5
+ expiry are flagged.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import json
11
+ import re
12
+ from typing import List, Optional
13
+
14
+ from ..http import HttpClient
15
+ from ..models import Finding, ScanConfig, Severity, Status
16
+ from .base import baseline_fail
17
+
18
+ CHECK = "jwt"
19
+
20
+ _JWT_RE = re.compile(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*")
21
+
22
+
23
+ def _b64url_json(segment: str) -> Optional[dict]:
24
+ pad = "=" * (-len(segment) % 4)
25
+ try:
26
+ return json.loads(base64.urlsafe_b64decode(segment + pad))
27
+ except Exception:
28
+ return None
29
+
30
+
31
+ def _find_jwt(client: HttpClient, cfg: ScanConfig) -> Optional[str]:
32
+ for _, value in client.observed_session_tokens:
33
+ if _JWT_RE.fullmatch(value):
34
+ return value
35
+ resp = baseline_fail(client, cfg)
36
+ for cookie in resp.set_cookies:
37
+ m = _JWT_RE.search(cookie)
38
+ if m:
39
+ return m.group(0)
40
+ m = _JWT_RE.search(resp.body)
41
+ return m.group(0) if m else None
42
+
43
+
44
+ def run(client: HttpClient, cfg: ScanConfig) -> List[Finding]:
45
+ token = _find_jwt(client, cfg)
46
+ if not token:
47
+ return [Finding(
48
+ check=CHECK, status=Status.SKIPPED, severity=Severity.INFO,
49
+ title="No JWT observed",
50
+ detail="No JSON Web Token seen in cookies or responses; check skipped.",
51
+ )]
52
+
53
+ parts = token.split(".")
54
+ header = _b64url_json(parts[0]) or {}
55
+ payload = _b64url_json(parts[1]) if len(parts) > 1 else {}
56
+ payload = payload or {}
57
+ alg = str(header.get("alg", "")).lower()
58
+
59
+ findings: List[Finding] = []
60
+ if alg == "none" or (len(parts) >= 3 and parts[2] == ""):
61
+ findings.append(Finding(
62
+ check=CHECK, status=Status.VULNERABLE, severity=Severity.CRITICAL,
63
+ title="JWT is unsigned (alg=none)",
64
+ detail="The token has no signature, so anyone can forge one and impersonate any user.",
65
+ remediation="Reject alg=none; verify a strong signature (e.g. RS256/ES256) server-side.",
66
+ evidence={"alg": header.get("alg")},
67
+ ))
68
+ if "exp" not in payload:
69
+ findings.append(Finding(
70
+ check=CHECK, status=Status.WARNING, severity=Severity.MEDIUM,
71
+ title="JWT has no expiry (exp)",
72
+ detail="Without exp the token never expires; a leaked token stays valid forever.",
73
+ remediation="Add a short exp claim and validate it on every request.",
74
+ ))
75
+ if not findings:
76
+ findings.append(Finding(
77
+ check=CHECK, status=Status.OK, severity=Severity.INFO,
78
+ title="JWT looks acceptable",
79
+ detail=f"Signed token (alg={header.get('alg')}) with an exp claim.",
80
+ ))
81
+ return findings
@@ -0,0 +1,52 @@
1
+ """Open redirect check.
2
+
3
+ Sends a benign external sentinel via common redirect parameters and checks
4
+ whether the server issues a redirect (3xx Location) to that external host.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import List
9
+ from urllib.parse import urlencode, urlparse, urlsplit, urlunsplit
10
+
11
+ from ..http import HttpClient
12
+ from ..models import Finding, ScanConfig, Severity, Status
13
+
14
+ CHECK = "open_redirect"
15
+
16
+ REDIRECT_PARAMS = ["next", "redirect", "redirect_uri", "returnUrl", "return_to",
17
+ "url", "dest", "continue"]
18
+ SENTINEL = "https://loginscan-external.example/"
19
+
20
+
21
+ def _with_query(base_url: str, param: str, value: str) -> str:
22
+ parts = urlsplit(base_url)
23
+ query = urlencode({param: value})
24
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, query, ""))
25
+
26
+
27
+ def run(client: HttpClient, cfg: ScanConfig) -> List[Finding]:
28
+ base = cfg.login_page_url or cfg.url
29
+ sentinel_host = urlparse(SENTINEL).netloc
30
+
31
+ for param in REDIRECT_PARAMS:
32
+ if client.remaining <= 0:
33
+ break
34
+ try:
35
+ resp = client.get(_with_query(base, param, SENTINEL))
36
+ except Exception: # noqa: BLE001
37
+ continue
38
+ location = resp.header("location") or ""
39
+ if 300 <= resp.status < 400 and sentinel_host in location:
40
+ return [Finding(
41
+ check=CHECK, status=Status.VULNERABLE, severity=Severity.MEDIUM,
42
+ title="Open redirect via login parameter",
43
+ detail=f"'{param}' is reflected into a redirect to an external host without validation.",
44
+ remediation="Allow redirects only to a fixed allowlist of internal paths/hosts.",
45
+ evidence={"param": param, "location": location},
46
+ )]
47
+
48
+ return [Finding(
49
+ check=CHECK, status=Status.OK, severity=Severity.INFO,
50
+ title="No open redirect via common parameters",
51
+ detail="Common redirect parameters did not trigger an external redirect.",
52
+ )]
@@ -0,0 +1,58 @@
1
+ """Brute-force protection presence check.
2
+
3
+ Not a brute-forcer: it sends a few failed attempts with a fixed dummy password
4
+ and only checks whether the server starts blocking. It never tries to crack anything.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import List
9
+
10
+ from ..http import HttpClient, RequestBudgetExceeded
11
+ from ..models import Finding, ScanConfig, Severity, Status
12
+ from .base import random_username, submit_login
13
+
14
+ CHECK = "ratelimit"
15
+
16
+ BLOCK_STATUSES = {429, 403, 503}
17
+ BLOCK_HINTS = ["too many", "rate limit", "try again later", "captcha", "locked", "blocked"]
18
+
19
+
20
+ def run(client: HttpClient, cfg: ScanConfig, attempts: int = 6) -> List[Finding]:
21
+ target_user = cfg.known_username or random_username("probe")
22
+ wrong_pw = "definitely-wrong-pw"
23
+
24
+ statuses = []
25
+ blocked_at = None
26
+ try:
27
+ for i in range(1, attempts + 1):
28
+ if client.remaining <= 0:
29
+ break
30
+ resp = submit_login(client, cfg, target_user, wrong_pw)
31
+ statuses.append(resp.status)
32
+ low = resp.body.lower()
33
+ if resp.status in BLOCK_STATUSES or any(h in low for h in BLOCK_HINTS):
34
+ blocked_at = i
35
+ break
36
+ except RequestBudgetExceeded:
37
+ pass
38
+
39
+ if blocked_at:
40
+ return [Finding(
41
+ check=CHECK, status=Status.OK, severity=Severity.INFO,
42
+ title="Brute-force protection active",
43
+ detail=f"Server blocked at attempt {blocked_at} (rate limiting present).",
44
+ evidence={"statuses": statuses},
45
+ )]
46
+ if not statuses:
47
+ return [Finding(
48
+ check=CHECK, status=Status.SKIPPED, severity=Severity.INFO,
49
+ title="Rate-limit test could not run",
50
+ detail="Request budget was exhausted.",
51
+ )]
52
+ return [Finding(
53
+ check=CHECK, status=Status.VULNERABLE, severity=Severity.MEDIUM,
54
+ title="No brute-force protection",
55
+ detail=f"{len(statuses)} failed attempts in a row were accepted without blocking.",
56
+ remediation="Add IP- and account-based rate limiting; use delays/CAPTCHA/temporary lockout.",
57
+ evidence={"attempts": len(statuses), "statuses": statuses},
58
+ )]
@@ -0,0 +1,70 @@
1
+ """Session token strength check: predictable, short, sequential or low-entropy tokens."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ from collections import Counter
6
+ from typing import List
7
+
8
+ from ..http import HttpClient
9
+ from ..models import Finding, ScanConfig, Severity, Status
10
+
11
+ CHECK = "session"
12
+
13
+ MIN_TOKEN_LEN = 16
14
+
15
+
16
+ def _entropy_bits(s: str) -> float:
17
+ if not s:
18
+ return 0.0
19
+ counts = Counter(s)
20
+ n = len(s)
21
+ per_char = -sum((c / n) * math.log2(c / n) for c in counts.values())
22
+ return per_char * n
23
+
24
+
25
+ def run(client: HttpClient, cfg: ScanConfig) -> List[Finding]:
26
+ tokens = [v for _, v in client.observed_session_tokens]
27
+ if not tokens:
28
+ return [Finding(
29
+ check=CHECK, status=Status.SKIPPED, severity=Severity.INFO,
30
+ title="No session token observed",
31
+ detail="Server issued no session token here (usually set only on success). Skipped.",
32
+ )]
33
+
34
+ uniq = list(dict.fromkeys(tokens))
35
+ sample = uniq[0]
36
+
37
+ numeric = [t for t in uniq if t.isdigit()]
38
+ if len(numeric) >= 2:
39
+ nums = sorted(int(t) for t in numeric)
40
+ if all(b - a == 1 for a, b in zip(nums, nums[1:])):
41
+ return [Finding(
42
+ check=CHECK, status=Status.VULNERABLE, severity=Severity.HIGH,
43
+ title="Session token is sequential and predictable",
44
+ detail=f"Tokens are consecutive numbers: {nums}. An attacker can guess other sessions.",
45
+ remediation="Use cryptographically random tokens (e.g. secrets.token_urlsafe(32)); never counters.",
46
+ evidence={"samples": nums[:5]},
47
+ )]
48
+
49
+ reasons = []
50
+ if len(sample) < MIN_TOKEN_LEN:
51
+ reasons.append(f"too short ({len(sample)} chars)")
52
+ if sample.isdigit():
53
+ reasons.append("all numeric")
54
+ entropy = _entropy_bits(sample)
55
+ if entropy < 64:
56
+ reasons.append(f"low entropy (~{entropy:.0f} bits)")
57
+
58
+ if reasons:
59
+ return [Finding(
60
+ check=CHECK, status=Status.WARNING, severity=Severity.MEDIUM,
61
+ title="Session token looks weak",
62
+ detail="Weak on: " + ", ".join(reasons) + ".",
63
+ remediation="Use a cryptographically random token with at least 128 bits of entropy.",
64
+ evidence={"sample_len": len(sample), "entropy_bits": round(entropy)},
65
+ )]
66
+ return [Finding(
67
+ check=CHECK, status=Status.OK, severity=Severity.INFO,
68
+ title="Session token looks reasonable",
69
+ detail=f"Length {len(sample)}, ~{entropy:.0f} bits of entropy.",
70
+ )]