"""Keyless Retire.js sampling and a bounded Wapiti profile.""" import asyncio import json import re import tempfile import time from html.parser import HTMLParser from pathlib import Path from urllib.parse import urljoin, urlsplit import httpx from . import config from .coverage import profile as coverage_settings from .pipeline import execute from .scope import public_host from .web_modules import same_origin, read_report, alert from .sampling import FetchProblem, fetch_page async def fetch_sample(url, agent, maximum): return await fetch_page(url, agent, maximum, public_host) class ScriptInventory(HTMLParser): def __init__(self, target, maximum=None): super().__init__() self.maximum = maximum or config.RETIRE_MAX_FILE_BYTES self.inline_oversized = 0 self.target, self.urls, self.inline = target, [], [] self.current = None self.inline_bytes = 0 def handle_starttag(self, tag, attrs): if tag != "script": return attrs = dict(attrs) src = attrs.get("src") if src: try: candidate = urljoin(self.target, src) if same_origin(candidate, self.target): self.urls.append(candidate) except ValueError: pass self.current = None elif str(attrs.get("type", "")).lower() in ("", "text/javascript", "application/javascript", "module"): self.current = [] def handle_data(self, data): if self.current is not None and self.inline_bytes < self.maximum * 16: self.current.append(data) self.inline_bytes += len(data.encode("utf-8")) def handle_endtag(self, tag): if tag == "script" and self.current is not None: code = "".join(self.current).encode("utf-8") if code.strip() and len(code) <= self.maximum: self.inline.append(code) elif code.strip(): self.inline_oversized += 1 self.current = None def parse_retire(report, mapping): results = [] entries = report.get("data", []) if isinstance(report, dict) else [] if not isinstance(entries, list): return [] for entry in entries: if not isinstance(entry, dict) or not isinstance(entry.get("file"), str): continue target = mapping.get(str(Path(entry["file"]).resolve())) if target is None: continue for component in entry.get("results", []): if not isinstance(component, dict): continue for vuln in component.get("vulnerabilities", []): if not isinstance(vuln, dict): continue ids = vuln.get("identifiers") or {} if not isinstance(ids, dict): ids = {} cves = ids.get("CVE") or [] references = ", ".join(str(v) for v in cves[:8]) if isinstance(cves, list) else str(cves) risk = str(vuln.get("severity") or "info").title() if risk not in ("Low", "Medium", "High", "Critical"): risk = "Informational" title = str(component.get("component") or "JavaScript library") + " " + str(component.get("version") or "") description = ("Unverified library/version advisory match. " + str(ids.get("summary") or vuln.get("details") or "") + ("; " + references if references else "")) results.append(alert("retirejs", title, target, description, risk, references)) return results[:config.WEB_FINDINGS_LIMIT] async def retirejs(url, host, agent, delay, discovered, profile="current"): settings = coverage_settings(profile) limit = config.RETIRE_MAX_FILES if profile == "current" else settings["retire_files"] maximum = config.RETIRE_MAX_FILE_BYTES if profile == "current" else settings["retire_bytes"] attempts_limit = limit if profile == "current" else settings["retire_attempts"] reasons, samples = [], [] policy_limits = set() byte_limit = settings["retire_total_bytes"] sampled_bytes = 0 parser = ScriptInventory(url, maximum) started = time.monotonic() coverage = {"profile": profile, "max_files": limit, "max_file_bytes": maximum, "attempted_scripts": 0, "oversized": 0, "fetch_failures": 0, "inline_available": 0, "external_available": 0, "max_total_bytes": byte_limit, "missing_inventory_scripts": 0} def add_sample(source, body): nonlocal sampled_bytes if sampled_bytes + len(body) > byte_limit: policy_limits.add("total_bytes") return False samples.append((source, body)) sampled_bytes += len(body) return True async def sample(candidate, cap): remaining = settings["retire_sampling_seconds"] - (time.monotonic() - started) if remaining <= 0: raise asyncio.TimeoutError() return await asyncio.wait_for(fetch_sample(candidate, agent, cap), remaining) try: page_cap = config.WEB_PREFLIGHT_MAX_BYTES if profile == "current" else maximum body, content_type, page_url = await sample(url, page_cap) parser = ScriptInventory(page_url, maximum) if "html" in content_type or body.lstrip().lower().startswith((b"= limit or attempted >= attempts_limit: policy_limits.add("file_or_attempt_count") break if time.monotonic() - started + max(config.REQUEST_DELAY_MIN, float(delay)) >= settings["retire_sampling_seconds"]: reasons.append("JavaScript sampling time budget reached") policy_limits.add("sampling_time") break await asyncio.sleep(max(config.REQUEST_DELAY_MIN, float(delay))) attempted += 1 coverage["attempted_scripts"] = attempted try: body, content_type, sampled_url = await sample(candidate, maximum) if "html" in content_type or body.lstrip().lower().startswith((b"