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.
- narvy/__init__.py +3 -0
- narvy/android/__init__.py +0 -0
- narvy/android/source_analyzer.py +268 -0
- narvy/android/split_bundle.py +475 -0
- narvy/android_rule_context.py +196 -0
- narvy/apk_memory_preflight.py +248 -0
- narvy/auth.py +40 -0
- narvy/ci_templates/bitbucket.yml +25 -0
- narvy/ci_templates/github.yml +60 -0
- narvy/ci_templates/gitlab.yml +32 -0
- narvy/cloud/__init__.py +0 -0
- narvy/cloud/aws_scan.py +1188 -0
- narvy/comment_filter.py +134 -0
- narvy/crypto_taint_lite.py +82 -0
- narvy/decompiler.py +337 -0
- narvy/doctor.py +244 -0
- narvy/host/__init__.py +0 -0
- narvy/host/audit.py +157 -0
- narvy/host/host_knowledge.py +982 -0
- narvy/host/lynis_bootstrap.py +400 -0
- narvy/host/lynis_parser.py +358 -0
- narvy/host/narvy_checks.py +789 -0
- narvy/host/report.py +69 -0
- narvy/host/ssh_exec.py +219 -0
- narvy/ios/__init__.py +0 -0
- narvy/ios/binary_analyzer.py +1201 -0
- narvy/ios/plist_checks.py +249 -0
- narvy/ios/source_analyzer.py +301 -0
- narvy/ios/third_party_filter.py +242 -0
- narvy/ios/trust_all_context.py +66 -0
- narvy/main.py +1983 -0
- narvy/native_hardening.py +503 -0
- narvy/reporter.py +151 -0
- narvy/rule_engine.py +57 -0
- narvy/rules/android/config.yml +77 -0
- narvy/rules/android/crypto.yml +34 -0
- narvy/rules/android/secrets.yml +161 -0
- narvy/rules/android/storage.yml +24 -0
- narvy/rules/android/webview.yml +23 -0
- narvy/rules/android.yml +219 -0
- narvy/rules/ios/objc/crypto.yml +56 -0
- narvy/rules/ios/objc/network.yml +67 -0
- narvy/rules/ios/objc/secrets.yml +79 -0
- narvy/rules/ios/objc/storage.yml +45 -0
- narvy/rules/ios/objc/webview.yml +45 -0
- narvy/rules/ios_swift.yml +327 -0
- narvy/rules/web/go.yml +2837 -0
- narvy/rules/web/java.yml +1576 -0
- narvy/rules/web/javascript.yml +3683 -0
- narvy/rules/web/kotlin.yml +413 -0
- narvy/rules/web/local/csharp_narvy/config.yml +61 -0
- narvy/rules/web/local/csharp_narvy/crypto.yml +64 -0
- narvy/rules/web/local/csharp_narvy/deserialization.yml +59 -0
- narvy/rules/web/local/csharp_narvy/injection.yml +122 -0
- narvy/rules/web/local/csharp_narvy/xxe.yml +48 -0
- narvy/rules/web/local/java_narvy/auth_jwt.yml +134 -0
- narvy/rules/web/local/java_narvy/deserialization.yml +108 -0
- narvy/rules/web/local/java_narvy/mybatis.yml +39 -0
- narvy/rules/web/local/java_narvy/snakeyaml.yml +34 -0
- narvy/rules/web/local/java_narvy/spring_authz.yml +32 -0
- narvy/rules/web/local/java_narvy/spring_config.yml +67 -0
- narvy/rules/web/local/java_narvy/spring_hardening.yml +291 -0
- narvy/rules/web/local/java_narvy/sqli.yml +235 -0
- narvy/rules/web/local/java_narvy/xxe.yml +212 -0
- narvy/rules/web/php.yml +1644 -0
- narvy/rules/web/python.yml +3967 -0
- narvy/rules/web/ruby.yml +703 -0
- narvy/rules/web/rust.yml +258 -0
- narvy/rules/web/secrets.yml +1420 -0
- narvy/rules/web/secrets_supplement.yml +383 -0
- narvy/sca/__init__.py +1 -0
- narvy/sca/android_deps.py +349 -0
- narvy/sca/ios_deps.py +578 -0
- narvy/sca/osv_client.py +617 -0
- narvy/sca/web_deps.py +955 -0
- narvy/scope_config.py +195 -0
- narvy/semgrep_engine.py +219 -0
- narvy/stack_protector_evidence.py +96 -0
- narvy/third_party_filter.py +211 -0
- narvy/uploader.py +92 -0
- narvy/weak_prng_context.py +270 -0
- narvy/web/__init__.py +0 -0
- narvy/web/nuclei_binary.py +105 -0
- narvy/web/scan_blocklist.py +96 -0
- narvy/web/scanner.py +842 -0
- narvy/web/source_analyzer.py +714 -0
- narvy/web/ssrf_guard.py +374 -0
- narvy_cli-1.0.0.dist-info/METADATA +165 -0
- narvy_cli-1.0.0.dist-info/RECORD +93 -0
- narvy_cli-1.0.0.dist-info/WHEEL +5 -0
- narvy_cli-1.0.0.dist-info/entry_points.txt +2 -0
- narvy_cli-1.0.0.dist-info/licenses/LICENSE +202 -0
- 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
|