narvy-cli 1.0.0__py3-none-any.whl

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 (93) hide show
  1. narvy/__init__.py +3 -0
  2. narvy/android/__init__.py +0 -0
  3. narvy/android/source_analyzer.py +268 -0
  4. narvy/android/split_bundle.py +475 -0
  5. narvy/android_rule_context.py +196 -0
  6. narvy/apk_memory_preflight.py +248 -0
  7. narvy/auth.py +40 -0
  8. narvy/ci_templates/bitbucket.yml +25 -0
  9. narvy/ci_templates/github.yml +60 -0
  10. narvy/ci_templates/gitlab.yml +32 -0
  11. narvy/cloud/__init__.py +0 -0
  12. narvy/cloud/aws_scan.py +1188 -0
  13. narvy/comment_filter.py +134 -0
  14. narvy/crypto_taint_lite.py +82 -0
  15. narvy/decompiler.py +337 -0
  16. narvy/doctor.py +244 -0
  17. narvy/host/__init__.py +0 -0
  18. narvy/host/audit.py +157 -0
  19. narvy/host/host_knowledge.py +982 -0
  20. narvy/host/lynis_bootstrap.py +400 -0
  21. narvy/host/lynis_parser.py +358 -0
  22. narvy/host/narvy_checks.py +789 -0
  23. narvy/host/report.py +69 -0
  24. narvy/host/ssh_exec.py +219 -0
  25. narvy/ios/__init__.py +0 -0
  26. narvy/ios/binary_analyzer.py +1201 -0
  27. narvy/ios/plist_checks.py +249 -0
  28. narvy/ios/source_analyzer.py +301 -0
  29. narvy/ios/third_party_filter.py +242 -0
  30. narvy/ios/trust_all_context.py +66 -0
  31. narvy/main.py +1983 -0
  32. narvy/native_hardening.py +503 -0
  33. narvy/reporter.py +151 -0
  34. narvy/rule_engine.py +57 -0
  35. narvy/rules/android/config.yml +77 -0
  36. narvy/rules/android/crypto.yml +34 -0
  37. narvy/rules/android/secrets.yml +161 -0
  38. narvy/rules/android/storage.yml +24 -0
  39. narvy/rules/android/webview.yml +23 -0
  40. narvy/rules/android.yml +219 -0
  41. narvy/rules/ios/objc/crypto.yml +56 -0
  42. narvy/rules/ios/objc/network.yml +67 -0
  43. narvy/rules/ios/objc/secrets.yml +79 -0
  44. narvy/rules/ios/objc/storage.yml +45 -0
  45. narvy/rules/ios/objc/webview.yml +45 -0
  46. narvy/rules/ios_swift.yml +327 -0
  47. narvy/rules/web/go.yml +2837 -0
  48. narvy/rules/web/java.yml +1576 -0
  49. narvy/rules/web/javascript.yml +3683 -0
  50. narvy/rules/web/kotlin.yml +413 -0
  51. narvy/rules/web/local/csharp_narvy/config.yml +61 -0
  52. narvy/rules/web/local/csharp_narvy/crypto.yml +64 -0
  53. narvy/rules/web/local/csharp_narvy/deserialization.yml +59 -0
  54. narvy/rules/web/local/csharp_narvy/injection.yml +122 -0
  55. narvy/rules/web/local/csharp_narvy/xxe.yml +48 -0
  56. narvy/rules/web/local/java_narvy/auth_jwt.yml +134 -0
  57. narvy/rules/web/local/java_narvy/deserialization.yml +108 -0
  58. narvy/rules/web/local/java_narvy/mybatis.yml +39 -0
  59. narvy/rules/web/local/java_narvy/snakeyaml.yml +34 -0
  60. narvy/rules/web/local/java_narvy/spring_authz.yml +32 -0
  61. narvy/rules/web/local/java_narvy/spring_config.yml +67 -0
  62. narvy/rules/web/local/java_narvy/spring_hardening.yml +291 -0
  63. narvy/rules/web/local/java_narvy/sqli.yml +235 -0
  64. narvy/rules/web/local/java_narvy/xxe.yml +212 -0
  65. narvy/rules/web/php.yml +1644 -0
  66. narvy/rules/web/python.yml +3967 -0
  67. narvy/rules/web/ruby.yml +703 -0
  68. narvy/rules/web/rust.yml +258 -0
  69. narvy/rules/web/secrets.yml +1420 -0
  70. narvy/rules/web/secrets_supplement.yml +383 -0
  71. narvy/sca/__init__.py +1 -0
  72. narvy/sca/android_deps.py +349 -0
  73. narvy/sca/ios_deps.py +578 -0
  74. narvy/sca/osv_client.py +617 -0
  75. narvy/sca/web_deps.py +955 -0
  76. narvy/scope_config.py +195 -0
  77. narvy/semgrep_engine.py +219 -0
  78. narvy/stack_protector_evidence.py +96 -0
  79. narvy/third_party_filter.py +211 -0
  80. narvy/uploader.py +92 -0
  81. narvy/weak_prng_context.py +270 -0
  82. narvy/web/__init__.py +0 -0
  83. narvy/web/nuclei_binary.py +105 -0
  84. narvy/web/scan_blocklist.py +96 -0
  85. narvy/web/scanner.py +842 -0
  86. narvy/web/source_analyzer.py +714 -0
  87. narvy/web/ssrf_guard.py +374 -0
  88. narvy_cli-1.0.0.dist-info/METADATA +165 -0
  89. narvy_cli-1.0.0.dist-info/RECORD +93 -0
  90. narvy_cli-1.0.0.dist-info/WHEEL +5 -0
  91. narvy_cli-1.0.0.dist-info/entry_points.txt +2 -0
  92. narvy_cli-1.0.0.dist-info/licenses/LICENSE +202 -0
  93. narvy_cli-1.0.0.dist-info/top_level.txt +1 -0
narvy/web/scanner.py ADDED
@@ -0,0 +1,842 @@
1
+ """Nuclei-based web scanner. Passive by default (GET/HEAD-only); opt-in active
2
+ mode sends the full detection template set. Findings are deduped and normalized.
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import re
9
+ import subprocess
10
+ import tempfile
11
+ from typing import Dict, List, Optional
12
+
13
+ from .nuclei_binary import resolve_nuclei_binary
14
+ from .ssrf_guard import validate_url as _ssrf_validate, SSRFBlocked as _SSRFBlocked
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ PASSIVE_TEMPLATES = [
19
+ "http/technologies/",
20
+ "http/misconfiguration/",
21
+ "http/exposures/",
22
+ ]
23
+
24
+ REQUEST_TIMEOUT = 7
25
+ RETRIES = 1
26
+ SUBPROCESS_TIMEOUT = 420
27
+
28
+ # Timeout budget must scale with template count / rate-limit or a low rate limit
29
+ # guarantees a timeout; nuclei's `-jle` writer flushes nothing on a kill.
30
+ TIMEOUT_SAFETY_FACTOR = 2.5
31
+ TIMEOUT_FIXED_OVERHEAD = 120
32
+ SUBPROCESS_TIMEOUT_CAP = 3600
33
+
34
+
35
+ def compute_subprocess_timeout(n_templates: int, rate_limit: int) -> int:
36
+ rate = max(int(rate_limit or 1), 1)
37
+ needed = (max(int(n_templates), 0) / rate) * TIMEOUT_SAFETY_FACTOR
38
+ return int(min(max(SUBPROCESS_TIMEOUT, needed + TIMEOUT_FIXED_OVERHEAD),
39
+ SUBPROCESS_TIMEOUT_CAP))
40
+ DEFAULT_SEVERITY = ["critical", "high", "medium", "low", "info"]
41
+ EXCLUDE_TAGS = ["dos"]
42
+
43
+
44
+ class NucleiScanner:
45
+ """Passive-only wrapper around the `nuclei` binary."""
46
+
47
+ _ACTIVE_TEMPLATE_PREFIXES = (
48
+ "dast/",
49
+ "fuzzing/",
50
+ "http/fuzzing/",
51
+ "http/vulnerabilities/",
52
+ )
53
+ _ACTIVE_CVE_SUBSTRINGS = (
54
+ "-rce", "-sqli", "-xss", "-ssrf", "-lfi", "-rfi",
55
+ "-cmdi", "-ssti", "-injection",
56
+ )
57
+
58
+ def __init__(self, nuclei_bin: Optional[str] = None):
59
+ self.nuclei_bin = nuclei_bin or resolve_nuclei_binary()
60
+
61
+ @classmethod
62
+ def _is_active_template(cls, template_path: str) -> bool:
63
+ if not template_path:
64
+ return False
65
+ tp = template_path.lower()
66
+ for prefix in cls._ACTIVE_TEMPLATE_PREFIXES:
67
+ if tp.startswith(prefix):
68
+ return True
69
+ if tp.startswith(("cves/", "http/cves/")):
70
+ for sub in cls._ACTIVE_CVE_SUBSTRINGS:
71
+ if sub in tp:
72
+ return True
73
+ return False
74
+
75
+ # Passive safety = GET/HEAD only, no body; an omitted method defaults to GET.
76
+ _SAFE_HTTP_METHODS = ("GET", "HEAD", "")
77
+
78
+ @classmethod
79
+ def _template_is_get_only(cls, yaml_path: str) -> bool:
80
+ """True iff every http request block uses GET or HEAD with no body.
81
+ Fails closed: any parse failure excludes the template."""
82
+ try:
83
+ import yaml
84
+ with open(yaml_path, "r") as fh:
85
+ doc = yaml.safe_load(fh)
86
+ if not isinstance(doc, dict):
87
+ return False
88
+ # A non-HTTP protocol block must not fall through to True.
89
+ _NON_HTTP_PROTOCOL_KEYS = (
90
+ "javascript", "network", "code", "dns", "ssl",
91
+ "websocket", "headless", "file",
92
+ )
93
+ if any(doc.get(k) for k in _NON_HTTP_PROTOCOL_KEYS):
94
+ return False
95
+ blocks = doc.get("http") or doc.get("requests") or []
96
+ if not isinstance(blocks, list):
97
+ return False
98
+ for block in blocks:
99
+ if not isinstance(block, dict):
100
+ return False
101
+ method = str(block.get("method") or "").upper()
102
+ if method not in cls._SAFE_HTTP_METHODS:
103
+ return False
104
+ if block.get("body"):
105
+ return False
106
+ if block.get("raw"):
107
+ return False
108
+ return True
109
+ except Exception as e:
110
+ logger.warning(f"_template_is_get_only: failed to parse {yaml_path}, excluding from passive set: {e}")
111
+ return False
112
+
113
+ _NUCLEI_TEMPLATES_ROOT_CANDIDATES = (
114
+ os.environ.get("NUCLEI_TEMPLATES_DIR") or "",
115
+ os.path.expanduser("~/nuclei-templates"),
116
+ os.path.expanduser("~/.local/nuclei-templates"),
117
+ )
118
+
119
+ @classmethod
120
+ def _resolve_templates_root(cls) -> Optional[str]:
121
+ for candidate in cls._NUCLEI_TEMPLATES_ROOT_CANDIDATES:
122
+ if candidate and os.path.isdir(candidate):
123
+ return candidate
124
+ return None
125
+
126
+ @classmethod
127
+ def _filter_get_only_templates(cls, template_paths: List[str]) -> List[str]:
128
+ """Expand directories and drop non-GET/HEAD templates; input unchanged if root unresolved."""
129
+ root = cls._resolve_templates_root()
130
+ if root is None:
131
+ logger.warning(
132
+ "_filter_get_only_templates: could not resolve a nuclei "
133
+ "templates root - passive template set NOT method-filtered "
134
+ "this run (falling back to directory-prefix filtering only)"
135
+ )
136
+ return template_paths
137
+
138
+ out: List[str] = []
139
+ dropped = 0
140
+ for t in template_paths:
141
+ abs_path = os.path.join(root, t) if not os.path.isabs(t) else t
142
+ if os.path.isdir(abs_path):
143
+ for dirpath, _dirs, files in os.walk(abs_path):
144
+ for fname in sorted(files):
145
+ if not fname.endswith((".yaml", ".yml")):
146
+ continue
147
+ fpath = os.path.join(dirpath, fname)
148
+ if cls._template_is_get_only(fpath):
149
+ out.append(fpath)
150
+ else:
151
+ dropped += 1
152
+ elif os.path.isfile(abs_path):
153
+ if cls._template_is_get_only(abs_path):
154
+ out.append(abs_path)
155
+ else:
156
+ dropped += 1
157
+ logger.warning(f"_filter_get_only_templates: dropped non-GET/HEAD template {abs_path}")
158
+ else:
159
+ out.append(t)
160
+ if dropped:
161
+ logger.info(f"_filter_get_only_templates: dropped {dropped} non-GET/HEAD/body template(s) from the passive set")
162
+ return out
163
+
164
+ # Redirects resolved here through ssrf_guard (IP-pins every hop); nuclei runs
165
+ # with `-disable-redirects` and is handed the already-validated final URL.
166
+ _REDIRECT_MAX_HOPS = 5
167
+
168
+ @classmethod
169
+ def _normalize_for_compare(cls, url: str) -> str:
170
+ return (url or "").rstrip("/").lower()
171
+
172
+ def _resolve_redirect_target(self, target: str) -> Dict:
173
+ """Follow `target`'s redirect chain through the SSRF guard; never raises
174
+ (keeps the original target on failure). Returns {url, note, final_status}."""
175
+ from .ssrf_guard import safe_get as _ssrf_safe_get, SSRFBlocked as _Blocked
176
+ out = {"url": target, "note": None, "final_status": None}
177
+ try:
178
+ resp = _ssrf_safe_get(
179
+ target, timeout=10, verify=False,
180
+ max_redirects=self._REDIRECT_MAX_HOPS,
181
+ headers={"User-Agent": self._BROWSER_UA},
182
+ )
183
+ except _Blocked as e:
184
+ location = self._peek_redirect_location(target)
185
+ where = f" to {location}" if location else ""
186
+ out["note"] = (
187
+ f"target redirects{where} which was not scanned (SSRF policy: {e}). "
188
+ f"Only the redirect stub at {target} was scanned - treat these results "
189
+ f"as incomplete."
190
+ )
191
+ logger.warning(f"_resolve_redirect_target: {out['note']}")
192
+ return out
193
+ except Exception as e:
194
+ out["note"] = (
195
+ f"could not pre-resolve redirects for {target} ({e}); scanned the URL "
196
+ f"as given"
197
+ )
198
+ logger.warning(f"_resolve_redirect_target: {out['note']}")
199
+ return out
200
+
201
+ out["final_status"] = getattr(resp, "status_code", None)
202
+ final_url = getattr(resp, "url", None) or target
203
+ if self._normalize_for_compare(final_url) != self._normalize_for_compare(target):
204
+ # Re-assert on the URL handed to an external process.
205
+ try:
206
+ _ssrf_validate(final_url)
207
+ except _SSRFBlocked as e:
208
+ out["note"] = (
209
+ f"target redirects to {final_url} which was not scanned "
210
+ f"(SSRF policy: {e}). Only the redirect stub at {target} was "
211
+ f"scanned - treat these results as incomplete."
212
+ )
213
+ logger.warning(f"_resolve_redirect_target: {out['note']}")
214
+ return out
215
+ out["url"] = final_url
216
+ out["note"] = (
217
+ f"{target} redirects to {final_url} (HTTP {out['final_status']}); scanned "
218
+ f"the resolved URL instead of the redirect stub"
219
+ )
220
+ logger.info(f"_resolve_redirect_target: {out['note']}")
221
+ return out
222
+
223
+ def _peek_redirect_location(self, target: str) -> Optional[str]:
224
+ """Best-effort read of the first hop's Location header for the report."""
225
+ try:
226
+ from urllib.parse import urljoin
227
+ from .ssrf_guard import safe_get as _ssrf_safe_get
228
+ resp = _ssrf_safe_get(
229
+ target, timeout=10, verify=False, max_redirects=0,
230
+ headers={"User-Agent": self._BROWSER_UA},
231
+ )
232
+ loc = resp.headers.get("Location")
233
+ return urljoin(target, loc) if loc else None
234
+ except Exception:
235
+ return None
236
+
237
+ # A throttled target yields zero findings, indistinguishable from clean unless probed.
238
+ _RATE_LIMIT_STATUSES = (429, 503)
239
+ _RATE_LIMIT_PROBES = 3
240
+ _RATE_LIMIT_PROBE_DELAY = 1.0
241
+
242
+ def _probe_rate_limited(self, target: str) -> Dict:
243
+ """Report whether the target is answering 429/503 (only on a zero-finding scan)."""
244
+ import time as _time
245
+ from .ssrf_guard import safe_get as _ssrf_safe_get
246
+ statuses: List[int] = []
247
+ for i in range(self._RATE_LIMIT_PROBES):
248
+ if i:
249
+ _time.sleep(self._RATE_LIMIT_PROBE_DELAY)
250
+ try:
251
+ resp = _ssrf_safe_get(
252
+ target, timeout=10, verify=False,
253
+ headers={"User-Agent": self._BROWSER_UA},
254
+ )
255
+ statuses.append(int(resp.status_code))
256
+ except Exception as e:
257
+ logger.debug(f"_probe_rate_limited: probe {i} failed: {e}")
258
+ blocked = [s for s in statuses if s in self._RATE_LIMIT_STATUSES]
259
+ limited = bool(statuses) and len(blocked) * 2 > len(statuses)
260
+ # No status at all = never answered; `limited` needs a code to compare.
261
+ return {"limited": limited, "statuses": statuses, "blocked": blocked,
262
+ "unreachable": not statuses}
263
+
264
+ def scan(
265
+ self,
266
+ target: str,
267
+ rate_limit: int = 50,
268
+ timeout: int = REQUEST_TIMEOUT,
269
+ retries: int = RETRIES,
270
+ severity: Optional[List[str]] = None,
271
+ active: bool = False,
272
+ ) -> Dict:
273
+ """Run a Nuclei scan. Passive (default): GET/HEAD-only, non-destructive.
274
+ Active: full template set, SSRF-guarded, sends detection requests - only
275
+ run on a target you own or are authorized to test."""
276
+ try:
277
+ _ssrf_validate(target)
278
+ except _SSRFBlocked as e:
279
+ logger.warning(f"SSRF guard blocked target {target}: {e}")
280
+ return {"success": False, "error": f"ssrf-blocked: {e}", "findings": []}
281
+
282
+ redirect = self._resolve_redirect_target(target)
283
+ scan_target = redirect["url"]
284
+ redirect_note = redirect["note"]
285
+
286
+ if active:
287
+ # No -t: nuclei runs every installed template.
288
+ templates = []
289
+ sev = severity or ["info", "low", "medium", "high", "critical"]
290
+ else:
291
+ templates = [t for t in PASSIVE_TEMPLATES if not self._is_active_template(t)]
292
+ templates = self._filter_get_only_templates(templates)
293
+ sev = severity or DEFAULT_SEVERITY
294
+
295
+ with tempfile.NamedTemporaryFile(mode="w+", suffix=".json", delete=False) as f:
296
+ output_file = f.name
297
+
298
+ cmd = [
299
+ self.nuclei_bin,
300
+ "-u", scan_target,
301
+ "-jle", output_file,
302
+ "-rate-limit", str(rate_limit),
303
+ "-timeout", str(timeout),
304
+ "-retries", str(retries),
305
+ "-c", "25",
306
+ "-silent",
307
+ "-no-interactsh",
308
+ # Never follow a redirect to a new host.
309
+ "-disable-redirects",
310
+ "-severity", ",".join(sev),
311
+ "-exclude-tags", ",".join(EXCLUDE_TAGS + (["fuzzing", "brute-force", "intrusive"] if active else [])),
312
+ ]
313
+ for t in templates:
314
+ cmd.extend(["-t", t])
315
+
316
+ # Active passes no -t, so size the timeout off a conservative set estimate, not len(0).
317
+ subprocess_timeout = compute_subprocess_timeout(len(templates) if templates else 6000, rate_limit)
318
+ if subprocess_timeout > SUBPROCESS_TIMEOUT:
319
+ logger.info(
320
+ f"Nuclei subprocess budget raised to {subprocess_timeout}s "
321
+ f"({len(templates)} templates at {rate_limit} req/s needs at least "
322
+ f"{len(templates) // max(rate_limit, 1)}s; the {SUBPROCESS_TIMEOUT}s "
323
+ f"default would have guaranteed a timeout)"
324
+ )
325
+
326
+ logger.info(f"Nuclei {'active' if active else 'passive'} scan: {' '.join(cmd)}")
327
+
328
+ try:
329
+ result = subprocess.run(
330
+ cmd, capture_output=True, text=True, timeout=subprocess_timeout
331
+ )
332
+ except subprocess.TimeoutExpired:
333
+ logger.error(f"Nuclei scan timed out after {subprocess_timeout}s")
334
+ self._safe_unlink(output_file)
335
+ return {
336
+ "success": False,
337
+ "error": (
338
+ f"scan timed out after {subprocess_timeout}s "
339
+ f"({'full active template set' if active else str(len(templates)) + ' passive templates'} "
340
+ f"at --rate-limit {rate_limit} req/s). Raise --rate-limit if the target can take it."
341
+ ),
342
+ "findings": [],
343
+ }
344
+ except Exception as e:
345
+ logger.error(f"Nuclei scan failed: {e}")
346
+ self._safe_unlink(output_file)
347
+ return {"success": False, "error": str(e), "findings": []}
348
+
349
+ findings = []
350
+ if os.path.exists(output_file) and os.path.getsize(output_file) > 0:
351
+ with open(output_file, "r") as f:
352
+ for line in f:
353
+ if not line.strip():
354
+ continue
355
+ try:
356
+ raw = json.loads(line)
357
+ except json.JSONDecodeError:
358
+ logger.warning(f"Failed to parse nuclei output line: {line[:100]}")
359
+ continue
360
+ template_id = (raw.get("template-id") or "").lower()
361
+ matcher_name = (raw.get("matcher-name") or "").lower()
362
+ if (template_id == "http-missing-security-headers"
363
+ and matcher_name in self._IGNORED_HEADER_MATCHERS):
364
+ continue
365
+ findings.append(self._parse_nuclei_finding(raw))
366
+ self._safe_unlink(output_file)
367
+
368
+ findings = self._validate_missing_header_findings(scan_target, findings)
369
+ findings = self._dedup_findings(findings)
370
+ findings = self._suppress_stale_eol_fp(findings)
371
+ findings = self._suppress_uncorroborated_tech_eol(findings)
372
+
373
+ out = {
374
+ "success": True,
375
+ "findings": findings,
376
+ "total_findings": len(findings),
377
+ "stderr": result.stderr,
378
+ "returncode": result.returncode,
379
+ "target": target,
380
+ "scanned_url": scan_target,
381
+ "redirect_note": redirect_note,
382
+ "degraded": False,
383
+ "degraded_reason": None,
384
+ }
385
+
386
+ # A zero-finding result is the only one a rate-limiter can fake.
387
+ if not findings:
388
+ probe = self._probe_rate_limited(scan_target)
389
+ if probe["limited"]:
390
+ reason = (
391
+ f"target is rate-limiting/blocking the scanner - {len(probe['blocked'])} "
392
+ f"of {len(probe['statuses'])} verification requests to {scan_target} came "
393
+ f"back HTTP {'/'.join(str(s) for s in sorted(set(probe['blocked'])))}. "
394
+ f"Nuclei's requests were throttled the same way, so this scan did not "
395
+ f"reach real content: 0 findings here means UNKNOWN, not clean. Re-run "
396
+ f"with a lower --rate-limit, or from an allowlisted source IP."
397
+ )
398
+ out["degraded"] = True
399
+ out["degraded_reason"] = reason
400
+ out["success"] = False
401
+ out["error"] = reason
402
+ logger.warning(f"scan: {reason}")
403
+ elif probe["unreachable"]:
404
+ reason = (
405
+ f"target never answered - all {self._RATE_LIMIT_PROBES} verification "
406
+ f"requests to {scan_target} failed to get any HTTP response at all "
407
+ f"(connection refused/timed out/DNS or TLS failure). Nuclei's requests "
408
+ f"were dropped the same way, so nothing was ever scanned: 0 findings "
409
+ f"here means UNREACHABLE, not clean. Check the host is up and reachable "
410
+ f"from this machine (proxy/firewall/egress rules), and that the scheme "
411
+ f"and port are right."
412
+ )
413
+ out["degraded"] = True
414
+ out["degraded_reason"] = reason
415
+ out["success"] = False
416
+ out["error"] = reason
417
+ logger.warning(f"scan: {reason}")
418
+ return out
419
+
420
+ @staticmethod
421
+ def _safe_unlink(path):
422
+ try:
423
+ os.unlink(path)
424
+ except OSError:
425
+ pass
426
+
427
+ _EOL_CURRENT_FLOOR = {
428
+ "nginx": (1, 24),
429
+ "apache": (2, 4),
430
+ "httpd": (2, 4),
431
+ "openssh": (9, 0),
432
+ "php": (8, 1),
433
+ "openssl": (3, 0),
434
+ }
435
+
436
+ @staticmethod
437
+ def _suppress_stale_eol_fp(findings: List[Dict]) -> List[Dict]:
438
+ kept: List[Dict] = []
439
+ for f in findings:
440
+ tag_blob = " ".join([
441
+ str(f.get("title", "")), str(f.get("template_id", "")),
442
+ " ".join(f.get("tags", []) or []),
443
+ ]).lower()
444
+ is_eol = ("end of life" in tag_blob or "end-of-life" in tag_blob
445
+ or "eol" in tag_blob)
446
+ if is_eol and not f.get("cve_id"):
447
+ text = " ".join([
448
+ str(f.get("title", "")), str(f.get("description", "")),
449
+ " ".join(str(x) for x in (f.get("evidence") or [])),
450
+ ]).lower()
451
+ soft = next((s for s in NucleiScanner._EOL_CURRENT_FLOOR if s in text), None)
452
+ m = re.search(r"(\d+)\.(\d+)", text)
453
+ if soft and m:
454
+ ver = (int(m.group(1)), int(m.group(2)))
455
+ if ver >= NucleiScanner._EOL_CURRENT_FLOOR[soft]:
456
+ continue
457
+ kept.append(f)
458
+ return kept
459
+
460
+ # Require independent evidence of the technology before reporting an EOL finding.
461
+ _EOL_REQUIRES_EVIDENCE = {
462
+ "wordpress-eol": (
463
+ "wp-content", "wp-includes", "wp-json", "/wp-admin",
464
+ 'content="wordpress', "content='wordpress",
465
+ ),
466
+ }
467
+
468
+ @classmethod
469
+ def _suppress_uncorroborated_tech_eol(cls, findings: List[Dict]) -> List[Dict]:
470
+ """Drop EOL findings when the response shows no evidence the technology is present."""
471
+ kept: List[Dict] = []
472
+ for f in findings:
473
+ required = cls._EOL_REQUIRES_EVIDENCE.get((f.get("template_id") or "").lower())
474
+ if required:
475
+ haystack = " ".join([
476
+ str(f.get("response_raw", "")), str(f.get("request_raw", "")),
477
+ str(f.get("description", "")),
478
+ " ".join(str(x) for x in (f.get("evidence") or [])),
479
+ ]).lower()
480
+ if not any(token in haystack for token in required):
481
+ continue
482
+ kept.append(f)
483
+ return kept
484
+
485
+ @staticmethod
486
+ def _dedup_findings(findings: List[Dict]) -> List[Dict]:
487
+ seen = set()
488
+ deduped: List[Dict] = []
489
+ for f in findings:
490
+ key = (
491
+ (f.get("template_id") or "").lower(),
492
+ (f.get("matcher_name") or "").lower(),
493
+ f.get("url") or "",
494
+ )
495
+ if key in seen:
496
+ continue
497
+ seen.add(key)
498
+ deduped.append(f)
499
+ return deduped
500
+
501
+ _IGNORED_HEADER_MATCHERS = {
502
+ "clear-site-data",
503
+ "x-permitted-cross-domain-policies",
504
+ "cross-origin-embedder-policy",
505
+ "cross-origin-opener-policy",
506
+ "cross-origin-resource-policy",
507
+ "missing-content-type",
508
+ "content-type-charset-specification",
509
+ }
510
+
511
+ _MATCHER_TO_HEADER = {
512
+ "strict-transport-security": "Strict-Transport-Security",
513
+ "content-security-policy": "Content-Security-Policy",
514
+ "permissions-policy": "Permissions-Policy",
515
+ "x-frame-options": "X-Frame-Options",
516
+ "x-content-type-options": "X-Content-Type-Options",
517
+ "referrer-policy": "Referrer-Policy",
518
+ }
519
+
520
+ _WAF_CHALLENGE_HEADERS = {
521
+ "x-vercel-mitigated", "x-vercel-challenge-token", "cf-mitigated",
522
+ "cf-chl-bypass", "x-amz-cf-id", "x-akamai-edgescape",
523
+ "x-akamai-bot-manager-action", "server-timing",
524
+ }
525
+
526
+ _BROWSER_UA = (
527
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
528
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) "
529
+ "Version/16.0 Safari/605.1.15"
530
+ )
531
+
532
+ @classmethod
533
+ def _is_waf_challenge(cls, status_code: int, headers: Dict, body: str = "") -> bool:
534
+ try:
535
+ hkeys = {k.lower() for k in headers.keys()}
536
+ except Exception:
537
+ return False
538
+ for marker in ("x-vercel-mitigated", "cf-mitigated", "x-vercel-challenge-token",
539
+ "cf-chl-bypass", "x-akamai-bot-manager-action"):
540
+ if marker in hkeys:
541
+ return True
542
+ # A 429 is always an edge response, never the origin page; no body heuristic needed.
543
+ if status_code == 429:
544
+ return True
545
+ if status_code in (403, 503):
546
+ body_lc = (body or "").lower()
547
+ if any(token in body_lc for token in (
548
+ "vercel security checkpoint", "attention required", "just a moment",
549
+ "cloudflare", "access denied", "cf-browser-verification",
550
+ "challenge-platform")):
551
+ return True
552
+ if len(body or "") < 200 and ("server" in hkeys):
553
+ return True
554
+ return False
555
+
556
+ @staticmethod
557
+ def _parse_raw_http_response(raw: str):
558
+ if not raw or not isinstance(raw, str):
559
+ return None, {}, ""
560
+ sep = "\r\n\r\n" if "\r\n\r\n" in raw else ("\n\n" if "\n\n" in raw else None)
561
+ if sep is None:
562
+ head, body = raw, ""
563
+ else:
564
+ head, _, body = raw.partition(sep)
565
+ head = head.replace("\r\n", "\n")
566
+ lines = head.split("\n")
567
+ if not lines:
568
+ return None, {}, body
569
+ status_line = lines[0]
570
+ status_code = None
571
+ try:
572
+ parts = status_line.split()
573
+ if len(parts) >= 2 and parts[1].isdigit():
574
+ status_code = int(parts[1])
575
+ except Exception:
576
+ status_code = None
577
+ headers: Dict[str, str] = {}
578
+ for line in lines[1:]:
579
+ if ":" not in line:
580
+ continue
581
+ k, _, v = line.partition(":")
582
+ headers[k.strip()] = v.strip()
583
+ return status_code, headers, body
584
+
585
+ def _validate_missing_header_findings(self, target: str, findings: List[Dict]) -> List[Dict]:
586
+ """Verify 'Missing Security Headers' findings against the captured response then
587
+ one live re-fetch; drop if the header is present or the response is a WAF challenge."""
588
+ if not findings:
589
+ return findings
590
+ header_findings = [
591
+ f for f in findings
592
+ if (f.get("template_id") or "").lower() == "http-missing-security-headers"
593
+ ]
594
+ if not header_findings:
595
+ return findings
596
+
597
+ live_fetch_done = False
598
+ live_status = None
599
+ live_headers: Dict = {}
600
+ live_body = ""
601
+ live_failed = False
602
+
603
+ def _do_live_fetch():
604
+ nonlocal live_fetch_done, live_status, live_headers, live_body, live_failed
605
+ if live_fetch_done:
606
+ return
607
+ live_fetch_done = True
608
+ try:
609
+ import urllib3
610
+ # `target` may be re-pointed since scan() entry, so re-guard this fetch.
611
+ from .ssrf_guard import safe_get as _ssrf_safe_get
612
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
613
+ resp = _ssrf_safe_get(
614
+ target, timeout=10, verify=False,
615
+ headers={
616
+ "User-Agent": self._BROWSER_UA,
617
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
618
+ "Accept-Language": "en-US,en;q=0.9",
619
+ },
620
+ )
621
+ live_status = resp.status_code
622
+ live_headers = dict(resp.headers)
623
+ live_body = (resp.text or "")[:4096]
624
+ except Exception as e:
625
+ live_failed = True
626
+ logger.warning(f"Header re-validation live fetch failed for {target}: {e}")
627
+
628
+ kept: List[Dict] = []
629
+ for f in findings:
630
+ tid = (f.get("template_id") or "").lower()
631
+ if tid != "http-missing-security-headers":
632
+ kept.append(f)
633
+ continue
634
+
635
+ matcher = (f.get("matcher_name") or "").lower()
636
+ header_name = self._MATCHER_TO_HEADER.get(matcher)
637
+ if not header_name:
638
+ kept.append(f)
639
+ continue
640
+
641
+ nuclei_resp_raw = f.get("response_raw") or ""
642
+ n_status, n_headers, n_body = self._parse_raw_http_response(nuclei_resp_raw)
643
+
644
+ if n_status is not None:
645
+ if self._is_waf_challenge(n_status, n_headers, n_body):
646
+ continue
647
+ hl = {k.lower(): v for k, v in n_headers.items()}
648
+ if hl.get(header_name.lower(), ""):
649
+ continue
650
+ kept.append(f)
651
+ continue
652
+
653
+ _do_live_fetch()
654
+ if live_failed:
655
+ kept.append(f)
656
+ continue
657
+ if self._is_waf_challenge(live_status or 0, live_headers, live_body):
658
+ continue
659
+ hl_live = {k.lower(): v for k, v in live_headers.items()}
660
+ if hl_live.get(header_name.lower(), ""):
661
+ continue
662
+ kept.append(f)
663
+
664
+ return kept
665
+
666
+ def _parse_nuclei_finding(self, raw: Dict) -> Dict:
667
+ info = raw.get("info", {})
668
+ matched_at = raw.get("matched-at", raw.get("matched", ""))
669
+
670
+ severity_map = {
671
+ "critical": "critical", "high": "high", "medium": "medium",
672
+ "low": "low", "info": "info", "unknown": "info",
673
+ }
674
+ severity = severity_map.get(info.get("severity", "unknown").lower(), "info")
675
+
676
+ title = info.get("name", "").lower()
677
+ template_id = raw.get("template-id", "").lower()
678
+ matcher_name = raw.get("matcher-name", "").lower()
679
+ cwe_id = None
680
+ cve_id = None
681
+ classification = info.get("classification", {})
682
+ if classification:
683
+ cwe_id = classification.get("cwe-id", [None])[0] if classification.get("cwe-id") else None
684
+ cve_id = classification.get("cve-id", [None])[0] if classification.get("cve-id") else None
685
+
686
+ tags = info.get("tags", [])
687
+ category = self._determine_category(tags)
688
+
689
+ extracted = raw.get("extracted-results") or []
690
+ severity = self._upgrade_severity_if_needed(
691
+ severity,
692
+ title,
693
+ template_id,
694
+ matcher_name,
695
+ tags=tags,
696
+ template_path=str(raw.get("template") or raw.get("template-path") or ""),
697
+ has_evidence=bool(extracted),
698
+ )
699
+
700
+ return {
701
+ "source": "nuclei",
702
+ "template_id": raw.get("template-id", "unknown"),
703
+ "template": raw.get("template", "unknown"),
704
+ "title": info.get("name", "Unknown Vulnerability"),
705
+ "description": info.get("description", ""),
706
+ "severity": severity,
707
+ "category": category,
708
+ "cwe_id": cwe_id,
709
+ "cve_id": cve_id,
710
+ "url": matched_at,
711
+ "method": raw.get("type", "http").upper(),
712
+ "request_raw": raw.get("request", ""),
713
+ "response_raw": raw.get("response", ""),
714
+ "evidence": raw.get("extracted-results", []),
715
+ "matcher_name": raw.get("matcher-name", ""),
716
+ "references": info.get("reference", []) if isinstance(info.get("reference"), list) else (
717
+ [info.get("reference")] if info.get("reference") else []),
718
+ "tags": tags,
719
+ "remediation": info.get("remediation", ""),
720
+ "metadata": raw.get("meta", {}),
721
+ }
722
+
723
+ def _determine_category(self, tags: List[str]) -> str:
724
+ category_map = {
725
+ "injection": ["sqli", "xss", "xxe", "ssti", "idor", "lfi", "rfi", "cmdi"],
726
+ "broken_access_control": ["idor", "traversal", "auth-bypass", "unauth"],
727
+ "cryptographic_failures": ["ssl", "tls", "weak-crypto", "exposed-keys"],
728
+ "security_misconfiguration": ["misconfig", "default-login", "exposure", "config"],
729
+ "vulnerable_components": ["cve", "outdated", "eol"],
730
+ "auth_failures": ["auth", "authentication", "session", "jwt"],
731
+ "ssrf": ["ssrf"],
732
+ "information_disclosure": ["disclosure", "exposure", "sensitive-data", "debug"],
733
+ "api_security": ["api", "graphql", "rest"],
734
+ }
735
+ tags_lower = [t.lower() for t in tags]
736
+ for category, keywords in category_map.items():
737
+ if any(keyword in tag for keyword in keywords for tag in tags_lower):
738
+ return category
739
+ return "other"
740
+
741
+ # Secret-finding promotion needs a secret word and is vetoed by a public-identifier word.
742
+ _SECRET_WORDS = (
743
+ "secret", "password", "passwd", "credential",
744
+ "token", "api key", "api-key", "apikey",
745
+ "access key", "access-key", "accesskey",
746
+ "private key", "private-key", "privatekey",
747
+ )
748
+ _PUBLIC_IDENTIFIER_WORDS = (
749
+ "client id", "client-id", "clientid",
750
+ "account id", "account-id", "accountid",
751
+ "public key", "public-key", "publickey",
752
+ "key id", "key-id", "keyid",
753
+ )
754
+
755
+ @classmethod
756
+ def _is_credential_exposure(cls, title: str, template_id: str) -> bool:
757
+ """True iff the template reports secret material, not a public identifier (title lowercase)."""
758
+ blob = f"{title} {template_id}".lower().replace("_", "-")
759
+ if any(w in blob for w in cls._PUBLIC_IDENTIFIER_WORDS):
760
+ return False
761
+ return any(w in blob for w in cls._SECRET_WORDS)
762
+
763
+ @staticmethod
764
+ def _is_exposure_finding(title: str, tags, template_path: str) -> bool:
765
+ """True iff the finding exposes something not meant to be public (judged on tags/path/title)."""
766
+ if "exposed" in title or "exposure" in title:
767
+ return True
768
+ try:
769
+ tag_blob = " ".join(str(t).lower() for t in (tags or []))
770
+ except Exception:
771
+ tag_blob = ""
772
+ if "exposure" in tag_blob or "disclosure" in tag_blob:
773
+ return True
774
+ return "/exposures/" in (template_path or "").lower()
775
+
776
+ def _upgrade_severity_if_needed(
777
+ self,
778
+ severity: str,
779
+ title: str,
780
+ template_id: str,
781
+ matcher_name: str,
782
+ tags=None,
783
+ template_path: str = "",
784
+ has_evidence: bool = False,
785
+ ) -> str:
786
+ """Upgrade "info" findings that carry real risk; tags and path are checked
787
+ too since many http/exposures/ templates lack "exposed" in the title."""
788
+ if severity != "info":
789
+ return severity
790
+
791
+ if ("missing" in title and "header" in title) or template_id == "http-missing-security-headers":
792
+ blob = f"{title} {matcher_name} {template_id}"
793
+ if "strict-transport" in blob or "hsts" in blob:
794
+ return "medium"
795
+ if "content-security-policy" in blob or "csp" in blob:
796
+ return "low"
797
+ if "x-frame" in blob or "frame-options" in blob:
798
+ return "low"
799
+ if "content-type-options" in blob or "nosniff" in blob:
800
+ return "low"
801
+ if "referrer-policy" in blob:
802
+ return "low"
803
+ if "permissions-policy" in blob or "feature-policy" in blob:
804
+ return "low"
805
+ return "low"
806
+
807
+ is_exposure = self._is_exposure_finding(title, tags, template_path)
808
+
809
+ if is_exposure and has_evidence and self._is_credential_exposure(title, template_id):
810
+ return "high"
811
+
812
+ if "exposed" in title or "exposure" in title:
813
+ if ".git" in title or "git" in template_id:
814
+ return "high"
815
+ # The dot is required: a bare "env" matches unrelated templates.
816
+ if ".env" in title or ".env" in template_id or "dotenv" in template_id:
817
+ return "critical"
818
+ if "backup" in title or "backup" in template_id:
819
+ return "medium"
820
+ if "config" in title or "phpinfo" in title:
821
+ return "medium"
822
+ return "low"
823
+
824
+ # Tag/path-detected exposure whose title never says "exposed".
825
+ if is_exposure:
826
+ if "backup" in title or "backup" in template_id:
827
+ return "medium"
828
+ if "config" in title or "phpinfo" in title:
829
+ return "medium"
830
+ return "low"
831
+
832
+ if "default" in title and ("login" in title or "credential" in title or "password" in title):
833
+ return "high"
834
+
835
+ if "version" in title or "server" in title:
836
+ return "info"
837
+
838
+ if "cookie" in title:
839
+ if "samesite" in title or "secure" in title or "httponly" in title:
840
+ return "low"
841
+
842
+ return severity