File size: 16,975 Bytes
bc6415e 83f7729 bc6415e 83f7729 bc6415e 4643d92 bc6415e 4643d92 bc6415e 83f7729 bc6415e 83f7729 bc6415e 83f7729 bc6415e 83f7729 bc6415e 83f7729 bc6415e 83f7729 bc6415e 569f341 83f7729 569f341 83f7729 bc6415e 83f7729 bc6415e 569f341 bc6415e 83f7729 bc6415e 83f7729 569f341 83f7729 569f341 83f7729 569f341 83f7729 bc6415e 83f7729 bc6415e 83f7729 bc6415e 569f341 bc6415e 83f7729 569f341 83f7729 569f341 83f7729 bc6415e 569f341 83f7729 569f341 83f7729 bc6415e 569f341 83f7729 569f341 83f7729 bc6415e 83f7729 bc6415e 83f7729 569f341 bc6415e 569f341 83f7729 bc6415e 569f341 bc6415e 83f7729 569f341 83f7729 569f341 bc6415e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 | """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"<!doctype html", b"<html")):
parser.feed(body.decode("utf-8", "replace"))
elif "javascript" in content_type or urlsplit(url).path.lower().endswith(".js"):
add_sample(page_url, body)
except (FetchProblem, httpx.HTTPError, asyncio.TimeoutError, ValueError) as exc:
reasons.append("Page sampling: " + (str(exc) if isinstance(exc, FetchProblem) else exc.__class__.__name__))
coverage["fetch_failures"] += 1
candidates = list(dict.fromkeys(parser.urls + [
item for item in discovered if isinstance(item, str) and same_origin(item, url) and
urlsplit(item).path.lower().endswith((".js", ".mjs"))]))
already = {source for source, _ in samples}
candidates = [candidate for candidate in candidates if candidate not in already]
coverage["eligible_files"] = len(candidates) + len(parser.inline) + len(samples)
coverage.update(external_available=len(candidates), inline_available=len(parser.inline),
oversized=parser.inline_oversized)
# Keep a mix of inline and external code. Cheap inline samples should not
# be crowded out by several oversized remote bundles.
initial_inline = 0 if profile == "current" else min(len(parser.inline), limit // 2)
for body in parser.inline[:initial_inline]:
add_sample(parser.target, body)
attempted = 0
for candidate in candidates:
if len(samples) >= 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"<!doctype html", b"<html")):
raise FetchProblem("HTML returned instead of JavaScript")
if "javascript" not in content_type and not urlsplit(candidate).path.lower().endswith((".js", ".mjs")):
raise FetchProblem("Response not identified as JavaScript")
if body:
add_sample(sampled_url, body)
except (FetchProblem, httpx.HTTPError, asyncio.TimeoutError, ValueError) as exc:
detail = str(exc) if isinstance(exc, FetchProblem) else exc.__class__.__name__
if detail in ("HTTP 404", "HTTP 410") and candidate not in parser.urls:
coverage["missing_inventory_scripts"] += 1
coverage["eligible_files"] -= 1
continue
if "byte limit" in detail:
coverage["oversized"] += 1
policy_limits.add("file_bytes")
else:
coverage["fetch_failures"] += 1
reasons.append("Script sampling: " + detail)
remaining_inline = parser.inline[initial_inline:]
inline_limit = max(0, limit - len(samples))
for body in remaining_inline[:inline_limit]:
add_sample(parser.target, body)
omitted_external = max(0, len(candidates) - attempted)
omitted_inline = max(0, len(remaining_inline) - inline_limit)
if omitted_external:
reasons.append("JavaScript URL sample limited to " + str(attempted))
policy_limits.add("external_sample_count")
if omitted_inline:
reasons.append("Inline JavaScript sample limited")
policy_limits.add("inline_sample_count")
if parser.inline_oversized:
reasons.append("Oversized inline JavaScript excluded")
policy_limits.add("inline_bytes")
if "total_bytes" in policy_limits:
reasons.append("Aggregate JavaScript byte budget reached")
coverage.update(sampled_bytes=sampled_bytes, policy_limits=sorted(policy_limits), sampled_files=len(samples), omitted_external=omitted_external,
omitted_inline=omitted_inline)
if not samples:
reason = "; ".join(reasons) or "No same-origin or inline JavaScript available"
return {"status": "skipped", "source": "retirejs", "alerts": [], "target": url,
"reason": reason, "coverage": coverage, "output": "Retire.js skipped: " + reason}
with tempfile.TemporaryDirectory(prefix="asfb-retirejs-") as tmp:
root = Path(tmp)
assets = root / "assets"
assets.mkdir()
mapping = {}
for index, (source, body) in enumerate(samples):
folder = assets / str(index)
folder.mkdir()
name = re.sub(r"[^A-Za-z0-9_.-]", "_", Path(urlsplit(source).path).name)[:100]
if not name.lower().endswith(".js"):
name = "sample.js"
filename = folder / name
filename.write_bytes(body)
mapping[str(filename.resolve())] = source
report_path = str(root / "report.json")
ignore_path = root / "ignore.json"
ignore_path.write_text("[]", encoding="utf-8")
rows, status = await execute([
"/usr/local/bin/node", "/opt/retire/lib/cli.js", "--path", str(assets),
"--jsrepo", "/opt/retire-db/jsrepository-v5.json", "--cachedir", str(root / "cache"),
"--ignorefile", str(ignore_path), "--outputformat", "json",
"--outputpath", report_path, "--exitwith", "13"],
config.RETIRE_TIMEOUT_SECONDS)
report = read_report(report_path)
findings = parse_retire(report, mapping)
diagnostic = [row[:180] for row in rows if row.startswith(("[stderr]", "[diagnostic]"))]
code13 = any(row.strip() == "[diagnostic] Process exit code: 13" for row in rows)
if code13 and isinstance(report, dict) and findings and not report.get("errors"):
status = "findings"
diagnostic = [s for s in diagnostic if s != "[diagnostic] Process exit code: 13"]
if not isinstance(report, dict) or not isinstance(report.get("data"), list):
if status == "completed":
status = "failed"
reasons.append("No readable Retire.js JSON report")
elif report.get("errors"):
reasons.append("Retire.js reported scanner errors")
if status in ("completed", "findings"):
status = "partial" if findings else "failed"
execution_state = status
early_stop = (bool(policy_limits) and coverage["fetch_failures"] == 0
and status in ("completed", "findings") and isinstance(report, dict)
and not report.get("errors"))
if reasons and status in ("completed", "findings") and not early_stop:
status = "partial"
if early_stop:
status = "findings" if findings else "completed"
reasons.insert(0, "Early stop: configured JavaScript sampling limits; coverage is incomplete")
coverage["engine_status"] = execution_state
coverage["coverage_limited"] = bool(reasons)
reasons.extend(diagnostic)
if coverage["missing_inventory_scripts"]:
reasons.append(str(coverage["missing_inventory_scripts"]) + " unavailable inventory-only script URLs excluded (HTTP 404/410)")
reasons.append("Bounded JavaScript sample using the image-bundled advisory snapshot")
return {"status": status, "source": "retirejs", "target": url, "alerts": findings,
"sampled_files": len(samples), "coverage": coverage,
**({"early_stop": True, "stop_reason": "sampling_policy", "coverage_complete": False} if early_stop else {}),
"reason": "; ".join(dict.fromkeys(reasons))[:500],
"output": f"Retire.js: engine {execution_state}; {len(samples)}/{coverage['eligible_files']} eligible files sampled; {len(findings)} unverified advisory matches; coverage: {'limited' if coverage['coverage_limited'] else 'complete'}"}
def parse_wapiti(report, target):
findings = []
groups = report.get("vulnerabilities", {}) if isinstance(report, dict) else {}
if not isinstance(groups, dict):
return []
risks = {0: "Informational", 1: "Low", 2: "Medium", 3: "High", 4: "Critical"}
for category, entries in groups.items():
if not isinstance(entries, list):
continue
for item in entries:
if not isinstance(item, dict) or str(item.get("method", "GET")).upper() not in ("GET", "HEAD"):
continue
candidate = urljoin(target, str(item.get("path") or target))
if same_origin(candidate, target):
findings.append(alert("wapiti", "Wapiti: " + str(category), candidate,
item.get("info") or "Unverified Wapiti observation",
risks.get(item.get("level"), "Informational"),
item.get("module", "")))
return findings[:config.WEB_FINDINGS_LIMIT]
async def wapiti(url, host, agent, delay):
with tempfile.TemporaryDirectory(prefix="asfb-wapiti-") as tmp:
root = Path(tmp)
report_path, stats_path = str(root / "report.json"), str(root / "stats.json")
args = ["/opt/wapiti/bin/python", "/app/app/wapiti_runner.py",
"--asfb-stats", stats_path, "--asfb-delay", str(delay),
"--asfb-budget", str(config.WAPITI_REQUEST_LIMIT),
"-u", url, "--scope", "url", "-m", "csp,http_headers,cookieflags",
"--headless", "no", "--tasks", "1", "-d", "0",
"--max-links-per-page", "1", "--max-files-per-dir", "1",
"--max-scan-time", str(config.WAPITI_SCAN_SECONDS), "--max-attack-time", "5",
"--max-parameters", "5", "-t", "5", "-A", agent, "--verify-ssl", "1",
"--store-session", str(root / "session"), "--store-config", str(root / "config"),
"--no-bugreport", "-f", "json", "-o", report_path]
rows, status = await execute(args, config.WAPITI_TIMEOUT_SECONDS)
report, stats = read_report(report_path), read_report(stats_path)
findings = parse_wapiti(report, url)
reasons = [row[:180] for row in rows if row.startswith(("[stderr]", "[diagnostic]"))]
if not isinstance(report, dict) or not isinstance(report.get("vulnerabilities"), dict):
if status == "completed":
status = "failed"
reasons.append("No valid Wapiti JSON report produced")
if isinstance(stats, dict):
if stats.get("limit_reached") or stats.get("response_limited") or stats.get("blocked"):
if status == "completed":
status = "partial"
reasons.append("Origin/method/request/response guard limited coverage")
else:
if status == "completed":
status = "partial"
reasons.append("Wapiti guard telemetry unavailable")
if any("maximum scan time" in row.lower() or "max scan time" in row.lower() for row in rows):
if status == "completed":
status = "partial"
reasons.append("Wapiti scan deadline reached")
reasons.append("Limited GET/HEAD CSP, header and cookie profile; no browser or injection modules")
return {"status": status, "source": "wapiti", "target": url, "alerts": findings,
"request_count": stats.get("requests", 0) if isinstance(stats, dict) else 0,
"reason": "; ".join(reasons)[:500],
"output": f"Wapiti: {len(findings)} unverified findings; status: {status}"}
|