@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.
Files changed (112) hide show
  1. package/.claude-plugin/plugin.json +8 -3
  2. package/README.md +8 -0
  3. package/commands/pentest.md +5 -0
  4. package/package.json +8 -3
  5. package/skills/analyzing-tls-config/SKILL.md +221 -0
  6. package/skills/analyzing-tls-config/references/AUTHORIZATION.md +133 -0
  7. package/skills/analyzing-tls-config/references/PLAYBOOK.md +267 -0
  8. package/skills/analyzing-tls-config/references/THEORY.md +128 -0
  9. package/skills/analyzing-tls-config/scripts/analyze_tls.py +415 -0
  10. package/skills/auditing-cors-policy/SKILL.md +186 -0
  11. package/skills/auditing-cors-policy/references/PLAYBOOK.md +220 -0
  12. package/skills/auditing-cors-policy/references/THEORY.md +142 -0
  13. package/skills/auditing-cors-policy/scripts/audit_cors.py +350 -0
  14. package/skills/auditing-npm-dependencies/SKILL.md +254 -0
  15. package/skills/auditing-npm-dependencies/references/PLAYBOOK.md +175 -0
  16. package/skills/auditing-npm-dependencies/references/THEORY.md +122 -0
  17. package/skills/auditing-npm-dependencies/scripts/audit_npm.py +408 -0
  18. package/skills/auditing-python-dependencies/SKILL.md +251 -0
  19. package/skills/auditing-python-dependencies/references/PLAYBOOK.md +193 -0
  20. package/skills/auditing-python-dependencies/references/THEORY.md +122 -0
  21. package/skills/auditing-python-dependencies/scripts/audit_python.py +459 -0
  22. package/skills/checking-http-security-headers/SKILL.md +176 -0
  23. package/skills/checking-http-security-headers/references/PLAYBOOK.md +212 -0
  24. package/skills/checking-http-security-headers/references/THEORY.md +137 -0
  25. package/skills/checking-http-security-headers/scripts/check_headers.py +362 -0
  26. package/skills/checking-license-compliance/SKILL.md +225 -0
  27. package/skills/checking-license-compliance/references/PLAYBOOK.md +161 -0
  28. package/skills/checking-license-compliance/references/THEORY.md +152 -0
  29. package/skills/checking-license-compliance/scripts/check_licenses.py +461 -0
  30. package/skills/composing-vulnerability-report/SKILL.md +212 -0
  31. package/skills/composing-vulnerability-report/references/PLAYBOOK.md +180 -0
  32. package/skills/composing-vulnerability-report/references/THEORY.md +178 -0
  33. package/skills/composing-vulnerability-report/scripts/compose_report.py +396 -0
  34. package/skills/confirming-pentest-authorization/SKILL.md +247 -0
  35. package/skills/confirming-pentest-authorization/references/PLAYBOOK.md +189 -0
  36. package/skills/confirming-pentest-authorization/references/THEORY.md +167 -0
  37. package/skills/confirming-pentest-authorization/scripts/check_authorization.py +457 -0
  38. package/skills/defining-pentest-scope/SKILL.md +227 -0
  39. package/skills/defining-pentest-scope/references/PLAYBOOK.md +238 -0
  40. package/skills/defining-pentest-scope/references/THEORY.md +170 -0
  41. package/skills/defining-pentest-scope/scripts/define_scope.py +472 -0
  42. package/skills/detecting-command-injection-patterns/SKILL.md +144 -0
  43. package/skills/detecting-command-injection-patterns/references/PLAYBOOK.md +302 -0
  44. package/skills/detecting-command-injection-patterns/references/THEORY.md +206 -0
  45. package/skills/detecting-command-injection-patterns/scripts/scan_cmdi.py +290 -0
  46. package/skills/detecting-debug-endpoints/SKILL.md +207 -0
  47. package/skills/detecting-debug-endpoints/references/PLAYBOOK.md +402 -0
  48. package/skills/detecting-debug-endpoints/references/THEORY.md +218 -0
  49. package/skills/detecting-debug-endpoints/scripts/probe_debug.py +518 -0
  50. package/skills/detecting-directory-listing/SKILL.md +206 -0
  51. package/skills/detecting-directory-listing/references/PLAYBOOK.md +277 -0
  52. package/skills/detecting-directory-listing/references/THEORY.md +203 -0
  53. package/skills/detecting-directory-listing/scripts/probe_directory_listing.py +180 -0
  54. package/skills/detecting-eval-exec-usage/SKILL.md +128 -0
  55. package/skills/detecting-eval-exec-usage/references/PLAYBOOK.md +306 -0
  56. package/skills/detecting-eval-exec-usage/references/THEORY.md +159 -0
  57. package/skills/detecting-eval-exec-usage/scripts/scan_eval.py +223 -0
  58. package/skills/detecting-exposed-secrets-files/SKILL.md +179 -0
  59. package/skills/detecting-exposed-secrets-files/references/PLAYBOOK.md +274 -0
  60. package/skills/detecting-exposed-secrets-files/references/THEORY.md +174 -0
  61. package/skills/detecting-exposed-secrets-files/scripts/probe_secrets.py +207 -0
  62. package/skills/detecting-insecure-deserialization/SKILL.md +148 -0
  63. package/skills/detecting-insecure-deserialization/references/PLAYBOOK.md +333 -0
  64. package/skills/detecting-insecure-deserialization/references/THEORY.md +199 -0
  65. package/skills/detecting-insecure-deserialization/scripts/scan_deserialization.py +250 -0
  66. package/skills/detecting-sql-injection-patterns/SKILL.md +161 -0
  67. package/skills/detecting-sql-injection-patterns/references/PLAYBOOK.md +317 -0
  68. package/skills/detecting-sql-injection-patterns/references/THEORY.md +261 -0
  69. package/skills/detecting-sql-injection-patterns/scripts/scan_sqli.py +354 -0
  70. package/skills/detecting-ssl-cert-issues/SKILL.md +182 -0
  71. package/skills/detecting-ssl-cert-issues/references/PLAYBOOK.md +203 -0
  72. package/skills/detecting-ssl-cert-issues/references/THEORY.md +133 -0
  73. package/skills/detecting-ssl-cert-issues/scripts/check_cert_chain.py +481 -0
  74. package/skills/detecting-weak-cryptography/SKILL.md +147 -0
  75. package/skills/detecting-weak-cryptography/references/PLAYBOOK.md +466 -0
  76. package/skills/detecting-weak-cryptography/references/THEORY.md +194 -0
  77. package/skills/detecting-weak-cryptography/scripts/scan_weak_crypto.py +417 -0
  78. package/skills/fingerprinting-server-software/SKILL.md +191 -0
  79. package/skills/fingerprinting-server-software/references/PLAYBOOK.md +337 -0
  80. package/skills/fingerprinting-server-software/references/THEORY.md +183 -0
  81. package/skills/fingerprinting-server-software/scripts/fingerprint_server.py +347 -0
  82. package/skills/generating-executive-summary/SKILL.md +261 -0
  83. package/skills/generating-executive-summary/references/PLAYBOOK.md +201 -0
  84. package/skills/generating-executive-summary/references/THEORY.md +195 -0
  85. package/skills/generating-executive-summary/scripts/exec_summary.py +538 -0
  86. package/skills/mapping-findings-to-owasp-top10/SKILL.md +235 -0
  87. package/skills/mapping-findings-to-owasp-top10/references/PLAYBOOK.md +193 -0
  88. package/skills/mapping-findings-to-owasp-top10/references/THEORY.md +160 -0
  89. package/skills/mapping-findings-to-owasp-top10/scripts/map_owasp.py +540 -0
  90. package/skills/performing-penetration-testing/SKILL.md +282 -190
  91. package/skills/performing-penetration-testing/references/OWASP_TOP_10.md +22 -0
  92. package/skills/performing-penetration-testing/references/REMEDIATION_PLAYBOOK.md +46 -0
  93. package/skills/performing-penetration-testing/references/SECURITY_HEADERS.md +41 -0
  94. package/skills/performing-penetration-testing/scripts/code_security_scanner.py +144 -79
  95. package/skills/performing-penetration-testing/scripts/dependency_auditor.py +116 -93
  96. package/skills/performing-penetration-testing/scripts/security_scanner.py +574 -446
  97. package/skills/probing-dangerous-http-methods/SKILL.md +182 -0
  98. package/skills/probing-dangerous-http-methods/references/PLAYBOOK.md +234 -0
  99. package/skills/probing-dangerous-http-methods/references/THEORY.md +145 -0
  100. package/skills/probing-dangerous-http-methods/scripts/probe_methods.py +263 -0
  101. package/skills/recording-pentest-engagement/SKILL.md +253 -0
  102. package/skills/recording-pentest-engagement/references/PLAYBOOK.md +203 -0
  103. package/skills/recording-pentest-engagement/references/THEORY.md +195 -0
  104. package/skills/recording-pentest-engagement/scripts/record_engagement.py +461 -0
  105. package/skills/scanning-for-hardcoded-secrets/SKILL.md +215 -0
  106. package/skills/scanning-for-hardcoded-secrets/references/PLAYBOOK.md +325 -0
  107. package/skills/scanning-for-hardcoded-secrets/references/THEORY.md +175 -0
  108. package/skills/scanning-for-hardcoded-secrets/scripts/scan_secrets.py +395 -0
  109. package/skills/tracing-transitive-vulnerabilities/SKILL.md +235 -0
  110. package/skills/tracing-transitive-vulnerabilities/references/PLAYBOOK.md +233 -0
  111. package/skills/tracing-transitive-vulnerabilities/references/THEORY.md +138 -0
  112. 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
- "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
- })
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(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
- ))
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(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
- ))
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(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
- ))
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(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
+ 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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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(
410
+ findings.append(
411
+ Finding(
382
412
  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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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", "api", "config", "backup", "private",
599
- "internal", "secret", "debug", "staging", "test",
600
- "tmp", "upload", "database", "db", "cgi-bin",
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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 = ("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.")
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 = ("PUT allows uploading or replacing files on the server, "
728
- "which may allow unauthorized content modification.")
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 = ("DELETE allows removing resources from the server, "
731
- "which may allow unauthorized data destruction.")
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 = ("CONNECT may allow the server to be used as a proxy, "
734
- "potentially enabling unauthorized network access.")
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 = ("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
- ))
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(Finding(
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="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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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(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
- ))
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, "high": 0, "medium": 0, "low": 0, "info": 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(f" SECURITY SCAN REPORT")
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", "-o",
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", "-c",
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", "-t",
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", "-v",
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