@intentsolutionsio/penetration-tester 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +19 -0
- package/LICENSE +21 -0
- package/README.md +160 -0
- package/commands/pentest.md +84 -0
- package/commands/scan-headers.md +43 -0
- package/package.json +40 -0
- package/skills/performing-penetration-testing/SKILL.md +266 -0
- package/skills/performing-penetration-testing/references/OWASP_TOP_10.md +284 -0
- package/skills/performing-penetration-testing/references/REMEDIATION_PLAYBOOK.md +452 -0
- package/skills/performing-penetration-testing/references/SECURITY_HEADERS.md +365 -0
- package/skills/performing-penetration-testing/scripts/code_security_scanner.py +780 -0
- package/skills/performing-penetration-testing/scripts/dependency_auditor.py +777 -0
- package/skills/performing-penetration-testing/scripts/requirements.txt +4 -0
- package/skills/performing-penetration-testing/scripts/security_scanner.py +1166 -0
- package/skills/performing-penetration-testing/scripts/setup_pentest_env.sh +199 -0
|
@@ -0,0 +1,1166 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP Security Scanner - Automated web application security assessment tool.
|
|
3
|
+
|
|
4
|
+
Performs non-intrusive security checks against a target URL including:
|
|
5
|
+
- Security header analysis (CSP, HSTS, X-Frame-Options, etc.)
|
|
6
|
+
- SSL/TLS certificate validation and expiry checking
|
|
7
|
+
- Common path exposure probing (git, env, admin panels)
|
|
8
|
+
- HTTP method enumeration and dangerous method detection
|
|
9
|
+
- CORS policy analysis and misconfiguration detection
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
python3 security_scanner.py https://example.com
|
|
13
|
+
python3 security_scanner.py https://example.com --output report.json
|
|
14
|
+
python3 security_scanner.py https://example.com --checks headers,ssl,cors
|
|
15
|
+
python3 security_scanner.py https://example.com --timeout 15 --verbose
|
|
16
|
+
|
|
17
|
+
This tool is intended for authorized security testing only. Always obtain
|
|
18
|
+
written permission before scanning any target you do not own.
|
|
19
|
+
|
|
20
|
+
Requires: Python 3.9+, requests library
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import socket
|
|
28
|
+
import ssl
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
from dataclasses import asdict, dataclass, field
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from typing import Optional
|
|
34
|
+
from urllib.parse import urlparse
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
import requests
|
|
38
|
+
from requests.adapters import HTTPAdapter
|
|
39
|
+
from urllib3.util.retry import Retry
|
|
40
|
+
except ImportError:
|
|
41
|
+
print(
|
|
42
|
+
"Error: 'requests' library is required. Install with: pip install requests",
|
|
43
|
+
file=sys.stderr,
|
|
44
|
+
)
|
|
45
|
+
sys.exit(2)
|
|
46
|
+
|
|
47
|
+
__version__ = "2.0.0"
|
|
48
|
+
|
|
49
|
+
USER_AGENT = "SecurityScanner/2.0 (authorized-testing)"
|
|
50
|
+
|
|
51
|
+
SEVERITY_WEIGHTS = {
|
|
52
|
+
"critical": 25,
|
|
53
|
+
"high": 15,
|
|
54
|
+
"medium": 8,
|
|
55
|
+
"low": 3,
|
|
56
|
+
"info": 0,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
ALL_CHECKS = ["headers", "ssl", "endpoints", "methods", "cors"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Finding:
|
|
64
|
+
"""A single security finding from a scan check."""
|
|
65
|
+
|
|
66
|
+
check: str
|
|
67
|
+
severity: str
|
|
68
|
+
title: str
|
|
69
|
+
detail: str
|
|
70
|
+
remediation: str
|
|
71
|
+
|
|
72
|
+
def __post_init__(self) -> None:
|
|
73
|
+
valid = ("critical", "high", "medium", "low", "info")
|
|
74
|
+
if self.severity not in valid:
|
|
75
|
+
raise ValueError(f"Invalid severity '{self.severity}', must be one of {valid}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class ScanResult:
|
|
80
|
+
"""Aggregated results from all scan checks."""
|
|
81
|
+
|
|
82
|
+
target: str
|
|
83
|
+
scan_start: str
|
|
84
|
+
scan_end: str = ""
|
|
85
|
+
duration_seconds: float = 0.0
|
|
86
|
+
checks_performed: list[str] = field(default_factory=list)
|
|
87
|
+
findings: list[Finding] = field(default_factory=list)
|
|
88
|
+
errors: list[str] = field(default_factory=list)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _log(message: str, verbose: bool = True) -> None:
|
|
92
|
+
"""Print a status message to stderr."""
|
|
93
|
+
if verbose:
|
|
94
|
+
print(f"[*] {message}", file=sys.stderr)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _log_error(message: str) -> None:
|
|
98
|
+
"""Print an error message to stderr."""
|
|
99
|
+
print(f"[!] {message}", file=sys.stderr)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def create_session(timeout: int = 10) -> requests.Session:
|
|
103
|
+
"""Create a requests session with retry logic and custom headers."""
|
|
104
|
+
session = requests.Session()
|
|
105
|
+
session.headers.update({
|
|
106
|
+
"User-Agent": USER_AGENT,
|
|
107
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
108
|
+
"Accept-Language": "en-US,en;q=0.5",
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
retry_strategy = Retry(
|
|
112
|
+
total=2,
|
|
113
|
+
backoff_factor=0.5,
|
|
114
|
+
status_forcelist=[502, 503, 504],
|
|
115
|
+
allowed_methods=["GET", "HEAD", "OPTIONS"],
|
|
116
|
+
)
|
|
117
|
+
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
118
|
+
session.mount("https://", adapter)
|
|
119
|
+
session.mount("http://", adapter)
|
|
120
|
+
|
|
121
|
+
# Store timeout on session for convenience; callers pass it per-request
|
|
122
|
+
session.timeout = timeout # type: ignore[attr-defined]
|
|
123
|
+
return session
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Check 1: Security Headers
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def scan_security_headers(url: str, session: requests.Session) -> list[Finding]:
|
|
131
|
+
"""Analyze HTTP response security headers for misconfigurations and missing headers."""
|
|
132
|
+
findings: list[Finding] = []
|
|
133
|
+
timeout: int = getattr(session, "timeout", 10)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
resp = session.get(url, timeout=timeout, allow_redirects=True)
|
|
137
|
+
except requests.RequestException as exc:
|
|
138
|
+
findings.append(Finding(
|
|
139
|
+
check="headers",
|
|
140
|
+
severity="high",
|
|
141
|
+
title="Unable to retrieve headers",
|
|
142
|
+
detail=f"Request failed: {exc}",
|
|
143
|
+
remediation="Verify the target URL is reachable and the server is running.",
|
|
144
|
+
))
|
|
145
|
+
return findings
|
|
146
|
+
|
|
147
|
+
headers = resp.headers
|
|
148
|
+
|
|
149
|
+
# --- Content-Security-Policy ---
|
|
150
|
+
csp = headers.get("Content-Security-Policy")
|
|
151
|
+
if not csp:
|
|
152
|
+
findings.append(Finding(
|
|
153
|
+
check="headers",
|
|
154
|
+
severity="high",
|
|
155
|
+
title="Missing Content-Security-Policy header",
|
|
156
|
+
detail="No CSP header was found in the response. This leaves the application "
|
|
157
|
+
"vulnerable to cross-site scripting and data injection attacks.",
|
|
158
|
+
remediation="Implement a Content-Security-Policy header. Start with a restrictive "
|
|
159
|
+
"policy such as \"default-src 'self'\" and expand as needed.",
|
|
160
|
+
))
|
|
161
|
+
else:
|
|
162
|
+
if "'unsafe-inline'" in csp:
|
|
163
|
+
findings.append(Finding(
|
|
164
|
+
check="headers",
|
|
165
|
+
severity="medium",
|
|
166
|
+
title="CSP allows unsafe-inline",
|
|
167
|
+
detail=f"The Content-Security-Policy contains 'unsafe-inline', which weakens "
|
|
168
|
+
f"XSS protections. Value: {csp[:200]}",
|
|
169
|
+
remediation="Replace 'unsafe-inline' with nonce-based or hash-based CSP directives.",
|
|
170
|
+
))
|
|
171
|
+
if "'unsafe-eval'" in csp:
|
|
172
|
+
findings.append(Finding(
|
|
173
|
+
check="headers",
|
|
174
|
+
severity="medium",
|
|
175
|
+
title="CSP allows unsafe-eval",
|
|
176
|
+
detail=f"The Content-Security-Policy contains 'unsafe-eval', allowing dynamic "
|
|
177
|
+
f"code execution. Value: {csp[:200]}",
|
|
178
|
+
remediation="Remove 'unsafe-eval' from CSP and refactor code to avoid eval().",
|
|
179
|
+
))
|
|
180
|
+
if "default-src" not in csp and "script-src" not in csp:
|
|
181
|
+
findings.append(Finding(
|
|
182
|
+
check="headers",
|
|
183
|
+
severity="medium",
|
|
184
|
+
title="CSP missing default-src or script-src directive",
|
|
185
|
+
detail="The CSP header does not define a default-src or script-src directive, "
|
|
186
|
+
"which may leave resource loading unrestricted.",
|
|
187
|
+
remediation="Add a 'default-src' directive as a fallback for all resource types.",
|
|
188
|
+
))
|
|
189
|
+
|
|
190
|
+
# --- Strict-Transport-Security ---
|
|
191
|
+
hsts = headers.get("Strict-Transport-Security")
|
|
192
|
+
if not hsts:
|
|
193
|
+
severity = "high" if url.startswith("https") else "medium"
|
|
194
|
+
findings.append(Finding(
|
|
195
|
+
check="headers",
|
|
196
|
+
severity=severity,
|
|
197
|
+
title="Missing Strict-Transport-Security (HSTS) header",
|
|
198
|
+
detail="The server does not send an HSTS header. Clients may connect over "
|
|
199
|
+
"insecure HTTP, enabling man-in-the-middle attacks.",
|
|
200
|
+
remediation="Add the header: Strict-Transport-Security: max-age=31536000; "
|
|
201
|
+
"includeSubDomains; preload",
|
|
202
|
+
))
|
|
203
|
+
else:
|
|
204
|
+
hsts_lower = hsts.lower()
|
|
205
|
+
# Check max-age value
|
|
206
|
+
max_age_val = 0
|
|
207
|
+
for part in hsts_lower.split(";"):
|
|
208
|
+
part = part.strip()
|
|
209
|
+
if part.startswith("max-age="):
|
|
210
|
+
try:
|
|
211
|
+
max_age_val = int(part.split("=", 1)[1].strip())
|
|
212
|
+
except ValueError:
|
|
213
|
+
max_age_val = 0
|
|
214
|
+
if max_age_val < 31536000:
|
|
215
|
+
findings.append(Finding(
|
|
216
|
+
check="headers",
|
|
217
|
+
severity="medium",
|
|
218
|
+
title="HSTS max-age is too short",
|
|
219
|
+
detail=f"HSTS max-age is {max_age_val} seconds (recommended minimum is "
|
|
220
|
+
f"31536000 / 1 year). Current value: {hsts}",
|
|
221
|
+
remediation="Set max-age to at least 31536000 (one year).",
|
|
222
|
+
))
|
|
223
|
+
if "includesubdomains" not in hsts_lower:
|
|
224
|
+
findings.append(Finding(
|
|
225
|
+
check="headers",
|
|
226
|
+
severity="low",
|
|
227
|
+
title="HSTS missing includeSubDomains directive",
|
|
228
|
+
detail=f"The HSTS header does not include the includeSubDomains directive. "
|
|
229
|
+
f"Subdomains may still be accessed over HTTP. Value: {hsts}",
|
|
230
|
+
remediation="Add 'includeSubDomains' to the HSTS header.",
|
|
231
|
+
))
|
|
232
|
+
|
|
233
|
+
# --- X-Frame-Options ---
|
|
234
|
+
xfo = headers.get("X-Frame-Options")
|
|
235
|
+
if not xfo:
|
|
236
|
+
# Only flag if CSP frame-ancestors is also missing
|
|
237
|
+
if not csp or "frame-ancestors" not in (csp or ""):
|
|
238
|
+
findings.append(Finding(
|
|
239
|
+
check="headers",
|
|
240
|
+
severity="medium",
|
|
241
|
+
title="Missing X-Frame-Options header",
|
|
242
|
+
detail="Neither X-Frame-Options nor CSP frame-ancestors is set. "
|
|
243
|
+
"The page may be embedded in frames, enabling clickjacking.",
|
|
244
|
+
remediation="Set X-Frame-Options to DENY or SAMEORIGIN, or use CSP "
|
|
245
|
+
"frame-ancestors directive.",
|
|
246
|
+
))
|
|
247
|
+
|
|
248
|
+
# --- X-Content-Type-Options ---
|
|
249
|
+
xcto = headers.get("X-Content-Type-Options")
|
|
250
|
+
if not xcto:
|
|
251
|
+
findings.append(Finding(
|
|
252
|
+
check="headers",
|
|
253
|
+
severity="medium",
|
|
254
|
+
title="Missing X-Content-Type-Options header",
|
|
255
|
+
detail="Without this header, browsers may MIME-sniff responses, potentially "
|
|
256
|
+
"interpreting files as executable content.",
|
|
257
|
+
remediation="Set the header: X-Content-Type-Options: nosniff",
|
|
258
|
+
))
|
|
259
|
+
elif xcto.strip().lower() != "nosniff":
|
|
260
|
+
findings.append(Finding(
|
|
261
|
+
check="headers",
|
|
262
|
+
severity="medium",
|
|
263
|
+
title="X-Content-Type-Options has unexpected value",
|
|
264
|
+
detail=f"Expected 'nosniff' but got '{xcto}'. The header may not function correctly.",
|
|
265
|
+
remediation="Set the value to exactly 'nosniff'.",
|
|
266
|
+
))
|
|
267
|
+
|
|
268
|
+
# --- Referrer-Policy ---
|
|
269
|
+
rp = headers.get("Referrer-Policy")
|
|
270
|
+
if not rp:
|
|
271
|
+
findings.append(Finding(
|
|
272
|
+
check="headers",
|
|
273
|
+
severity="low",
|
|
274
|
+
title="Missing Referrer-Policy header",
|
|
275
|
+
detail="Without a Referrer-Policy, the browser sends the full URL as referrer "
|
|
276
|
+
"to other sites, potentially leaking sensitive URL parameters.",
|
|
277
|
+
remediation="Set Referrer-Policy to 'strict-origin-when-cross-origin' or 'no-referrer'.",
|
|
278
|
+
))
|
|
279
|
+
|
|
280
|
+
# --- Permissions-Policy ---
|
|
281
|
+
pp = headers.get("Permissions-Policy")
|
|
282
|
+
if not pp:
|
|
283
|
+
findings.append(Finding(
|
|
284
|
+
check="headers",
|
|
285
|
+
severity="low",
|
|
286
|
+
title="Missing Permissions-Policy header",
|
|
287
|
+
detail="No Permissions-Policy header found. Browser features like camera, "
|
|
288
|
+
"microphone, and geolocation are not explicitly restricted.",
|
|
289
|
+
remediation="Add a Permissions-Policy header to restrict unnecessary browser features, "
|
|
290
|
+
"e.g., Permissions-Policy: camera=(), microphone=(), geolocation=()",
|
|
291
|
+
))
|
|
292
|
+
|
|
293
|
+
# --- X-XSS-Protection (deprecated) ---
|
|
294
|
+
xxp = headers.get("X-XSS-Protection")
|
|
295
|
+
if xxp:
|
|
296
|
+
findings.append(Finding(
|
|
297
|
+
check="headers",
|
|
298
|
+
severity="info",
|
|
299
|
+
title="X-XSS-Protection header present (deprecated)",
|
|
300
|
+
detail=f"The X-XSS-Protection header is set to '{xxp}'. This header is deprecated "
|
|
301
|
+
f"in modern browsers and the XSS auditor has been removed. Relying on it "
|
|
302
|
+
f"provides a false sense of security.",
|
|
303
|
+
remediation="Remove X-XSS-Protection and rely on a strong Content-Security-Policy instead.",
|
|
304
|
+
))
|
|
305
|
+
|
|
306
|
+
# --- Server header version disclosure ---
|
|
307
|
+
server = headers.get("Server")
|
|
308
|
+
if server and any(ch.isdigit() for ch in server):
|
|
309
|
+
findings.append(Finding(
|
|
310
|
+
check="headers",
|
|
311
|
+
severity="low",
|
|
312
|
+
title="Server header discloses version information",
|
|
313
|
+
detail=f"The Server header value '{server}' contains version numbers, "
|
|
314
|
+
f"which aids attackers in identifying known vulnerabilities.",
|
|
315
|
+
remediation="Configure the web server to suppress or generalize the Server header.",
|
|
316
|
+
))
|
|
317
|
+
|
|
318
|
+
if not findings:
|
|
319
|
+
findings.append(Finding(
|
|
320
|
+
check="headers",
|
|
321
|
+
severity="info",
|
|
322
|
+
title="All recommended security headers are present",
|
|
323
|
+
detail="The response includes the standard set of security headers.",
|
|
324
|
+
remediation="Continue monitoring headers as security best practices evolve.",
|
|
325
|
+
))
|
|
326
|
+
|
|
327
|
+
return findings
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
# Check 2: SSL/TLS Certificate
|
|
332
|
+
# ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
def check_ssl_tls(url: str) -> list[Finding]:
|
|
335
|
+
"""Validate SSL/TLS certificate properties for the target host."""
|
|
336
|
+
findings: list[Finding] = []
|
|
337
|
+
parsed = urlparse(url)
|
|
338
|
+
|
|
339
|
+
if parsed.scheme != "https":
|
|
340
|
+
findings.append(Finding(
|
|
341
|
+
check="ssl",
|
|
342
|
+
severity="high",
|
|
343
|
+
title="Target does not use HTTPS",
|
|
344
|
+
detail=f"The target URL uses the '{parsed.scheme}' scheme. All traffic "
|
|
345
|
+
f"is transmitted in plaintext, vulnerable to interception.",
|
|
346
|
+
remediation="Configure the server to use HTTPS with a valid TLS certificate.",
|
|
347
|
+
))
|
|
348
|
+
return findings
|
|
349
|
+
|
|
350
|
+
hostname = parsed.hostname or ""
|
|
351
|
+
port = parsed.port or 443
|
|
352
|
+
|
|
353
|
+
context = ssl.create_default_context()
|
|
354
|
+
|
|
355
|
+
try:
|
|
356
|
+
with socket.create_connection((hostname, port), timeout=10) as sock:
|
|
357
|
+
with context.wrap_socket(sock, server_hostname=hostname) as tls_sock:
|
|
358
|
+
cert = tls_sock.getpeercert()
|
|
359
|
+
protocol_version = tls_sock.version()
|
|
360
|
+
|
|
361
|
+
if not cert:
|
|
362
|
+
findings.append(Finding(
|
|
363
|
+
check="ssl",
|
|
364
|
+
severity="critical",
|
|
365
|
+
title="No certificate returned by server",
|
|
366
|
+
detail="The TLS handshake completed but no certificate was presented.",
|
|
367
|
+
remediation="Ensure the server is configured with a valid TLS certificate.",
|
|
368
|
+
))
|
|
369
|
+
return findings
|
|
370
|
+
|
|
371
|
+
# Protocol version
|
|
372
|
+
if protocol_version:
|
|
373
|
+
findings.append(Finding(
|
|
374
|
+
check="ssl",
|
|
375
|
+
severity="info",
|
|
376
|
+
title=f"TLS protocol version: {protocol_version}",
|
|
377
|
+
detail=f"The server negotiated {protocol_version}.",
|
|
378
|
+
remediation="Ensure TLS 1.2 or higher is used; disable TLS 1.0 and 1.1.",
|
|
379
|
+
))
|
|
380
|
+
if protocol_version in ("TLSv1", "TLSv1.1"):
|
|
381
|
+
findings.append(Finding(
|
|
382
|
+
check="ssl",
|
|
383
|
+
severity="high",
|
|
384
|
+
title=f"Outdated TLS protocol: {protocol_version}",
|
|
385
|
+
detail=f"{protocol_version} is deprecated and has known vulnerabilities.",
|
|
386
|
+
remediation="Disable TLS 1.0 and TLS 1.1. Use TLS 1.2 or TLS 1.3.",
|
|
387
|
+
))
|
|
388
|
+
|
|
389
|
+
# Certificate expiry
|
|
390
|
+
not_after_str = cert.get("notAfter", "")
|
|
391
|
+
if not_after_str:
|
|
392
|
+
# Python ssl cert dates use format: 'Mon DD HH:MM:SS YYYY GMT'
|
|
393
|
+
try:
|
|
394
|
+
not_after = datetime.strptime(not_after_str, "%b %d %H:%M:%S %Y %Z")
|
|
395
|
+
not_after = not_after.replace(tzinfo=timezone.utc)
|
|
396
|
+
now = datetime.now(timezone.utc)
|
|
397
|
+
days_remaining = (not_after - now).days
|
|
398
|
+
|
|
399
|
+
if days_remaining < 0:
|
|
400
|
+
findings.append(Finding(
|
|
401
|
+
check="ssl",
|
|
402
|
+
severity="critical",
|
|
403
|
+
title="SSL certificate has expired",
|
|
404
|
+
detail=f"The certificate expired on {not_after_str} "
|
|
405
|
+
f"({abs(days_remaining)} days ago).",
|
|
406
|
+
remediation="Renew the SSL/TLS certificate immediately.",
|
|
407
|
+
))
|
|
408
|
+
elif days_remaining < 7:
|
|
409
|
+
findings.append(Finding(
|
|
410
|
+
check="ssl",
|
|
411
|
+
severity="critical",
|
|
412
|
+
title=f"SSL certificate expires in {days_remaining} days",
|
|
413
|
+
detail=f"The certificate expires on {not_after_str}. "
|
|
414
|
+
f"Immediate renewal is required.",
|
|
415
|
+
remediation="Renew the SSL/TLS certificate before expiry.",
|
|
416
|
+
))
|
|
417
|
+
elif days_remaining < 30:
|
|
418
|
+
findings.append(Finding(
|
|
419
|
+
check="ssl",
|
|
420
|
+
severity="high",
|
|
421
|
+
title=f"SSL certificate expires in {days_remaining} days",
|
|
422
|
+
detail=f"The certificate expires on {not_after_str}. "
|
|
423
|
+
f"Plan renewal soon to avoid service disruption.",
|
|
424
|
+
remediation="Renew the SSL/TLS certificate within the next two weeks.",
|
|
425
|
+
))
|
|
426
|
+
else:
|
|
427
|
+
findings.append(Finding(
|
|
428
|
+
check="ssl",
|
|
429
|
+
severity="info",
|
|
430
|
+
title=f"SSL certificate valid for {days_remaining} days",
|
|
431
|
+
detail=f"The certificate expires on {not_after_str}.",
|
|
432
|
+
remediation="Monitor certificate expiry and renew before it lapses.",
|
|
433
|
+
))
|
|
434
|
+
except ValueError:
|
|
435
|
+
findings.append(Finding(
|
|
436
|
+
check="ssl",
|
|
437
|
+
severity="medium",
|
|
438
|
+
title="Unable to parse certificate expiry date",
|
|
439
|
+
detail=f"Certificate notAfter value: '{not_after_str}' could not be parsed.",
|
|
440
|
+
remediation="Manually verify the certificate expiry date.",
|
|
441
|
+
))
|
|
442
|
+
|
|
443
|
+
# Subject and issuer info
|
|
444
|
+
subject_parts = []
|
|
445
|
+
for rdn in cert.get("subject", ()):
|
|
446
|
+
for attr_name, attr_value in rdn:
|
|
447
|
+
subject_parts.append(f"{attr_name}={attr_value}")
|
|
448
|
+
subject_str = ", ".join(subject_parts) if subject_parts else "unknown"
|
|
449
|
+
|
|
450
|
+
issuer_parts = []
|
|
451
|
+
for rdn in cert.get("issuer", ()):
|
|
452
|
+
for attr_name, attr_value in rdn:
|
|
453
|
+
issuer_parts.append(f"{attr_name}={attr_value}")
|
|
454
|
+
issuer_str = ", ".join(issuer_parts) if issuer_parts else "unknown"
|
|
455
|
+
|
|
456
|
+
findings.append(Finding(
|
|
457
|
+
check="ssl",
|
|
458
|
+
severity="info",
|
|
459
|
+
title="Certificate subject and issuer",
|
|
460
|
+
detail=f"Subject: {subject_str} | Issuer: {issuer_str}",
|
|
461
|
+
remediation="Verify the certificate is issued by a trusted certificate authority.",
|
|
462
|
+
))
|
|
463
|
+
|
|
464
|
+
# SAN (Subject Alternative Names)
|
|
465
|
+
san_list = cert.get("subjectAltName", ())
|
|
466
|
+
san_names = [val for typ, val in san_list if typ == "DNS"]
|
|
467
|
+
if san_names:
|
|
468
|
+
findings.append(Finding(
|
|
469
|
+
check="ssl",
|
|
470
|
+
severity="info",
|
|
471
|
+
title=f"Certificate covers {len(san_names)} domain(s)",
|
|
472
|
+
detail=f"SANs: {', '.join(san_names[:10])}"
|
|
473
|
+
+ (f" ... and {len(san_names) - 10} more" if len(san_names) > 10 else ""),
|
|
474
|
+
remediation="Ensure all required domains are listed in the certificate SANs.",
|
|
475
|
+
))
|
|
476
|
+
|
|
477
|
+
except ssl.SSLCertVerificationError as exc:
|
|
478
|
+
findings.append(Finding(
|
|
479
|
+
check="ssl",
|
|
480
|
+
severity="critical",
|
|
481
|
+
title="SSL certificate verification failed",
|
|
482
|
+
detail=f"Certificate validation error: {exc}",
|
|
483
|
+
remediation="Replace the certificate with one issued by a trusted CA. "
|
|
484
|
+
"Ensure the certificate chain is complete.",
|
|
485
|
+
))
|
|
486
|
+
except ssl.SSLError as exc:
|
|
487
|
+
findings.append(Finding(
|
|
488
|
+
check="ssl",
|
|
489
|
+
severity="high",
|
|
490
|
+
title="SSL/TLS connection error",
|
|
491
|
+
detail=f"TLS handshake failed: {exc}",
|
|
492
|
+
remediation="Check the server TLS configuration and ensure modern cipher suites are enabled.",
|
|
493
|
+
))
|
|
494
|
+
except socket.timeout:
|
|
495
|
+
findings.append(Finding(
|
|
496
|
+
check="ssl",
|
|
497
|
+
severity="medium",
|
|
498
|
+
title="SSL connection timed out",
|
|
499
|
+
detail="The TLS handshake did not complete within 10 seconds.",
|
|
500
|
+
remediation="Verify the server is reachable and TLS is properly configured.",
|
|
501
|
+
))
|
|
502
|
+
except OSError as exc:
|
|
503
|
+
findings.append(Finding(
|
|
504
|
+
check="ssl",
|
|
505
|
+
severity="high",
|
|
506
|
+
title="Unable to establish SSL connection",
|
|
507
|
+
detail=f"Connection error: {exc}",
|
|
508
|
+
remediation="Verify the hostname, port, and network connectivity.",
|
|
509
|
+
))
|
|
510
|
+
|
|
511
|
+
return findings
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# ---------------------------------------------------------------------------
|
|
515
|
+
# Check 3: Common Path Exposures
|
|
516
|
+
# ---------------------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
_SENSITIVE_PATHS = [
|
|
519
|
+
("/.git/HEAD", "Git repository metadata", "critical"),
|
|
520
|
+
("/.git/config", "Git configuration file", "critical"),
|
|
521
|
+
("/.env", "Environment variables file", "critical"),
|
|
522
|
+
("/server-status", "Apache server status page", "high"),
|
|
523
|
+
("/server-info", "Apache server info page", "high"),
|
|
524
|
+
("/admin", "Admin panel", "medium"),
|
|
525
|
+
("/wp-admin", "WordPress admin panel", "medium"),
|
|
526
|
+
("/phpmyadmin", "phpMyAdmin database interface", "high"),
|
|
527
|
+
("/elmah.axd", ".NET error log handler", "high"),
|
|
528
|
+
("/actuator", "Spring Boot actuator endpoint", "high"),
|
|
529
|
+
("/debug", "Debug interface", "high"),
|
|
530
|
+
]
|
|
531
|
+
|
|
532
|
+
_DIRECTORY_LISTING_INDICATORS = [
|
|
533
|
+
"Index of /",
|
|
534
|
+
"Directory listing for",
|
|
535
|
+
"Parent Directory",
|
|
536
|
+
"[To Parent Directory]",
|
|
537
|
+
]
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def probe_common_exposures(url: str, session: requests.Session) -> list[Finding]:
|
|
541
|
+
"""Probe for commonly exposed files, directories, and admin interfaces."""
|
|
542
|
+
findings: list[Finding] = []
|
|
543
|
+
timeout: int = getattr(session, "timeout", 10)
|
|
544
|
+
base = url.rstrip("/")
|
|
545
|
+
|
|
546
|
+
# --- Sensitive path probing ---
|
|
547
|
+
for path, description, severity in _SENSITIVE_PATHS:
|
|
548
|
+
target = base + path
|
|
549
|
+
try:
|
|
550
|
+
resp = session.get(target, timeout=timeout, allow_redirects=False)
|
|
551
|
+
if resp.status_code == 200:
|
|
552
|
+
# Verify it is not a generic error page or redirect by checking content length
|
|
553
|
+
content_length = len(resp.content)
|
|
554
|
+
if content_length > 0:
|
|
555
|
+
findings.append(Finding(
|
|
556
|
+
check="endpoints",
|
|
557
|
+
severity=severity,
|
|
558
|
+
title=f"Exposed: {description} ({path})",
|
|
559
|
+
detail=f"HTTP 200 returned for {target} with {content_length} bytes. "
|
|
560
|
+
f"This resource should not be publicly accessible.",
|
|
561
|
+
remediation=f"Block access to {path} via web server configuration. "
|
|
562
|
+
f"Return 403 or 404 for this path.",
|
|
563
|
+
))
|
|
564
|
+
elif resp.status_code in (401, 403):
|
|
565
|
+
findings.append(Finding(
|
|
566
|
+
check="endpoints",
|
|
567
|
+
severity="info",
|
|
568
|
+
title=f"Path exists but access denied: {path}",
|
|
569
|
+
detail=f"HTTP {resp.status_code} returned for {target}. "
|
|
570
|
+
f"The path exists but requires authentication.",
|
|
571
|
+
remediation=f"Consider returning 404 instead of {resp.status_code} "
|
|
572
|
+
f"to avoid confirming the path exists.",
|
|
573
|
+
))
|
|
574
|
+
except requests.RequestException:
|
|
575
|
+
# Silently skip unreachable paths
|
|
576
|
+
continue
|
|
577
|
+
|
|
578
|
+
# --- robots.txt analysis ---
|
|
579
|
+
try:
|
|
580
|
+
resp = session.get(base + "/robots.txt", timeout=timeout, allow_redirects=True)
|
|
581
|
+
if resp.status_code == 200 and "disallow" in resp.text.lower():
|
|
582
|
+
findings.append(Finding(
|
|
583
|
+
check="endpoints",
|
|
584
|
+
severity="info",
|
|
585
|
+
title="robots.txt found",
|
|
586
|
+
detail=f"The robots.txt file is accessible at {base}/robots.txt.",
|
|
587
|
+
remediation="Review robots.txt entries. Disallowed paths may reveal "
|
|
588
|
+
"sensitive directories that warrant additional access controls.",
|
|
589
|
+
))
|
|
590
|
+
# Parse interesting disallows
|
|
591
|
+
interesting_disallows = []
|
|
592
|
+
for line in resp.text.splitlines():
|
|
593
|
+
line_stripped = line.strip().lower()
|
|
594
|
+
if line_stripped.startswith("disallow:"):
|
|
595
|
+
path_part = line.strip().split(":", 1)[1].strip()
|
|
596
|
+
if path_part and path_part != "/":
|
|
597
|
+
sensitive_keywords = [
|
|
598
|
+
"admin", "api", "config", "backup", "private",
|
|
599
|
+
"internal", "secret", "debug", "staging", "test",
|
|
600
|
+
"tmp", "upload", "database", "db", "cgi-bin",
|
|
601
|
+
]
|
|
602
|
+
if any(kw in path_part.lower() for kw in sensitive_keywords):
|
|
603
|
+
interesting_disallows.append(path_part)
|
|
604
|
+
if interesting_disallows:
|
|
605
|
+
findings.append(Finding(
|
|
606
|
+
check="endpoints",
|
|
607
|
+
severity="low",
|
|
608
|
+
title="robots.txt reveals potentially sensitive paths",
|
|
609
|
+
detail=f"Interesting disallowed paths: {', '.join(interesting_disallows[:10])}",
|
|
610
|
+
remediation="Ensure disallowed paths have proper access controls beyond "
|
|
611
|
+
"robots.txt, which is advisory only and publicly readable.",
|
|
612
|
+
))
|
|
613
|
+
except requests.RequestException:
|
|
614
|
+
pass
|
|
615
|
+
|
|
616
|
+
# --- security.txt ---
|
|
617
|
+
for sec_path in ["/.well-known/security.txt", "/security.txt"]:
|
|
618
|
+
try:
|
|
619
|
+
resp = session.get(base + sec_path, timeout=timeout, allow_redirects=True)
|
|
620
|
+
if resp.status_code == 200 and "contact:" in resp.text.lower():
|
|
621
|
+
findings.append(Finding(
|
|
622
|
+
check="endpoints",
|
|
623
|
+
severity="info",
|
|
624
|
+
title="security.txt found",
|
|
625
|
+
detail=f"A security.txt file is accessible at {base}{sec_path}. "
|
|
626
|
+
f"This is a good security practice (RFC 9116).",
|
|
627
|
+
remediation="Ensure the security.txt contact information is current.",
|
|
628
|
+
))
|
|
629
|
+
break # Only report once
|
|
630
|
+
except requests.RequestException:
|
|
631
|
+
continue
|
|
632
|
+
|
|
633
|
+
# --- Directory listing detection ---
|
|
634
|
+
try:
|
|
635
|
+
resp = session.get(base + "/", timeout=timeout, allow_redirects=True)
|
|
636
|
+
content_sample = resp.text[:2000] if resp.text else ""
|
|
637
|
+
for indicator in _DIRECTORY_LISTING_INDICATORS:
|
|
638
|
+
if indicator.lower() in content_sample.lower():
|
|
639
|
+
findings.append(Finding(
|
|
640
|
+
check="endpoints",
|
|
641
|
+
severity="high",
|
|
642
|
+
title="Directory listing is enabled",
|
|
643
|
+
detail=f"The root path appears to expose a directory listing "
|
|
644
|
+
f"(detected indicator: '{indicator}').",
|
|
645
|
+
remediation="Disable directory listing in the web server configuration. "
|
|
646
|
+
"For Apache: 'Options -Indexes'. For Nginx: remove 'autoindex on'.",
|
|
647
|
+
))
|
|
648
|
+
break
|
|
649
|
+
except requests.RequestException:
|
|
650
|
+
pass
|
|
651
|
+
|
|
652
|
+
# --- Server header version disclosure ---
|
|
653
|
+
try:
|
|
654
|
+
resp = session.get(base + "/", timeout=timeout, allow_redirects=True)
|
|
655
|
+
server_header = resp.headers.get("Server", "")
|
|
656
|
+
x_powered = resp.headers.get("X-Powered-By", "")
|
|
657
|
+
if server_header and any(ch.isdigit() for ch in server_header):
|
|
658
|
+
findings.append(Finding(
|
|
659
|
+
check="endpoints",
|
|
660
|
+
severity="low",
|
|
661
|
+
title="Server version disclosure",
|
|
662
|
+
detail=f"The Server header reveals: '{server_header}'.",
|
|
663
|
+
remediation="Suppress version information in the Server header.",
|
|
664
|
+
))
|
|
665
|
+
if x_powered:
|
|
666
|
+
findings.append(Finding(
|
|
667
|
+
check="endpoints",
|
|
668
|
+
severity="low",
|
|
669
|
+
title="X-Powered-By header exposes technology stack",
|
|
670
|
+
detail=f"The X-Powered-By header reveals: '{x_powered}'.",
|
|
671
|
+
remediation="Remove the X-Powered-By header from server responses.",
|
|
672
|
+
))
|
|
673
|
+
except requests.RequestException:
|
|
674
|
+
pass
|
|
675
|
+
|
|
676
|
+
if not findings:
|
|
677
|
+
findings.append(Finding(
|
|
678
|
+
check="endpoints",
|
|
679
|
+
severity="info",
|
|
680
|
+
title="No common exposures detected",
|
|
681
|
+
detail="None of the probed paths returned accessible content.",
|
|
682
|
+
remediation="Continue monitoring for accidental exposure of sensitive paths.",
|
|
683
|
+
))
|
|
684
|
+
|
|
685
|
+
return findings
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
# ---------------------------------------------------------------------------
|
|
689
|
+
# Check 4: HTTP Methods
|
|
690
|
+
# ---------------------------------------------------------------------------
|
|
691
|
+
|
|
692
|
+
def check_http_methods(url: str, session: requests.Session) -> list[Finding]:
|
|
693
|
+
"""Test for enabled HTTP methods and flag potentially dangerous ones."""
|
|
694
|
+
findings: list[Finding] = []
|
|
695
|
+
timeout: int = getattr(session, "timeout", 10)
|
|
696
|
+
|
|
697
|
+
dangerous_methods = {"PUT", "DELETE", "TRACE", "CONNECT", "PATCH"}
|
|
698
|
+
|
|
699
|
+
try:
|
|
700
|
+
resp = session.options(url, timeout=timeout, allow_redirects=True)
|
|
701
|
+
allow_header = resp.headers.get("Allow", "")
|
|
702
|
+
access_control = resp.headers.get("Access-Control-Allow-Methods", "")
|
|
703
|
+
|
|
704
|
+
methods_str = allow_header or access_control
|
|
705
|
+
if methods_str:
|
|
706
|
+
methods = {m.strip().upper() for m in methods_str.split(",") if m.strip()}
|
|
707
|
+
enabled_dangerous = methods & dangerous_methods
|
|
708
|
+
|
|
709
|
+
findings.append(Finding(
|
|
710
|
+
check="methods",
|
|
711
|
+
severity="info",
|
|
712
|
+
title=f"Allowed HTTP methods: {', '.join(sorted(methods))}",
|
|
713
|
+
detail=f"The server advertises these methods via the Allow or "
|
|
714
|
+
f"Access-Control-Allow-Methods header.",
|
|
715
|
+
remediation="Restrict HTTP methods to only those required by the application.",
|
|
716
|
+
))
|
|
717
|
+
|
|
718
|
+
if enabled_dangerous:
|
|
719
|
+
for method in sorted(enabled_dangerous):
|
|
720
|
+
severity = "high" if method == "TRACE" else "medium"
|
|
721
|
+
detail_msg = ""
|
|
722
|
+
if method == "TRACE":
|
|
723
|
+
detail_msg = ("TRACE reflects the request back to the client, which "
|
|
724
|
+
"can be exploited in cross-site tracing (XST) attacks "
|
|
725
|
+
"to steal credentials from HTTP headers.")
|
|
726
|
+
elif method == "PUT":
|
|
727
|
+
detail_msg = ("PUT allows uploading or replacing files on the server, "
|
|
728
|
+
"which may allow unauthorized content modification.")
|
|
729
|
+
elif method == "DELETE":
|
|
730
|
+
detail_msg = ("DELETE allows removing resources from the server, "
|
|
731
|
+
"which may allow unauthorized data destruction.")
|
|
732
|
+
elif method == "CONNECT":
|
|
733
|
+
detail_msg = ("CONNECT may allow the server to be used as a proxy, "
|
|
734
|
+
"potentially enabling unauthorized network access.")
|
|
735
|
+
elif method == "PATCH":
|
|
736
|
+
detail_msg = ("PATCH allows partial resource modification. Ensure it "
|
|
737
|
+
"requires proper authentication and authorization.")
|
|
738
|
+
findings.append(Finding(
|
|
739
|
+
check="methods",
|
|
740
|
+
severity=severity,
|
|
741
|
+
title=f"Dangerous HTTP method enabled: {method}",
|
|
742
|
+
detail=detail_msg,
|
|
743
|
+
remediation=f"Disable the {method} method unless explicitly required. "
|
|
744
|
+
f"Configure the web server or application firewall to block it.",
|
|
745
|
+
))
|
|
746
|
+
else:
|
|
747
|
+
findings.append(Finding(
|
|
748
|
+
check="methods",
|
|
749
|
+
severity="info",
|
|
750
|
+
title="No Allow header in OPTIONS response",
|
|
751
|
+
detail=f"The OPTIONS request returned HTTP {resp.status_code} without "
|
|
752
|
+
f"an Allow header. Method enumeration was not possible.",
|
|
753
|
+
remediation="This is acceptable. The server does not advertise allowed methods.",
|
|
754
|
+
))
|
|
755
|
+
except requests.RequestException as exc:
|
|
756
|
+
findings.append(Finding(
|
|
757
|
+
check="methods",
|
|
758
|
+
severity="info",
|
|
759
|
+
title="OPTIONS request failed",
|
|
760
|
+
detail=f"Could not perform method enumeration: {exc}",
|
|
761
|
+
remediation="OPTIONS may be blocked by a firewall or WAF, which is acceptable.",
|
|
762
|
+
))
|
|
763
|
+
|
|
764
|
+
return findings
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
# ---------------------------------------------------------------------------
|
|
768
|
+
# Check 5: CORS Policy
|
|
769
|
+
# ---------------------------------------------------------------------------
|
|
770
|
+
|
|
771
|
+
def check_cors_policy(url: str, session: requests.Session) -> list[Finding]:
|
|
772
|
+
"""Analyze CORS configuration for misconfigurations that allow unauthorized access."""
|
|
773
|
+
findings: list[Finding] = []
|
|
774
|
+
timeout: int = getattr(session, "timeout", 10)
|
|
775
|
+
|
|
776
|
+
test_origin = "https://evil.example.com"
|
|
777
|
+
|
|
778
|
+
try:
|
|
779
|
+
headers = {"Origin": test_origin}
|
|
780
|
+
resp = session.get(url, headers=headers, timeout=timeout, allow_redirects=True)
|
|
781
|
+
|
|
782
|
+
acao = resp.headers.get("Access-Control-Allow-Origin", "")
|
|
783
|
+
acac = resp.headers.get("Access-Control-Allow-Credentials", "").lower()
|
|
784
|
+
|
|
785
|
+
if not acao:
|
|
786
|
+
findings.append(Finding(
|
|
787
|
+
check="cors",
|
|
788
|
+
severity="info",
|
|
789
|
+
title="No CORS headers in response",
|
|
790
|
+
detail="The server did not return an Access-Control-Allow-Origin header. "
|
|
791
|
+
"Cross-origin requests from browsers will be blocked by default.",
|
|
792
|
+
remediation="This is the secure default. Only add CORS headers if cross-origin "
|
|
793
|
+
"access is intentionally required.",
|
|
794
|
+
))
|
|
795
|
+
elif acao == "*":
|
|
796
|
+
if acac == "true":
|
|
797
|
+
findings.append(Finding(
|
|
798
|
+
check="cors",
|
|
799
|
+
severity="critical",
|
|
800
|
+
title="CORS: Wildcard origin with credentials allowed",
|
|
801
|
+
detail="Access-Control-Allow-Origin is set to '*' and "
|
|
802
|
+
"Access-Control-Allow-Credentials is 'true'. While browsers block "
|
|
803
|
+
"this combination, server-side misconfiguration indicates a flawed "
|
|
804
|
+
"CORS implementation that could be exploited.",
|
|
805
|
+
remediation="Never combine wildcard origin with Allow-Credentials. "
|
|
806
|
+
"Implement an origin allowlist and validate requests against it.",
|
|
807
|
+
))
|
|
808
|
+
else:
|
|
809
|
+
findings.append(Finding(
|
|
810
|
+
check="cors",
|
|
811
|
+
severity="medium",
|
|
812
|
+
title="CORS: Wildcard origin configured",
|
|
813
|
+
detail="Access-Control-Allow-Origin is set to '*', allowing any website "
|
|
814
|
+
"to make cross-origin requests. If the API serves sensitive data "
|
|
815
|
+
"or requires authentication, this is a security risk.",
|
|
816
|
+
remediation="Replace the wildcard with specific trusted origins. "
|
|
817
|
+
"Use an allowlist approach for cross-origin access.",
|
|
818
|
+
))
|
|
819
|
+
elif acao.lower() == test_origin.lower():
|
|
820
|
+
severity = "critical" if acac == "true" else "high"
|
|
821
|
+
cred_note = " with credentials" if acac == "true" else ""
|
|
822
|
+
findings.append(Finding(
|
|
823
|
+
check="cors",
|
|
824
|
+
severity=severity,
|
|
825
|
+
title=f"CORS: Origin reflection detected{cred_note}",
|
|
826
|
+
detail=f"The server reflected the arbitrary origin '{test_origin}' in the "
|
|
827
|
+
f"Access-Control-Allow-Origin header{cred_note}. This means any "
|
|
828
|
+
f"website can make authenticated cross-origin requests.",
|
|
829
|
+
remediation="Implement a strict origin allowlist. Never reflect the Origin "
|
|
830
|
+
"header value without validation against a list of trusted domains.",
|
|
831
|
+
))
|
|
832
|
+
else:
|
|
833
|
+
findings.append(Finding(
|
|
834
|
+
check="cors",
|
|
835
|
+
severity="info",
|
|
836
|
+
title=f"CORS: Specific origin configured ({acao})",
|
|
837
|
+
detail=f"The server returned a specific origin '{acao}' in the CORS header, "
|
|
838
|
+
f"not reflecting the test origin. This indicates proper origin validation.",
|
|
839
|
+
remediation="Periodically review the allowed origins to ensure they are still trusted.",
|
|
840
|
+
))
|
|
841
|
+
|
|
842
|
+
# Check for overly permissive methods in preflight
|
|
843
|
+
acam = resp.headers.get("Access-Control-Allow-Methods", "")
|
|
844
|
+
if acam and acao:
|
|
845
|
+
allowed_methods = {m.strip().upper() for m in acam.split(",") if m.strip()}
|
|
846
|
+
risky = allowed_methods & {"PUT", "DELETE", "PATCH"}
|
|
847
|
+
if risky:
|
|
848
|
+
findings.append(Finding(
|
|
849
|
+
check="cors",
|
|
850
|
+
severity="low",
|
|
851
|
+
title=f"CORS allows state-changing methods: {', '.join(sorted(risky))}",
|
|
852
|
+
detail=f"Cross-origin requests are permitted to use {', '.join(sorted(risky))} "
|
|
853
|
+
f"methods. Ensure these endpoints have proper authentication.",
|
|
854
|
+
remediation="Only expose the minimum set of HTTP methods required for "
|
|
855
|
+
"legitimate cross-origin requests.",
|
|
856
|
+
))
|
|
857
|
+
|
|
858
|
+
except requests.RequestException as exc:
|
|
859
|
+
findings.append(Finding(
|
|
860
|
+
check="cors",
|
|
861
|
+
severity="info",
|
|
862
|
+
title="CORS check failed",
|
|
863
|
+
detail=f"Could not perform CORS analysis: {exc}",
|
|
864
|
+
remediation="Verify the target is reachable and retry the scan.",
|
|
865
|
+
))
|
|
866
|
+
|
|
867
|
+
return findings
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
# ---------------------------------------------------------------------------
|
|
871
|
+
# Report Generation
|
|
872
|
+
# ---------------------------------------------------------------------------
|
|
873
|
+
|
|
874
|
+
def _calculate_risk_score(findings: list[Finding]) -> int:
|
|
875
|
+
"""Calculate a security risk score from 0 (worst) to 100 (best).
|
|
876
|
+
|
|
877
|
+
Starts at 100 and subtracts points for each finding based on severity.
|
|
878
|
+
Info-level findings do not affect the score.
|
|
879
|
+
"""
|
|
880
|
+
deductions = 0
|
|
881
|
+
for f in findings:
|
|
882
|
+
deductions += SEVERITY_WEIGHTS.get(f.severity, 0)
|
|
883
|
+
score = max(0, 100 - deductions)
|
|
884
|
+
return score
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _severity_sort_key(severity: str) -> int:
|
|
888
|
+
"""Return sort order for severities (critical first)."""
|
|
889
|
+
order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
|
|
890
|
+
return order.get(severity, 5)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def generate_report(
|
|
894
|
+
url: str,
|
|
895
|
+
results: ScanResult,
|
|
896
|
+
output_path: Optional[str] = None,
|
|
897
|
+
) -> str:
|
|
898
|
+
"""Generate a security scan report in Markdown format and optionally write JSON.
|
|
899
|
+
|
|
900
|
+
Args:
|
|
901
|
+
url: The target URL that was scanned.
|
|
902
|
+
results: The aggregated scan results.
|
|
903
|
+
output_path: If provided, write a JSON report to this file path.
|
|
904
|
+
|
|
905
|
+
Returns:
|
|
906
|
+
The Markdown-formatted report string.
|
|
907
|
+
"""
|
|
908
|
+
# Count findings by severity
|
|
909
|
+
severity_counts: dict[str, int] = {
|
|
910
|
+
"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0,
|
|
911
|
+
}
|
|
912
|
+
for f in results.findings:
|
|
913
|
+
severity_counts[f.severity] = severity_counts.get(f.severity, 0) + 1
|
|
914
|
+
|
|
915
|
+
total_findings = len(results.findings)
|
|
916
|
+
risk_score = _calculate_risk_score(results.findings)
|
|
917
|
+
|
|
918
|
+
# Risk rating label
|
|
919
|
+
if risk_score >= 90:
|
|
920
|
+
risk_label = "LOW RISK"
|
|
921
|
+
elif risk_score >= 70:
|
|
922
|
+
risk_label = "MODERATE RISK"
|
|
923
|
+
elif risk_score >= 50:
|
|
924
|
+
risk_label = "HIGH RISK"
|
|
925
|
+
else:
|
|
926
|
+
risk_label = "CRITICAL RISK"
|
|
927
|
+
|
|
928
|
+
# Build Markdown report
|
|
929
|
+
lines: list[str] = []
|
|
930
|
+
lines.append("=" * 72)
|
|
931
|
+
lines.append(f" SECURITY SCAN REPORT")
|
|
932
|
+
lines.append("=" * 72)
|
|
933
|
+
lines.append("")
|
|
934
|
+
lines.append(f"Target: {url}")
|
|
935
|
+
lines.append(f"Scan start: {results.scan_start}")
|
|
936
|
+
lines.append(f"Scan end: {results.scan_end}")
|
|
937
|
+
lines.append(f"Duration: {results.duration_seconds:.1f} seconds")
|
|
938
|
+
lines.append(f"Checks: {', '.join(results.checks_performed)}")
|
|
939
|
+
lines.append("")
|
|
940
|
+
lines.append("-" * 72)
|
|
941
|
+
lines.append(f" RISK SCORE: {risk_score}/100 ({risk_label})")
|
|
942
|
+
lines.append("-" * 72)
|
|
943
|
+
lines.append("")
|
|
944
|
+
lines.append("## Summary")
|
|
945
|
+
lines.append("")
|
|
946
|
+
lines.append(f" Total findings: {total_findings}")
|
|
947
|
+
lines.append(f" Critical: {severity_counts['critical']}")
|
|
948
|
+
lines.append(f" High: {severity_counts['high']}")
|
|
949
|
+
lines.append(f" Medium: {severity_counts['medium']}")
|
|
950
|
+
lines.append(f" Low: {severity_counts['low']}")
|
|
951
|
+
lines.append(f" Informational: {severity_counts['info']}")
|
|
952
|
+
lines.append("")
|
|
953
|
+
|
|
954
|
+
if results.errors:
|
|
955
|
+
lines.append("## Scan Errors")
|
|
956
|
+
lines.append("")
|
|
957
|
+
for err in results.errors:
|
|
958
|
+
lines.append(f" - {err}")
|
|
959
|
+
lines.append("")
|
|
960
|
+
|
|
961
|
+
# Detailed findings sorted by severity
|
|
962
|
+
lines.append("## Detailed Findings")
|
|
963
|
+
lines.append("")
|
|
964
|
+
|
|
965
|
+
sorted_findings = sorted(results.findings, key=lambda f: _severity_sort_key(f.severity))
|
|
966
|
+
|
|
967
|
+
for i, finding in enumerate(sorted_findings, 1):
|
|
968
|
+
sev_upper = finding.severity.upper()
|
|
969
|
+
lines.append(f"### [{sev_upper}] {finding.title}")
|
|
970
|
+
lines.append("")
|
|
971
|
+
lines.append(f" Check: {finding.check}")
|
|
972
|
+
lines.append(f" Severity: {sev_upper}")
|
|
973
|
+
lines.append(f" Detail: {finding.detail}")
|
|
974
|
+
lines.append(f" Remediation: {finding.remediation}")
|
|
975
|
+
lines.append("")
|
|
976
|
+
|
|
977
|
+
lines.append("=" * 72)
|
|
978
|
+
lines.append(f" End of report. {total_findings} finding(s) across "
|
|
979
|
+
f"{len(results.checks_performed)} check(s).")
|
|
980
|
+
lines.append("=" * 72)
|
|
981
|
+
|
|
982
|
+
report_text = "\n".join(lines)
|
|
983
|
+
|
|
984
|
+
# JSON output
|
|
985
|
+
if output_path:
|
|
986
|
+
json_data = {
|
|
987
|
+
"scanner": f"SecurityScanner/{__version__}",
|
|
988
|
+
"target": url,
|
|
989
|
+
"scan_start": results.scan_start,
|
|
990
|
+
"scan_end": results.scan_end,
|
|
991
|
+
"duration_seconds": results.duration_seconds,
|
|
992
|
+
"checks_performed": results.checks_performed,
|
|
993
|
+
"risk_score": risk_score,
|
|
994
|
+
"risk_label": risk_label,
|
|
995
|
+
"severity_counts": severity_counts,
|
|
996
|
+
"total_findings": total_findings,
|
|
997
|
+
"findings": [asdict(f) for f in sorted_findings],
|
|
998
|
+
"errors": results.errors,
|
|
999
|
+
}
|
|
1000
|
+
try:
|
|
1001
|
+
with open(output_path, "w", encoding="utf-8") as fh:
|
|
1002
|
+
json.dump(json_data, fh, indent=2, ensure_ascii=False)
|
|
1003
|
+
_log(f"JSON report written to: {output_path}")
|
|
1004
|
+
except OSError as exc:
|
|
1005
|
+
_log_error(f"Failed to write JSON report: {exc}")
|
|
1006
|
+
|
|
1007
|
+
return report_text
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
# ---------------------------------------------------------------------------
|
|
1011
|
+
# CLI / Main
|
|
1012
|
+
# ---------------------------------------------------------------------------
|
|
1013
|
+
|
|
1014
|
+
def main() -> int:
|
|
1015
|
+
"""Parse arguments and execute the security scan.
|
|
1016
|
+
|
|
1017
|
+
Returns:
|
|
1018
|
+
Exit code: 0 if no critical/high findings, 1 otherwise, 2 on error.
|
|
1019
|
+
"""
|
|
1020
|
+
parser = argparse.ArgumentParser(
|
|
1021
|
+
prog="security_scanner",
|
|
1022
|
+
description=(
|
|
1023
|
+
"HTTP Security Scanner - Automated web application security assessment. "
|
|
1024
|
+
"Performs non-intrusive checks against a target URL to identify "
|
|
1025
|
+
"misconfigurations and security weaknesses."
|
|
1026
|
+
),
|
|
1027
|
+
epilog=(
|
|
1028
|
+
"Examples:\n"
|
|
1029
|
+
" %(prog)s https://example.com\n"
|
|
1030
|
+
" %(prog)s https://example.com --output report.json\n"
|
|
1031
|
+
" %(prog)s https://example.com --checks headers,ssl,cors\n"
|
|
1032
|
+
" %(prog)s https://example.com --timeout 15 --verbose\n"
|
|
1033
|
+
),
|
|
1034
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1035
|
+
)
|
|
1036
|
+
parser.add_argument(
|
|
1037
|
+
"url",
|
|
1038
|
+
help="Target URL to scan (must include scheme, e.g., https://example.com)",
|
|
1039
|
+
)
|
|
1040
|
+
parser.add_argument(
|
|
1041
|
+
"--output", "-o",
|
|
1042
|
+
metavar="FILE",
|
|
1043
|
+
help="Write JSON report to the specified file path",
|
|
1044
|
+
)
|
|
1045
|
+
parser.add_argument(
|
|
1046
|
+
"--checks", "-c",
|
|
1047
|
+
metavar="LIST",
|
|
1048
|
+
default=",".join(ALL_CHECKS),
|
|
1049
|
+
help=(
|
|
1050
|
+
f"Comma-separated list of checks to run. "
|
|
1051
|
+
f"Available: {', '.join(ALL_CHECKS)}. Default: all"
|
|
1052
|
+
),
|
|
1053
|
+
)
|
|
1054
|
+
parser.add_argument(
|
|
1055
|
+
"--timeout", "-t",
|
|
1056
|
+
type=int,
|
|
1057
|
+
default=10,
|
|
1058
|
+
metavar="SECONDS",
|
|
1059
|
+
help="Request timeout in seconds (default: 10)",
|
|
1060
|
+
)
|
|
1061
|
+
parser.add_argument(
|
|
1062
|
+
"--verbose", "-v",
|
|
1063
|
+
action="store_true",
|
|
1064
|
+
help="Print progress messages to stderr",
|
|
1065
|
+
)
|
|
1066
|
+
parser.add_argument(
|
|
1067
|
+
"--version",
|
|
1068
|
+
action="version",
|
|
1069
|
+
version=f"%(prog)s {__version__}",
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
args = parser.parse_args()
|
|
1073
|
+
|
|
1074
|
+
# Validate URL
|
|
1075
|
+
parsed = urlparse(args.url)
|
|
1076
|
+
if parsed.scheme not in ("http", "https"):
|
|
1077
|
+
_log_error(f"Invalid URL scheme: '{parsed.scheme}'. Use http:// or https://")
|
|
1078
|
+
return 2
|
|
1079
|
+
if not parsed.hostname:
|
|
1080
|
+
_log_error("Invalid URL: no hostname found.")
|
|
1081
|
+
return 2
|
|
1082
|
+
|
|
1083
|
+
# Parse requested checks
|
|
1084
|
+
requested = [c.strip().lower() for c in args.checks.split(",") if c.strip()]
|
|
1085
|
+
invalid_checks = [c for c in requested if c not in ALL_CHECKS]
|
|
1086
|
+
if invalid_checks:
|
|
1087
|
+
_log_error(f"Unknown check(s): {', '.join(invalid_checks)}. "
|
|
1088
|
+
f"Available: {', '.join(ALL_CHECKS)}")
|
|
1089
|
+
return 2
|
|
1090
|
+
|
|
1091
|
+
# Initialize
|
|
1092
|
+
url = args.url.rstrip("/")
|
|
1093
|
+
session = create_session(timeout=args.timeout)
|
|
1094
|
+
scan_start = datetime.now(timezone.utc)
|
|
1095
|
+
|
|
1096
|
+
result = ScanResult(
|
|
1097
|
+
target=url,
|
|
1098
|
+
scan_start=scan_start.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
1099
|
+
)
|
|
1100
|
+
|
|
1101
|
+
_log(f"SecurityScanner v{__version__} starting scan of {url}", args.verbose)
|
|
1102
|
+
_log(f"Checks: {', '.join(requested)}", args.verbose)
|
|
1103
|
+
_log(f"Timeout: {args.timeout}s", args.verbose)
|
|
1104
|
+
_log("", args.verbose)
|
|
1105
|
+
|
|
1106
|
+
# Connectivity pre-check
|
|
1107
|
+
try:
|
|
1108
|
+
session.head(url, timeout=args.timeout, allow_redirects=True)
|
|
1109
|
+
except requests.ConnectionError:
|
|
1110
|
+
_log_error(f"Cannot connect to {url}. Verify the URL and network connectivity.")
|
|
1111
|
+
return 2
|
|
1112
|
+
except requests.Timeout:
|
|
1113
|
+
_log_error(f"Connection to {url} timed out after {args.timeout} seconds.")
|
|
1114
|
+
return 2
|
|
1115
|
+
except requests.RequestException as exc:
|
|
1116
|
+
_log_error(f"Pre-check failed: {exc}")
|
|
1117
|
+
# Continue anyway; individual checks handle their own errors
|
|
1118
|
+
|
|
1119
|
+
# Execute checks
|
|
1120
|
+
check_map: dict[str, tuple[str, object]] = {
|
|
1121
|
+
"headers": ("Security Headers", lambda: scan_security_headers(url, session)),
|
|
1122
|
+
"ssl": ("SSL/TLS Certificate", lambda: check_ssl_tls(url)),
|
|
1123
|
+
"endpoints": ("Common Exposures", lambda: probe_common_exposures(url, session)),
|
|
1124
|
+
"methods": ("HTTP Methods", lambda: check_http_methods(url, session)),
|
|
1125
|
+
"cors": ("CORS Policy", lambda: check_cors_policy(url, session)),
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
for check_name in requested:
|
|
1129
|
+
label, check_fn = check_map[check_name]
|
|
1130
|
+
_log(f"Running check: {label}...", args.verbose)
|
|
1131
|
+
try:
|
|
1132
|
+
findings = check_fn()
|
|
1133
|
+
result.findings.extend(findings)
|
|
1134
|
+
result.checks_performed.append(check_name)
|
|
1135
|
+
_log(f" {label}: {len(findings)} finding(s)", args.verbose)
|
|
1136
|
+
except Exception as exc:
|
|
1137
|
+
error_msg = f"Check '{check_name}' failed with unexpected error: {exc}"
|
|
1138
|
+
_log_error(error_msg)
|
|
1139
|
+
result.errors.append(error_msg)
|
|
1140
|
+
|
|
1141
|
+
# Finalize timing
|
|
1142
|
+
scan_end = datetime.now(timezone.utc)
|
|
1143
|
+
result.scan_end = scan_end.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1144
|
+
result.duration_seconds = round((scan_end - scan_start).total_seconds(), 2)
|
|
1145
|
+
|
|
1146
|
+
_log("", args.verbose)
|
|
1147
|
+
_log(f"Scan complete. {len(result.findings)} finding(s) in "
|
|
1148
|
+
f"{result.duration_seconds}s", args.verbose)
|
|
1149
|
+
_log("", args.verbose)
|
|
1150
|
+
|
|
1151
|
+
# Generate and print report
|
|
1152
|
+
report = generate_report(url, result, output_path=args.output)
|
|
1153
|
+
print(report)
|
|
1154
|
+
|
|
1155
|
+
# Determine exit code
|
|
1156
|
+
critical_or_high = sum(
|
|
1157
|
+
1 for f in result.findings if f.severity in ("critical", "high")
|
|
1158
|
+
)
|
|
1159
|
+
if critical_or_high > 0:
|
|
1160
|
+
_log(f"Exiting with code 1: {critical_or_high} critical/high finding(s)", args.verbose)
|
|
1161
|
+
return 1
|
|
1162
|
+
return 0
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
if __name__ == "__main__":
|
|
1166
|
+
sys.exit(main())
|