@intentsolutionsio/penetration-tester 2.0.0 → 3.0.4
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 +8 -3
- package/README.md +8 -0
- package/commands/pentest.md +5 -0
- package/package.json +8 -3
- package/skills/analyzing-tls-config/SKILL.md +221 -0
- package/skills/analyzing-tls-config/references/AUTHORIZATION.md +133 -0
- package/skills/analyzing-tls-config/references/PLAYBOOK.md +267 -0
- package/skills/analyzing-tls-config/references/THEORY.md +128 -0
- package/skills/analyzing-tls-config/scripts/analyze_tls.py +415 -0
- package/skills/auditing-cors-policy/SKILL.md +186 -0
- package/skills/auditing-cors-policy/references/PLAYBOOK.md +220 -0
- package/skills/auditing-cors-policy/references/THEORY.md +142 -0
- package/skills/auditing-cors-policy/scripts/audit_cors.py +350 -0
- package/skills/auditing-npm-dependencies/SKILL.md +254 -0
- package/skills/auditing-npm-dependencies/references/PLAYBOOK.md +175 -0
- package/skills/auditing-npm-dependencies/references/THEORY.md +122 -0
- package/skills/auditing-npm-dependencies/scripts/audit_npm.py +408 -0
- package/skills/auditing-python-dependencies/SKILL.md +251 -0
- package/skills/auditing-python-dependencies/references/PLAYBOOK.md +193 -0
- package/skills/auditing-python-dependencies/references/THEORY.md +122 -0
- package/skills/auditing-python-dependencies/scripts/audit_python.py +459 -0
- package/skills/checking-http-security-headers/SKILL.md +176 -0
- package/skills/checking-http-security-headers/references/PLAYBOOK.md +212 -0
- package/skills/checking-http-security-headers/references/THEORY.md +137 -0
- package/skills/checking-http-security-headers/scripts/check_headers.py +362 -0
- package/skills/checking-license-compliance/SKILL.md +225 -0
- package/skills/checking-license-compliance/references/PLAYBOOK.md +161 -0
- package/skills/checking-license-compliance/references/THEORY.md +152 -0
- package/skills/checking-license-compliance/scripts/check_licenses.py +461 -0
- package/skills/composing-vulnerability-report/SKILL.md +212 -0
- package/skills/composing-vulnerability-report/references/PLAYBOOK.md +180 -0
- package/skills/composing-vulnerability-report/references/THEORY.md +178 -0
- package/skills/composing-vulnerability-report/scripts/compose_report.py +396 -0
- package/skills/confirming-pentest-authorization/SKILL.md +247 -0
- package/skills/confirming-pentest-authorization/references/PLAYBOOK.md +189 -0
- package/skills/confirming-pentest-authorization/references/THEORY.md +167 -0
- package/skills/confirming-pentest-authorization/scripts/check_authorization.py +457 -0
- package/skills/defining-pentest-scope/SKILL.md +227 -0
- package/skills/defining-pentest-scope/references/PLAYBOOK.md +238 -0
- package/skills/defining-pentest-scope/references/THEORY.md +170 -0
- package/skills/defining-pentest-scope/scripts/define_scope.py +472 -0
- package/skills/detecting-command-injection-patterns/SKILL.md +144 -0
- package/skills/detecting-command-injection-patterns/references/PLAYBOOK.md +302 -0
- package/skills/detecting-command-injection-patterns/references/THEORY.md +206 -0
- package/skills/detecting-command-injection-patterns/scripts/scan_cmdi.py +290 -0
- package/skills/detecting-debug-endpoints/SKILL.md +207 -0
- package/skills/detecting-debug-endpoints/references/PLAYBOOK.md +402 -0
- package/skills/detecting-debug-endpoints/references/THEORY.md +218 -0
- package/skills/detecting-debug-endpoints/scripts/probe_debug.py +518 -0
- package/skills/detecting-directory-listing/SKILL.md +206 -0
- package/skills/detecting-directory-listing/references/PLAYBOOK.md +277 -0
- package/skills/detecting-directory-listing/references/THEORY.md +203 -0
- package/skills/detecting-directory-listing/scripts/probe_directory_listing.py +180 -0
- package/skills/detecting-eval-exec-usage/SKILL.md +128 -0
- package/skills/detecting-eval-exec-usage/references/PLAYBOOK.md +306 -0
- package/skills/detecting-eval-exec-usage/references/THEORY.md +159 -0
- package/skills/detecting-eval-exec-usage/scripts/scan_eval.py +223 -0
- package/skills/detecting-exposed-secrets-files/SKILL.md +179 -0
- package/skills/detecting-exposed-secrets-files/references/PLAYBOOK.md +274 -0
- package/skills/detecting-exposed-secrets-files/references/THEORY.md +174 -0
- package/skills/detecting-exposed-secrets-files/scripts/probe_secrets.py +207 -0
- package/skills/detecting-insecure-deserialization/SKILL.md +148 -0
- package/skills/detecting-insecure-deserialization/references/PLAYBOOK.md +333 -0
- package/skills/detecting-insecure-deserialization/references/THEORY.md +199 -0
- package/skills/detecting-insecure-deserialization/scripts/scan_deserialization.py +250 -0
- package/skills/detecting-sql-injection-patterns/SKILL.md +161 -0
- package/skills/detecting-sql-injection-patterns/references/PLAYBOOK.md +317 -0
- package/skills/detecting-sql-injection-patterns/references/THEORY.md +261 -0
- package/skills/detecting-sql-injection-patterns/scripts/scan_sqli.py +354 -0
- package/skills/detecting-ssl-cert-issues/SKILL.md +182 -0
- package/skills/detecting-ssl-cert-issues/references/PLAYBOOK.md +203 -0
- package/skills/detecting-ssl-cert-issues/references/THEORY.md +133 -0
- package/skills/detecting-ssl-cert-issues/scripts/check_cert_chain.py +481 -0
- package/skills/detecting-weak-cryptography/SKILL.md +147 -0
- package/skills/detecting-weak-cryptography/references/PLAYBOOK.md +466 -0
- package/skills/detecting-weak-cryptography/references/THEORY.md +194 -0
- package/skills/detecting-weak-cryptography/scripts/scan_weak_crypto.py +417 -0
- package/skills/fingerprinting-server-software/SKILL.md +191 -0
- package/skills/fingerprinting-server-software/references/PLAYBOOK.md +337 -0
- package/skills/fingerprinting-server-software/references/THEORY.md +183 -0
- package/skills/fingerprinting-server-software/scripts/fingerprint_server.py +347 -0
- package/skills/generating-executive-summary/SKILL.md +261 -0
- package/skills/generating-executive-summary/references/PLAYBOOK.md +201 -0
- package/skills/generating-executive-summary/references/THEORY.md +195 -0
- package/skills/generating-executive-summary/scripts/exec_summary.py +538 -0
- package/skills/mapping-findings-to-owasp-top10/SKILL.md +235 -0
- package/skills/mapping-findings-to-owasp-top10/references/PLAYBOOK.md +193 -0
- package/skills/mapping-findings-to-owasp-top10/references/THEORY.md +160 -0
- package/skills/mapping-findings-to-owasp-top10/scripts/map_owasp.py +540 -0
- package/skills/performing-penetration-testing/SKILL.md +282 -190
- package/skills/performing-penetration-testing/references/OWASP_TOP_10.md +22 -0
- package/skills/performing-penetration-testing/references/REMEDIATION_PLAYBOOK.md +46 -0
- package/skills/performing-penetration-testing/references/SECURITY_HEADERS.md +41 -0
- package/skills/performing-penetration-testing/scripts/code_security_scanner.py +144 -79
- package/skills/performing-penetration-testing/scripts/dependency_auditor.py +116 -93
- package/skills/performing-penetration-testing/scripts/security_scanner.py +574 -446
- package/skills/probing-dangerous-http-methods/SKILL.md +182 -0
- package/skills/probing-dangerous-http-methods/references/PLAYBOOK.md +234 -0
- package/skills/probing-dangerous-http-methods/references/THEORY.md +145 -0
- package/skills/probing-dangerous-http-methods/scripts/probe_methods.py +263 -0
- package/skills/recording-pentest-engagement/SKILL.md +253 -0
- package/skills/recording-pentest-engagement/references/PLAYBOOK.md +203 -0
- package/skills/recording-pentest-engagement/references/THEORY.md +195 -0
- package/skills/recording-pentest-engagement/scripts/record_engagement.py +461 -0
- package/skills/scanning-for-hardcoded-secrets/SKILL.md +215 -0
- package/skills/scanning-for-hardcoded-secrets/references/PLAYBOOK.md +325 -0
- package/skills/scanning-for-hardcoded-secrets/references/THEORY.md +175 -0
- package/skills/scanning-for-hardcoded-secrets/scripts/scan_secrets.py +395 -0
- package/skills/tracing-transitive-vulnerabilities/SKILL.md +235 -0
- package/skills/tracing-transitive-vulnerabilities/references/PLAYBOOK.md +233 -0
- package/skills/tracing-transitive-vulnerabilities/references/THEORY.md +138 -0
- package/skills/tracing-transitive-vulnerabilities/scripts/trace_vulns.py +484 -0
|
@@ -27,7 +27,6 @@ import json
|
|
|
27
27
|
import socket
|
|
28
28
|
import ssl
|
|
29
29
|
import sys
|
|
30
|
-
import time
|
|
31
30
|
from dataclasses import asdict, dataclass, field
|
|
32
31
|
from datetime import datetime, timezone
|
|
33
32
|
from typing import Optional
|
|
@@ -102,11 +101,13 @@ def _log_error(message: str) -> None:
|
|
|
102
101
|
def create_session(timeout: int = 10) -> requests.Session:
|
|
103
102
|
"""Create a requests session with retry logic and custom headers."""
|
|
104
103
|
session = requests.Session()
|
|
105
|
-
session.headers.update(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
104
|
+
session.headers.update(
|
|
105
|
+
{
|
|
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
|
+
)
|
|
110
111
|
|
|
111
112
|
retry_strategy = Retry(
|
|
112
113
|
total=2,
|
|
@@ -127,6 +128,7 @@ def create_session(timeout: int = 10) -> requests.Session:
|
|
|
127
128
|
# Check 1: Security Headers
|
|
128
129
|
# ---------------------------------------------------------------------------
|
|
129
130
|
|
|
131
|
+
|
|
130
132
|
def scan_security_headers(url: str, session: requests.Session) -> list[Finding]:
|
|
131
133
|
"""Analyze HTTP response security headers for misconfigurations and missing headers."""
|
|
132
134
|
findings: list[Finding] = []
|
|
@@ -135,13 +137,15 @@ def scan_security_headers(url: str, session: requests.Session) -> list[Finding]:
|
|
|
135
137
|
try:
|
|
136
138
|
resp = session.get(url, timeout=timeout, allow_redirects=True)
|
|
137
139
|
except requests.RequestException as exc:
|
|
138
|
-
findings.append(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
140
|
+
findings.append(
|
|
141
|
+
Finding(
|
|
142
|
+
check="headers",
|
|
143
|
+
severity="high",
|
|
144
|
+
title="Unable to retrieve headers",
|
|
145
|
+
detail=f"Request failed: {exc}",
|
|
146
|
+
remediation="Verify the target URL is reachable and the server is running.",
|
|
147
|
+
)
|
|
148
|
+
)
|
|
145
149
|
return findings
|
|
146
150
|
|
|
147
151
|
headers = resp.headers
|
|
@@ -149,57 +153,66 @@ def scan_security_headers(url: str, session: requests.Session) -> list[Finding]:
|
|
|
149
153
|
# --- Content-Security-Policy ---
|
|
150
154
|
csp = headers.get("Content-Security-Policy")
|
|
151
155
|
if not csp:
|
|
152
|
-
findings.append(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
156
|
+
findings.append(
|
|
157
|
+
Finding(
|
|
158
|
+
check="headers",
|
|
159
|
+
severity="high",
|
|
160
|
+
title="Missing Content-Security-Policy header",
|
|
161
|
+
detail="No CSP header was found in the response. This leaves the application "
|
|
162
|
+
"vulnerable to cross-site scripting and data injection attacks.",
|
|
163
|
+
remediation="Implement a Content-Security-Policy header. Start with a restrictive "
|
|
164
|
+
"policy such as \"default-src 'self'\" and expand as needed.",
|
|
165
|
+
)
|
|
166
|
+
)
|
|
161
167
|
else:
|
|
162
168
|
if "'unsafe-inline'" in csp:
|
|
163
|
-
findings.append(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
findings.append(
|
|
170
|
+
Finding(
|
|
171
|
+
check="headers",
|
|
172
|
+
severity="medium",
|
|
173
|
+
title="CSP allows unsafe-inline",
|
|
174
|
+
detail=f"The Content-Security-Policy contains 'unsafe-inline', which weakens "
|
|
175
|
+
f"XSS protections. Value: {csp[:200]}",
|
|
176
|
+
remediation="Replace 'unsafe-inline' with nonce-based or hash-based CSP directives.",
|
|
177
|
+
)
|
|
178
|
+
)
|
|
171
179
|
if "'unsafe-eval'" in csp:
|
|
172
|
-
findings.append(
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
+
findings.append(
|
|
181
|
+
Finding(
|
|
182
|
+
check="headers",
|
|
183
|
+
severity="medium",
|
|
184
|
+
title="CSP allows unsafe-eval",
|
|
185
|
+
detail=f"The Content-Security-Policy contains 'unsafe-eval', allowing dynamic "
|
|
186
|
+
f"code execution. Value: {csp[:200]}",
|
|
187
|
+
remediation="Remove 'unsafe-eval' from CSP and refactor code to avoid eval().",
|
|
188
|
+
)
|
|
189
|
+
)
|
|
180
190
|
if "default-src" not in csp and "script-src" not in csp:
|
|
181
|
-
findings.append(
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
191
|
+
findings.append(
|
|
192
|
+
Finding(
|
|
193
|
+
check="headers",
|
|
194
|
+
severity="medium",
|
|
195
|
+
title="CSP missing default-src or script-src directive",
|
|
196
|
+
detail="The CSP header does not define a default-src or script-src directive, "
|
|
197
|
+
"which may leave resource loading unrestricted.",
|
|
198
|
+
remediation="Add a 'default-src' directive as a fallback for all resource types.",
|
|
199
|
+
)
|
|
200
|
+
)
|
|
189
201
|
|
|
190
202
|
# --- Strict-Transport-Security ---
|
|
191
203
|
hsts = headers.get("Strict-Transport-Security")
|
|
192
204
|
if not hsts:
|
|
193
205
|
severity = "high" if url.startswith("https") else "medium"
|
|
194
|
-
findings.append(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
206
|
+
findings.append(
|
|
207
|
+
Finding(
|
|
208
|
+
check="headers",
|
|
209
|
+
severity=severity,
|
|
210
|
+
title="Missing Strict-Transport-Security (HSTS) header",
|
|
211
|
+
detail="The server does not send an HSTS header. Clients may connect over "
|
|
212
|
+
"insecure HTTP, enabling man-in-the-middle attacks.",
|
|
213
|
+
remediation="Add the header: Strict-Transport-Security: max-age=31536000; includeSubDomains; preload",
|
|
214
|
+
)
|
|
215
|
+
)
|
|
203
216
|
else:
|
|
204
217
|
hsts_lower = hsts.lower()
|
|
205
218
|
# Check max-age value
|
|
@@ -212,117 +225,136 @@ def scan_security_headers(url: str, session: requests.Session) -> list[Finding]:
|
|
|
212
225
|
except ValueError:
|
|
213
226
|
max_age_val = 0
|
|
214
227
|
if max_age_val < 31536000:
|
|
215
|
-
findings.append(
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
228
|
+
findings.append(
|
|
229
|
+
Finding(
|
|
230
|
+
check="headers",
|
|
231
|
+
severity="medium",
|
|
232
|
+
title="HSTS max-age is too short",
|
|
233
|
+
detail=f"HSTS max-age is {max_age_val} seconds (recommended minimum is "
|
|
234
|
+
f"31536000 / 1 year). Current value: {hsts}",
|
|
235
|
+
remediation="Set max-age to at least 31536000 (one year).",
|
|
236
|
+
)
|
|
237
|
+
)
|
|
223
238
|
if "includesubdomains" not in hsts_lower:
|
|
224
|
-
findings.append(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
239
|
+
findings.append(
|
|
240
|
+
Finding(
|
|
241
|
+
check="headers",
|
|
242
|
+
severity="low",
|
|
243
|
+
title="HSTS missing includeSubDomains directive",
|
|
244
|
+
detail=f"The HSTS header does not include the includeSubDomains directive. "
|
|
245
|
+
f"Subdomains may still be accessed over HTTP. Value: {hsts}",
|
|
246
|
+
remediation="Add 'includeSubDomains' to the HSTS header.",
|
|
247
|
+
)
|
|
248
|
+
)
|
|
232
249
|
|
|
233
250
|
# --- X-Frame-Options ---
|
|
234
251
|
xfo = headers.get("X-Frame-Options")
|
|
235
252
|
if not xfo:
|
|
236
253
|
# Only flag if CSP frame-ancestors is also missing
|
|
237
254
|
if not csp or "frame-ancestors" not in (csp or ""):
|
|
238
|
-
findings.append(
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
255
|
+
findings.append(
|
|
256
|
+
Finding(
|
|
257
|
+
check="headers",
|
|
258
|
+
severity="medium",
|
|
259
|
+
title="Missing X-Frame-Options header",
|
|
260
|
+
detail="Neither X-Frame-Options nor CSP frame-ancestors is set. "
|
|
261
|
+
"The page may be embedded in frames, enabling clickjacking.",
|
|
262
|
+
remediation="Set X-Frame-Options to DENY or SAMEORIGIN, or use CSP frame-ancestors directive.",
|
|
263
|
+
)
|
|
264
|
+
)
|
|
247
265
|
|
|
248
266
|
# --- X-Content-Type-Options ---
|
|
249
267
|
xcto = headers.get("X-Content-Type-Options")
|
|
250
268
|
if not xcto:
|
|
251
|
-
findings.append(
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
269
|
+
findings.append(
|
|
270
|
+
Finding(
|
|
271
|
+
check="headers",
|
|
272
|
+
severity="medium",
|
|
273
|
+
title="Missing X-Content-Type-Options header",
|
|
274
|
+
detail="Without this header, browsers may MIME-sniff responses, potentially "
|
|
275
|
+
"interpreting files as executable content.",
|
|
276
|
+
remediation="Set the header: X-Content-Type-Options: nosniff",
|
|
277
|
+
)
|
|
278
|
+
)
|
|
259
279
|
elif xcto.strip().lower() != "nosniff":
|
|
260
|
-
findings.append(
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
280
|
+
findings.append(
|
|
281
|
+
Finding(
|
|
282
|
+
check="headers",
|
|
283
|
+
severity="medium",
|
|
284
|
+
title="X-Content-Type-Options has unexpected value",
|
|
285
|
+
detail=f"Expected 'nosniff' but got '{xcto}'. The header may not function correctly.",
|
|
286
|
+
remediation="Set the value to exactly 'nosniff'.",
|
|
287
|
+
)
|
|
288
|
+
)
|
|
267
289
|
|
|
268
290
|
# --- Referrer-Policy ---
|
|
269
291
|
rp = headers.get("Referrer-Policy")
|
|
270
292
|
if not rp:
|
|
271
|
-
findings.append(
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
293
|
+
findings.append(
|
|
294
|
+
Finding(
|
|
295
|
+
check="headers",
|
|
296
|
+
severity="low",
|
|
297
|
+
title="Missing Referrer-Policy header",
|
|
298
|
+
detail="Without a Referrer-Policy, the browser sends the full URL as referrer "
|
|
299
|
+
"to other sites, potentially leaking sensitive URL parameters.",
|
|
300
|
+
remediation="Set Referrer-Policy to 'strict-origin-when-cross-origin' or 'no-referrer'.",
|
|
301
|
+
)
|
|
302
|
+
)
|
|
279
303
|
|
|
280
304
|
# --- Permissions-Policy ---
|
|
281
305
|
pp = headers.get("Permissions-Policy")
|
|
282
306
|
if not pp:
|
|
283
|
-
findings.append(
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
307
|
+
findings.append(
|
|
308
|
+
Finding(
|
|
309
|
+
check="headers",
|
|
310
|
+
severity="low",
|
|
311
|
+
title="Missing Permissions-Policy header",
|
|
312
|
+
detail="No Permissions-Policy header found. Browser features like camera, "
|
|
313
|
+
"microphone, and geolocation are not explicitly restricted.",
|
|
314
|
+
remediation="Add a Permissions-Policy header to restrict unnecessary browser features, "
|
|
315
|
+
"e.g., Permissions-Policy: camera=(), microphone=(), geolocation=()",
|
|
316
|
+
)
|
|
317
|
+
)
|
|
292
318
|
|
|
293
319
|
# --- X-XSS-Protection (deprecated) ---
|
|
294
320
|
xxp = headers.get("X-XSS-Protection")
|
|
295
321
|
if xxp:
|
|
296
|
-
findings.append(
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
322
|
+
findings.append(
|
|
323
|
+
Finding(
|
|
324
|
+
check="headers",
|
|
325
|
+
severity="info",
|
|
326
|
+
title="X-XSS-Protection header present (deprecated)",
|
|
327
|
+
detail=f"The X-XSS-Protection header is set to '{xxp}'. This header is deprecated "
|
|
328
|
+
f"in modern browsers and the XSS auditor has been removed. Relying on it "
|
|
329
|
+
f"provides a false sense of security.",
|
|
330
|
+
remediation="Remove X-XSS-Protection and rely on a strong Content-Security-Policy instead.",
|
|
331
|
+
)
|
|
332
|
+
)
|
|
305
333
|
|
|
306
334
|
# --- Server header version disclosure ---
|
|
307
335
|
server = headers.get("Server")
|
|
308
336
|
if server and any(ch.isdigit() for ch in server):
|
|
309
|
-
findings.append(
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
337
|
+
findings.append(
|
|
338
|
+
Finding(
|
|
339
|
+
check="headers",
|
|
340
|
+
severity="low",
|
|
341
|
+
title="Server header discloses version information",
|
|
342
|
+
detail=f"The Server header value '{server}' contains version numbers, "
|
|
343
|
+
f"which aids attackers in identifying known vulnerabilities.",
|
|
344
|
+
remediation="Configure the web server to suppress or generalize the Server header.",
|
|
345
|
+
)
|
|
346
|
+
)
|
|
317
347
|
|
|
318
348
|
if not findings:
|
|
319
|
-
findings.append(
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
349
|
+
findings.append(
|
|
350
|
+
Finding(
|
|
351
|
+
check="headers",
|
|
352
|
+
severity="info",
|
|
353
|
+
title="All recommended security headers are present",
|
|
354
|
+
detail="The response includes the standard set of security headers.",
|
|
355
|
+
remediation="Continue monitoring headers as security best practices evolve.",
|
|
356
|
+
)
|
|
357
|
+
)
|
|
326
358
|
|
|
327
359
|
return findings
|
|
328
360
|
|
|
@@ -331,20 +363,23 @@ def scan_security_headers(url: str, session: requests.Session) -> list[Finding]:
|
|
|
331
363
|
# Check 2: SSL/TLS Certificate
|
|
332
364
|
# ---------------------------------------------------------------------------
|
|
333
365
|
|
|
366
|
+
|
|
334
367
|
def check_ssl_tls(url: str) -> list[Finding]:
|
|
335
368
|
"""Validate SSL/TLS certificate properties for the target host."""
|
|
336
369
|
findings: list[Finding] = []
|
|
337
370
|
parsed = urlparse(url)
|
|
338
371
|
|
|
339
372
|
if parsed.scheme != "https":
|
|
340
|
-
findings.append(
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
373
|
+
findings.append(
|
|
374
|
+
Finding(
|
|
375
|
+
check="ssl",
|
|
376
|
+
severity="high",
|
|
377
|
+
title="Target does not use HTTPS",
|
|
378
|
+
detail=f"The target URL uses the '{parsed.scheme}' scheme. All traffic "
|
|
379
|
+
f"is transmitted in plaintext, vulnerable to interception.",
|
|
380
|
+
remediation="Configure the server to use HTTPS with a valid TLS certificate.",
|
|
381
|
+
)
|
|
382
|
+
)
|
|
348
383
|
return findings
|
|
349
384
|
|
|
350
385
|
hostname = parsed.hostname or ""
|
|
@@ -359,32 +394,38 @@ def check_ssl_tls(url: str) -> list[Finding]:
|
|
|
359
394
|
protocol_version = tls_sock.version()
|
|
360
395
|
|
|
361
396
|
if not cert:
|
|
362
|
-
findings.append(
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
397
|
+
findings.append(
|
|
398
|
+
Finding(
|
|
399
|
+
check="ssl",
|
|
400
|
+
severity="critical",
|
|
401
|
+
title="No certificate returned by server",
|
|
402
|
+
detail="The TLS handshake completed but no certificate was presented.",
|
|
403
|
+
remediation="Ensure the server is configured with a valid TLS certificate.",
|
|
404
|
+
)
|
|
405
|
+
)
|
|
369
406
|
return findings
|
|
370
407
|
|
|
371
408
|
# Protocol version
|
|
372
409
|
if protocol_version:
|
|
373
|
-
findings.append(
|
|
374
|
-
|
|
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(
|
|
410
|
+
findings.append(
|
|
411
|
+
Finding(
|
|
382
412
|
check="ssl",
|
|
383
|
-
severity="
|
|
384
|
-
title=f"
|
|
385
|
-
detail=f"{protocol_version}
|
|
386
|
-
remediation="
|
|
387
|
-
)
|
|
413
|
+
severity="info",
|
|
414
|
+
title=f"TLS protocol version: {protocol_version}",
|
|
415
|
+
detail=f"The server negotiated {protocol_version}.",
|
|
416
|
+
remediation="Ensure TLS 1.2 or higher is used; disable TLS 1.0 and 1.1.",
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
if protocol_version in ("TLSv1", "TLSv1.1"):
|
|
420
|
+
findings.append(
|
|
421
|
+
Finding(
|
|
422
|
+
check="ssl",
|
|
423
|
+
severity="high",
|
|
424
|
+
title=f"Outdated TLS protocol: {protocol_version}",
|
|
425
|
+
detail=f"{protocol_version} is deprecated and has known vulnerabilities.",
|
|
426
|
+
remediation="Disable TLS 1.0 and TLS 1.1. Use TLS 1.2 or TLS 1.3.",
|
|
427
|
+
)
|
|
428
|
+
)
|
|
388
429
|
|
|
389
430
|
# Certificate expiry
|
|
390
431
|
not_after_str = cert.get("notAfter", "")
|
|
@@ -397,48 +438,58 @@ def check_ssl_tls(url: str) -> list[Finding]:
|
|
|
397
438
|
days_remaining = (not_after - now).days
|
|
398
439
|
|
|
399
440
|
if days_remaining < 0:
|
|
400
|
-
findings.append(
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
441
|
+
findings.append(
|
|
442
|
+
Finding(
|
|
443
|
+
check="ssl",
|
|
444
|
+
severity="critical",
|
|
445
|
+
title="SSL certificate has expired",
|
|
446
|
+
detail=f"The certificate expired on {not_after_str} "
|
|
447
|
+
f"({abs(days_remaining)} days ago).",
|
|
448
|
+
remediation="Renew the SSL/TLS certificate immediately.",
|
|
449
|
+
)
|
|
450
|
+
)
|
|
408
451
|
elif days_remaining < 7:
|
|
409
|
-
findings.append(
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
452
|
+
findings.append(
|
|
453
|
+
Finding(
|
|
454
|
+
check="ssl",
|
|
455
|
+
severity="critical",
|
|
456
|
+
title=f"SSL certificate expires in {days_remaining} days",
|
|
457
|
+
detail=f"The certificate expires on {not_after_str}. "
|
|
458
|
+
f"Immediate renewal is required.",
|
|
459
|
+
remediation="Renew the SSL/TLS certificate before expiry.",
|
|
460
|
+
)
|
|
461
|
+
)
|
|
417
462
|
elif days_remaining < 30:
|
|
418
|
-
findings.append(
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
463
|
+
findings.append(
|
|
464
|
+
Finding(
|
|
465
|
+
check="ssl",
|
|
466
|
+
severity="high",
|
|
467
|
+
title=f"SSL certificate expires in {days_remaining} days",
|
|
468
|
+
detail=f"The certificate expires on {not_after_str}. "
|
|
469
|
+
f"Plan renewal soon to avoid service disruption.",
|
|
470
|
+
remediation="Renew the SSL/TLS certificate within the next two weeks.",
|
|
471
|
+
)
|
|
472
|
+
)
|
|
426
473
|
else:
|
|
427
|
-
findings.append(
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
474
|
+
findings.append(
|
|
475
|
+
Finding(
|
|
476
|
+
check="ssl",
|
|
477
|
+
severity="info",
|
|
478
|
+
title=f"SSL certificate valid for {days_remaining} days",
|
|
479
|
+
detail=f"The certificate expires on {not_after_str}.",
|
|
480
|
+
remediation="Monitor certificate expiry and renew before it lapses.",
|
|
481
|
+
)
|
|
482
|
+
)
|
|
434
483
|
except ValueError:
|
|
435
|
-
findings.append(
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
484
|
+
findings.append(
|
|
485
|
+
Finding(
|
|
486
|
+
check="ssl",
|
|
487
|
+
severity="medium",
|
|
488
|
+
title="Unable to parse certificate expiry date",
|
|
489
|
+
detail=f"Certificate notAfter value: '{not_after_str}' could not be parsed.",
|
|
490
|
+
remediation="Manually verify the certificate expiry date.",
|
|
491
|
+
)
|
|
492
|
+
)
|
|
442
493
|
|
|
443
494
|
# Subject and issuer info
|
|
444
495
|
subject_parts = []
|
|
@@ -453,60 +504,72 @@ def check_ssl_tls(url: str) -> list[Finding]:
|
|
|
453
504
|
issuer_parts.append(f"{attr_name}={attr_value}")
|
|
454
505
|
issuer_str = ", ".join(issuer_parts) if issuer_parts else "unknown"
|
|
455
506
|
|
|
456
|
-
findings.append(
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
507
|
+
findings.append(
|
|
508
|
+
Finding(
|
|
509
|
+
check="ssl",
|
|
510
|
+
severity="info",
|
|
511
|
+
title="Certificate subject and issuer",
|
|
512
|
+
detail=f"Subject: {subject_str} | Issuer: {issuer_str}",
|
|
513
|
+
remediation="Verify the certificate is issued by a trusted certificate authority.",
|
|
514
|
+
)
|
|
515
|
+
)
|
|
463
516
|
|
|
464
517
|
# SAN (Subject Alternative Names)
|
|
465
518
|
san_list = cert.get("subjectAltName", ())
|
|
466
519
|
san_names = [val for typ, val in san_list if typ == "DNS"]
|
|
467
520
|
if san_names:
|
|
468
|
-
findings.append(
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
521
|
+
findings.append(
|
|
522
|
+
Finding(
|
|
523
|
+
check="ssl",
|
|
524
|
+
severity="info",
|
|
525
|
+
title=f"Certificate covers {len(san_names)} domain(s)",
|
|
526
|
+
detail=f"SANs: {', '.join(san_names[:10])}"
|
|
527
|
+
+ (f" ... and {len(san_names) - 10} more" if len(san_names) > 10 else ""),
|
|
528
|
+
remediation="Ensure all required domains are listed in the certificate SANs.",
|
|
529
|
+
)
|
|
530
|
+
)
|
|
476
531
|
|
|
477
532
|
except ssl.SSLCertVerificationError as exc:
|
|
478
|
-
findings.append(
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
533
|
+
findings.append(
|
|
534
|
+
Finding(
|
|
535
|
+
check="ssl",
|
|
536
|
+
severity="critical",
|
|
537
|
+
title="SSL certificate verification failed",
|
|
538
|
+
detail=f"Certificate validation error: {exc}",
|
|
539
|
+
remediation="Replace the certificate with one issued by a trusted CA. "
|
|
540
|
+
"Ensure the certificate chain is complete.",
|
|
541
|
+
)
|
|
542
|
+
)
|
|
486
543
|
except ssl.SSLError as exc:
|
|
487
|
-
findings.append(
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
544
|
+
findings.append(
|
|
545
|
+
Finding(
|
|
546
|
+
check="ssl",
|
|
547
|
+
severity="high",
|
|
548
|
+
title="SSL/TLS connection error",
|
|
549
|
+
detail=f"TLS handshake failed: {exc}",
|
|
550
|
+
remediation="Check the server TLS configuration and ensure modern cipher suites are enabled.",
|
|
551
|
+
)
|
|
552
|
+
)
|
|
494
553
|
except socket.timeout:
|
|
495
|
-
findings.append(
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
554
|
+
findings.append(
|
|
555
|
+
Finding(
|
|
556
|
+
check="ssl",
|
|
557
|
+
severity="medium",
|
|
558
|
+
title="SSL connection timed out",
|
|
559
|
+
detail="The TLS handshake did not complete within 10 seconds.",
|
|
560
|
+
remediation="Verify the server is reachable and TLS is properly configured.",
|
|
561
|
+
)
|
|
562
|
+
)
|
|
502
563
|
except OSError as exc:
|
|
503
|
-
findings.append(
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
564
|
+
findings.append(
|
|
565
|
+
Finding(
|
|
566
|
+
check="ssl",
|
|
567
|
+
severity="high",
|
|
568
|
+
title="Unable to establish SSL connection",
|
|
569
|
+
detail=f"Connection error: {exc}",
|
|
570
|
+
remediation="Verify the hostname, port, and network connectivity.",
|
|
571
|
+
)
|
|
572
|
+
)
|
|
510
573
|
|
|
511
574
|
return findings
|
|
512
575
|
|
|
@@ -552,25 +615,29 @@ def probe_common_exposures(url: str, session: requests.Session) -> list[Finding]
|
|
|
552
615
|
# Verify it is not a generic error page or redirect by checking content length
|
|
553
616
|
content_length = len(resp.content)
|
|
554
617
|
if content_length > 0:
|
|
555
|
-
findings.append(
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
618
|
+
findings.append(
|
|
619
|
+
Finding(
|
|
620
|
+
check="endpoints",
|
|
621
|
+
severity=severity,
|
|
622
|
+
title=f"Exposed: {description} ({path})",
|
|
623
|
+
detail=f"HTTP 200 returned for {target} with {content_length} bytes. "
|
|
624
|
+
f"This resource should not be publicly accessible.",
|
|
625
|
+
remediation=f"Block access to {path} via web server configuration. "
|
|
626
|
+
f"Return 403 or 404 for this path.",
|
|
627
|
+
)
|
|
628
|
+
)
|
|
564
629
|
elif resp.status_code in (401, 403):
|
|
565
|
-
findings.append(
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
630
|
+
findings.append(
|
|
631
|
+
Finding(
|
|
632
|
+
check="endpoints",
|
|
633
|
+
severity="info",
|
|
634
|
+
title=f"Path exists but access denied: {path}",
|
|
635
|
+
detail=f"HTTP {resp.status_code} returned for {target}. "
|
|
636
|
+
f"The path exists but requires authentication.",
|
|
637
|
+
remediation=f"Consider returning 404 instead of {resp.status_code} "
|
|
638
|
+
f"to avoid confirming the path exists.",
|
|
639
|
+
)
|
|
640
|
+
)
|
|
574
641
|
except requests.RequestException:
|
|
575
642
|
# Silently skip unreachable paths
|
|
576
643
|
continue
|
|
@@ -579,14 +646,16 @@ def probe_common_exposures(url: str, session: requests.Session) -> list[Finding]
|
|
|
579
646
|
try:
|
|
580
647
|
resp = session.get(base + "/robots.txt", timeout=timeout, allow_redirects=True)
|
|
581
648
|
if resp.status_code == 200 and "disallow" in resp.text.lower():
|
|
582
|
-
findings.append(
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
649
|
+
findings.append(
|
|
650
|
+
Finding(
|
|
651
|
+
check="endpoints",
|
|
652
|
+
severity="info",
|
|
653
|
+
title="robots.txt found",
|
|
654
|
+
detail=f"The robots.txt file is accessible at {base}/robots.txt.",
|
|
655
|
+
remediation="Review robots.txt entries. Disallowed paths may reveal "
|
|
656
|
+
"sensitive directories that warrant additional access controls.",
|
|
657
|
+
)
|
|
658
|
+
)
|
|
590
659
|
# Parse interesting disallows
|
|
591
660
|
interesting_disallows = []
|
|
592
661
|
for line in resp.text.splitlines():
|
|
@@ -595,21 +664,35 @@ def probe_common_exposures(url: str, session: requests.Session) -> list[Finding]
|
|
|
595
664
|
path_part = line.strip().split(":", 1)[1].strip()
|
|
596
665
|
if path_part and path_part != "/":
|
|
597
666
|
sensitive_keywords = [
|
|
598
|
-
"admin",
|
|
599
|
-
"
|
|
600
|
-
"
|
|
667
|
+
"admin",
|
|
668
|
+
"api",
|
|
669
|
+
"config",
|
|
670
|
+
"backup",
|
|
671
|
+
"private",
|
|
672
|
+
"internal",
|
|
673
|
+
"secret",
|
|
674
|
+
"debug",
|
|
675
|
+
"staging",
|
|
676
|
+
"test",
|
|
677
|
+
"tmp",
|
|
678
|
+
"upload",
|
|
679
|
+
"database",
|
|
680
|
+
"db",
|
|
681
|
+
"cgi-bin",
|
|
601
682
|
]
|
|
602
683
|
if any(kw in path_part.lower() for kw in sensitive_keywords):
|
|
603
684
|
interesting_disallows.append(path_part)
|
|
604
685
|
if interesting_disallows:
|
|
605
|
-
findings.append(
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
686
|
+
findings.append(
|
|
687
|
+
Finding(
|
|
688
|
+
check="endpoints",
|
|
689
|
+
severity="low",
|
|
690
|
+
title="robots.txt reveals potentially sensitive paths",
|
|
691
|
+
detail=f"Interesting disallowed paths: {', '.join(interesting_disallows[:10])}",
|
|
692
|
+
remediation="Ensure disallowed paths have proper access controls beyond "
|
|
693
|
+
"robots.txt, which is advisory only and publicly readable.",
|
|
694
|
+
)
|
|
695
|
+
)
|
|
613
696
|
except requests.RequestException:
|
|
614
697
|
pass
|
|
615
698
|
|
|
@@ -618,14 +701,16 @@ def probe_common_exposures(url: str, session: requests.Session) -> list[Finding]
|
|
|
618
701
|
try:
|
|
619
702
|
resp = session.get(base + sec_path, timeout=timeout, allow_redirects=True)
|
|
620
703
|
if resp.status_code == 200 and "contact:" in resp.text.lower():
|
|
621
|
-
findings.append(
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
704
|
+
findings.append(
|
|
705
|
+
Finding(
|
|
706
|
+
check="endpoints",
|
|
707
|
+
severity="info",
|
|
708
|
+
title="security.txt found",
|
|
709
|
+
detail=f"A security.txt file is accessible at {base}{sec_path}. "
|
|
710
|
+
f"This is a good security practice (RFC 9116).",
|
|
711
|
+
remediation="Ensure the security.txt contact information is current.",
|
|
712
|
+
)
|
|
713
|
+
)
|
|
629
714
|
break # Only report once
|
|
630
715
|
except requests.RequestException:
|
|
631
716
|
continue
|
|
@@ -636,15 +721,17 @@ def probe_common_exposures(url: str, session: requests.Session) -> list[Finding]
|
|
|
636
721
|
content_sample = resp.text[:2000] if resp.text else ""
|
|
637
722
|
for indicator in _DIRECTORY_LISTING_INDICATORS:
|
|
638
723
|
if indicator.lower() in content_sample.lower():
|
|
639
|
-
findings.append(
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
724
|
+
findings.append(
|
|
725
|
+
Finding(
|
|
726
|
+
check="endpoints",
|
|
727
|
+
severity="high",
|
|
728
|
+
title="Directory listing is enabled",
|
|
729
|
+
detail=f"The root path appears to expose a directory listing "
|
|
730
|
+
f"(detected indicator: '{indicator}').",
|
|
731
|
+
remediation="Disable directory listing in the web server configuration. "
|
|
732
|
+
"For Apache: 'Options -Indexes'. For Nginx: remove 'autoindex on'.",
|
|
733
|
+
)
|
|
734
|
+
)
|
|
648
735
|
break
|
|
649
736
|
except requests.RequestException:
|
|
650
737
|
pass
|
|
@@ -655,32 +742,38 @@ def probe_common_exposures(url: str, session: requests.Session) -> list[Finding]
|
|
|
655
742
|
server_header = resp.headers.get("Server", "")
|
|
656
743
|
x_powered = resp.headers.get("X-Powered-By", "")
|
|
657
744
|
if server_header and any(ch.isdigit() for ch in server_header):
|
|
658
|
-
findings.append(
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
745
|
+
findings.append(
|
|
746
|
+
Finding(
|
|
747
|
+
check="endpoints",
|
|
748
|
+
severity="low",
|
|
749
|
+
title="Server version disclosure",
|
|
750
|
+
detail=f"The Server header reveals: '{server_header}'.",
|
|
751
|
+
remediation="Suppress version information in the Server header.",
|
|
752
|
+
)
|
|
753
|
+
)
|
|
665
754
|
if x_powered:
|
|
666
|
-
findings.append(
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
755
|
+
findings.append(
|
|
756
|
+
Finding(
|
|
757
|
+
check="endpoints",
|
|
758
|
+
severity="low",
|
|
759
|
+
title="X-Powered-By header exposes technology stack",
|
|
760
|
+
detail=f"The X-Powered-By header reveals: '{x_powered}'.",
|
|
761
|
+
remediation="Remove the X-Powered-By header from server responses.",
|
|
762
|
+
)
|
|
763
|
+
)
|
|
673
764
|
except requests.RequestException:
|
|
674
765
|
pass
|
|
675
766
|
|
|
676
767
|
if not findings:
|
|
677
|
-
findings.append(
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
768
|
+
findings.append(
|
|
769
|
+
Finding(
|
|
770
|
+
check="endpoints",
|
|
771
|
+
severity="info",
|
|
772
|
+
title="No common exposures detected",
|
|
773
|
+
detail="None of the probed paths returned accessible content.",
|
|
774
|
+
remediation="Continue monitoring for accidental exposure of sensitive paths.",
|
|
775
|
+
)
|
|
776
|
+
)
|
|
684
777
|
|
|
685
778
|
return findings
|
|
686
779
|
|
|
@@ -689,6 +782,7 @@ def probe_common_exposures(url: str, session: requests.Session) -> list[Finding]
|
|
|
689
782
|
# Check 4: HTTP Methods
|
|
690
783
|
# ---------------------------------------------------------------------------
|
|
691
784
|
|
|
785
|
+
|
|
692
786
|
def check_http_methods(url: str, session: requests.Session) -> list[Finding]:
|
|
693
787
|
"""Test for enabled HTTP methods and flag potentially dangerous ones."""
|
|
694
788
|
findings: list[Finding] = []
|
|
@@ -706,60 +800,77 @@ def check_http_methods(url: str, session: requests.Session) -> list[Finding]:
|
|
|
706
800
|
methods = {m.strip().upper() for m in methods_str.split(",") if m.strip()}
|
|
707
801
|
enabled_dangerous = methods & dangerous_methods
|
|
708
802
|
|
|
709
|
-
findings.append(
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
803
|
+
findings.append(
|
|
804
|
+
Finding(
|
|
805
|
+
check="methods",
|
|
806
|
+
severity="info",
|
|
807
|
+
title=f"Allowed HTTP methods: {', '.join(sorted(methods))}",
|
|
808
|
+
detail="The server advertises these methods via the Allow or Access-Control-Allow-Methods header.",
|
|
809
|
+
remediation="Restrict HTTP methods to only those required by the application.",
|
|
810
|
+
)
|
|
811
|
+
)
|
|
717
812
|
|
|
718
813
|
if enabled_dangerous:
|
|
719
814
|
for method in sorted(enabled_dangerous):
|
|
720
815
|
severity = "high" if method == "TRACE" else "medium"
|
|
721
816
|
detail_msg = ""
|
|
722
817
|
if method == "TRACE":
|
|
723
|
-
detail_msg = (
|
|
724
|
-
|
|
725
|
-
|
|
818
|
+
detail_msg = (
|
|
819
|
+
"TRACE reflects the request back to the client, which "
|
|
820
|
+
"can be exploited in cross-site tracing (XST) attacks "
|
|
821
|
+
"to steal credentials from HTTP headers."
|
|
822
|
+
)
|
|
726
823
|
elif method == "PUT":
|
|
727
|
-
detail_msg = (
|
|
728
|
-
|
|
824
|
+
detail_msg = (
|
|
825
|
+
"PUT allows uploading or replacing files on the server, "
|
|
826
|
+
"which may allow unauthorized content modification."
|
|
827
|
+
)
|
|
729
828
|
elif method == "DELETE":
|
|
730
|
-
detail_msg = (
|
|
731
|
-
|
|
829
|
+
detail_msg = (
|
|
830
|
+
"DELETE allows removing resources from the server, "
|
|
831
|
+
"which may allow unauthorized data destruction."
|
|
832
|
+
)
|
|
732
833
|
elif method == "CONNECT":
|
|
733
|
-
detail_msg = (
|
|
734
|
-
|
|
834
|
+
detail_msg = (
|
|
835
|
+
"CONNECT may allow the server to be used as a proxy, "
|
|
836
|
+
"potentially enabling unauthorized network access."
|
|
837
|
+
)
|
|
735
838
|
elif method == "PATCH":
|
|
736
|
-
detail_msg = (
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
839
|
+
detail_msg = (
|
|
840
|
+
"PATCH allows partial resource modification. Ensure it "
|
|
841
|
+
"requires proper authentication and authorization."
|
|
842
|
+
)
|
|
843
|
+
findings.append(
|
|
844
|
+
Finding(
|
|
845
|
+
check="methods",
|
|
846
|
+
severity=severity,
|
|
847
|
+
title=f"Dangerous HTTP method enabled: {method}",
|
|
848
|
+
detail=detail_msg,
|
|
849
|
+
remediation=f"Disable the {method} method unless explicitly required. "
|
|
850
|
+
f"Configure the web server or application firewall to block it.",
|
|
851
|
+
)
|
|
852
|
+
)
|
|
746
853
|
else:
|
|
747
|
-
findings.append(
|
|
854
|
+
findings.append(
|
|
855
|
+
Finding(
|
|
856
|
+
check="methods",
|
|
857
|
+
severity="info",
|
|
858
|
+
title="No Allow header in OPTIONS response",
|
|
859
|
+
detail=f"The OPTIONS request returned HTTP {resp.status_code} without "
|
|
860
|
+
f"an Allow header. Method enumeration was not possible.",
|
|
861
|
+
remediation="This is acceptable. The server does not advertise allowed methods.",
|
|
862
|
+
)
|
|
863
|
+
)
|
|
864
|
+
except requests.RequestException as exc:
|
|
865
|
+
findings.append(
|
|
866
|
+
Finding(
|
|
748
867
|
check="methods",
|
|
749
868
|
severity="info",
|
|
750
|
-
title="
|
|
751
|
-
detail=f"
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
))
|
|
869
|
+
title="OPTIONS request failed",
|
|
870
|
+
detail=f"Could not perform method enumeration: {exc}",
|
|
871
|
+
remediation="OPTIONS may be blocked by a firewall or WAF, which is acceptable.",
|
|
872
|
+
)
|
|
873
|
+
)
|
|
763
874
|
|
|
764
875
|
return findings
|
|
765
876
|
|
|
@@ -768,6 +879,7 @@ def check_http_methods(url: str, session: requests.Session) -> list[Finding]:
|
|
|
768
879
|
# Check 5: CORS Policy
|
|
769
880
|
# ---------------------------------------------------------------------------
|
|
770
881
|
|
|
882
|
+
|
|
771
883
|
def check_cors_policy(url: str, session: requests.Session) -> list[Finding]:
|
|
772
884
|
"""Analyze CORS configuration for misconfigurations that allow unauthorized access."""
|
|
773
885
|
findings: list[Finding] = []
|
|
@@ -783,61 +895,71 @@ def check_cors_policy(url: str, session: requests.Session) -> list[Finding]:
|
|
|
783
895
|
acac = resp.headers.get("Access-Control-Allow-Credentials", "").lower()
|
|
784
896
|
|
|
785
897
|
if not acao:
|
|
786
|
-
findings.append(
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
898
|
+
findings.append(
|
|
899
|
+
Finding(
|
|
900
|
+
check="cors",
|
|
901
|
+
severity="info",
|
|
902
|
+
title="No CORS headers in response",
|
|
903
|
+
detail="The server did not return an Access-Control-Allow-Origin header. "
|
|
904
|
+
"Cross-origin requests from browsers will be blocked by default.",
|
|
905
|
+
remediation="This is the secure default. Only add CORS headers if cross-origin "
|
|
906
|
+
"access is intentionally required.",
|
|
907
|
+
)
|
|
908
|
+
)
|
|
795
909
|
elif acao == "*":
|
|
796
910
|
if acac == "true":
|
|
797
|
-
findings.append(
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
911
|
+
findings.append(
|
|
912
|
+
Finding(
|
|
913
|
+
check="cors",
|
|
914
|
+
severity="critical",
|
|
915
|
+
title="CORS: Wildcard origin with credentials allowed",
|
|
916
|
+
detail="Access-Control-Allow-Origin is set to '*' and "
|
|
917
|
+
"Access-Control-Allow-Credentials is 'true'. While browsers block "
|
|
918
|
+
"this combination, server-side misconfiguration indicates a flawed "
|
|
919
|
+
"CORS implementation that could be exploited.",
|
|
920
|
+
remediation="Never combine wildcard origin with Allow-Credentials. "
|
|
921
|
+
"Implement an origin allowlist and validate requests against it.",
|
|
922
|
+
)
|
|
923
|
+
)
|
|
808
924
|
else:
|
|
809
|
-
findings.append(
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
925
|
+
findings.append(
|
|
926
|
+
Finding(
|
|
927
|
+
check="cors",
|
|
928
|
+
severity="medium",
|
|
929
|
+
title="CORS: Wildcard origin configured",
|
|
930
|
+
detail="Access-Control-Allow-Origin is set to '*', allowing any website "
|
|
931
|
+
"to make cross-origin requests. If the API serves sensitive data "
|
|
932
|
+
"or requires authentication, this is a security risk.",
|
|
933
|
+
remediation="Replace the wildcard with specific trusted origins. "
|
|
934
|
+
"Use an allowlist approach for cross-origin access.",
|
|
935
|
+
)
|
|
936
|
+
)
|
|
819
937
|
elif acao.lower() == test_origin.lower():
|
|
820
938
|
severity = "critical" if acac == "true" else "high"
|
|
821
939
|
cred_note = " with credentials" if acac == "true" else ""
|
|
822
|
-
findings.append(
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
940
|
+
findings.append(
|
|
941
|
+
Finding(
|
|
942
|
+
check="cors",
|
|
943
|
+
severity=severity,
|
|
944
|
+
title=f"CORS: Origin reflection detected{cred_note}",
|
|
945
|
+
detail=f"The server reflected the arbitrary origin '{test_origin}' in the "
|
|
946
|
+
f"Access-Control-Allow-Origin header{cred_note}. This means any "
|
|
947
|
+
f"website can make authenticated cross-origin requests.",
|
|
948
|
+
remediation="Implement a strict origin allowlist. Never reflect the Origin "
|
|
949
|
+
"header value without validation against a list of trusted domains.",
|
|
950
|
+
)
|
|
951
|
+
)
|
|
832
952
|
else:
|
|
833
|
-
findings.append(
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
953
|
+
findings.append(
|
|
954
|
+
Finding(
|
|
955
|
+
check="cors",
|
|
956
|
+
severity="info",
|
|
957
|
+
title=f"CORS: Specific origin configured ({acao})",
|
|
958
|
+
detail=f"The server returned a specific origin '{acao}' in the CORS header, "
|
|
959
|
+
f"not reflecting the test origin. This indicates proper origin validation.",
|
|
960
|
+
remediation="Periodically review the allowed origins to ensure they are still trusted.",
|
|
961
|
+
)
|
|
962
|
+
)
|
|
841
963
|
|
|
842
964
|
# Check for overly permissive methods in preflight
|
|
843
965
|
acam = resp.headers.get("Access-Control-Allow-Methods", "")
|
|
@@ -845,24 +967,28 @@ def check_cors_policy(url: str, session: requests.Session) -> list[Finding]:
|
|
|
845
967
|
allowed_methods = {m.strip().upper() for m in acam.split(",") if m.strip()}
|
|
846
968
|
risky = allowed_methods & {"PUT", "DELETE", "PATCH"}
|
|
847
969
|
if risky:
|
|
848
|
-
findings.append(
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
970
|
+
findings.append(
|
|
971
|
+
Finding(
|
|
972
|
+
check="cors",
|
|
973
|
+
severity="low",
|
|
974
|
+
title=f"CORS allows state-changing methods: {', '.join(sorted(risky))}",
|
|
975
|
+
detail=f"Cross-origin requests are permitted to use {', '.join(sorted(risky))} "
|
|
976
|
+
f"methods. Ensure these endpoints have proper authentication.",
|
|
977
|
+
remediation="Only expose the minimum set of HTTP methods required for "
|
|
978
|
+
"legitimate cross-origin requests.",
|
|
979
|
+
)
|
|
980
|
+
)
|
|
857
981
|
|
|
858
982
|
except requests.RequestException as exc:
|
|
859
|
-
findings.append(
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
983
|
+
findings.append(
|
|
984
|
+
Finding(
|
|
985
|
+
check="cors",
|
|
986
|
+
severity="info",
|
|
987
|
+
title="CORS check failed",
|
|
988
|
+
detail=f"Could not perform CORS analysis: {exc}",
|
|
989
|
+
remediation="Verify the target is reachable and retry the scan.",
|
|
990
|
+
)
|
|
991
|
+
)
|
|
866
992
|
|
|
867
993
|
return findings
|
|
868
994
|
|
|
@@ -871,6 +997,7 @@ def check_cors_policy(url: str, session: requests.Session) -> list[Finding]:
|
|
|
871
997
|
# Report Generation
|
|
872
998
|
# ---------------------------------------------------------------------------
|
|
873
999
|
|
|
1000
|
+
|
|
874
1001
|
def _calculate_risk_score(findings: list[Finding]) -> int:
|
|
875
1002
|
"""Calculate a security risk score from 0 (worst) to 100 (best).
|
|
876
1003
|
|
|
@@ -907,7 +1034,11 @@ def generate_report(
|
|
|
907
1034
|
"""
|
|
908
1035
|
# Count findings by severity
|
|
909
1036
|
severity_counts: dict[str, int] = {
|
|
910
|
-
"critical": 0,
|
|
1037
|
+
"critical": 0,
|
|
1038
|
+
"high": 0,
|
|
1039
|
+
"medium": 0,
|
|
1040
|
+
"low": 0,
|
|
1041
|
+
"info": 0,
|
|
911
1042
|
}
|
|
912
1043
|
for f in results.findings:
|
|
913
1044
|
severity_counts[f.severity] = severity_counts.get(f.severity, 0) + 1
|
|
@@ -928,7 +1059,7 @@ def generate_report(
|
|
|
928
1059
|
# Build Markdown report
|
|
929
1060
|
lines: list[str] = []
|
|
930
1061
|
lines.append("=" * 72)
|
|
931
|
-
lines.append(
|
|
1062
|
+
lines.append(" SECURITY SCAN REPORT")
|
|
932
1063
|
lines.append("=" * 72)
|
|
933
1064
|
lines.append("")
|
|
934
1065
|
lines.append(f"Target: {url}")
|
|
@@ -975,8 +1106,7 @@ def generate_report(
|
|
|
975
1106
|
lines.append("")
|
|
976
1107
|
|
|
977
1108
|
lines.append("=" * 72)
|
|
978
|
-
lines.append(f" End of report. {total_findings} finding(s) across "
|
|
979
|
-
f"{len(results.checks_performed)} check(s).")
|
|
1109
|
+
lines.append(f" End of report. {total_findings} finding(s) across {len(results.checks_performed)} check(s).")
|
|
980
1110
|
lines.append("=" * 72)
|
|
981
1111
|
|
|
982
1112
|
report_text = "\n".join(lines)
|
|
@@ -1011,6 +1141,7 @@ def generate_report(
|
|
|
1011
1141
|
# CLI / Main
|
|
1012
1142
|
# ---------------------------------------------------------------------------
|
|
1013
1143
|
|
|
1144
|
+
|
|
1014
1145
|
def main() -> int:
|
|
1015
1146
|
"""Parse arguments and execute the security scan.
|
|
1016
1147
|
|
|
@@ -1038,28 +1169,29 @@ def main() -> int:
|
|
|
1038
1169
|
help="Target URL to scan (must include scheme, e.g., https://example.com)",
|
|
1039
1170
|
)
|
|
1040
1171
|
parser.add_argument(
|
|
1041
|
-
"--output",
|
|
1172
|
+
"--output",
|
|
1173
|
+
"-o",
|
|
1042
1174
|
metavar="FILE",
|
|
1043
1175
|
help="Write JSON report to the specified file path",
|
|
1044
1176
|
)
|
|
1045
1177
|
parser.add_argument(
|
|
1046
|
-
"--checks",
|
|
1178
|
+
"--checks",
|
|
1179
|
+
"-c",
|
|
1047
1180
|
metavar="LIST",
|
|
1048
1181
|
default=",".join(ALL_CHECKS),
|
|
1049
|
-
help=(
|
|
1050
|
-
f"Comma-separated list of checks to run. "
|
|
1051
|
-
f"Available: {', '.join(ALL_CHECKS)}. Default: all"
|
|
1052
|
-
),
|
|
1182
|
+
help=(f"Comma-separated list of checks to run. Available: {', '.join(ALL_CHECKS)}. Default: all"),
|
|
1053
1183
|
)
|
|
1054
1184
|
parser.add_argument(
|
|
1055
|
-
"--timeout",
|
|
1185
|
+
"--timeout",
|
|
1186
|
+
"-t",
|
|
1056
1187
|
type=int,
|
|
1057
1188
|
default=10,
|
|
1058
1189
|
metavar="SECONDS",
|
|
1059
1190
|
help="Request timeout in seconds (default: 10)",
|
|
1060
1191
|
)
|
|
1061
1192
|
parser.add_argument(
|
|
1062
|
-
"--verbose",
|
|
1193
|
+
"--verbose",
|
|
1194
|
+
"-v",
|
|
1063
1195
|
action="store_true",
|
|
1064
1196
|
help="Print progress messages to stderr",
|
|
1065
1197
|
)
|
|
@@ -1084,8 +1216,7 @@ def main() -> int:
|
|
|
1084
1216
|
requested = [c.strip().lower() for c in args.checks.split(",") if c.strip()]
|
|
1085
1217
|
invalid_checks = [c for c in requested if c not in ALL_CHECKS]
|
|
1086
1218
|
if invalid_checks:
|
|
1087
|
-
_log_error(f"Unknown check(s): {', '.join(invalid_checks)}. "
|
|
1088
|
-
f"Available: {', '.join(ALL_CHECKS)}")
|
|
1219
|
+
_log_error(f"Unknown check(s): {', '.join(invalid_checks)}. Available: {', '.join(ALL_CHECKS)}")
|
|
1089
1220
|
return 2
|
|
1090
1221
|
|
|
1091
1222
|
# Initialize
|
|
@@ -1144,8 +1275,7 @@ def main() -> int:
|
|
|
1144
1275
|
result.duration_seconds = round((scan_end - scan_start).total_seconds(), 2)
|
|
1145
1276
|
|
|
1146
1277
|
_log("", args.verbose)
|
|
1147
|
-
_log(f"Scan complete. {len(result.findings)} finding(s) in "
|
|
1148
|
-
f"{result.duration_seconds}s", args.verbose)
|
|
1278
|
+
_log(f"Scan complete. {len(result.findings)} finding(s) in {result.duration_seconds}s", args.verbose)
|
|
1149
1279
|
_log("", args.verbose)
|
|
1150
1280
|
|
|
1151
1281
|
# Generate and print report
|
|
@@ -1153,9 +1283,7 @@ def main() -> int:
|
|
|
1153
1283
|
print(report)
|
|
1154
1284
|
|
|
1155
1285
|
# Determine exit code
|
|
1156
|
-
critical_or_high = sum(
|
|
1157
|
-
1 for f in result.findings if f.severity in ("critical", "high")
|
|
1158
|
-
)
|
|
1286
|
+
critical_or_high = sum(1 for f in result.findings if f.severity in ("critical", "high"))
|
|
1159
1287
|
if critical_or_high > 0:
|
|
1160
1288
|
_log(f"Exiting with code 1: {critical_or_high} critical/high finding(s)", args.verbose)
|
|
1161
1289
|
return 1
|