mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
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:
@@ -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();
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
6
justfile
6
justfile
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 "<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", "<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()
|
|
||||||
Reference in New Issue
Block a user