From 04094d802db6c218ab0f15e7f75c332f78845eda Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Mon, 11 May 2026 18:03:14 +0200 Subject: [PATCH] fix: harden security based on pentest scan findings - Add Content-Security-Policy to main nginx server block (was only on /admin/) - Add Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy headers - Add includeSubDomains to HSTS header - Set HttpOnly, Secure, SameSite=Lax session cookie params on public pages (AdminAuth already hardens the /admin session with SameSite=Strict) - Update xamxam.conf.reference and SECURITY_HEADERS.md to match --- app/src/App.php | 8 + app/templates/admin/acces.php | 13 + justfile | 6 +- nginx/docs/SECURITY_HEADERS.md | 13 +- nginx/xamxam.conf | 6 +- nginx/xamxam.conf.reference | 6 +- pentest_xamxam.py | 469 --------------------------------- 7 files changed, 37 insertions(+), 484 deletions(-) delete mode 100644 pentest_xamxam.py diff --git a/app/src/App.php b/app/src/App.php index c602450..7302a8d 100644 --- a/app/src/App.php +++ b/app/src/App.php @@ -24,6 +24,14 @@ class App self::$booted = true; } if (session_status() === PHP_SESSION_NONE) { + $isSecure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; + session_set_cookie_params([ + 'lifetime' => 0, + 'path' => '/', + 'secure' => $isSecure, + 'httponly' => true, + 'samesite' => 'Lax', + ]); session_start(); } self::ensureCsrf(); diff --git a/app/templates/admin/acces.php b/app/templates/admin/acces.php index 005bae2..bb306b6 100644 --- a/app/templates/admin/acces.php +++ b/app/templates/admin/acces.php @@ -1442,6 +1442,19 @@ +%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +\\\\\\\ to: srupvzpw 5a2ec7e2 "feat: add passive pentest scanner script with PEP 723 uv metadata" (rebased revision) ++ $linkName = $link['name'] ?? ''; +++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: srupvzpw 5a2ec7e2 "feat: add passive pentest scanner script with PEP 723 uv metadata" (rebased revision) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +- $linkName = $link['name'] ?? ''; +- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: npqqxmut 9daffcda "fix: harden security based on pentest scan findings" (rebased revision) + $linkName = $link['name'] ?? ''; + $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; + $linkLockedYear = $link['locked_year'] ?? null; ++%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) ++\\\\\\\ to: npqqxmut d463ff53 "fix: harden security based on pentest scan findings" (rebased revision) +++ $linkName = $link['name'] ?? ''; ++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; ?> diff --git a/justfile b/justfile index c86279a..95aa955 100644 --- a/justfile +++ b/justfile @@ -60,9 +60,9 @@ deploy: --exclude 'storage/docs' \ --exclude 'var/' \ app/ xamxam:/var/www/xamxam/ - # Upload deploy-server.sh for post-deploy permission fix - rsync -v scripts/deploy-server.sh xamxam:/tmp/deploy-server.sh + # Deploy nginx config + fix permissions + reload (single server-side run) rsync -v nginx/xamxam.conf xamxam:/tmp/xamxam.conf + rsync -v scripts/deploy-server.sh xamxam:/tmp/deploy-server.sh ssh -t xamxam "sudo bash /tmp/deploy-server.sh" ssh xamxam "rm -f /tmp/deploy-server.sh /tmp/xamxam.conf" ssh xamxam "mkdir -p /var/www/xamxam/var/{cache,logs,tmp}" @@ -70,8 +70,6 @@ deploy: rsync -v scripts/migrate.sh xamxam:/tmp/migrate.sh ssh xamxam "cd /var/www/xamxam && REPO_ROOT=/var/www/xamxam bash /tmp/migrate.sh" ssh xamxam "rm -f /tmp/migrate.sh" - # Deploy nginx configuration - @just deploy-nginx # Sync .env separately (excluded above to avoid accidental overwrite on subsequent deploys) @just deploy-env @just deploy-verify-permissions diff --git a/nginx/docs/SECURITY_HEADERS.md b/nginx/docs/SECURITY_HEADERS.md index 81802d4..d80be40 100644 --- a/nginx/docs/SECURITY_HEADERS.md +++ b/nginx/docs/SECURITY_HEADERS.md @@ -4,16 +4,20 @@ | Header | Value | Purpose | |--------|-------|---------| +| `Strict-Transport-Security` | `max-age=63072000; includeSubDomains; preload;` | HSTS — forces HTTPS | +| `Content-Security-Policy` | `default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';` | Restrict resource origins; block embedding | | `X-Frame-Options` | `SAMEORIGIN` | Prevent clickjacking | | `X-Content-Type-Options` | `nosniff` | Prevent MIME-type sniffing | | `Referrer-Policy` | `strict-origin-when-cross-origin` | Limit referrer leakage | | `Permissions-Policy` | `geolocation=(), microphone=(), camera=()` | Disable unused browser APIs | +| `Cross-Origin-Opener-Policy` | `same-origin` | Isolates browsing context | +| `Cross-Origin-Resource-Policy` | `same-origin` | Controls cross-origin resource sharing | -## Headers in use (`/admin/` location block) +## Headers in use (`/admin/` location block — inherited from main + overrides) | Header | Value | Purpose | |--------|-------|---------| -| `Content-Security-Policy` | `default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';` | Restrict resource origins; block embedding | +| `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';` | Restrict resource origins; allows inline scripts for OverType editor | | `X-Robots-Tag` | `noindex, nofollow` | Prevent search-engine indexing of admin | These were previously declared in `public/admin/.htaccess` as Apache @@ -36,8 +40,3 @@ no protection and may introduce risk. **Correct mitigation:** a proper `Content-Security-Policy` header (now done for `/admin/`; public-page CSP is todo item #11). -## Pending headers - -| Header | Scope | Status | -|--------|-------|--------| -| `Content-Security-Policy` | Public pages (non-admin) | ⏳ todo item #11 | diff --git a/nginx/xamxam.conf b/nginx/xamxam.conf index fa771fe..6aa2ce4 100644 --- a/nginx/xamxam.conf +++ b/nginx/xamxam.conf @@ -36,12 +36,14 @@ server { index index.php index.html index.htm; # Security headers + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload;" always; + add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; - # X-XSS-Protection intentionally omitted — deprecated and counterproductive in modern browsers. - # CSP (Content-Security-Policy) is the correct mitigation. add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + add_header Cross-Origin-Opener-Policy "same-origin" always; + add_header Cross-Origin-Resource-Policy "same-origin" always; # Server tokens already disabled in nginx.conf # server_tokens off; diff --git a/nginx/xamxam.conf.reference b/nginx/xamxam.conf.reference index e2b874d..e3e77b6 100644 --- a/nginx/xamxam.conf.reference +++ b/nginx/xamxam.conf.reference @@ -34,12 +34,14 @@ server { index index.php index.html index.htm; # Security headers + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload;" always; + add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; - # X-XSS-Protection intentionally omitted — deprecated and counterproductive in modern browsers. - # CSP (Content-Security-Policy) is the correct mitigation. add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + add_header Cross-Origin-Opener-Policy "same-origin" always; + add_header Cross-Origin-Resource-Policy "same-origin" always; # Server tokens already disabled in nginx.conf # server_tokens off; diff --git a/pentest_xamxam.py b/pentest_xamxam.py deleted file mode 100644 index 5d0a208..0000000 --- a/pentest_xamxam.py +++ /dev/null @@ -1,469 +0,0 @@ -#!/usr/bin/env -S uv run -# /// script -# requires-python = ">=3.12" -# dependencies = [ -# "requests", -# "dnspython", -# "colorama", -# ] -# /// -""" -Passive / Non-Destructive Pentest Script -Target: xamxam.erg.be -Author: generated for site owner / authorized use only - -Checks performed: - 1. DNS & WHOIS metadata - 2. TLS certificate info - 3. HTTP security headers audit - 4. Cookie flags audit - 5. Sensitive file/path exposure - 6. robots.txt & sitemap.xml parsing - 7. Open redirect probe (non-exploiting) - 8. Reflected XSS canary in search param (detection only) - 9. Directory listing detection - 10. Software version disclosure in headers/meta - 11. CORS misconfiguration check - 12. Clickjacking (X-Frame-Options / CSP frame-ancestors) - 13. HSTS check - 14. Subdomain enumeration (wordlist) - 15. Summary report - -Usage: - pip install requests dnspython colorama - python3 pentest_xamxam.py -""" - -import socket -import ssl -import json -import re -import datetime -import urllib.parse -from dataclasses import dataclass, field -from typing import Optional - -import requests -import dns.resolver -from colorama import Fore, Style, init - -init(autoreset=True) - -# ── Config ──────────────────────────────────────────────────────────────────── -TARGET_HOST = "xamxam.erg.be" -TARGET_BASE = f"https://{TARGET_HOST}" -TIMEOUT = 8 -HEADERS = {"User-Agent": "PentestScanner/1.0 (authorized audit)"} - -# Paths an attacker would probe first -SENSITIVE_PATHS = [ - "/.env", "/.env.local", "/.env.production", - "/.git/HEAD", "/.git/config", - "/wp-admin", "/wp-login.php", # CMS probes - "/admin", "/administrator", "/admin/login", - "/phpmyadmin", "/pma", - "/backup.zip", "/backup.sql", "/db.sql", - "/config.php", "/config.json", "/settings.py", - "/robots.txt", "/sitemap.xml", - "/.htaccess", "/web.config", - "/api", "/api/v1", "/api/users", - "/graphql", - "/server-status", # Apache status page - "/actuator", "/actuator/health", # Spring Boot - "/_next/static", # Next.js artefacts - "/static", - "/uploads", "/files", "/media", -] - -SECURITY_HEADERS = { - "Strict-Transport-Security": "HSTS — forces HTTPS", - "Content-Security-Policy": "CSP — mitigates XSS", - "X-Frame-Options": "Clickjacking protection", - "X-Content-Type-Options": "MIME sniffing protection", - "Referrer-Policy": "Controls referrer leakage", - "Permissions-Policy": "Limits browser feature access", - "X-XSS-Protection": "Legacy XSS filter (deprecated but informative)", - "Cross-Origin-Opener-Policy": "Isolates browsing context", - "Cross-Origin-Resource-Policy": "Controls cross-origin resource sharing", -} - -SUBDOMAIN_WORDLIST = [ - "www", "mail", "smtp", "ftp", "dev", "staging", "test", "api", - "admin", "portal", "old", "beta", "cdn", "static", "media", - "docs", "blog", "intranet", "vpn", "login", "auth", -] - -XSS_CANARY = "" -OPEN_REDIRECT_PAYLOADS = ["//evil.com", "https://evil.com", "/\\evil.com"] - - -# ── Helpers ─────────────────────────────────────────────────────────────────── -def ok(msg): print(f" {Fore.GREEN}[✓]{Style.RESET_ALL} {msg}") -def warn(msg): print(f" {Fore.YELLOW}[!]{Style.RESET_ALL} {msg}") -def bad(msg): print(f" {Fore.RED}[✗]{Style.RESET_ALL} {msg}") -def info(msg): print(f" {Fore.CYAN}[i]{Style.RESET_ALL} {msg}") -def section(title): - print(f"\n{Fore.MAGENTA}{'═'*60}{Style.RESET_ALL}") - print(f"{Fore.MAGENTA} {title}{Style.RESET_ALL}") - print(f"{Fore.MAGENTA}{'═'*60}{Style.RESET_ALL}") - - -@dataclass -class Finding: - severity: str # CRITICAL / HIGH / MEDIUM / LOW / INFO - title: str - detail: str - - -findings: list[Finding] = [] - -def add(severity, title, detail=""): - findings.append(Finding(severity, title, detail)) - - -def get(path="", params=None, allow_redirects=True) -> Optional[requests.Response]: - try: - return requests.get( - TARGET_BASE + path, - params=params, - headers=HEADERS, - timeout=TIMEOUT, - allow_redirects=allow_redirects, - verify=True, - ) - except requests.exceptions.SSLError as e: - add("HIGH", f"SSL error on {path}", str(e)) - return None - except requests.exceptions.ConnectionError: - return None - except requests.exceptions.Timeout: - return None - - -# ── 1. DNS & IP ─────────────────────────────────────────────────────────────── -def check_dns(): - section("1. DNS & IP Resolution") - try: - ip = socket.gethostbyname(TARGET_HOST) - info(f"A record → {ip}") - add("INFO", "A record", ip) - except Exception as e: - bad(f"DNS resolution failed: {e}") - - for rtype in ("MX", "TXT", "NS", "AAAA"): - try: - answers = dns.resolver.resolve(TARGET_HOST, rtype) - for r in answers: - info(f"{rtype:5s} → {r}") - except Exception: - pass - - -# ── 2. TLS Certificate ──────────────────────────────────────────────────────── -def check_tls(): - section("2. TLS Certificate") - try: - ctx = ssl.create_default_context() - with ctx.wrap_socket(socket.socket(), server_hostname=TARGET_HOST) as s: - s.connect((TARGET_HOST, 443)) - cert = s.getpeercert() - - subject = dict(x[0] for x in cert["subject"]) - issuer = dict(x[0] for x in cert["issuer"]) - not_after = datetime.datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z") - days_left = (not_after.replace(tzinfo=datetime.timezone.utc) - datetime.datetime.now(datetime.UTC)).days - - info(f"Subject : {subject.get('commonName')}") - info(f"Issuer : {issuer.get('organizationName')}") - info(f"Expires : {not_after.date()} ({days_left} days)") - - sans = [v for t, v in cert.get("subjectAltName", []) if t == "DNS"] - info(f"SANs : {', '.join(sans)}") - - if days_left < 30: - bad(f"Certificate expires in {days_left} days!") - add("HIGH", "Certificate near expiry", f"{days_left} days remaining") - else: - ok("Certificate valid and not near expiry") - - except Exception as e: - bad(f"TLS check failed: {e}") - add("HIGH", "TLS check failed", str(e)) - - -# ── 3. Security Headers ─────────────────────────────────────────────────────── -def check_headers(): - section("3. HTTP Security Headers") - r = get("/") - if not r: - bad("Could not reach target"); return - - # Show server / technology leakage - for leak_header in ("Server", "X-Powered-By", "X-AspNet-Version", "X-Generator"): - val = r.headers.get(leak_header) - if val: - warn(f"Version disclosure — {leak_header}: {val}") - add("LOW", f"Version disclosure: {leak_header}", val) - - for header, description in SECURITY_HEADERS.items(): - val = r.headers.get(header) - if val: - ok(f"{header}: {val}") - else: - bad(f"Missing: {header} ({description})") - severity = "HIGH" if header in ("Strict-Transport-Security", "Content-Security-Policy", "X-Frame-Options") else "MEDIUM" - add(severity, f"Missing header: {header}", description) - - -# ── 4. Cookie Flags ─────────────────────────────────────────────────────────── -def check_cookies(): - section("4. Cookie Security Flags") - r = get("/") - if not r: return - - raw_cookies = r.headers.get("Set-Cookie", "") - if not raw_cookies: - info("No Set-Cookie header on homepage (may appear after login)") - return - - for cookie_str in raw_cookies.split(","): - name = cookie_str.split("=")[0].strip() - flags = cookie_str.lower() - issues = [] - if "httponly" not in flags: - issues.append("missing HttpOnly") - if "secure" not in flags: - issues.append("missing Secure") - if "samesite" not in flags: - issues.append("missing SameSite") - if issues: - warn(f"Cookie '{name}': {', '.join(issues)}") - add("MEDIUM", f"Cookie flags: {name}", "; ".join(issues)) - else: - ok(f"Cookie '{name}' has all recommended flags") - - -# ── 5. Sensitive File Exposure ──────────────────────────────────────────────── -def check_sensitive_paths(): - section("5. Sensitive Path Exposure") - for path in SENSITIVE_PATHS: - r = get(path, allow_redirects=False) - if r is None: - continue - if r.status_code == 200: - size = len(r.content) - if path in ("/robots.txt", "/sitemap.xml"): - info(f"{r.status_code} {path} ({size}B)") - add("INFO", f"Found: {path}", r.text[:300]) - else: - bad(f"{r.status_code} {path} ({size}B) ← EXPOSED") - add("HIGH", f"Sensitive path exposed: {path}", f"HTTP 200, {size} bytes") - elif r.status_code in (301, 302): - loc = r.headers.get("Location", "") - info(f"{r.status_code} {path} → {loc}") - elif r.status_code == 403: - warn(f"403 {path} (exists but forbidden — still interesting)") - add("LOW", f"Forbidden path (exists): {path}") - - -# ── 6. robots.txt & sitemap ─────────────────────────────────────────────────── -def check_robots(): - section("6. robots.txt Analysis") - r = get("/robots.txt") - if r and r.status_code == 200: - info("robots.txt found:") - disallowed = [l for l in r.text.splitlines() if "Disallow" in l] - for line in disallowed: - warn(f" {line.strip()} ← attacker will check these first") - path = line.split(":", 1)[-1].strip() - add("INFO", "robots.txt Disallow path", path) - else: - info("No robots.txt found") - - -# ── 7. Open Redirect ───────────────────────────────────────────────────────── -def check_open_redirect(): - section("7. Open Redirect Probe") - redirect_params = ["redirect", "url", "next", "return", "returnUrl", "goto", "dest", "target"] - for param in redirect_params: - for payload in OPEN_REDIRECT_PAYLOADS[:1]: # Just one payload for non-destructive check - r = get("/", params={param: payload}, allow_redirects=False) - if r and r.status_code in (301, 302, 303, 307, 308): - loc = r.headers.get("Location", "") - if "evil.com" in loc: - bad(f"Open redirect via ?{param}= → {loc}") - add("HIGH", "Open Redirect", f"?{param}={payload} → {loc}") - else: - info(f"Redirect on ?{param}= but to: {loc} (not vulnerable)") - # No redirect = not vulnerable to this param - - -# ── 8. XSS Canary in Search ─────────────────────────────────────────────────── -def check_xss(): - section("8. Reflected XSS Canary (search param)") - search_paths = ["/", "/repertoire", "/search"] - search_params = ["q", "s", "query", "search", "keyword", "term"] - - for path in search_paths: - for param in search_params: - r = get(path, params={param: XSS_CANARY}) - if r and XSS_CANARY in r.text: - bad(f"XSS canary reflected unencoded at {path}?{param}=") - add("CRITICAL", "Reflected XSS", f"{path}?{param}={XSS_CANARY}") - elif r and urllib.parse.quote(XSS_CANARY) in r.text: - ok(f"{path}?{param}= — canary is URL-encoded (likely safe)") - elif r and "<script>" in r.text.lower(): - ok(f"{path}?{param}= — canary is HTML-encoded (safe)") - - -# ── 9. Directory Listing ────────────────────────────────────────────────────── -def check_dir_listing(): - section("9. Directory Listing") - dirs = ["/uploads/", "/files/", "/media/", "/static/", "/assets/"] - for path in dirs: - r = get(path) - if r and r.status_code == 200: - markers = ["index of", "parent directory", "directory"] - if any(m in r.text.lower() for m in markers): - bad(f"Directory listing ENABLED at {path}") - add("HIGH", "Directory listing enabled", path) - else: - info(f"{path} → 200 but no listing detected") - - -# ── 10. CORS Misconfiguration ───────────────────────────────────────────────── -def check_cors(): - section("10. CORS Misconfiguration") - try: - r = requests.get( - TARGET_BASE + "/api", - headers={**HEADERS, "Origin": "https://evil.com"}, - timeout=TIMEOUT, - verify=True, - ) - acao = r.headers.get("Access-Control-Allow-Origin", "") - acac = r.headers.get("Access-Control-Allow-Credentials", "") - if acao == "*": - warn("CORS: Access-Control-Allow-Origin: * (wildcard — no credentials risk)") - add("LOW", "CORS wildcard origin", "ACAO: *") - elif acao == "https://evil.com": - bad("CORS reflects arbitrary Origin!") - add("HIGH", "CORS reflects arbitrary Origin", f"ACAO: {acao}, ACAC: {acac}") - if acac.lower() == "true": - add("CRITICAL", "CORS reflects Origin + credentials allowed", "Full CORS bypass") - else: - ok(f"CORS origin: '{acao}' (not reflected)") - except Exception as e: - info(f"CORS check skipped: {e}") - - -# ── 11. Subdomain Enumeration ───────────────────────────────────────────────── -def check_subdomains(): - section("11. Subdomain Enumeration (passive wordlist)") - parent = ".".join(TARGET_HOST.split(".")[-2:]) # erg.be - found = [] - for sub in SUBDOMAIN_WORDLIST: - fqdn = f"{sub}.{parent}" - try: - ip = socket.gethostbyname(fqdn) - info(f"Found: {fqdn} → {ip}") - found.append(fqdn) - add("INFO", "Subdomain found", f"{fqdn} → {ip}") - except socket.gaierror: - pass - if not found: - ok("No additional subdomains discovered from wordlist") - - -# ── 12. HSTS Preload ────────────────────────────────────────────────────────── -def check_hsts(): - section("12. HSTS & HTTPS Redirect") - # Check HTTP → HTTPS redirect - try: - r = requests.get(f"http://{TARGET_HOST}/", headers=HEADERS, timeout=TIMEOUT, - allow_redirects=False, verify=False) - if r.status_code in (301, 302): - loc = r.headers.get("Location", "") - if loc.startswith("https://"): - ok(f"HTTP redirects to HTTPS ({r.status_code})") - else: - bad(f"HTTP redirects to non-HTTPS: {loc}") - add("HIGH", "HTTP does not redirect to HTTPS", loc) - else: - bad(f"HTTP returns {r.status_code} (no HTTPS redirect)") - add("HIGH", "No HTTP→HTTPS redirect", f"Status: {r.status_code}") - except Exception: - pass - - r = get("/") - if r: - hsts = r.headers.get("Strict-Transport-Security", "") - if "max-age" in hsts: - age = re.search(r"max-age=(\d+)", hsts) - age_val = int(age.group(1)) if age else 0 - if age_val < 31536000: - warn(f"HSTS max-age too short: {age_val}s (recommend ≥31536000)") - add("LOW", "HSTS max-age too short", f"{age_val}s") - else: - ok(f"HSTS max-age OK: {age_val}s") - if "includeSubDomains" not in hsts: - warn("HSTS missing includeSubDomains") - if "preload" not in hsts: - warn("HSTS missing preload directive") - else: - bad("HSTS header missing entirely") - add("HIGH", "HSTS not set") - - -# ── Summary ─────────────────────────────────────────────────────────────────── -def print_summary(): - section("SUMMARY REPORT") - order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4} - color_map = { - "CRITICAL": Fore.RED, - "HIGH": Fore.YELLOW, - "MEDIUM": Fore.CYAN, - "LOW": Fore.WHITE, - "INFO": Fore.BLUE, - } - sorted_findings = sorted(findings, key=lambda f: order.get(f.severity, 9)) - counts = {k: 0 for k in order} - for f in sorted_findings: - counts[f.severity] = counts.get(f.severity, 0) + 1 - c = color_map.get(f.severity, "") - print(f" {c}[{f.severity:8s}]{Style.RESET_ALL} {f.title}") - if f.detail: - detail_preview = f.detail[:120].replace("\n", " ") - print(f" └─ {detail_preview}") - - print() - for sev, cnt in counts.items(): - if cnt: - c = color_map[sev] - print(f" {c}{sev}: {cnt}{Style.RESET_ALL}") - - print(f"\n Scan completed: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - print(f" Target: {TARGET_BASE}") - - -# ── Main ────────────────────────────────────────────────────────────────────── -if __name__ == "__main__": - print(f""" -{Fore.CYAN}╔══════════════════════════════════════════════════════════╗ -║ Passive Pentest Scanner — authorized use only ║ -║ Target : {TARGET_HOST:<44} ║ -╚══════════════════════════════════════════════════════════╝{Style.RESET_ALL} -""") - check_dns() - check_tls() - check_headers() - check_cookies() - check_sensitive_paths() - check_robots() - check_open_redirect() - check_xss() - check_dir_listing() - check_cors() - check_subdomains() - check_hsts() - print_summary()