larxius commited on
Commit
adc5559
Β·
verified Β·
1 Parent(s): 0b685bd

Update backend/scanners/base_scanner.py

Browse files
Files changed (1) hide show
  1. backend/scanners/base_scanner.py +696 -694
backend/scanners/base_scanner.py CHANGED
@@ -1,694 +1,696 @@
1
- """
2
- base_scanner.py β€” Foundation for all WSS scanners
3
- ===================================================
4
- Security hardened per Expert Audit (June 2026):
5
- GAP-001: confidence + scanner_key + cve_ids + timestamp fields in build_vuln()
6
- GAP-002: Target SSRF self-validation (_validate_target) β€” NOW CALLED IN __init__
7
- GAP-003: _make_request() / _make_headers() unified helper (auth-aware)
8
- GAP-004: Ring buffer (max 5000 lines) for active_scan_logs
9
- GAP-S1: Secrets masking in log output
10
- GAP-S2: Structured JSON security event logging
11
-
12
- FIXES (June 2026):
13
- BUG-12/SEC-3: _validate_target() is now called inside __init__ so ALL scanners
14
- are protected from being used as SSRF pivots β€” was dead code before.
15
- ENH: Added _safe_url_join() to construct test URLs without path confusion.
16
- ENH: Added _deduplicate_vulns() to remove identical findings before reporting.
17
- ENH: _make_async_requests() now properly captures exceptions per-future.
18
- """
19
- import re
20
- import os
21
- import json
22
- import ssl
23
- import time
24
- import http.client
25
- import socket
26
- import logging
27
- import ipaddress
28
- import threading
29
- import urllib.request
30
- import urllib.error
31
- from datetime import datetime, timezone
32
- from urllib.robotparser import RobotFileParser
33
- from urllib.parse import urlparse, urljoin, urlencode, quote
34
- from utils.vuln_classifier import enrich as _classify_enrich
35
- from scanners.core.baseline import SiteBaseline
36
- from scanners.core.confidence import ConfidenceTracker
37
-
38
- # ── Log store ─────────────────────────────────────────────────────────────
39
- active_scan_logs: dict[str, list[str]] = {}
40
- _logs_lock = threading.Lock()
41
- MAX_LOG_LINES = 5000 # GAP-004: ring buffer cap
42
-
43
- # ── WebSocket integration for real-time updates ───────────────────────────
44
- _socketio_instance = None
45
- _socketio_lock = threading.Lock()
46
-
47
- def set_socketio_instance(socketio):
48
- """Set the global SocketIO instance for real-time progress updates."""
49
- global _socketio_instance
50
- with _socketio_lock:
51
- _socketio_instance = socketio
52
-
53
- def emit_scan_progress(scan_id: str, event_type: str, data: dict) -> None:
54
- """Emit real-time scan progress events via WebSocket."""
55
- global _socketio_instance
56
- with _socketio_lock:
57
- if _socketio_instance:
58
- try:
59
- _socketio_instance.emit(event_type, data, room=f'scan_{scan_id}')
60
- except Exception as e:
61
- # Silently fail if WebSocket is not available
62
- pass
63
-
64
- def parse_domain(url):
65
- try:
66
- parsed = urlparse(url)
67
- return parsed.netloc or parsed.path
68
- except Exception:
69
- return url
70
-
71
- def cleanup_scan_logs(scan_id):
72
- with _logs_lock:
73
- if scan_id in active_scan_logs:
74
- del active_scan_logs[scan_id]
75
-
76
- def schedule_log_cleanup(scan_id, delay=3600):
77
- def cleanup_task():
78
- time.sleep(delay)
79
- cleanup_scan_logs(scan_id)
80
- threading.Thread(target=cleanup_task, daemon=True).start()
81
-
82
- # ── Environment ──────────────────────────────────────────────────────────
83
- DEFAULT_VERIFY_SSL = os.environ.get("WSS_VERIFY_SSL", "0") == "1"
84
- XSS_CALLBACK_URL = os.environ.get(
85
- "WSS_XSS_CALLBACK_URL",
86
- "https://xss-reporting.internal/callback",
87
- )
88
-
89
- # ── Secret patterns to mask in logs (GAP-S1) ─────────────────────────────
90
- _SECRET_PATTERNS = [
91
- (re.compile(r'(AKIA[0-9A-Z]{16})'), r'AKIA****'),
92
- (re.compile(r'(sk-[a-zA-Z0-9]{40,})'), r'sk-****'),
93
- (re.compile(r'([Bb]earer\s+)[A-Za-z0-9\-_.~+/]+=*'), r'\1****'),
94
- (re.compile(r'(password["\s:=]+)[^\s&"\']+', re.I), r'\1****'),
95
- (re.compile(r'(token["\s:=]+)[^\s&"\']{8,}', re.I), r'\1****'),
96
- ]
97
-
98
- # ── Structured security event logger (GAP-S2) ────────────────────────────
99
- _sec_logger = logging.getLogger("LarShield.Security")
100
- if not _sec_logger.handlers:
101
- _h = logging.FileHandler("security_events.log", encoding="utf-8")
102
- _h.setFormatter(logging.Formatter("%(message)s"))
103
- _sec_logger.addHandler(_h)
104
- _sec_logger.setLevel(logging.INFO)
105
- _sec_logger.propagate = False
106
-
107
-
108
- def _clean_nul(val) -> str:
109
- if val is None:
110
- return ""
111
- if not isinstance(val, str):
112
- val = str(val)
113
- return val.replace("\x00", "").replace("\u0000", "")
114
-
115
-
116
- def _mask_secrets(text: str) -> str:
117
- """Redact known secret patterns before writing to logs."""
118
- text = _clean_nul(text)
119
- for pattern, replacement in _SECRET_PATTERNS:
120
- text = pattern.sub(replacement, text)
121
- return text
122
-
123
-
124
- def _log_security_event(event_type: str, scan_id: str, message: str, level: str) -> None:
125
- """Write structured JSON security event for SIEM ingestion."""
126
- event = {
127
- "ts": datetime.now(timezone.utc).isoformat(),
128
- "event_type": event_type,
129
- "level": level,
130
- "scan_id": scan_id,
131
- "message": _mask_secrets(message),
132
- }
133
- _sec_logger.info(json.dumps(event))
134
-
135
-
136
- def get_scan_logs(scan_id: str) -> list[str]:
137
- with _logs_lock:
138
- return list(active_scan_logs.get(scan_id, []))
139
-
140
-
141
- def add_log(scan_id: str, level: str, message: str) -> None:
142
- timestamp = datetime.now().strftime("%H:%M:%S")
143
- safe_msg = _mask_secrets(message)
144
- log_line = f"[{timestamp}] [{level}] {safe_msg}"
145
-
146
- with _logs_lock:
147
- # GAP-004: ring buffer β€” cap at MAX_LOG_LINES
148
- logs = active_scan_logs.setdefault(scan_id, [])
149
- if len(logs) >= MAX_LOG_LINES:
150
- logs.pop(0)
151
- logs.append(log_line)
152
-
153
- # Structured security event for critical/warning levels
154
- if level in ("CRITICAL", "WARNING", "ERROR"):
155
- _log_security_event(f"SCAN_{level}", scan_id, message, level)
156
-
157
- try:
158
- print(log_line, flush=True)
159
- except UnicodeEncodeError:
160
- print(log_line.encode("ascii", "replace").decode("ascii"), flush=True)
161
-
162
-
163
- def cleanup_scan_logs(scan_id: str) -> None:
164
- with _logs_lock:
165
- active_scan_logs.pop(scan_id, None)
166
-
167
-
168
- def schedule_log_cleanup(scan_id: str, delay_seconds: int = 300) -> None:
169
- """
170
- Schedule scan log cleanup after `delay_seconds` (default 5 min).
171
- BUG-6 FIX: Prevents premature cleanup while frontend polls /logs.
172
- """
173
- def _cleanup():
174
- time.sleep(delay_seconds)
175
- cleanup_scan_logs(scan_id)
176
-
177
- t = threading.Thread(target=_cleanup, daemon=True)
178
- t.start()
179
-
180
-
181
- def parse_domain(url: str) -> str:
182
- return (
183
- url.replace("https://", "")
184
- .replace("http://", "")
185
- .split("/")[0]
186
- .split(":")[0]
187
- .split("?")[0]
188
- .strip()
189
- )
190
-
191
-
192
- def build_vuln(
193
- title: str,
194
- severity: str,
195
- category: str,
196
- cvss_score: float,
197
- description: str,
198
- remediation: str,
199
- evidence: str = "",
200
- payload: str = "",
201
- request_details: str = "",
202
- response_details: str = "",
203
- confidence: str = "Medium",
204
- scanner_key: str = "unknown",
205
- cve_ids: list | None = None,
206
- references: list | None = None,
207
- cwe_ids: list | None = None,
208
- owasp_category: str | None = None,
209
- ) -> dict:
210
- result = {
211
- "title": _clean_nul(title),
212
- "severity": _clean_nul(severity),
213
- "category": _clean_nul(category),
214
- "cvss_score": cvss_score,
215
- "description": _clean_nul(description),
216
- "remediation": _clean_nul(remediation),
217
- "evidence": _mask_secrets(evidence),
218
- "payload": _clean_nul(payload),
219
- "request_details": _clean_nul(request_details),
220
- "response_details": _mask_secrets(response_details),
221
- "confidence": _clean_nul(confidence),
222
- "scanner_key": _clean_nul(scanner_key),
223
- "cve_ids": cve_ids or [],
224
- "references": references or [],
225
- "timestamp": datetime.now(timezone.utc).isoformat(),
226
- }
227
- if cwe_ids:
228
- result["cwe_ids"] = cwe_ids
229
- if owasp_category:
230
- result["owasp_category"] = _clean_nul(owasp_category)
231
- _classify_enrich(result, scanner_key)
232
- return result
233
-
234
-
235
- def make_ssl_context(verify: bool | None = None):
236
- import ssl as _ssl
237
- ctx = _ssl.create_default_context()
238
- if verify is False or (verify is None and not DEFAULT_VERIFY_SSL):
239
- ctx.check_hostname = False
240
- ctx.verify_mode = _ssl.CERT_NONE
241
- else:
242
- # Enforce TLS 1.2+ minimum (report Β§1.3)
243
- try:
244
- ctx.minimum_version = _ssl.TLSVersion.TLSv1_2
245
- except AttributeError:
246
- pass # Older Python β€” skip
247
- return ctx
248
-
249
-
250
- def check_robots_txt(target: str, user_agent: str = "LarShield/2.0") -> RobotFileParser | None:
251
- try:
252
- parsed = urlparse(target)
253
- robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
254
- rp = RobotFileParser(robots_url)
255
- rp.read()
256
- return rp
257
- except Exception as e:
258
- print(f"ERROR: [Base] check_robots_txt error: {e}")
259
- return None
260
-
261
-
262
- # ── Blocked target sets (GAP-002) ─────────────────────────────────────────
263
- _BLOCKED_HOSTS = frozenset({
264
- "localhost", "127.0.0.1", "::1", "0.0.0.0",
265
- "169.254.169.254", # AWS/Azure IMDS
266
- "metadata.google.internal", # GCP metadata
267
- "100.100.100.200", # Alibaba Cloud ECS metadata
268
- "kubernetes.default.svc",
269
- "kubernetes.default",
270
- })
271
- _BLOCKED_SCHEMES = frozenset({"file", "ftp", "gopher", "dict", "ldap", "ldaps"})
272
-
273
- # Raised from 60β†’150 to reduce per-scanner throttle waits and cut scan time
274
- _SCANNER_RATE_LIMIT = int(os.environ.get("SCANNER_RATE_LIMIT", "150"))
275
- _SCANNER_RATE_WINDOW = int(os.environ.get("SCANNER_RATE_WINDOW", "60"))
276
-
277
- # ── Module-level shared SSL context (avoids rebuilding per-instance) ──────────
278
- _SHARED_SSL_CONTEXT = None
279
- _SSL_CONTEXT_LOCK = threading.Lock()
280
-
281
- def _get_shared_ssl_context():
282
- """Return (or lazily create) a module-level SSL context."""
283
- global _SHARED_SSL_CONTEXT
284
- if _SHARED_SSL_CONTEXT is None:
285
- with _SSL_CONTEXT_LOCK:
286
- if _SHARED_SSL_CONTEXT is None:
287
- _SHARED_SSL_CONTEXT = make_ssl_context(None)
288
- return _SHARED_SSL_CONTEXT
289
-
290
-
291
- class TokenBucket:
292
- def __init__(self, rate: int = 60, window: int = 60):
293
- self._rate = rate
294
- self._window = window
295
- self._tokens = rate
296
- self._last_refill = time.monotonic()
297
- self._lock = threading.Lock()
298
-
299
- def _refill(self):
300
- now = time.monotonic()
301
- elapsed = now - self._last_refill
302
- self._tokens = min(self._rate, self._tokens + elapsed * (self._rate / self._window))
303
- self._last_refill = now
304
-
305
- def acquire(self, block: bool = True) -> bool:
306
- with self._lock:
307
- self._refill()
308
- if self._tokens >= 1:
309
- self._tokens -= 1
310
- return True
311
- if block:
312
- sleep_time = (self._window / self._rate) * 1.1
313
- time.sleep(sleep_time)
314
- self._refill()
315
- if self._tokens >= 1:
316
- self._tokens -= 1
317
- return True
318
- return False
319
-
320
-
321
- class BaseScanner:
322
- SCANNER_NAME: str = "Base Scanner"
323
-
324
- def __init__(
325
- self,
326
- scan_id: str,
327
- target: str,
328
- domain: str,
329
- auth_headers: dict | None = None,
330
- verify_ssl: bool | None = None,
331
- red_team: bool = False,
332
- **kwargs,
333
- ) -> None:
334
- self.scan_id = scan_id
335
- self.target = target
336
- self.domain = domain
337
- self.auth_headers = auth_headers or {}
338
- self.verify_ssl = verify_ssl
339
- self.red_team = red_team
340
- self.vulns: list[dict] = []
341
- self._ssl_context = None
342
- self._robots_parser = None
343
-
344
- # GAP-ADV: Centralized discovery context to prevent redundant crawling
345
- self.discovery_context = kwargs.get("discovery_context", {})
346
-
347
- # PHASE 1: Build per-scan site baseline for SPA/404 false-positive suppression
348
- self._baseline = SiteBaseline()
349
- try:
350
- ssl_ctx = make_ssl_context(verify_ssl)
351
- self._baseline.build(
352
- target,
353
- ssl_context=ssl_ctx,
354
- headers={"User-Agent": "LarShield/2.0"},
355
- timeout=6,
356
- )
357
- except Exception as _be:
358
- add_log(scan_id, "WARNING", f"[Base] Baseline build error (suppression disabled): {_be}")
359
-
360
- # BUG-12 FIX: Validate target on init so ALL scanners are protected.
361
- # We catch ValueError here (not re-raise) to log and continue β€” some
362
- # scan types like API scanners may legitimately call with non-HTTP URLs.
363
- try:
364
- self._validate_target(self.target)
365
- except ValueError as e:
366
- add_log(scan_id, "WARNING",
367
- f"[Base] Target validation warning for '{target}': {e}")
368
-
369
- # ── SSL / robots ─────────────────────────────────────────────────────
370
-
371
- def get_ssl_context(self):
372
- if self._ssl_context is None:
373
- self._ssl_context = make_ssl_context(self.verify_ssl)
374
- return self._ssl_context
375
-
376
- def get_robots_parser(self):
377
- if self._robots_parser is None:
378
- self._robots_parser = check_robots_txt(self.target)
379
- return self._robots_parser
380
-
381
- def can_fetch(self, path: str = "/") -> bool:
382
- rp = self.get_robots_parser()
383
- if rp is None:
384
- return True
385
- return rp.can_fetch("LarShield/2.0", path)
386
-
387
- # ── PHASE 1: Baseline convenience helpers ─────────────────────────────
388
-
389
- def _is_baseline(self, status: int, body: str | bytes) -> bool:
390
- """
391
- Return True when this response matches the site's generic SPA/404 catch-all.
392
- Use this before reporting any path as "found" to suppress false positives.
393
- """
394
- return self._baseline.is_baseline(status, body)
395
-
396
- def _is_not_found(self, status: int, body: str | bytes = b"") -> bool:
397
- """True when status >= 400 OR response matches the baseline catch-all."""
398
- return self._baseline.is_not_found(status, body)
399
-
400
- # ── Logging ──────────────────────────────────────────────────────────
401
-
402
- def log(self, level: str, message: str) -> None:
403
- add_log(self.scan_id, level, message)
404
- # Emit real-time log event
405
- emit_scan_progress(self.scan_id, 'scan_log', {
406
- 'level': level,
407
- 'message': message,
408
- 'timestamp': datetime.now(timezone.utc).isoformat()
409
- })
410
-
411
- # ── Vulnerability reporting ──────────────────────────────────────────
412
-
413
- def add_vuln(
414
- self,
415
- title: str,
416
- severity: str,
417
- category: str,
418
- cvss_score: float,
419
- description: str,
420
- remediation: str,
421
- evidence: str = "",
422
- payload: str = "",
423
- request_details: str = "",
424
- response_details: str = "",
425
- confidence: str = "Medium",
426
- cve_ids: list | None = None,
427
- references: list | None = None,
428
- cwe_ids: list | None = None,
429
- owasp_category: str | None = None,
430
- ) -> None:
431
- vuln = build_vuln(
432
- title, severity, category, cvss_score,
433
- description, remediation,
434
- evidence, payload, request_details, response_details,
435
- confidence=confidence,
436
- scanner_key=getattr(self, "_SCANNER_KEY", "unknown"),
437
- cve_ids=cve_ids,
438
- references=references,
439
- cwe_ids=cwe_ids,
440
- owasp_category=owasp_category,
441
- )
442
- # Inline dedup: skip if same title+category already recorded this run
443
- for existing in self.vulns:
444
- if existing["title"] == vuln["title"] and existing["category"] == vuln["category"]:
445
- # Update confidence if the new one is stronger
446
- conf_rank = {"Low": 0, "Medium": 1, "High": 2, "Confirmed": 3}
447
- if conf_rank.get(vuln["confidence"], 0) > conf_rank.get(existing["confidence"], 0):
448
- existing["confidence"] = vuln["confidence"]
449
- if vuln.get("payload"):
450
- existing["payload"] = vuln["payload"]
451
- if vuln.get("evidence"):
452
- existing["evidence"] = vuln["evidence"]
453
- return
454
- self.vulns.append(vuln)
455
- # Emit real-time vulnerability found event
456
- emit_scan_progress(self.scan_id, 'vulnerability_found', {
457
- 'title': title,
458
- 'severity': severity,
459
- 'category': category,
460
- 'cvss_score': cvss_score,
461
- 'confidence': confidence,
462
- 'scanner_key': getattr(self, "_SCANNER_KEY", "unknown"),
463
- 'timestamp': datetime.now(timezone.utc).isoformat()
464
- })
465
-
466
- def run(self) -> list[dict]:
467
- raise NotImplementedError("Subclasses must implement run()")
468
-
469
- # ── GAP-003: Unified auth-aware HTTP helpers ──────────────────────────
470
-
471
- def _make_headers(self, additional: dict | None = None) -> dict:
472
- """Build headers dict merging auth_headers (always include for authenticated scanning)."""
473
- headers = {"User-Agent": "LarShield/2.0"}
474
- if self.auth_headers:
475
- headers.update(self.auth_headers)
476
- if additional:
477
- headers.update(additional)
478
- return headers
479
-
480
- def _throttle(self):
481
- """Rate-limit requests per scanner instance. Blocks (sleeps) when rate limit is hit."""
482
- if not hasattr(self, '_bucket'):
483
- self._bucket = TokenBucket(_SCANNER_RATE_LIMIT, _SCANNER_RATE_WINDOW)
484
- self._bucket.acquire(block=True)
485
-
486
- def _make_request(
487
- self,
488
- url: str,
489
- method: str = "GET",
490
- data: bytes | None = None,
491
- headers: dict | None = None,
492
- timeout: int = 15, # Increased from 5s -> 15s to handle slower external sites
493
- return_response_obj: bool = False,
494
- ) -> tuple[str | None, int] | tuple[str | None, int, dict]:
495
- """
496
- Unified HTTP request helper β€” always includes auth_headers.
497
- Returns (body_str, status_code). On error returns (None, 0).
498
- Automatically handles HTTPError bodies.
499
- """
500
- self._throttle()
501
- req_headers = self._make_headers(headers)
502
- # Add Connection: close to prevent keep-alive pool exhaustion on stressed targets
503
- req_headers.setdefault("Connection", "close")
504
- # Use shared SSL context to avoid per-call context creation overhead
505
- ssl_ctx = _get_shared_ssl_context() if self.verify_ssl is None else self.get_ssl_context()
506
-
507
- # PHASE 7.3: Retry with backoff on IncompleteRead / transient errors
508
- _RETRY_DELAYS = [0.0, 0.5, 1.5] # 3 attempts: immediate, +0.5s, +1.5s
509
- for _attempt, _delay in enumerate(_RETRY_DELAYS):
510
- if _delay:
511
- time.sleep(_delay)
512
- try:
513
- req = urllib.request.Request(
514
- url, data=data, headers=req_headers, method=method
515
- )
516
- with urllib.request.urlopen(
517
- req, timeout=timeout, context=ssl_ctx
518
- ) as r:
519
- body = r.read().decode("utf-8", errors="ignore")
520
- if return_response_obj:
521
- return body, r.status, r.headers # type: ignore[return-value]
522
- return body, r.status
523
- except http.client.IncompleteRead as e:
524
- if _attempt < len(_RETRY_DELAYS) - 1:
525
- self.log("WARNING", f"[Base] IncompleteRead on {url} (attempt {_attempt+1}), retrying...")
526
- continue
527
- # Last attempt β€” return partial data
528
- partial = e.partial.decode("utf-8", errors="ignore") if e.partial else ""
529
- if return_response_obj:
530
- return partial, 200, {} # type: ignore[return-value]
531
- return partial, 200
532
- except urllib.error.HTTPError as e:
533
- try:
534
- body = e.read().decode("utf-8", errors="ignore")
535
- except Exception as ex:
536
- self.log("ERROR", f"[Base] _make_request HTTPError body read error: {ex}")
537
- body = ""
538
- if return_response_obj:
539
- return body, e.code, e.headers # type: ignore[return-value]
540
- return body, e.code
541
- except ValueError as e:
542
- err_str = str(e).lower()
543
- # Suppress expected errors from newline/CRLF injection payloads in headers
544
- if "control characters" in err_str or "invalid header" in err_str:
545
- if return_response_obj:
546
- return None, 0, {} # type: ignore[return-value]
547
- return None, 0
548
- self.log("ERROR", f"[Base] _make_request ValueError: {e}")
549
- if return_response_obj:
550
- return None, 0, {} # type: ignore[return-value]
551
- return None, 0
552
- except Exception as e:
553
- # Suppress verbose logging for expected/common probe errors
554
- err_str = str(e).lower()
555
- _suppressed = (
556
- "timed out", "connection refused", "name or service",
557
- "getaddrinfo", # DNS resolution failure
558
- "errno 11001", # Windows: getaddrinfo failed
559
- "control characters", # Expected when CRLF payloads hit urllib
560
- "no connection could be made",
561
- "actively refused",
562
- "10054", # Connection forcibly closed
563
- "forcibly closed",
564
- )
565
- if not any(x in err_str for x in _suppressed):
566
- self.log("ERROR", f"[Base] _make_request error: {e}")
567
- if return_response_obj:
568
- return None, 0, {} # type: ignore[return-value]
569
- return None, 0
570
- # Should not reach here
571
- if return_response_obj:
572
- return None, 0, {} # type: ignore[return-value]
573
- return None, 0
574
-
575
-
576
- def _make_timed_request(
577
- self, url: str, method: str = "GET",
578
- data: bytes | None = None, headers: dict | None = None, timeout: int = 8,
579
- ) -> tuple[str | None, int, float]:
580
- """Returns (body, status, elapsed_seconds). Used for timing-based detection."""
581
- t0 = time.monotonic()
582
- body, status = self._make_request(url, method, data, headers, timeout)
583
- return body, status, time.monotonic() - t0
584
-
585
- # ── GAP-ADV: Concurrent execution helpers ──────────────────────────────
586
-
587
- def _make_async_requests(
588
- self,
589
- requests_list: list[dict],
590
- max_workers: int = 10, # PHASE 7.3: Reduced 25 β†’ 10 to prevent connection pool exhaustion
591
- ) -> list[tuple[dict, str | None, int]]:
592
- """
593
- Executes a list of requests concurrently using a thread pool.
594
- Each request in `requests_list` must be a dict with keys:
595
- 'url' (required), optionally 'method', 'data', 'headers', 'timeout'.
596
- Returns a list of tuples: (request_dict, response_body, status_code).
597
- """
598
- import concurrent.futures
599
-
600
- results: list[tuple[dict, str | None, int]] = []
601
-
602
- def worker(req: dict) -> tuple[dict, str | None, int]:
603
- url = req.get("url")
604
- if not url:
605
- return req, None, 0
606
- method = req.get("method", "GET")
607
- data = req.get("data")
608
- headers = req.get("headers")
609
- timeout = req.get("timeout", 15) # Consistent 15s default
610
- body, status = self._make_request(url, method, data, headers, timeout)
611
- return req, body, status
612
-
613
- with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
614
- future_to_req = {executor.submit(worker, req): req for req in requests_list}
615
- for future in concurrent.futures.as_completed(future_to_req):
616
- req = future_to_req[future]
617
- try:
618
- res = future.result()
619
- results.append(res)
620
- except Exception as exc:
621
- self.log("ERROR", f"[Base] _make_async_requests future error: {exc}")
622
- results.append((req, None, 0))
623
-
624
- return results
625
-
626
- # ── URL helpers ────────────────────────────────────────────────────────
627
-
628
- def _safe_url_join(self, base: str, path: str) -> str:
629
- """
630
- Safely join a base URL with a relative path.
631
- Handles edge cases like missing slashes, query strings, fragments.
632
- """
633
- try:
634
- if path.startswith("http://") or path.startswith("https://"):
635
- return path
636
- return urljoin(base.rstrip("/") + "/", path.lstrip("/"))
637
- except Exception:
638
- return base
639
-
640
- def _deduplicate_vulns(self) -> None:
641
- """
642
- Remove duplicate vulnerabilities from self.vulns in-place.
643
- Dedup key: (title, category).
644
- Keeps the highest-confidence occurrence.
645
- """
646
- seen: dict[tuple, dict] = {}
647
- conf_rank = {"Low": 0, "Medium": 1, "High": 2, "Confirmed": 3}
648
- for v in self.vulns:
649
- key = (v["title"], v["category"])
650
- if key not in seen:
651
- seen[key] = v
652
- else:
653
- existing_rank = conf_rank.get(seen[key].get("confidence", "Low"), 0)
654
- new_rank = conf_rank.get(v.get("confidence", "Low"), 0)
655
- if new_rank > existing_rank:
656
- seen[key] = v
657
- self.vulns = list(seen.values())
658
-
659
- # ── GAP-002: Target SSRF self-protection ─────────────────────────────
660
-
661
- def _validate_target(self, url: str | None = None) -> None:
662
- """
663
- Prevent the scanner engine from being used as an SSRF pivot.
664
- Raises ValueError for blocked targets.
665
- BUG-12 FIX: Now called in __init__ automatically for every scanner.
666
- """
667
- target = url or self.target
668
- try:
669
- p = urlparse(target)
670
- except Exception as exc:
671
- raise ValueError(f"Invalid URL: {exc}") from exc
672
-
673
- # Block dangerous schemes
674
- if p.scheme in _BLOCKED_SCHEMES:
675
- raise ValueError(f"Blocked URL scheme: {p.scheme!r}")
676
-
677
- hostname = (p.hostname or "").lower().strip()
678
- if not hostname:
679
- raise ValueError("URL has no hostname")
680
-
681
- # Block known metadata / internal service hostnames
682
- if hostname in _BLOCKED_HOSTS:
683
- raise ValueError(f"Blocked host: {hostname}")
684
-
685
- # Block private / loopback / link-local IP ranges
686
- try:
687
- ip = ipaddress.ip_address(hostname)
688
- if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast:
689
- raise ValueError(f"Private/internal IP blocked: {ip}")
690
- except ValueError as exc:
691
- if "Blocked" in str(exc) or "Private" in str(exc) or "internal" in str(exc):
692
- raise # Re-raise our own checks
693
- # Not an IP address (it's a hostname) β€” fine, proceed
694
- pass
 
 
 
1
+ """
2
+ base_scanner.py β€” Foundation for all WSS scanners
3
+ ===================================================
4
+ Security hardened per Expert Audit (June 2026):
5
+ GAP-001: confidence + scanner_key + cve_ids + timestamp fields in build_vuln()
6
+ GAP-002: Target SSRF self-validation (_validate_target) β€” NOW CALLED IN __init__
7
+ GAP-003: _make_request() / _make_headers() unified helper (auth-aware)
8
+ GAP-004: Ring buffer (max 5000 lines) for active_scan_logs
9
+ GAP-S1: Secrets masking in log output
10
+ GAP-S2: Structured JSON security event logging
11
+
12
+ FIXES (June 2026):
13
+ BUG-12/SEC-3: _validate_target() is now called inside __init__ so ALL scanners
14
+ are protected from being used as SSRF pivots β€” was dead code before.
15
+ ENH: Added _safe_url_join() to construct test URLs without path confusion.
16
+ ENH: Added _deduplicate_vulns() to remove identical findings before reporting.
17
+ ENH: _make_async_requests() now properly captures exceptions per-future.
18
+ """
19
+ import re
20
+ import os
21
+ import json
22
+ import ssl
23
+ import time
24
+ import http.client
25
+ import socket
26
+ import logging
27
+ import ipaddress
28
+ import threading
29
+ import urllib.request
30
+ import urllib.error
31
+ from datetime import datetime, timezone, timedelta
32
+
33
+ IST = timezone(timedelta(hours=5, minutes=30))
34
+ from urllib.robotparser import RobotFileParser
35
+ from urllib.parse import urlparse, urljoin, urlencode, quote
36
+ from utils.vuln_classifier import enrich as _classify_enrich
37
+ from scanners.core.baseline import SiteBaseline
38
+ from scanners.core.confidence import ConfidenceTracker
39
+
40
+ # ── Log store ─────────────────────────────────────────────────────────────
41
+ active_scan_logs: dict[str, list[str]] = {}
42
+ _logs_lock = threading.Lock()
43
+ MAX_LOG_LINES = 5000 # GAP-004: ring buffer cap
44
+
45
+ # ── WebSocket integration for real-time updates ───────────────────────────
46
+ _socketio_instance = None
47
+ _socketio_lock = threading.Lock()
48
+
49
+ def set_socketio_instance(socketio):
50
+ """Set the global SocketIO instance for real-time progress updates."""
51
+ global _socketio_instance
52
+ with _socketio_lock:
53
+ _socketio_instance = socketio
54
+
55
+ def emit_scan_progress(scan_id: str, event_type: str, data: dict) -> None:
56
+ """Emit real-time scan progress events via WebSocket."""
57
+ global _socketio_instance
58
+ with _socketio_lock:
59
+ if _socketio_instance:
60
+ try:
61
+ _socketio_instance.emit(event_type, data, room=f'scan_{scan_id}')
62
+ except Exception as e:
63
+ # Silently fail if WebSocket is not available
64
+ pass
65
+
66
+ def parse_domain(url):
67
+ try:
68
+ parsed = urlparse(url)
69
+ return parsed.netloc or parsed.path
70
+ except Exception:
71
+ return url
72
+
73
+ def cleanup_scan_logs(scan_id):
74
+ with _logs_lock:
75
+ if scan_id in active_scan_logs:
76
+ del active_scan_logs[scan_id]
77
+
78
+ def schedule_log_cleanup(scan_id, delay=3600):
79
+ def cleanup_task():
80
+ time.sleep(delay)
81
+ cleanup_scan_logs(scan_id)
82
+ threading.Thread(target=cleanup_task, daemon=True).start()
83
+
84
+ # ── Environment ──────────────────────────────────────────────────────────
85
+ DEFAULT_VERIFY_SSL = os.environ.get("WSS_VERIFY_SSL", "0") == "1"
86
+ XSS_CALLBACK_URL = os.environ.get(
87
+ "WSS_XSS_CALLBACK_URL",
88
+ "https://xss-reporting.internal/callback",
89
+ )
90
+
91
+ # ── Secret patterns to mask in logs (GAP-S1) ─────────────────────────────
92
+ _SECRET_PATTERNS = [
93
+ (re.compile(r'(AKIA[0-9A-Z]{16})'), r'AKIA****'),
94
+ (re.compile(r'(sk-[a-zA-Z0-9]{40,})'), r'sk-****'),
95
+ (re.compile(r'([Bb]earer\s+)[A-Za-z0-9\-_.~+/]+=*'), r'\1****'),
96
+ (re.compile(r'(password["\s:=]+)[^\s&"\']+', re.I), r'\1****'),
97
+ (re.compile(r'(token["\s:=]+)[^\s&"\']{8,}', re.I), r'\1****'),
98
+ ]
99
+
100
+ # ── Structured security event logger (GAP-S2) ────────────────────────────
101
+ _sec_logger = logging.getLogger("LarShield.Security")
102
+ if not _sec_logger.handlers:
103
+ _h = logging.FileHandler("security_events.log", encoding="utf-8")
104
+ _h.setFormatter(logging.Formatter("%(message)s"))
105
+ _sec_logger.addHandler(_h)
106
+ _sec_logger.setLevel(logging.INFO)
107
+ _sec_logger.propagate = False
108
+
109
+
110
+ def _clean_nul(val) -> str:
111
+ if val is None:
112
+ return ""
113
+ if not isinstance(val, str):
114
+ val = str(val)
115
+ return val.replace("\x00", "").replace("\u0000", "")
116
+
117
+
118
+ def _mask_secrets(text: str) -> str:
119
+ """Redact known secret patterns before writing to logs."""
120
+ text = _clean_nul(text)
121
+ for pattern, replacement in _SECRET_PATTERNS:
122
+ text = pattern.sub(replacement, text)
123
+ return text
124
+
125
+
126
+ def _log_security_event(event_type: str, scan_id: str, message: str, level: str) -> None:
127
+ """Write structured JSON security event for SIEM ingestion."""
128
+ event = {
129
+ "ts": datetime.now(timezone.utc).isoformat(),
130
+ "event_type": event_type,
131
+ "level": level,
132
+ "scan_id": scan_id,
133
+ "message": _mask_secrets(message),
134
+ }
135
+ _sec_logger.info(json.dumps(event))
136
+
137
+
138
+ def get_scan_logs(scan_id: str) -> list[str]:
139
+ with _logs_lock:
140
+ return list(active_scan_logs.get(scan_id, []))
141
+
142
+
143
+ def add_log(scan_id: str, level: str, message: str) -> None:
144
+ timestamp = datetime.now(timezone.utc).astimezone(IST).strftime("%H:%M:%S")
145
+ safe_msg = _mask_secrets(message)
146
+ log_line = f"[{timestamp}] [{level}] {safe_msg}"
147
+
148
+ with _logs_lock:
149
+ # GAP-004: ring buffer β€” cap at MAX_LOG_LINES
150
+ logs = active_scan_logs.setdefault(scan_id, [])
151
+ if len(logs) >= MAX_LOG_LINES:
152
+ logs.pop(0)
153
+ logs.append(log_line)
154
+
155
+ # Structured security event for critical/warning levels
156
+ if level in ("CRITICAL", "WARNING", "ERROR"):
157
+ _log_security_event(f"SCAN_{level}", scan_id, message, level)
158
+
159
+ try:
160
+ print(log_line, flush=True)
161
+ except UnicodeEncodeError:
162
+ print(log_line.encode("ascii", "replace").decode("ascii"), flush=True)
163
+
164
+
165
+ def cleanup_scan_logs(scan_id: str) -> None:
166
+ with _logs_lock:
167
+ active_scan_logs.pop(scan_id, None)
168
+
169
+
170
+ def schedule_log_cleanup(scan_id: str, delay_seconds: int = 300) -> None:
171
+ """
172
+ Schedule scan log cleanup after `delay_seconds` (default 5 min).
173
+ BUG-6 FIX: Prevents premature cleanup while frontend polls /logs.
174
+ """
175
+ def _cleanup():
176
+ time.sleep(delay_seconds)
177
+ cleanup_scan_logs(scan_id)
178
+
179
+ t = threading.Thread(target=_cleanup, daemon=True)
180
+ t.start()
181
+
182
+
183
+ def parse_domain(url: str) -> str:
184
+ return (
185
+ url.replace("https://", "")
186
+ .replace("http://", "")
187
+ .split("/")[0]
188
+ .split(":")[0]
189
+ .split("?")[0]
190
+ .strip()
191
+ )
192
+
193
+
194
+ def build_vuln(
195
+ title: str,
196
+ severity: str,
197
+ category: str,
198
+ cvss_score: float,
199
+ description: str,
200
+ remediation: str,
201
+ evidence: str = "",
202
+ payload: str = "",
203
+ request_details: str = "",
204
+ response_details: str = "",
205
+ confidence: str = "Medium",
206
+ scanner_key: str = "unknown",
207
+ cve_ids: list | None = None,
208
+ references: list | None = None,
209
+ cwe_ids: list | None = None,
210
+ owasp_category: str | None = None,
211
+ ) -> dict:
212
+ result = {
213
+ "title": _clean_nul(title),
214
+ "severity": _clean_nul(severity),
215
+ "category": _clean_nul(category),
216
+ "cvss_score": cvss_score,
217
+ "description": _clean_nul(description),
218
+ "remediation": _clean_nul(remediation),
219
+ "evidence": _mask_secrets(evidence),
220
+ "payload": _clean_nul(payload),
221
+ "request_details": _clean_nul(request_details),
222
+ "response_details": _mask_secrets(response_details),
223
+ "confidence": _clean_nul(confidence),
224
+ "scanner_key": _clean_nul(scanner_key),
225
+ "cve_ids": cve_ids or [],
226
+ "references": references or [],
227
+ "timestamp": datetime.now(timezone.utc).isoformat(),
228
+ }
229
+ if cwe_ids:
230
+ result["cwe_ids"] = cwe_ids
231
+ if owasp_category:
232
+ result["owasp_category"] = _clean_nul(owasp_category)
233
+ _classify_enrich(result, scanner_key)
234
+ return result
235
+
236
+
237
+ def make_ssl_context(verify: bool | None = None):
238
+ import ssl as _ssl
239
+ ctx = _ssl.create_default_context()
240
+ if verify is False or (verify is None and not DEFAULT_VERIFY_SSL):
241
+ ctx.check_hostname = False
242
+ ctx.verify_mode = _ssl.CERT_NONE
243
+ else:
244
+ # Enforce TLS 1.2+ minimum (report Β§1.3)
245
+ try:
246
+ ctx.minimum_version = _ssl.TLSVersion.TLSv1_2
247
+ except AttributeError:
248
+ pass # Older Python β€” skip
249
+ return ctx
250
+
251
+
252
+ def check_robots_txt(target: str, user_agent: str = "LarShield/2.0") -> RobotFileParser | None:
253
+ try:
254
+ parsed = urlparse(target)
255
+ robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
256
+ rp = RobotFileParser(robots_url)
257
+ rp.read()
258
+ return rp
259
+ except Exception as e:
260
+ print(f"ERROR: [Base] check_robots_txt error: {e}")
261
+ return None
262
+
263
+
264
+ # ── Blocked target sets (GAP-002) ─────────────────────────────────────────
265
+ _BLOCKED_HOSTS = frozenset({
266
+ "localhost", "127.0.0.1", "::1", "0.0.0.0",
267
+ "169.254.169.254", # AWS/Azure IMDS
268
+ "metadata.google.internal", # GCP metadata
269
+ "100.100.100.200", # Alibaba Cloud ECS metadata
270
+ "kubernetes.default.svc",
271
+ "kubernetes.default",
272
+ })
273
+ _BLOCKED_SCHEMES = frozenset({"file", "ftp", "gopher", "dict", "ldap", "ldaps"})
274
+
275
+ # Raised from 60β†’150 to reduce per-scanner throttle waits and cut scan time
276
+ _SCANNER_RATE_LIMIT = int(os.environ.get("SCANNER_RATE_LIMIT", "150"))
277
+ _SCANNER_RATE_WINDOW = int(os.environ.get("SCANNER_RATE_WINDOW", "60"))
278
+
279
+ # ── Module-level shared SSL context (avoids rebuilding per-instance) ──────────
280
+ _SHARED_SSL_CONTEXT = None
281
+ _SSL_CONTEXT_LOCK = threading.Lock()
282
+
283
+ def _get_shared_ssl_context():
284
+ """Return (or lazily create) a module-level SSL context."""
285
+ global _SHARED_SSL_CONTEXT
286
+ if _SHARED_SSL_CONTEXT is None:
287
+ with _SSL_CONTEXT_LOCK:
288
+ if _SHARED_SSL_CONTEXT is None:
289
+ _SHARED_SSL_CONTEXT = make_ssl_context(None)
290
+ return _SHARED_SSL_CONTEXT
291
+
292
+
293
+ class TokenBucket:
294
+ def __init__(self, rate: int = 60, window: int = 60):
295
+ self._rate = rate
296
+ self._window = window
297
+ self._tokens = rate
298
+ self._last_refill = time.monotonic()
299
+ self._lock = threading.Lock()
300
+
301
+ def _refill(self):
302
+ now = time.monotonic()
303
+ elapsed = now - self._last_refill
304
+ self._tokens = min(self._rate, self._tokens + elapsed * (self._rate / self._window))
305
+ self._last_refill = now
306
+
307
+ def acquire(self, block: bool = True) -> bool:
308
+ with self._lock:
309
+ self._refill()
310
+ if self._tokens >= 1:
311
+ self._tokens -= 1
312
+ return True
313
+ if block:
314
+ sleep_time = (self._window / self._rate) * 1.1
315
+ time.sleep(sleep_time)
316
+ self._refill()
317
+ if self._tokens >= 1:
318
+ self._tokens -= 1
319
+ return True
320
+ return False
321
+
322
+
323
+ class BaseScanner:
324
+ SCANNER_NAME: str = "Base Scanner"
325
+
326
+ def __init__(
327
+ self,
328
+ scan_id: str,
329
+ target: str,
330
+ domain: str,
331
+ auth_headers: dict | None = None,
332
+ verify_ssl: bool | None = None,
333
+ red_team: bool = False,
334
+ **kwargs,
335
+ ) -> None:
336
+ self.scan_id = scan_id
337
+ self.target = target
338
+ self.domain = domain
339
+ self.auth_headers = auth_headers or {}
340
+ self.verify_ssl = verify_ssl
341
+ self.red_team = red_team
342
+ self.vulns: list[dict] = []
343
+ self._ssl_context = None
344
+ self._robots_parser = None
345
+
346
+ # GAP-ADV: Centralized discovery context to prevent redundant crawling
347
+ self.discovery_context = kwargs.get("discovery_context", {})
348
+
349
+ # PHASE 1: Build per-scan site baseline for SPA/404 false-positive suppression
350
+ self._baseline = SiteBaseline()
351
+ try:
352
+ ssl_ctx = make_ssl_context(verify_ssl)
353
+ self._baseline.build(
354
+ target,
355
+ ssl_context=ssl_ctx,
356
+ headers={"User-Agent": "LarShield/2.0"},
357
+ timeout=6,
358
+ )
359
+ except Exception as _be:
360
+ add_log(scan_id, "WARNING", f"[Base] Baseline build error (suppression disabled): {_be}")
361
+
362
+ # BUG-12 FIX: Validate target on init so ALL scanners are protected.
363
+ # We catch ValueError here (not re-raise) to log and continue β€” some
364
+ # scan types like API scanners may legitimately call with non-HTTP URLs.
365
+ try:
366
+ self._validate_target(self.target)
367
+ except ValueError as e:
368
+ add_log(scan_id, "WARNING",
369
+ f"[Base] Target validation warning for '{target}': {e}")
370
+
371
+ # ── SSL / robots ─────────────────────────────────────────────────────
372
+
373
+ def get_ssl_context(self):
374
+ if self._ssl_context is None:
375
+ self._ssl_context = make_ssl_context(self.verify_ssl)
376
+ return self._ssl_context
377
+
378
+ def get_robots_parser(self):
379
+ if self._robots_parser is None:
380
+ self._robots_parser = check_robots_txt(self.target)
381
+ return self._robots_parser
382
+
383
+ def can_fetch(self, path: str = "/") -> bool:
384
+ rp = self.get_robots_parser()
385
+ if rp is None:
386
+ return True
387
+ return rp.can_fetch("LarShield/2.0", path)
388
+
389
+ # ── PHASE 1: Baseline convenience helpers ─────────────────────────────
390
+
391
+ def _is_baseline(self, status: int, body: str | bytes) -> bool:
392
+ """
393
+ Return True when this response matches the site's generic SPA/404 catch-all.
394
+ Use this before reporting any path as "found" to suppress false positives.
395
+ """
396
+ return self._baseline.is_baseline(status, body)
397
+
398
+ def _is_not_found(self, status: int, body: str | bytes = b"") -> bool:
399
+ """True when status >= 400 OR response matches the baseline catch-all."""
400
+ return self._baseline.is_not_found(status, body)
401
+
402
+ # ── Logging ──────────────────────────────────────────────────────────
403
+
404
+ def log(self, level: str, message: str) -> None:
405
+ add_log(self.scan_id, level, message)
406
+ # Emit real-time log event
407
+ emit_scan_progress(self.scan_id, 'scan_log', {
408
+ 'level': level,
409
+ 'message': message,
410
+ 'timestamp': datetime.now(timezone.utc).astimezone(IST).isoformat()
411
+ })
412
+
413
+ # ── Vulnerability reporting ──────────────────────────────────────────
414
+
415
+ def add_vuln(
416
+ self,
417
+ title: str,
418
+ severity: str,
419
+ category: str,
420
+ cvss_score: float,
421
+ description: str,
422
+ remediation: str,
423
+ evidence: str = "",
424
+ payload: str = "",
425
+ request_details: str = "",
426
+ response_details: str = "",
427
+ confidence: str = "Medium",
428
+ cve_ids: list | None = None,
429
+ references: list | None = None,
430
+ cwe_ids: list | None = None,
431
+ owasp_category: str | None = None,
432
+ ) -> None:
433
+ vuln = build_vuln(
434
+ title, severity, category, cvss_score,
435
+ description, remediation,
436
+ evidence, payload, request_details, response_details,
437
+ confidence=confidence,
438
+ scanner_key=getattr(self, "_SCANNER_KEY", "unknown"),
439
+ cve_ids=cve_ids,
440
+ references=references,
441
+ cwe_ids=cwe_ids,
442
+ owasp_category=owasp_category,
443
+ )
444
+ # Inline dedup: skip if same title+category already recorded this run
445
+ for existing in self.vulns:
446
+ if existing["title"] == vuln["title"] and existing["category"] == vuln["category"]:
447
+ # Update confidence if the new one is stronger
448
+ conf_rank = {"Low": 0, "Medium": 1, "High": 2, "Confirmed": 3}
449
+ if conf_rank.get(vuln["confidence"], 0) > conf_rank.get(existing["confidence"], 0):
450
+ existing["confidence"] = vuln["confidence"]
451
+ if vuln.get("payload"):
452
+ existing["payload"] = vuln["payload"]
453
+ if vuln.get("evidence"):
454
+ existing["evidence"] = vuln["evidence"]
455
+ return
456
+ self.vulns.append(vuln)
457
+ # Emit real-time vulnerability found event
458
+ emit_scan_progress(self.scan_id, 'vulnerability_found', {
459
+ 'title': title,
460
+ 'severity': severity,
461
+ 'category': category,
462
+ 'cvss_score': cvss_score,
463
+ 'confidence': confidence,
464
+ 'scanner_key': getattr(self, "_SCANNER_KEY", "unknown"),
465
+ 'timestamp': datetime.now(timezone.utc).astimezone(IST).isoformat()
466
+ })
467
+
468
+ def run(self) -> list[dict]:
469
+ raise NotImplementedError("Subclasses must implement run()")
470
+
471
+ # ── GAP-003: Unified auth-aware HTTP helpers ──────────────────────────
472
+
473
+ def _make_headers(self, additional: dict | None = None) -> dict:
474
+ """Build headers dict merging auth_headers (always include for authenticated scanning)."""
475
+ headers = {"User-Agent": "LarShield/2.0"}
476
+ if self.auth_headers:
477
+ headers.update(self.auth_headers)
478
+ if additional:
479
+ headers.update(additional)
480
+ return headers
481
+
482
+ def _throttle(self):
483
+ """Rate-limit requests per scanner instance. Blocks (sleeps) when rate limit is hit."""
484
+ if not hasattr(self, '_bucket'):
485
+ self._bucket = TokenBucket(_SCANNER_RATE_LIMIT, _SCANNER_RATE_WINDOW)
486
+ self._bucket.acquire(block=True)
487
+
488
+ def _make_request(
489
+ self,
490
+ url: str,
491
+ method: str = "GET",
492
+ data: bytes | None = None,
493
+ headers: dict | None = None,
494
+ timeout: int = 15, # Increased from 5s -> 15s to handle slower external sites
495
+ return_response_obj: bool = False,
496
+ ) -> tuple[str | None, int] | tuple[str | None, int, dict]:
497
+ """
498
+ Unified HTTP request helper β€” always includes auth_headers.
499
+ Returns (body_str, status_code). On error returns (None, 0).
500
+ Automatically handles HTTPError bodies.
501
+ """
502
+ self._throttle()
503
+ req_headers = self._make_headers(headers)
504
+ # Add Connection: close to prevent keep-alive pool exhaustion on stressed targets
505
+ req_headers.setdefault("Connection", "close")
506
+ # Use shared SSL context to avoid per-call context creation overhead
507
+ ssl_ctx = _get_shared_ssl_context() if self.verify_ssl is None else self.get_ssl_context()
508
+
509
+ # PHASE 7.3: Retry with backoff on IncompleteRead / transient errors
510
+ _RETRY_DELAYS = [0.0, 0.5, 1.5] # 3 attempts: immediate, +0.5s, +1.5s
511
+ for _attempt, _delay in enumerate(_RETRY_DELAYS):
512
+ if _delay:
513
+ time.sleep(_delay)
514
+ try:
515
+ req = urllib.request.Request(
516
+ url, data=data, headers=req_headers, method=method
517
+ )
518
+ with urllib.request.urlopen(
519
+ req, timeout=timeout, context=ssl_ctx
520
+ ) as r:
521
+ body = r.read().decode("utf-8", errors="ignore")
522
+ if return_response_obj:
523
+ return body, r.status, r.headers # type: ignore[return-value]
524
+ return body, r.status
525
+ except http.client.IncompleteRead as e:
526
+ if _attempt < len(_RETRY_DELAYS) - 1:
527
+ self.log("WARNING", f"[Base] IncompleteRead on {url} (attempt {_attempt+1}), retrying...")
528
+ continue
529
+ # Last attempt β€” return partial data
530
+ partial = e.partial.decode("utf-8", errors="ignore") if e.partial else ""
531
+ if return_response_obj:
532
+ return partial, 200, {} # type: ignore[return-value]
533
+ return partial, 200
534
+ except urllib.error.HTTPError as e:
535
+ try:
536
+ body = e.read().decode("utf-8", errors="ignore")
537
+ except Exception as ex:
538
+ self.log("ERROR", f"[Base] _make_request HTTPError body read error: {ex}")
539
+ body = ""
540
+ if return_response_obj:
541
+ return body, e.code, e.headers # type: ignore[return-value]
542
+ return body, e.code
543
+ except ValueError as e:
544
+ err_str = str(e).lower()
545
+ # Suppress expected errors from newline/CRLF injection payloads in headers
546
+ if "control characters" in err_str or "invalid header" in err_str:
547
+ if return_response_obj:
548
+ return None, 0, {} # type: ignore[return-value]
549
+ return None, 0
550
+ self.log("ERROR", f"[Base] _make_request ValueError: {e}")
551
+ if return_response_obj:
552
+ return None, 0, {} # type: ignore[return-value]
553
+ return None, 0
554
+ except Exception as e:
555
+ # Suppress verbose logging for expected/common probe errors
556
+ err_str = str(e).lower()
557
+ _suppressed = (
558
+ "timed out", "connection refused", "name or service",
559
+ "getaddrinfo", # DNS resolution failure
560
+ "errno 11001", # Windows: getaddrinfo failed
561
+ "control characters", # Expected when CRLF payloads hit urllib
562
+ "no connection could be made",
563
+ "actively refused",
564
+ "10054", # Connection forcibly closed
565
+ "forcibly closed",
566
+ )
567
+ if not any(x in err_str for x in _suppressed):
568
+ self.log("ERROR", f"[Base] _make_request error: {e}")
569
+ if return_response_obj:
570
+ return None, 0, {} # type: ignore[return-value]
571
+ return None, 0
572
+ # Should not reach here
573
+ if return_response_obj:
574
+ return None, 0, {} # type: ignore[return-value]
575
+ return None, 0
576
+
577
+
578
+ def _make_timed_request(
579
+ self, url: str, method: str = "GET",
580
+ data: bytes | None = None, headers: dict | None = None, timeout: int = 8,
581
+ ) -> tuple[str | None, int, float]:
582
+ """Returns (body, status, elapsed_seconds). Used for timing-based detection."""
583
+ t0 = time.monotonic()
584
+ body, status = self._make_request(url, method, data, headers, timeout)
585
+ return body, status, time.monotonic() - t0
586
+
587
+ # ── GAP-ADV: Concurrent execution helpers ──────────────────────────────
588
+
589
+ def _make_async_requests(
590
+ self,
591
+ requests_list: list[dict],
592
+ max_workers: int = 10, # PHASE 7.3: Reduced 25 β†’ 10 to prevent connection pool exhaustion
593
+ ) -> list[tuple[dict, str | None, int]]:
594
+ """
595
+ Executes a list of requests concurrently using a thread pool.
596
+ Each request in `requests_list` must be a dict with keys:
597
+ 'url' (required), optionally 'method', 'data', 'headers', 'timeout'.
598
+ Returns a list of tuples: (request_dict, response_body, status_code).
599
+ """
600
+ import concurrent.futures
601
+
602
+ results: list[tuple[dict, str | None, int]] = []
603
+
604
+ def worker(req: dict) -> tuple[dict, str | None, int]:
605
+ url = req.get("url")
606
+ if not url:
607
+ return req, None, 0
608
+ method = req.get("method", "GET")
609
+ data = req.get("data")
610
+ headers = req.get("headers")
611
+ timeout = req.get("timeout", 15) # Consistent 15s default
612
+ body, status = self._make_request(url, method, data, headers, timeout)
613
+ return req, body, status
614
+
615
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
616
+ future_to_req = {executor.submit(worker, req): req for req in requests_list}
617
+ for future in concurrent.futures.as_completed(future_to_req):
618
+ req = future_to_req[future]
619
+ try:
620
+ res = future.result()
621
+ results.append(res)
622
+ except Exception as exc:
623
+ self.log("ERROR", f"[Base] _make_async_requests future error: {exc}")
624
+ results.append((req, None, 0))
625
+
626
+ return results
627
+
628
+ # ── URL helpers ────────────────────────────────────────────────────────
629
+
630
+ def _safe_url_join(self, base: str, path: str) -> str:
631
+ """
632
+ Safely join a base URL with a relative path.
633
+ Handles edge cases like missing slashes, query strings, fragments.
634
+ """
635
+ try:
636
+ if path.startswith("http://") or path.startswith("https://"):
637
+ return path
638
+ return urljoin(base.rstrip("/") + "/", path.lstrip("/"))
639
+ except Exception:
640
+ return base
641
+
642
+ def _deduplicate_vulns(self) -> None:
643
+ """
644
+ Remove duplicate vulnerabilities from self.vulns in-place.
645
+ Dedup key: (title, category).
646
+ Keeps the highest-confidence occurrence.
647
+ """
648
+ seen: dict[tuple, dict] = {}
649
+ conf_rank = {"Low": 0, "Medium": 1, "High": 2, "Confirmed": 3}
650
+ for v in self.vulns:
651
+ key = (v["title"], v["category"])
652
+ if key not in seen:
653
+ seen[key] = v
654
+ else:
655
+ existing_rank = conf_rank.get(seen[key].get("confidence", "Low"), 0)
656
+ new_rank = conf_rank.get(v.get("confidence", "Low"), 0)
657
+ if new_rank > existing_rank:
658
+ seen[key] = v
659
+ self.vulns = list(seen.values())
660
+
661
+ # ── GAP-002: Target SSRF self-protection ─────────────────────────────
662
+
663
+ def _validate_target(self, url: str | None = None) -> None:
664
+ """
665
+ Prevent the scanner engine from being used as an SSRF pivot.
666
+ Raises ValueError for blocked targets.
667
+ BUG-12 FIX: Now called in __init__ automatically for every scanner.
668
+ """
669
+ target = url or self.target
670
+ try:
671
+ p = urlparse(target)
672
+ except Exception as exc:
673
+ raise ValueError(f"Invalid URL: {exc}") from exc
674
+
675
+ # Block dangerous schemes
676
+ if p.scheme in _BLOCKED_SCHEMES:
677
+ raise ValueError(f"Blocked URL scheme: {p.scheme!r}")
678
+
679
+ hostname = (p.hostname or "").lower().strip()
680
+ if not hostname:
681
+ raise ValueError("URL has no hostname")
682
+
683
+ # Block known metadata / internal service hostnames
684
+ if hostname in _BLOCKED_HOSTS:
685
+ raise ValueError(f"Blocked host: {hostname}")
686
+
687
+ # Block private / loopback / link-local IP ranges
688
+ try:
689
+ ip = ipaddress.ip_address(hostname)
690
+ if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast:
691
+ raise ValueError(f"Private/internal IP blocked: {ip}")
692
+ except ValueError as exc:
693
+ if "Blocked" in str(exc) or "Private" in str(exc) or "internal" in str(exc):
694
+ raise # Re-raise our own checks
695
+ # Not an IP address (it's a hostname) β€” fine, proceed
696
+ pass