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
This commit is contained in:
Pontoporeia
2026-05-11 18:03:14 +02:00
parent 4717b4d67e
commit 04094d802d
7 changed files with 37 additions and 484 deletions

View File

@@ -24,6 +24,14 @@ class App
self::$booted = true; self::$booted = true;
} }
if (session_status() === PHP_SESSION_NONE) { 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(); session_start();
} }
self::ensureCsrf(); self::ensureCsrf();

View File

@@ -1442,6 +1442,19 @@
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +%%%%%%% 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) +\\\\\\\ to: srupvzpw 5a2ec7e2 "feat: add passive pentest scanner script with PEP 723 uv metadata" (rebased revision)
++ $linkName = $link['name'] ?? ''; ++ $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'])) : ''; ++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
?> ?>
<tr class="admin-table-row" onclick="event.stopPropagation(); window.open('/partage/<?= urlencode($link['slug']) ?>', '_blank')" style="cursor:pointer"> <tr class="admin-table-row" onclick="event.stopPropagation(); window.open('/partage/<?= urlencode($link['slug']) ?>', '_blank')" style="cursor:pointer">

View File

@@ -60,9 +60,9 @@ deploy:
--exclude 'storage/docs' \ --exclude 'storage/docs' \
--exclude 'var/' \ --exclude 'var/' \
app/ xamxam:/var/www/xamxam/ app/ xamxam:/var/www/xamxam/
# Upload deploy-server.sh for post-deploy permission fix # Deploy nginx config + fix permissions + reload (single server-side run)
rsync -v scripts/deploy-server.sh xamxam:/tmp/deploy-server.sh
rsync -v nginx/xamxam.conf xamxam:/tmp/xamxam.conf 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 -t xamxam "sudo bash /tmp/deploy-server.sh"
ssh xamxam "rm -f /tmp/deploy-server.sh /tmp/xamxam.conf" ssh xamxam "rm -f /tmp/deploy-server.sh /tmp/xamxam.conf"
ssh xamxam "mkdir -p /var/www/xamxam/var/{cache,logs,tmp}" 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 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 "cd /var/www/xamxam && REPO_ROOT=/var/www/xamxam bash /tmp/migrate.sh"
ssh xamxam "rm -f /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) # Sync .env separately (excluded above to avoid accidental overwrite on subsequent deploys)
@just deploy-env @just deploy-env
@just deploy-verify-permissions @just deploy-verify-permissions

View File

@@ -4,16 +4,20 @@
| Header | Value | Purpose | | 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-Frame-Options` | `SAMEORIGIN` | Prevent clickjacking |
| `X-Content-Type-Options` | `nosniff` | Prevent MIME-type sniffing | | `X-Content-Type-Options` | `nosniff` | Prevent MIME-type sniffing |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Limit referrer leakage | | `Referrer-Policy` | `strict-origin-when-cross-origin` | Limit referrer leakage |
| `Permissions-Policy` | `geolocation=(), microphone=(), camera=()` | Disable unused browser APIs | | `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 | | 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 | | `X-Robots-Tag` | `noindex, nofollow` | Prevent search-engine indexing of admin |
These were previously declared in `public/admin/.htaccess` as Apache 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 **Correct mitigation:** a proper `Content-Security-Policy` header (now done for
`/admin/`; public-page CSP is todo item #11). `/admin/`; public-page CSP is todo item #11).
## Pending headers
| Header | Scope | Status |
|--------|-------|--------|
| `Content-Security-Policy` | Public pages (non-admin) | ⏳ todo item #11 |

View File

@@ -36,12 +36,14 @@ server {
index index.php index.html index.htm; index index.php index.html index.htm;
# Security headers # 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-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" 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 Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" 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 already disabled in nginx.conf
# server_tokens off; # server_tokens off;

View File

@@ -34,12 +34,14 @@ server {
index index.php index.html index.htm; index index.php index.html index.htm;
# Security headers # 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-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" 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 Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" 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 already disabled in nginx.conf
# server_tokens off; # server_tokens off;

View File

@@ -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 = "<script>alert(1)</script>"
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 "&lt;script&gt;" 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", "<title>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()