loginscan 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. loginscan-0.1.0/LICENSE +21 -0
  2. loginscan-0.1.0/PKG-INFO +165 -0
  3. loginscan-0.1.0/README.md +124 -0
  4. loginscan-0.1.0/loginscan/__init__.py +17 -0
  5. loginscan-0.1.0/loginscan/authorization.py +22 -0
  6. loginscan-0.1.0/loginscan/checks/__init__.py +11 -0
  7. loginscan-0.1.0/loginscan/checks/base.py +77 -0
  8. loginscan-0.1.0/loginscan/checks/cookies.py +61 -0
  9. loginscan-0.1.0/loginscan/checks/csrf.py +44 -0
  10. loginscan-0.1.0/loginscan/checks/enumeration.py +74 -0
  11. loginscan-0.1.0/loginscan/checks/headers.py +88 -0
  12. loginscan-0.1.0/loginscan/checks/jwt.py +81 -0
  13. loginscan-0.1.0/loginscan/checks/open_redirect.py +52 -0
  14. loginscan-0.1.0/loginscan/checks/ratelimit.py +58 -0
  15. loginscan-0.1.0/loginscan/checks/session.py +70 -0
  16. loginscan-0.1.0/loginscan/checks/sqli.py +54 -0
  17. loginscan-0.1.0/loginscan/cli.py +131 -0
  18. loginscan-0.1.0/loginscan/discovery.py +205 -0
  19. loginscan-0.1.0/loginscan/http.py +136 -0
  20. loginscan-0.1.0/loginscan/models.py +68 -0
  21. loginscan-0.1.0/loginscan/report.py +162 -0
  22. loginscan-0.1.0/loginscan/scanner.py +44 -0
  23. loginscan-0.1.0/loginscan.egg-info/PKG-INFO +165 -0
  24. loginscan-0.1.0/loginscan.egg-info/SOURCES.txt +30 -0
  25. loginscan-0.1.0/loginscan.egg-info/dependency_links.txt +1 -0
  26. loginscan-0.1.0/loginscan.egg-info/entry_points.txt +2 -0
  27. loginscan-0.1.0/loginscan.egg-info/requires.txt +3 -0
  28. loginscan-0.1.0/loginscan.egg-info/top_level.txt +1 -0
  29. loginscan-0.1.0/pyproject.toml +36 -0
  30. loginscan-0.1.0/setup.cfg +4 -0
  31. loginscan-0.1.0/tests/test_integration.py +35 -0
  32. loginscan-0.1.0/tests/test_unit.py +109 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ulku Bayraktar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.4
2
+ Name: loginscan
3
+ Version: 0.1.0
4
+ Summary: Kendi login endpoint'inizi savunma amaçlı tarayan, bağımlılıksız güvenlik aracı.
5
+ Author: Ulku Bayraktar
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Ulku Bayraktar
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/ulkubayraktar/loginscan
29
+ Keywords: security,login,authentication,self-audit,sql-injection,defensive
30
+ Classifier: Development Status :: 3 - Alpha
31
+ Classifier: Intended Audience :: Developers
32
+ Classifier: Topic :: Security
33
+ Classifier: Programming Language :: Python :: 3
34
+ Classifier: Programming Language :: Python :: 3.9
35
+ Requires-Python: >=3.9
36
+ Description-Content-Type: text/markdown
37
+ License-File: LICENSE
38
+ Provides-Extra: dev
39
+ Requires-Dist: pytest>=7; extra == "dev"
40
+ Dynamic: license-file
41
+
42
+ # loginscan
43
+
44
+ A lightweight, dependency-free **defensive self-audit scanner** for login endpoints.
45
+ Point it at your own login page or API, and it runs a series of **safe, low-volume**
46
+ checks and reports what is wrong and **how to fix it**. You can scan by giving just a
47
+ **site link** or a **Swagger/OpenAPI link** — the login endpoint is auto-discovered.
48
+
49
+ > ⚠️ **Authorized use only.** Run this only against systems you **own** or have **written
50
+ > permission** to test. Unauthorized scanning is illegal. The tool refuses to run without
51
+ > an explicit confirmation (`--i-own-this` / `authorized=True`).
52
+
53
+ ## Why it's safe
54
+
55
+ This is not an attack tool. By design it:
56
+
57
+ - **Does not crack passwords** — no brute-force, no wordlists. The "brute-force" check only
58
+ sends 5–6 requests to see *whether rate limiting exists*.
59
+ - **Is low-volume** — a total request budget (default 25) caps every scan.
60
+ - **Is non-destructive** — only well-known detection probes; it never modifies data.
61
+
62
+ ## Install
63
+
64
+ ```bash
65
+ pip install .
66
+ # or, for development:
67
+ pip install -e .
68
+ ```
69
+
70
+ ## Usage
71
+
72
+ Scan by explicit URL:
73
+
74
+ ```bash
75
+ loginscan https://site/login --i-own-this --user alice --success "Welcome"
76
+ ```
77
+
78
+ Scan by **site link** (login form auto-discovered):
79
+
80
+ ```bash
81
+ loginscan --site https://site/login-page --i-own-this --user alice
82
+ ```
83
+
84
+ Scan by **Swagger/OpenAPI link** (JSON spec; endpoint + fields auto-discovered):
85
+
86
+ ```bash
87
+ loginscan --swagger https://api.site/openapi.json --i-own-this --user alice
88
+ ```
89
+
90
+ Useful options:
91
+
92
+ | Option | Description |
93
+ |---|---|
94
+ | `--i-own-this` | **Required.** Asserts you are authorized to test the target. |
95
+ | `--user NAME` | A username that really exists (not the password). Strengthens the enumeration check. |
96
+ | `--success "text"` | Text that means "login succeeded". Repeatable. |
97
+ | `--json-body` | Send credentials as JSON instead of form-encoded. |
98
+ | `--username-field` / `--password-field` | Override field names. |
99
+ | `--field csrf=abc` | Constant field added to every request. |
100
+ | `--max-requests 25` | Total request budget. |
101
+ | `--json report.json` | Also write the report as JSON. |
102
+ | `--html report.html` | Also write a shareable, styled HTML report. |
103
+ | `--insecure` | Disable TLS verification (for your own test server). |
104
+
105
+ Exit code: `1` if any vulnerability is found, `0` if clean, `2` if unauthorized/misused (handy for CI).
106
+
107
+ ## Python API
108
+
109
+ ```python
110
+ from loginscan import Scanner, ScanConfig
111
+
112
+ cfg = ScanConfig(url="https://site/login", known_username="alice",
113
+ success_indicators=["Welcome"])
114
+ report = Scanner(cfg, authorized=True).run()
115
+ print(report.to_text())
116
+ print(report.to_json())
117
+ ```
118
+
119
+ ## Checks
120
+
121
+ | Check | What it looks for |
122
+ |---|---|
123
+ | `sqli` | SQL-injection auth bypass + database error leakage |
124
+ | `enumeration` | Different responses for existing vs unknown users |
125
+ | `ratelimit` | Whether repeated failed logins get blocked |
126
+ | `cookies` | Session cookie HttpOnly / Secure / SameSite flags |
127
+ | `session` | Predictable session tokens (short / numeric / sequential / low-entropy) |
128
+ | `csrf` | CSRF token / cookie / SameSite defense on the login page |
129
+ | `open_redirect` | Unvalidated redirect via `?next=`, `?returnUrl=`, etc. |
130
+ | `jwt` | Passive JWT weaknesses (`alg=none`, missing `exp`) |
131
+ | `headers` | HTTPS, HSTS, nosniff, clickjacking, Referrer-Policy, version disclosure |
132
+
133
+ ## Roadmap (checks to add next, in order)
134
+
135
+ - [ ] `verb` — HTTP method handling / verb tampering
136
+ - [ ] `cors` — permissive CORS on the auth endpoint (`ACAO: *` with credentials)
137
+ - [ ] `cache` — sensitive responses missing `Cache-Control: no-store`
138
+
139
+ ## Learning demo
140
+
141
+ This repo also ships an intentionally **vulnerable** and a **secure** server to try loginscan against:
142
+
143
+ ```bash
144
+ python3 vulnerable_app.py # terminal 1 -> port 8001
145
+ loginscan --site http://127.0.0.1:8001/ --i-own-this --user admin --success "Giriş başarılı"
146
+ # -> 4 vulnerabilities found
147
+
148
+ python3 secure_app.py # port 8002
149
+ loginscan --site http://127.0.0.1:8002/ --i-own-this --user admin --success "Giriş başarılı"
150
+ # -> sqli / enumeration / rate-limit all OK
151
+ ```
152
+
153
+ ## Releasing to PyPI
154
+
155
+ The package builds cleanly (`python -m build`) and passes `twine check`. To publish:
156
+
157
+ 1. Create the `loginscan` project on PyPI and add a Trusted Publisher for this repo's
158
+ `Publish to PyPI` workflow (environment `pypi`).
159
+ 2. Push a version tag: `git tag v0.1.0 && git push origin v0.1.0`.
160
+
161
+ The workflow then builds and uploads automatically — no API token stored in the repo.
162
+
163
+ ## License
164
+
165
+ MIT. Authorized use only.
@@ -0,0 +1,124 @@
1
+ # loginscan
2
+
3
+ A lightweight, dependency-free **defensive self-audit scanner** for login endpoints.
4
+ Point it at your own login page or API, and it runs a series of **safe, low-volume**
5
+ checks and reports what is wrong and **how to fix it**. You can scan by giving just a
6
+ **site link** or a **Swagger/OpenAPI link** — the login endpoint is auto-discovered.
7
+
8
+ > ⚠️ **Authorized use only.** Run this only against systems you **own** or have **written
9
+ > permission** to test. Unauthorized scanning is illegal. The tool refuses to run without
10
+ > an explicit confirmation (`--i-own-this` / `authorized=True`).
11
+
12
+ ## Why it's safe
13
+
14
+ This is not an attack tool. By design it:
15
+
16
+ - **Does not crack passwords** — no brute-force, no wordlists. The "brute-force" check only
17
+ sends 5–6 requests to see *whether rate limiting exists*.
18
+ - **Is low-volume** — a total request budget (default 25) caps every scan.
19
+ - **Is non-destructive** — only well-known detection probes; it never modifies data.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install .
25
+ # or, for development:
26
+ pip install -e .
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ Scan by explicit URL:
32
+
33
+ ```bash
34
+ loginscan https://site/login --i-own-this --user alice --success "Welcome"
35
+ ```
36
+
37
+ Scan by **site link** (login form auto-discovered):
38
+
39
+ ```bash
40
+ loginscan --site https://site/login-page --i-own-this --user alice
41
+ ```
42
+
43
+ Scan by **Swagger/OpenAPI link** (JSON spec; endpoint + fields auto-discovered):
44
+
45
+ ```bash
46
+ loginscan --swagger https://api.site/openapi.json --i-own-this --user alice
47
+ ```
48
+
49
+ Useful options:
50
+
51
+ | Option | Description |
52
+ |---|---|
53
+ | `--i-own-this` | **Required.** Asserts you are authorized to test the target. |
54
+ | `--user NAME` | A username that really exists (not the password). Strengthens the enumeration check. |
55
+ | `--success "text"` | Text that means "login succeeded". Repeatable. |
56
+ | `--json-body` | Send credentials as JSON instead of form-encoded. |
57
+ | `--username-field` / `--password-field` | Override field names. |
58
+ | `--field csrf=abc` | Constant field added to every request. |
59
+ | `--max-requests 25` | Total request budget. |
60
+ | `--json report.json` | Also write the report as JSON. |
61
+ | `--html report.html` | Also write a shareable, styled HTML report. |
62
+ | `--insecure` | Disable TLS verification (for your own test server). |
63
+
64
+ Exit code: `1` if any vulnerability is found, `0` if clean, `2` if unauthorized/misused (handy for CI).
65
+
66
+ ## Python API
67
+
68
+ ```python
69
+ from loginscan import Scanner, ScanConfig
70
+
71
+ cfg = ScanConfig(url="https://site/login", known_username="alice",
72
+ success_indicators=["Welcome"])
73
+ report = Scanner(cfg, authorized=True).run()
74
+ print(report.to_text())
75
+ print(report.to_json())
76
+ ```
77
+
78
+ ## Checks
79
+
80
+ | Check | What it looks for |
81
+ |---|---|
82
+ | `sqli` | SQL-injection auth bypass + database error leakage |
83
+ | `enumeration` | Different responses for existing vs unknown users |
84
+ | `ratelimit` | Whether repeated failed logins get blocked |
85
+ | `cookies` | Session cookie HttpOnly / Secure / SameSite flags |
86
+ | `session` | Predictable session tokens (short / numeric / sequential / low-entropy) |
87
+ | `csrf` | CSRF token / cookie / SameSite defense on the login page |
88
+ | `open_redirect` | Unvalidated redirect via `?next=`, `?returnUrl=`, etc. |
89
+ | `jwt` | Passive JWT weaknesses (`alg=none`, missing `exp`) |
90
+ | `headers` | HTTPS, HSTS, nosniff, clickjacking, Referrer-Policy, version disclosure |
91
+
92
+ ## Roadmap (checks to add next, in order)
93
+
94
+ - [ ] `verb` — HTTP method handling / verb tampering
95
+ - [ ] `cors` — permissive CORS on the auth endpoint (`ACAO: *` with credentials)
96
+ - [ ] `cache` — sensitive responses missing `Cache-Control: no-store`
97
+
98
+ ## Learning demo
99
+
100
+ This repo also ships an intentionally **vulnerable** and a **secure** server to try loginscan against:
101
+
102
+ ```bash
103
+ python3 vulnerable_app.py # terminal 1 -> port 8001
104
+ loginscan --site http://127.0.0.1:8001/ --i-own-this --user admin --success "Giriş başarılı"
105
+ # -> 4 vulnerabilities found
106
+
107
+ python3 secure_app.py # port 8002
108
+ loginscan --site http://127.0.0.1:8002/ --i-own-this --user admin --success "Giriş başarılı"
109
+ # -> sqli / enumeration / rate-limit all OK
110
+ ```
111
+
112
+ ## Releasing to PyPI
113
+
114
+ The package builds cleanly (`python -m build`) and passes `twine check`. To publish:
115
+
116
+ 1. Create the `loginscan` project on PyPI and add a Trusted Publisher for this repo's
117
+ `Publish to PyPI` workflow (environment `pypi`).
118
+ 2. Push a version tag: `git tag v0.1.0 && git push origin v0.1.0`.
119
+
120
+ The workflow then builds and uploads automatically — no API token stored in the repo.
121
+
122
+ ## License
123
+
124
+ MIT. Authorized use only.
@@ -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
+ )]