Security · python · 27 copies
Web Application Security Scanner
OWASP Top 10 automated scanner for web applications. Detects SQLi, reflected XSS, directory traversal, open redirects, sensitive file exposure (.env, .git, backups), missing security headers (HSTS/CSP/X-Frame), insecure cookie flags, server version CVEs, and CORS misconfigurations. Produces risk-scored findings with remediation steps. For authorized testing only.
- Linux
- macOS
- Windows
$
pip install "requests>=2.28"terminal
web_app_scanner.py
#!/usr/bin/env python3
"""
Web Application Security Scanner
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/web-app-scanner
It covers what the script does, whether it is safe to run, the
inventory file and credentials it needs, how to read the output,
and what to do when something fails.
pip install "requests>=2.28"
"""
import argparse, json, re, sys, urllib.parse
from datetime import datetime
# Windows consoles default to a legacy code page (cp1252), which cannot encode
# the arrows, box-drawing and check-mark characters used in the output below.
# Printing one raises UnicodeEncodeError and kills the script mid-scan, so make
# stdout UTF-8 and degrade unencodable characters instead of crashing.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass # already wrapped, or not a real stream (piped/redirected)
try:
import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
# Suppressed because an unverified request is a DELIBERATE, reported
# decision here (see check_target_tls), not a silent default.
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
except ImportError:
print("[!] pip install requests")
sys.exit(1)
SENSITIVE_FILES = [
".git/config", ".git/HEAD", ".env", ".env.local", ".env.production",
"backup.zip", "backup.tar.gz", "db.sql", "phpinfo.php",
"wp-admin/", "wp-config.php", "robots.txt", "sitemap.xml",
"admin/", "administrator/", "config.php", "web.config",
"server-status", "server-info", ".htaccess", "Dockerfile",
"docker-compose.yml", "package.json", "composer.json"
]
SQLI_PAYLOADS = [
"' OR '1'='1",
"' OR '1'='1' --",
"\" OR \"1\"=\"1",
"'; DROP TABLE users--",
"1 AND 1=1",
"1 AND 1=2",
"' UNION SELECT null,null--",
]
XSS_PAYLOADS = [
"<script>alert(1)</script>",
"\"><script>alert(1)</script>",
"<img src=x onerror=alert(1)>",
"javascript:alert(1)",
"'><svg onload=alert(1)>",
]
TRAVERSAL_PAYLOADS = [
"../../../etc/passwd",
"..%2F..%2F..%2Fetc%2Fpasswd",
"....//....//....//etc/passwd",
"%2e%2e%2f%2e%2e%2fetc%2fpasswd",
"../../../windows/win.ini",
"..\\..\\..\\windows\\win.ini",
]
OPEN_REDIRECT_PARAMS = ["redirect", "url", "next", "return", "returnUrl", "return_to", "goto"]
EVIL_URL = "https://evil.example.com"
REQUIRED_HEADERS = {
"Strict-Transport-Security": "HSTS — browser won't enforce HTTPS on next visit",
"Content-Security-Policy": "CSP — XSS attacks have no mitigation layer",
"X-Frame-Options": "Clickjacking — site can be embedded in iframe",
"X-Content-Type-Options": "MIME sniffing — browser may interpret files as different type",
"Referrer-Policy": "Leaks URL in Referer header to third parties",
"Permissions-Policy": "Browser features not restricted (camera, microphone, geolocation)",
}
KNOWN_CVE_SERVERS = {
"Apache/2.4.49": "CVE-2021-41773 Path Traversal / RCE — CRITICAL — PATCH IMMEDIATELY",
"Apache/2.4.50": "CVE-2021-42013 Path Traversal / RCE — CRITICAL — PATCH IMMEDIATELY",
"nginx/1.14": "CVE-2019-9511 HTTP/2 DoS — update to 1.20+",
"PHP/5": "EOL since 2018 — dozens of unpatched CVEs",
"PHP/7.0": "EOL since 2019 — update to 8.x",
"PHP/7.1": "EOL since 2019 — update to 8.x",
"OpenSSL/1.0": "Multiple CVEs — EOL — update to 3.x",
}
SESSION = requests.Session()
SESSION.headers.update({"User-Agent": "Mozilla/5.0 (Security-Scanner/1.0 authorized)"})
TIMEOUT = 10
# Verification is ON by default. check_target_tls() turns it off for the rest of
# the scan if the target's certificate cannot be verified - but only after
# recording WHY as a finding, so the information is not lost the way a blanket
# verify=False loses it.
SESSION.verify = True
def check_target_tls(url: str, insecure: bool) -> bool:
"""
Decide whether the scan runs verified, and report the target's TLS state.
Returns True if the rest of the scan should verify certificates.
The certificate is treated as something to REPORT rather than a
precondition for scanning. A staging host with a self-signed certificate is
the normal case for this tool, so refusing to scan it would make the tool
useless; silently accepting it would hide a real finding. Both are avoided
by probing once, naming the specific failure, and continuing.
"""
parsed = urllib.parse.urlparse(url)
if parsed.scheme == "http":
add_finding("HIGH", "Transport Security",
"Target is served over plain HTTP, not HTTPS",
"Everything in this session crosses the network in cleartext and "
"can be read or modified in transit. Serve the site over HTTPS and "
"redirect HTTP to it.")
print(" ✗ Plain HTTP — this scan and the site's traffic are unencrypted")
return True # no TLS to verify; nothing to turn off
if insecure:
print(" ⚠ --insecure: certificate not verified, and not reported")
return False
try:
requests.get(url, timeout=TIMEOUT, verify=True,
headers=dict(SESSION.headers), allow_redirects=False)
print(" ✓ TLS certificate verified")
return True
except requests.exceptions.SSLError as exc:
reason = str(getattr(exc, "args", [""])[0])
low = reason.lower()
if "certificate has expired" in low or "certificate_expired" in low:
why = "the certificate has expired"
elif "self signed" in low or "self-signed" in low:
why = "the certificate is self-signed, so it chains to no trusted root"
elif "hostname" in low or "doesn't match" in low or "subjectaltname" in low:
why = ("the certificate is valid but was not issued for this hostname "
"(check for a misconfigured load balancer or vhost)")
elif "unable to get local issuer" in low or "unable to verify" in low:
why = ("the issuer is not trusted here — the server may be omitting an "
"intermediate certificate")
else:
why = f"verification failed: {reason[:160]}"
add_finding("HIGH", "Transport Security",
f"TLS certificate does not verify — {why}",
"Users see a browser warning, which trains them to click through it. "
"Install a certificate from a trusted CA, or add your internal root to "
"the trust stores that need it. Run /scripts/ssl-cert-checker against "
"this host for the expiry date, chain and key details.")
print(f" ✗ TLS certificate does not verify — {why}")
print(" Continuing unverified so the rest of the scan still runs.")
return False
except Exception:
# Not a certificate problem - let the main connection attempt report it.
return True
findings = []
def add_finding(severity, category, detail, recommendation=""):
findings.append({
"severity": severity, "category": category,
"detail": detail, "recommendation": recommendation
})
def check_security_headers(url, response):
print("\n[*] Security Headers Audit...")
for header, desc in REQUIRED_HEADERS.items():
if header.lower() not in {k.lower() for k in response.headers}:
print(f" \u2717 {header}: MISSING — {desc}")
add_finding("MEDIUM", "Missing Security Header", f"{header} not set", f"Add header: {header}")
else:
val = response.headers.get(header, "")
print(f" \u2713 {header}: {val[:60]}")
def check_server_disclosure(response):
server = response.headers.get("Server", "")
x_powered = response.headers.get("X-Powered-By", "")
if server:
print(f"\n[*] Server: {server}")
for pattern, cve in KNOWN_CVE_SERVERS.items():
if pattern in server:
print(f" \u2717 CRITICAL CVE: {cve}")
add_finding("CRITICAL", "Server Version CVE", f"Server: {server} — {cve}", "Update immediately")
if "/" in server:
add_finding("LOW", "Server Version Disclosure", f"Server header reveals: {server}",
"Set 'ServerTokens Prod' (Apache) or 'server_tokens off' (nginx)")
if x_powered:
print(f" X-Powered-By: {x_powered} ← version disclosure")
add_finding("LOW", "Technology Disclosure", f"X-Powered-By: {x_powered}",
"Remove X-Powered-By header")
def discover_params(url):
parsed = urllib.parse.urlparse(url)
params = dict(urllib.parse.parse_qsl(parsed.query))
return params, parsed
def test_sqli(base_url, params):
if not params:
print("\n[*] SQLi Test — no URL parameters found, testing form POST if available...")
return
print(f"\n[*] SQLi Test — {base_url} params: {list(params.keys())}...")
for param, original in params.items():
for payload in SQLI_PAYLOADS[:3]:
test_params = dict(params)
test_params[param] = payload
try:
r = SESSION.get(base_url, params=test_params, timeout=TIMEOUT)
errors = ["sql syntax", "mysql_fetch", "ORA-", "pg_query", "sqlite",
"syntax error", "unclosed quotation", "ODBC SQL"]
if any(e.lower() in r.text.lower() for e in errors):
msg = f"SQLi detected in param '{param}' with payload: {payload}"
print(f" \u2717 CRITICAL: {msg}")
print(f" SQL error in response — database commands accepted")
add_finding("CRITICAL", "SQL Injection", msg,
"Use parameterized queries / prepared statements")
return
if r.status_code == 200 and len(r.text) != len(SESSION.get(base_url, params=params, timeout=TIMEOUT).text):
msg = f"Possible SQLi in param '{param}' (response length changed with payload)"
print(f" \u26a0 SUSPICIOUS: {msg}")
add_finding("HIGH", "Possible SQL Injection", msg,
"Manual verification recommended")
return
except Exception:
pass
def test_xss(base_url, params):
if not params:
return
print(f"\n[*] XSS Test — {base_url}...")
for param, original in params.items():
for payload in XSS_PAYLOADS[:2]:
test_params = dict(params)
test_params[param] = payload
try:
r = SESSION.get(base_url, params=test_params, timeout=TIMEOUT)
if payload in r.text and "text/html" in r.headers.get("content-type", ""):
msg = f"Reflected XSS in param '{param}' — payload echoed unescaped"
print(f" \u2717 HIGH: {msg}")
print(f" Attacker can inject scripts into victim's browser")
add_finding("HIGH", "Cross-Site Scripting (XSS)", msg,
"HTML-encode all user input before output. Implement CSP.")
return
except Exception:
pass
def test_directory_traversal(base_url, params):
print(f"\n[*] Directory Traversal Test...")
if params:
for param in params:
for payload in TRAVERSAL_PAYLOADS[:2]:
test_params = dict(params)
test_params[param] = payload
try:
r = SESSION.get(base_url, params=test_params, timeout=TIMEOUT)
if any(sig in r.text for sig in ["root:x:", "[extensions]", "[fonts]"]):
msg = f"Directory traversal in param '{param}' — system file read!"
print(f" \u2717 CRITICAL: {msg}")
add_finding("CRITICAL", "Directory Traversal", msg,
"Validate and sanitize file path inputs. Use allowlist.")
return
except Exception:
pass
def check_sensitive_files(base_url):
print(f"\n[*] Sensitive File Exposure Check...")
parsed = urllib.parse.urlparse(base_url)
base = f"{parsed.scheme}://{parsed.netloc}"
for path in SENSITIVE_FILES:
try:
url = f"{base}/{path}"
r = SESSION.get(url, timeout=TIMEOUT, allow_redirects=False)
if r.status_code in (200, 301, 302):
severity = "CRITICAL" if path in (".env", ".env.local", ".env.production", "wp-config.php") else "HIGH"
hint = ""
if ".env" in path:
hint = " — DB credentials, API keys likely exposed!"
elif ".git" in path:
hint = " — source code leaked!"
elif "backup" in path:
hint = " — database or application backup exposed!"
print(f" \u2717 {severity}: GET /{path} → {r.status_code}{hint}")
add_finding(severity, "Sensitive File Exposed", f"/{path} returned HTTP {r.status_code}{hint}",
f"Block access to /{path} in web server config")
elif r.status_code == 403:
print(f" \u26a0 MEDIUM: GET /{path} → 403 (exists but forbidden)")
else:
print(f" \u2713 GET /{path} → {r.status_code}")
except Exception:
pass
def check_open_redirect(base_url):
print(f"\n[*] Open Redirect Check...")
parsed = urllib.parse.urlparse(base_url)
base = f"{parsed.scheme}://{parsed.netloc}"
for param in OPEN_REDIRECT_PARAMS:
try:
url = f"{base}/?{param}={EVIL_URL}"
r = SESSION.get(url, timeout=TIMEOUT, allow_redirects=False)
if r.status_code in (301, 302, 303, 307, 308):
loc = r.headers.get("Location", "")
if "evil.example.com" in loc or EVIL_URL in loc:
print(f" \u2717 HIGH: Open redirect via ?{param}= — redirects to attacker URL")
add_finding("HIGH", "Open Redirect", f"?{param}={EVIL_URL} causes redirect",
"Validate redirect targets against an allowlist of trusted domains")
return
except Exception:
pass
def check_cookie_flags(response):
print(f"\n[*] Cookie Security Analysis...")
cookies = response.headers.get("Set-Cookie", "")
if not cookies:
print(" No cookies set on initial request.")
return
for line in cookies.split(","):
name = line.strip().split("=")[0].strip()
httponly = "httponly" in line.lower()
secure = "secure" in line.lower()
samesite = "samesite" in line.lower()
issues = []
if not httponly: issues.append("HttpOnly: MISSING — cookie stealable via XSS")
if not secure: issues.append("Secure: MISSING — cookie sent over HTTP")
if not samesite: issues.append("SameSite: MISSING — CSRF risk")
if issues:
print(f" Cookie: {name}")
for issue in issues:
print(f" \u2717 {issue}")
add_finding("MEDIUM", "Insecure Cookie Flags",
f"Cookie '{name}' missing flags: {', '.join(issues)}",
"Set HttpOnly; Secure; SameSite=Strict on all session cookies")
else:
print(f" \u2713 Cookie: {name} — all flags set")
def check_cors(base_url):
print(f"\n[*] CORS Misconfiguration Check...")
try:
r = SESSION.get(base_url, timeout=TIMEOUT,
headers={"Origin": "https://evil.example.com"})
acao = r.headers.get("Access-Control-Allow-Origin", "")
acac = r.headers.get("Access-Control-Allow-Credentials", "")
if "evil.example.com" in acao or acao == "*":
if acac.lower() == "true" or acao == "*":
print(f" \u2717 HIGH: CORS accepts Origin: evil.example.com — authenticated requests allowed!")
add_finding("HIGH", "CORS Misconfiguration",
f"Access-Control-Allow-Origin: {acao} with Credentials: {acac}",
"Restrict CORS to specific trusted origins. Never combine * with credentials.")
else:
print(f" \u26a0 MEDIUM: CORS accepts arbitrary origins (no credentials)")
add_finding("MEDIUM", "CORS Misconfiguration", f"ACAO: {acao}",
"Restrict to specific trusted origins")
else:
print(f" \u2713 CORS properly restricted: {acao or 'not set'}")
except Exception as e:
print(f" [!] CORS check error: {e}")
def main():
parser = argparse.ArgumentParser(description="Web App Security Scanner — OWASP Top 10")
parser.add_argument("--url", required=True, help="Target URL (e.g. http://target.example.com)")
parser.add_argument("--output", default=None, help="JSON output file")
parser.add_argument("--insecure", action="store_true",
help="Skip TLS verification and do not report certificate "
"problems. Use only for a staging host whose self-signed "
"certificate you already know about — otherwise let the "
"scan report what it finds.")
args = parser.parse_args()
url = args.url.rstrip("/")
print(f"[*] Web App Scanner — OWASP Top 10 | {url}")
print("\n[*] Transport Security...")
SESSION.verify = check_target_tls(url, args.insecure)
try:
resp = SESSION.get(url, timeout=TIMEOUT)
except requests.exceptions.SSLError as e:
print(f"[!] TLS error connecting to {url}: {e}")
print(" Re-run with --insecure if this host's certificate is known-bad.")
sys.exit(1)
except Exception as e:
print(f"[!] Cannot connect to {url}: {e}")
sys.exit(1)
print(f"[*] Target: {url} | HTTP {resp.status_code}")
check_server_disclosure(resp)
check_security_headers(url, resp)
params, parsed = discover_params(url)
test_sqli(url, params)
test_xss(url, params)
test_directory_traversal(url, params)
check_sensitive_files(url)
check_open_redirect(url)
check_cookie_flags(resp)
check_cors(url)
critical = sum(1 for f in findings if f["severity"] == "CRITICAL")
high = sum(1 for f in findings if f["severity"] == "HIGH")
medium = sum(1 for f in findings if f["severity"] == "MEDIUM")
low = sum(1 for f in findings if f["severity"] == "LOW")
print(f"\n[*] Summary: {critical} CRITICAL | {high} HIGH | {medium} MEDIUM | {low} LOW")
output = args.output or f"web_scan_{parsed.netloc}_{datetime.now().strftime('%Y-%m-%d')}.json"
with open(output, "w") as fh:
json.dump({"target": url, "scan_date": datetime.now().isoformat(),
"findings": findings, "summary": {"critical": critical, "high": high,
"medium": medium, "low": low}}, fh, indent=2)
print(f"[*] Report saved: {output}")
return 2 if critical else (1 if high or medium else 0)
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)
--urlstrrequiredTarget URL, e.g. http://target.example.com
--outputstrJSON output file. Default: web_scan_<host>_<date>.json
--insecureflagSkip TLS verification and do not report certificate problems. By default the scan verifies the target's certificate, reports the specific reason if it fails, and then continues unverified so the rest of the scan still runs.
What This Solves
Most web applications fail the same handful of checks, and most of those failures are found by asking the application a few well-chosen questions rather than by anything clever. This runs that first pass automatically against one target: SQL injection reflected error and behaviour probes reflected XSS directory traversal open redirects sensitive file exposure .env, .git, backup files missing security headers HSTS, CSP, X-Frame-Options insecure cookie flags Secure, HttpOnly, SameSite server version CVEs CORS misconfiguration The output is risk-scored, so the `.git` directory you just exposed to the internet does not arrive in the same paragraph as a missing `X-Frame-Options`. Use it as the first pass on an application you own, as a pre-release gate, or to check that the thing somebody deployed last Friday is not serving its own source control.
Is It Safe to Run - Read This First
**Only scan applications you own or are explicitly authorised to test.** This sends attack payloads. SQL injection strings, XSS payloads, traversal sequences - that is how the checks work. Against an application you do not have permission to test, that is not research, it is an attack, and the logs will describe it accurately. Get it in writing. "It's only a scanner" has never been a defence. Given authorisation, understand what it does to the target: It writes, in the sense that it submits. The injection and XSS probes submit data to forms and parameters. On an application with a database behind it, that can create rows, send email, trigger webhooks or fire whatever the form normally fires. **Prefer staging.** If you must run it against production, know what the forms do first. It is noisy in the logs and to a WAF. Expect a WAF to block it partway through, which also means a clean result from behind a WAF may mean the WAF worked, not that the application is sound. It can be slow and heavy. Many requests, in sequence. On a small instance that is load. It does not exploit, pivot or persist. It probes and reports. It does not attempt to extract data beyond what a positive check needs, and it leaves nothing behind. By default it verifies TLS and reports certificate problems. `--insecure` skips verification - useful on a staging box with a self-signed certificate, and it means you are no longer being told about certificate issues. On your own machine it writes the JSON report under --output.
Before You Start
1. Python 3.9 or newer: python --version 2. The library: pip install "requests>=2.28" 3. Written authorisation for the target. See above. 4. A staging URL, if one exists. See the note about submitted data.
Your First Run
Point it at something you own, ideally not production: python web_app_scanner.py --url http://staging.example.com One argument. It crawls, probes, scores and prints. Read the whole report before acting on any single line - the scoring matters more than the count, and a long list of header findings can bury one critical exposure. Save it: python web_app_scanner.py --url https://staging.example.com \ --output scan-$(date +%F).json Against a box with a self-signed certificate: python web_app_scanner.py --url https://10.0.0.5 --insecure
What the Output Means
Findings are risk-scored. Read them in that order, because they are not remotely equivalent: CRITICAL - exposed .env, .git, backup files An exposed `.env` is usually database credentials and API keys. An exposed `.git` is your source code, your history, and often secrets that were committed once and "removed" later. These need fixing today and they are not theoretical - they are a file anyone can download. HIGH - SQL injection, directory traversal A positive here means a probe got a response that should not have been possible. Verify by hand before reporting it, then treat it as urgent. MEDIUM - reflected XSS, open redirect, CORS misconfiguration Real, exploitable in the right context, and usually needing a user to be induced into something. A permissive CORS policy on an authenticated API is worse than this tier suggests - look at what the API returns before filing it as medium. HSTS, CSP, X-Frame-Options, Secure, HttpOnly, SameSite. Worth fixing, cheap to fix, and **not** what a report should lead with. A scanner that lists fifteen header findings and one exposed `.env` has told you one thing. Server version CVEs Derived from the `Server` header. Same caveat as every banner-based finding: the version string does not reflect backported patches. A lead to verify, not a finding to report. Two things worth knowing about false results: A false positive on injection is common. Applications that return a generic error page for everything can look like they are swallowing a payload. Reproduce by hand. A false negative behind a WAF is also common. If the WAF blocked the probes, the application was never actually tested. A clean scan from outside the WAF says the WAF is working - which is good, and is a different statement.
Exit Codes for Cron and CI
0 nothing above low severity 1 high or medium findings 2 critical findings 130 you pressed Ctrl-C This maps cleanly onto a release gate: fail the build on 2, warn on 1. python web_app_scanner.py --url https://staging.example.com \ --output scan.json || [ $? -eq 1 ] Scanning staging on every deploy is reasonable. Scanning production on a schedule is a decision to make deliberately, because of the submitted data and the WAF noise.
Every Flag
--url URL REQUIRED. The target, with scheme: https://app.example.com --output PATH JSON report. Default web_scan_<host>_<date>.json. --insecure Skip TLS verification and do not report certificate problems. For staging hosts with self-signed certificates. Note what you give up: with this on, the scan will not tell you the certificate is expired or wrong.
When Something Fails
ModuleNotFoundError The pip install step was skipped. See Before You Start. Connection errors on a site you can open in a browser Usually a proxy. The browser has one configured and the script does not - set `HTTP_PROXY` and `HTTPS_PROXY` in the environment. Also check the scheme: `--url example.com` without `http://` or `https://` is not a URL. A TLS error on a host you control A self-signed or internal CA certificate. `--insecure` gets past it, and silences certificate findings for the rest of the run. The scan stops early or everything comes back clean A WAF blocked it, or rate limiting kicked in. A clean result from behind a WAF is not a clean application. Test from inside the WAF, or allow-list your source address for the duration. An injection finding you cannot reproduce Likely a generic error page that looks like a swallowed payload. Reproduce by hand with the exact request before it goes in a report - a false critical costs you credibility on the real ones. No findings at all on an application you know has problems Check that the scan actually reached the application rather than a login page or a load balancer health endpoint. A single-page application that renders everything client-side also presents very little to a request-based scanner. Something changed on the target during the scan The injection and XSS probes submit data. On an application with live forms that can mean created records or sent messages. This is the reason to prefer staging, and the reason to know what the forms do before running it against production.
Automation Scripts
/scripts/subnet-scanner/scripts/host-vulnerability-scanner/scripts/threat-intel-lookup/scripts/ssl-cert-checkerSkills
/skills/f5-waf-tuning/skills/f5-bigip-troubleshooting/skills/f5-irules-patterns/skills/pentest-scoping-and-reportingAgents
/agents/pentest-reporterTags
webowaspsqlixsscsrfsecurity-headerscookiescorspenetration-testingappsec