Wuhp commited on
Commit ·
bc6415e
1
Parent(s): 2ec3fb3
Add keyless Retire.js and bounded Wapiti modules with opt-in Nuclei checks
Browse files- .github/workflows/deploy-hf.yml +10 -1
- AGENTS.md +2 -0
- Dockerfile +15 -1
- app/client_modules.py +254 -0
- app/config.py +10 -0
- app/index.html +7 -7
- app/main.py +1 -1
- app/nuclei-templates/directory-listing.yaml +27 -0
- app/nuclei-templates/openapi-json.yaml +26 -0
- app/pipeline.py +3 -2
- app/wapiti_guard.py +90 -0
- app/wapiti_runner.py +37 -0
- app/web_modules.py +7 -2
- docs/MODULE_ROADMAP.md +16 -5
- tests/retire_offline_smoke.js +24 -0
- tests/test_client_modules.py +161 -0
- tests/test_wapiti_guard.py +59 -0
- tests/wapiti_offline_smoke.py +49 -0
.github/workflows/deploy-hf.yml
CHANGED
|
@@ -22,7 +22,7 @@ jobs:
|
|
| 22 |
ZAP_API_KEY: test-key
|
| 23 |
run: python3 -m pytest -q tests/
|
| 24 |
- name: Validate Python backend syntax
|
| 25 |
-
run: python3 -m py_compile app/main.py app/pipeline.py app/web_modules.py app/config.py app/review.py app/zap_control.py app/scope.py app/jwtcheck.py app/storage.py
|
| 26 |
- name: Validate dashboard JavaScript syntax
|
| 27 |
run: |
|
| 28 |
python3 - <<'PY'
|
|
@@ -37,6 +37,15 @@ jobs:
|
|
| 37 |
run: |
|
| 38 |
docker build --progress plain -t asfb-ci .
|
| 39 |
docker run --rm -v "$PWD/tests:/app/tests:ro" --entrypoint /bin/sh asfb-ci -ec '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
test -f /opt/nikto/program/nikto.pl
|
| 41 |
test -f /opt/testssl/testssl.sh
|
| 42 |
ruby -c /app/app/wpscan_runner.rb
|
|
|
|
| 22 |
ZAP_API_KEY: test-key
|
| 23 |
run: python3 -m pytest -q tests/
|
| 24 |
- name: Validate Python backend syntax
|
| 25 |
+
run: python3 -m py_compile app/main.py app/pipeline.py app/web_modules.py app/client_modules.py app/wapiti_guard.py app/wapiti_runner.py app/config.py app/review.py app/zap_control.py app/scope.py app/jwtcheck.py app/storage.py
|
| 26 |
- name: Validate dashboard JavaScript syntax
|
| 27 |
run: |
|
| 28 |
python3 - <<'PY'
|
|
|
|
| 37 |
run: |
|
| 38 |
docker build --progress plain -t asfb-ci .
|
| 39 |
docker run --rm -v "$PWD/tests:/app/tests:ro" --entrypoint /bin/sh asfb-ci -ec '
|
| 40 |
+
/usr/local/bin/node /opt/retire/lib/cli.js --help > /tmp/retire-help
|
| 41 |
+
grep -q -- "--path" /tmp/retire-help
|
| 42 |
+
grep -q -- "--jsrepo" /tmp/retire-help
|
| 43 |
+
grep -q -- "--exitwith" /tmp/retire-help
|
| 44 |
+
/usr/local/bin/node /app/tests/retire_offline_smoke.js
|
| 45 |
+
/opt/wapiti/bin/python /app/app/wapiti_runner.py --help > /tmp/wapiti-help
|
| 46 |
+
grep -q -- "--max-scan-time" /tmp/wapiti-help
|
| 47 |
+
grep -q -- "--no-bugreport" /tmp/wapiti-help
|
| 48 |
+
/opt/wapiti/bin/python /app/tests/wapiti_offline_smoke.py
|
| 49 |
test -f /opt/nikto/program/nikto.pl
|
| 50 |
test -f /opt/testssl/testssl.sh
|
| 51 |
ruby -c /app/app/wpscan_runner.rb
|
AGENTS.md
CHANGED
|
@@ -6,3 +6,5 @@
|
|
| 6 |
- Do not execute project code, tests, scanners, containers, or installation commands on the owner's device.
|
| 7 |
- Run validation only on GitHub-hosted Actions runners. Deployment runs on Hugging Face through the existing main-branch workflow.
|
| 8 |
- Do not initiate live scans as part of verification. Use offline tests and mocked target/provider responses.
|
|
|
|
|
|
|
|
|
| 6 |
- Do not execute project code, tests, scanners, containers, or installation commands on the owner's device.
|
| 7 |
- Run validation only on GitHub-hosted Actions runners. Deployment runs on Hugging Face through the existing main-branch workflow.
|
| 8 |
- Do not initiate live scans as part of verification. Use offline tests and mocked target/provider responses.
|
| 9 |
+
|
| 10 |
+
- Prefer modules and providers that work without API keys. Keep optional credentials optional and disclose keyless coverage limitations. Do not add required API-key setup to the full pipeline.
|
Dockerfile
CHANGED
|
@@ -16,9 +16,23 @@ RUN gem install wpscan -v 4.2.0 --no-document
|
|
| 16 |
# Metadata is fetched during the remote image build, never during a target scan.
|
| 17 |
RUN wpscan --update
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
FROM zaproxy/zap-stable
|
| 20 |
USER root
|
| 21 |
COPY --from=wp-tools /usr/local/ /usr/local/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
COPY --from=wp-tools /root/.cache/wpscan/db /opt/wpscan-db
|
| 23 |
COPY --from=pd-tools /opt/nikto /opt/nikto
|
| 24 |
COPY --from=pd-tools /opt/testssl /opt/testssl
|
|
@@ -29,7 +43,7 @@ COPY --from=pd-tools /usr/local/bin/gau /usr/local/bin/gau
|
|
| 29 |
COPY --from=pd-tools /usr/local/bin/ffuf /usr/local/bin/ffuf
|
| 30 |
COPY --from=pd-tools /usr/local/bin/dalfox /usr/local/bin/dalfox
|
| 31 |
COPY --from=pd-tools /opt/sqlmap /opt/sqlmap
|
| 32 |
-
RUN apt-get update && apt-get install -y --no-install-recommends python3-venv python3-pip perl libxml-writer-perl libxml-libxml-perl libjson-perl libnet-ssleay-perl libio-socket-ssl-perl openssl libcurl4 libyaml-0-2 libgmp10 libffi8 libgdbm6 libgdbm-compat4 libreadline8 zlib1g procps dnsutils ldnsutils coreutils bsdextrautils xxd gawk socat curl libidn2-0 && rm -rf /var/lib/apt/lists/*
|
| 33 |
RUN python3 -m venv /opt/asfb-venv && /opt/asfb-venv/bin/pip install --no-cache-dir fastapi==0.115.12 uvicorn==0.34.2 python-multipart==0.0.20 httpx==0.28.1 arjun==2.2.7 PyYAML==6.0.2
|
| 34 |
WORKDIR /app
|
| 35 |
COPY --chown=zap:zap app /app/app
|
|
|
|
| 16 |
# Metadata is fetched during the remote image build, never during a target scan.
|
| 17 |
RUN wpscan --update
|
| 18 |
|
| 19 |
+
FROM node:22-bookworm-slim AS js-tools
|
| 20 |
+
RUN npm install --global retire@6.1.0
|
| 21 |
+
RUN mkdir -p /opt/retire-db && node -e "fetch('https://raw.githubusercontent.com/RetireJS/retire.js/6.1.0/repository/jsrepository-v5.json').then(r => { if (!r.ok) throw Error(r.status); return r.text(); }).then(t => { JSON.parse(t); require('fs').writeFileSync('/opt/retire-db/jsrepository-v5.json', t); }).catch(e => { console.error(e); process.exit(1); })"
|
| 22 |
+
|
| 23 |
+
FROM python:3.12-slim-bookworm AS wapiti-tools
|
| 24 |
+
RUN python -m venv --copies /opt/wapiti && /opt/wapiti/bin/pip install --no-cache-dir wapiti3==3.3.2
|
| 25 |
+
|
| 26 |
FROM zaproxy/zap-stable
|
| 27 |
USER root
|
| 28 |
COPY --from=wp-tools /usr/local/ /usr/local/
|
| 29 |
+
COPY --from=js-tools /usr/local/bin/node /usr/local/bin/node
|
| 30 |
+
COPY --from=js-tools /usr/local/lib/node_modules/retire /opt/retire
|
| 31 |
+
COPY --from=js-tools /opt/retire-db /opt/retire-db
|
| 32 |
+
COPY --from=wapiti-tools /opt/wapiti /opt/wapiti
|
| 33 |
+
COPY --from=wapiti-tools /usr/local/bin/python3.12 /usr/local/bin/python3.12
|
| 34 |
+
COPY --from=wapiti-tools /usr/local/lib/libpython3.12.so* /usr/local/lib/
|
| 35 |
+
COPY --from=wapiti-tools /usr/local/lib/python3.12 /usr/local/lib/python3.12
|
| 36 |
COPY --from=wp-tools /root/.cache/wpscan/db /opt/wpscan-db
|
| 37 |
COPY --from=pd-tools /opt/nikto /opt/nikto
|
| 38 |
COPY --from=pd-tools /opt/testssl /opt/testssl
|
|
|
|
| 43 |
COPY --from=pd-tools /usr/local/bin/ffuf /usr/local/bin/ffuf
|
| 44 |
COPY --from=pd-tools /usr/local/bin/dalfox /usr/local/bin/dalfox
|
| 45 |
COPY --from=pd-tools /opt/sqlmap /opt/sqlmap
|
| 46 |
+
RUN apt-get update && apt-get install -y --no-install-recommends python3-venv python3-pip perl libxml-writer-perl libxml-libxml-perl libjson-perl libnet-ssleay-perl libio-socket-ssl-perl openssl libcurl4 libyaml-0-2 libgmp10 libffi8 libgdbm6 libgdbm-compat4 libreadline8 zlib1g procps dnsutils ldnsutils coreutils bsdextrautils xxd gawk socat curl libidn2-0 libstdc++6 libsqlite3-0 && rm -rf /var/lib/apt/lists/*
|
| 47 |
RUN python3 -m venv /opt/asfb-venv && /opt/asfb-venv/bin/pip install --no-cache-dir fastapi==0.115.12 uvicorn==0.34.2 python-multipart==0.0.20 httpx==0.28.1 arjun==2.2.7 PyYAML==6.0.2
|
| 48 |
WORKDIR /app
|
| 49 |
COPY --chown=zap:zap app /app/app
|
app/client_modules.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Keyless Retire.js sampling and a bounded Wapiti profile."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import json
|
| 4 |
+
import re
|
| 5 |
+
import tempfile
|
| 6 |
+
from html.parser import HTMLParser
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from urllib.parse import urljoin, urlsplit
|
| 9 |
+
|
| 10 |
+
import httpx
|
| 11 |
+
|
| 12 |
+
from . import config
|
| 13 |
+
from .pipeline import execute
|
| 14 |
+
from .scope import public_host
|
| 15 |
+
from .web_modules import same_origin, read_report, alert
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class FetchProblem(ValueError):
|
| 19 |
+
pass
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
async def fetch_sample(url, agent, maximum):
|
| 23 |
+
"""Read a bounded current response on HF; never follow redirects."""
|
| 24 |
+
import os
|
| 25 |
+
public_host(urlsplit(url).hostname, os.environ.get("ALLOW_PRIVATE_TARGETS") == "1")
|
| 26 |
+
|
| 27 |
+
async def fetch():
|
| 28 |
+
async with httpx.AsyncClient(timeout=5, follow_redirects=False, trust_env=False) as client:
|
| 29 |
+
async with client.stream("GET", url, headers={"User-Agent": agent}) as response:
|
| 30 |
+
if response.status_code != 200:
|
| 31 |
+
raise FetchProblem("HTTP " + str(response.status_code))
|
| 32 |
+
data = bytearray()
|
| 33 |
+
async for chunk in response.aiter_bytes(chunk_size=16384):
|
| 34 |
+
if len(data) + len(chunk) > maximum:
|
| 35 |
+
raise FetchProblem("response exceeds sample byte limit")
|
| 36 |
+
data.extend(chunk)
|
| 37 |
+
return bytes(data), str(response.headers.get("content-type", "")).lower()
|
| 38 |
+
return await asyncio.wait_for(fetch(), 8)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class ScriptInventory(HTMLParser):
|
| 42 |
+
def __init__(self, target):
|
| 43 |
+
super().__init__()
|
| 44 |
+
self.target, self.urls, self.inline = target, [], []
|
| 45 |
+
self.current = None
|
| 46 |
+
self.inline_bytes = 0
|
| 47 |
+
|
| 48 |
+
def handle_starttag(self, tag, attrs):
|
| 49 |
+
if tag != "script":
|
| 50 |
+
return
|
| 51 |
+
attrs = dict(attrs)
|
| 52 |
+
src = attrs.get("src")
|
| 53 |
+
if src:
|
| 54 |
+
try:
|
| 55 |
+
candidate = urljoin(self.target, src)
|
| 56 |
+
if same_origin(candidate, self.target):
|
| 57 |
+
self.urls.append(candidate)
|
| 58 |
+
except ValueError:
|
| 59 |
+
pass
|
| 60 |
+
self.current = None
|
| 61 |
+
elif str(attrs.get("type", "")).lower() in ("", "text/javascript", "application/javascript", "module"):
|
| 62 |
+
self.current = []
|
| 63 |
+
|
| 64 |
+
def handle_data(self, data):
|
| 65 |
+
if self.current is not None and self.inline_bytes < config.RETIRE_MAX_FILE_BYTES:
|
| 66 |
+
self.current.append(data)
|
| 67 |
+
self.inline_bytes += len(data.encode("utf-8"))
|
| 68 |
+
|
| 69 |
+
def handle_endtag(self, tag):
|
| 70 |
+
if tag == "script" and self.current is not None:
|
| 71 |
+
code = "".join(self.current).encode("utf-8")
|
| 72 |
+
if code.strip() and len(code) <= config.RETIRE_MAX_FILE_BYTES:
|
| 73 |
+
self.inline.append(code)
|
| 74 |
+
self.current = None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def parse_retire(report, mapping):
|
| 78 |
+
results = []
|
| 79 |
+
entries = report.get("data", []) if isinstance(report, dict) else []
|
| 80 |
+
if not isinstance(entries, list):
|
| 81 |
+
return []
|
| 82 |
+
for entry in entries:
|
| 83 |
+
if not isinstance(entry, dict) or not isinstance(entry.get("file"), str):
|
| 84 |
+
continue
|
| 85 |
+
target = mapping.get(str(Path(entry["file"]).resolve()))
|
| 86 |
+
if target is None:
|
| 87 |
+
continue
|
| 88 |
+
for component in entry.get("results", []):
|
| 89 |
+
if not isinstance(component, dict):
|
| 90 |
+
continue
|
| 91 |
+
for vuln in component.get("vulnerabilities", []):
|
| 92 |
+
if not isinstance(vuln, dict):
|
| 93 |
+
continue
|
| 94 |
+
ids = vuln.get("identifiers") or {}
|
| 95 |
+
if not isinstance(ids, dict):
|
| 96 |
+
ids = {}
|
| 97 |
+
cves = ids.get("CVE") or []
|
| 98 |
+
references = ", ".join(str(v) for v in cves[:8]) if isinstance(cves, list) else str(cves)
|
| 99 |
+
risk = str(vuln.get("severity") or "info").title()
|
| 100 |
+
if risk not in ("Low", "Medium", "High", "Critical"):
|
| 101 |
+
risk = "Informational"
|
| 102 |
+
title = str(component.get("component") or "JavaScript library") + " " + str(component.get("version") or "")
|
| 103 |
+
description = ("Unverified library/version advisory match. " + str(ids.get("summary") or vuln.get("details") or "") +
|
| 104 |
+
("; " + references if references else ""))
|
| 105 |
+
results.append(alert("retirejs", title, target, description, risk, references))
|
| 106 |
+
return results[:config.WEB_FINDINGS_LIMIT]
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
async def retirejs(url, host, agent, delay, discovered):
|
| 110 |
+
reasons, samples = [], []
|
| 111 |
+
parser = ScriptInventory(url)
|
| 112 |
+
try:
|
| 113 |
+
body, content_type = await fetch_sample(url, agent, config.WEB_PREFLIGHT_MAX_BYTES)
|
| 114 |
+
if "html" in content_type or body.lstrip().lower().startswith((b"<!doctype html", b"<html")):
|
| 115 |
+
parser.feed(body.decode("utf-8", "replace"))
|
| 116 |
+
elif "javascript" in content_type or urlsplit(url).path.lower().endswith(".js"):
|
| 117 |
+
samples.append((url, body))
|
| 118 |
+
except (FetchProblem, httpx.HTTPError, asyncio.TimeoutError, ValueError) as exc:
|
| 119 |
+
reasons.append("Page sampling: " + (str(exc) if isinstance(exc, FetchProblem) else exc.__class__.__name__))
|
| 120 |
+
candidates = list(dict.fromkeys(parser.urls + [
|
| 121 |
+
item for item in discovered if isinstance(item, str) and same_origin(item, url) and
|
| 122 |
+
urlsplit(item).path.lower().endswith((".js", ".mjs"))]))
|
| 123 |
+
limit = config.RETIRE_MAX_FILES
|
| 124 |
+
selected = candidates[:max(0, limit - len(samples))]
|
| 125 |
+
if len(selected) < len(candidates):
|
| 126 |
+
reasons.append("JavaScript URL sample limited to " + str(len(selected)))
|
| 127 |
+
for candidate in selected:
|
| 128 |
+
await asyncio.sleep(max(config.REQUEST_DELAY_MIN, float(delay)))
|
| 129 |
+
try:
|
| 130 |
+
body, content_type = await fetch_sample(candidate, agent, config.RETIRE_MAX_FILE_BYTES)
|
| 131 |
+
if "html" in content_type or body.lstrip().lower().startswith((b"<!doctype html", b"<html")):
|
| 132 |
+
raise FetchProblem("HTML returned instead of JavaScript")
|
| 133 |
+
if "javascript" not in content_type and not urlsplit(candidate).path.lower().endswith((".js", ".mjs")):
|
| 134 |
+
raise FetchProblem("Response not identified as JavaScript")
|
| 135 |
+
if body:
|
| 136 |
+
samples.append((candidate, body))
|
| 137 |
+
except (FetchProblem, httpx.HTTPError, asyncio.TimeoutError, ValueError) as exc:
|
| 138 |
+
reasons.append("Script sampling: " + (str(exc) if isinstance(exc, FetchProblem) else exc.__class__.__name__))
|
| 139 |
+
inline_limit = max(0, limit - len(samples))
|
| 140 |
+
samples.extend((url, body) for body in parser.inline[:inline_limit])
|
| 141 |
+
if len(parser.inline) > inline_limit:
|
| 142 |
+
reasons.append("Inline JavaScript sample limited")
|
| 143 |
+
if not samples:
|
| 144 |
+
reason = "; ".join(reasons) or "No same-origin or inline JavaScript available"
|
| 145 |
+
return {"status": "skipped", "source": "retirejs", "alerts": [], "target": url,
|
| 146 |
+
"reason": reason, "output": "Retire.js skipped: " + reason}
|
| 147 |
+
with tempfile.TemporaryDirectory(prefix="asfb-retirejs-") as tmp:
|
| 148 |
+
root = Path(tmp)
|
| 149 |
+
assets = root / "assets"
|
| 150 |
+
assets.mkdir()
|
| 151 |
+
mapping = {}
|
| 152 |
+
for index, (source, body) in enumerate(samples):
|
| 153 |
+
folder = assets / str(index)
|
| 154 |
+
folder.mkdir()
|
| 155 |
+
name = re.sub(r"[^A-Za-z0-9_.-]", "_", Path(urlsplit(source).path).name)[:100]
|
| 156 |
+
if not name.lower().endswith(".js"):
|
| 157 |
+
name = "sample.js"
|
| 158 |
+
filename = folder / name
|
| 159 |
+
filename.write_bytes(body)
|
| 160 |
+
mapping[str(filename.resolve())] = source
|
| 161 |
+
report_path = str(root / "report.json")
|
| 162 |
+
ignore_path = root / "ignore.json"
|
| 163 |
+
ignore_path.write_text("[]", encoding="utf-8")
|
| 164 |
+
rows, status = await execute([
|
| 165 |
+
"/usr/local/bin/node", "/opt/retire/lib/cli.js", "--path", str(assets),
|
| 166 |
+
"--jsrepo", "/opt/retire-db/jsrepository-v5.json", "--cachedir", str(root / "cache"),
|
| 167 |
+
"--ignorefile", str(ignore_path), "--outputformat", "json",
|
| 168 |
+
"--outputpath", report_path, "--exitwith", "13"],
|
| 169 |
+
config.RETIRE_TIMEOUT_SECONDS)
|
| 170 |
+
report = read_report(report_path)
|
| 171 |
+
findings = parse_retire(report, mapping)
|
| 172 |
+
diagnostic = [row[:180] for row in rows if row.startswith(("[stderr]", "[diagnostic]"))]
|
| 173 |
+
code13 = any(row.strip() == "[diagnostic] Process exit code: 13" for row in rows)
|
| 174 |
+
if code13 and isinstance(report, dict) and findings and not report.get("errors"):
|
| 175 |
+
status = "findings"
|
| 176 |
+
diagnostic = [s for s in diagnostic if s != "[diagnostic] Process exit code: 13"]
|
| 177 |
+
if not isinstance(report, dict) or not isinstance(report.get("data"), list):
|
| 178 |
+
if status == "completed":
|
| 179 |
+
status = "failed"
|
| 180 |
+
reasons.append("No readable Retire.js JSON report")
|
| 181 |
+
elif report.get("errors"):
|
| 182 |
+
reasons.append("Retire.js reported scanner errors")
|
| 183 |
+
if status in ("completed", "findings"):
|
| 184 |
+
status = "partial" if findings else "failed"
|
| 185 |
+
if reasons and status in ("completed", "findings"):
|
| 186 |
+
status = "partial"
|
| 187 |
+
reasons.extend(diagnostic)
|
| 188 |
+
reasons.append("Bounded JavaScript sample using the image-bundled advisory snapshot")
|
| 189 |
+
return {"status": status, "source": "retirejs", "target": url, "alerts": findings,
|
| 190 |
+
"sampled_files": len(samples), "reason": "; ".join(reasons)[:500],
|
| 191 |
+
"output": f"Retire.js: {len(samples)} sampled files; {len(findings)} unverified advisory matches; status: {status}"}
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def parse_wapiti(report, target):
|
| 195 |
+
findings = []
|
| 196 |
+
groups = report.get("vulnerabilities", {}) if isinstance(report, dict) else {}
|
| 197 |
+
if not isinstance(groups, dict):
|
| 198 |
+
return []
|
| 199 |
+
risks = {0: "Informational", 1: "Low", 2: "Medium", 3: "High", 4: "Critical"}
|
| 200 |
+
for category, entries in groups.items():
|
| 201 |
+
if not isinstance(entries, list):
|
| 202 |
+
continue
|
| 203 |
+
for item in entries:
|
| 204 |
+
if not isinstance(item, dict) or str(item.get("method", "GET")).upper() not in ("GET", "HEAD"):
|
| 205 |
+
continue
|
| 206 |
+
candidate = urljoin(target, str(item.get("path") or target))
|
| 207 |
+
if same_origin(candidate, target):
|
| 208 |
+
findings.append(alert("wapiti", "Wapiti: " + str(category), candidate,
|
| 209 |
+
item.get("info") or "Unverified Wapiti observation",
|
| 210 |
+
risks.get(item.get("level"), "Informational"),
|
| 211 |
+
item.get("module", "")))
|
| 212 |
+
return findings[:config.WEB_FINDINGS_LIMIT]
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
async def wapiti(url, host, agent, delay):
|
| 216 |
+
with tempfile.TemporaryDirectory(prefix="asfb-wapiti-") as tmp:
|
| 217 |
+
root = Path(tmp)
|
| 218 |
+
report_path, stats_path = str(root / "report.json"), str(root / "stats.json")
|
| 219 |
+
args = ["/opt/wapiti/bin/python", "/app/app/wapiti_runner.py",
|
| 220 |
+
"--asfb-stats", stats_path, "--asfb-delay", str(delay),
|
| 221 |
+
"--asfb-budget", str(config.WAPITI_REQUEST_LIMIT),
|
| 222 |
+
"-u", url, "--scope", "url", "-m", "csp,http_headers,cookieflags",
|
| 223 |
+
"--headless", "no", "--tasks", "1", "-d", "0",
|
| 224 |
+
"--max-links-per-page", "1", "--max-files-per-dir", "1",
|
| 225 |
+
"--max-scan-time", str(config.WAPITI_SCAN_SECONDS), "--max-attack-time", "5",
|
| 226 |
+
"--max-parameters", "5", "-t", "5", "-A", agent, "--verify-ssl", "1",
|
| 227 |
+
"--store-session", str(root / "session"), "--store-config", str(root / "config"),
|
| 228 |
+
"--no-bugreport", "-f", "json", "-o", report_path]
|
| 229 |
+
rows, status = await execute(args, config.WAPITI_TIMEOUT_SECONDS)
|
| 230 |
+
report, stats = read_report(report_path), read_report(stats_path)
|
| 231 |
+
findings = parse_wapiti(report, url)
|
| 232 |
+
reasons = [row[:180] for row in rows if row.startswith(("[stderr]", "[diagnostic]"))]
|
| 233 |
+
if not isinstance(report, dict) or not isinstance(report.get("vulnerabilities"), dict):
|
| 234 |
+
if status == "completed":
|
| 235 |
+
status = "failed"
|
| 236 |
+
reasons.append("No valid Wapiti JSON report produced")
|
| 237 |
+
if isinstance(stats, dict):
|
| 238 |
+
if stats.get("limit_reached") or stats.get("response_limited") or stats.get("blocked"):
|
| 239 |
+
if status == "completed":
|
| 240 |
+
status = "partial"
|
| 241 |
+
reasons.append("Origin/method/request/response guard limited coverage")
|
| 242 |
+
else:
|
| 243 |
+
if status == "completed":
|
| 244 |
+
status = "partial"
|
| 245 |
+
reasons.append("Wapiti guard telemetry unavailable")
|
| 246 |
+
if any("maximum scan time" in row.lower() or "max scan time" in row.lower() for row in rows):
|
| 247 |
+
if status == "completed":
|
| 248 |
+
status = "partial"
|
| 249 |
+
reasons.append("Wapiti scan deadline reached")
|
| 250 |
+
reasons.append("Limited GET/HEAD CSP, header and cookie profile; no browser or injection modules")
|
| 251 |
+
return {"status": status, "source": "wapiti", "target": url, "alerts": findings,
|
| 252 |
+
"request_count": stats.get("requests", 0) if isinstance(stats, dict) else 0,
|
| 253 |
+
"reason": "; ".join(reasons)[:500],
|
| 254 |
+
"output": f"Wapiti: {len(findings)} unverified findings; status: {status}"}
|
app/config.py
CHANGED
|
@@ -90,6 +90,8 @@ NUCLEI_CATALOG_PAGE_SIZE = 30
|
|
| 90 |
|
| 91 |
# Optional curated profiles (not enabled unless explicitly selected).
|
| 92 |
NUCLEI_OPTIONAL_TEMPLATE_PATHS = {
|
|
|
|
|
|
|
| 93 |
"bounty-ultimate-lite": "/app/app/nuclei-templates/bounty-ultimate-lite.yaml",
|
| 94 |
"bounty-ultimate-medium": "/app/app/nuclei-templates/bounty-ultimate-medium.yaml",
|
| 95 |
"bounty-ultimate-large": "/app/app/nuclei-templates/bounty-ultimate-large.yaml",
|
|
@@ -128,3 +130,11 @@ TESTSSL_TIMEOUT_SECONDS = 90
|
|
| 128 |
WEB_PREFLIGHT_MAX_BYTES = 524288
|
| 129 |
WEB_REPORT_MAX_BYTES = 2000000
|
| 130 |
WEB_FINDINGS_LIMIT = 100
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
# Optional curated profiles (not enabled unless explicitly selected).
|
| 92 |
NUCLEI_OPTIONAL_TEMPLATE_PATHS = {
|
| 93 |
+
"directory-listing": "/app/app/nuclei-templates/directory-listing.yaml",
|
| 94 |
+
"openapi-json": "/app/app/nuclei-templates/openapi-json.yaml",
|
| 95 |
"bounty-ultimate-lite": "/app/app/nuclei-templates/bounty-ultimate-lite.yaml",
|
| 96 |
"bounty-ultimate-medium": "/app/app/nuclei-templates/bounty-ultimate-medium.yaml",
|
| 97 |
"bounty-ultimate-large": "/app/app/nuclei-templates/bounty-ultimate-large.yaml",
|
|
|
|
| 130 |
WEB_PREFLIGHT_MAX_BYTES = 524288
|
| 131 |
WEB_REPORT_MAX_BYTES = 2000000
|
| 132 |
WEB_FINDINGS_LIMIT = 100
|
| 133 |
+
|
| 134 |
+
# Keyless browser-free modules; downloaded target samples exist only in HF temp directories.
|
| 135 |
+
RETIRE_MAX_FILES = 4
|
| 136 |
+
RETIRE_MAX_FILE_BYTES = 524288
|
| 137 |
+
RETIRE_TIMEOUT_SECONDS = 20
|
| 138 |
+
WAPITI_REQUEST_LIMIT = 12
|
| 139 |
+
WAPITI_SCAN_SECONDS = 25
|
| 140 |
+
WAPITI_TIMEOUT_SECONDS = 40
|
app/index.html
CHANGED
|
@@ -13,11 +13,11 @@ button.danger{background:#fff0f2;color:#a53551;border:1px solid #e8b8c3}button.d
|
|
| 13 |
<header><div><h1>ASFB <span class="tag">v0.1</span></h1><div class="muted">Bug bounty scope manager · OWASP ZAP passive analysis</div></div><div class="topbar"><button onclick="refresh()">Connect</button></div></header>
|
| 14 |
<div class="notice">Importing scope is not approval to scan. Check each program's automation rules and test only accounts/assets you are authorized to use. Scanning uses a sequential passive queue and requires confirmation of program policy.</div>
|
| 15 |
<nav class="tabs" aria-label="Workspace tabs"><button class="tab active" id="tab-scope" onclick="switchTab('scope')">Scope & scanning</button><button class="tab" id="tab-findings" onclick="switchTab('findings')">Findings <span id="findBadge">0</span></button><button class="tab" id="tab-jobs" onclick="switchTab('jobs')">Scan jobs</button><button class="tab" id="tab-settings" onclick="switchTab('settings')">Settings</button><button class="tab" id="tab-logs" onclick="switchTab('logs')">Logs</button></nav><div class="pane" id="pane-scope"><div class="grid"><section class="card"><h2>1. Import HackerOne scope</h2><div class="controls"><input id="file" type="file" accept=".csv,text/csv"/><button onclick="upload()">Import CSV</button><button id="clearScope" class="danger" disabled onclick="clearScope()">Clear imported scope</button></div><p class="muted">Headers supported: Asset name, Type, Coverage, Max. severity, Bounty, Last update, Resolved Reports, plus common API export variants.</p></section>
|
| 16 |
-
<section class="card"><h2>2. Scan settings</h2><div class="controls" style="margin-bottom:12px"><label><input type="checkbox" id="modZap" checked/> ZAP passive</label><label><input type="checkbox" id="modGau"/> GAU archives</label><label><input type="checkbox" id="modKatana"/> Katana crawler</label><label><input type="checkbox" id="modHttpx"/> HTTPX probe</label><label><input type="checkbox" id="modNuclei"/> Nuclei tech checks</label></div><div class="field"><label>Pipeline order (comma-separated, edit to rearrange)</label><input id="stageOrder" value="gau, katana, httpx, zap-passive, nuclei, nikto, wpscan, testssl, ffuf, arjun, dalfox, sqlmap"/></div><p class="muted">Katana and Nuclei generate additional requests, independently of the ZAP request setting. Enable only when program policy permits these modules.</p><div class="controls"><div class="field"><label>Custom User-Agent</label><input id="agent" value="ASFB-Research/0.1"/></div><div class="field"><label>Requests (max 10)</label><input id="requests" type="number" min="1" max="10" value="1"/></div></div><div class="controls" style="margin-top:10px"><div class="field"><label>Identity</label><select id="account"><option value="">Anonymous</option></select></div><div class="field"><label>Delay, seconds</label><input id="delay" type="number" value="1.5" min="1" max="30" step=".5"/></div></div><div style="margin-top:15px"><label><input type="checkbox" id="policy"/> I have checked this program's rules and confirm this passive automated testing is permitted.</label></div><p id="selected" class="muted">Select targets in the scope filters below.</p><button id="start" disabled onclick="scan()">Queue selected scans</button><p id="scanHint" class="muted">Select eligible web assets and confirm the program policy.</p></section></div>
|
| 17 |
<section class="card"><h2>Scope assets</h2><p class="muted">Select any imported asset, including manual-review scope entries. Only explicit web-compatible URLs can run HTTP scanner modules; non-web selections stay selected. Up to 1,000 web jobs queued sequentially.</p><div class="controls"><div class="field"><label>Search</label><input id="search" placeholder="Hostname / description" oninput="render()"/></div><div class="field"><label>Minimum max. severity</label><select id="severity" onchange="render()"><option value="all">All</option><option value="medium">Medium+</option><option value="high">High+</option><option value="critical">Critical only</option></select></div><div class="field"><label>Maximum resolved reports</label><input id="reports" type="number" min="0" placeholder="Any" oninput="render()"/></div></div><div class="controls" style="margin-top:13px"><label><input id="bounty" type="checkbox" onchange="render()"/> Reward eligible only</label><label><input id="compatible" type="checkbox" onchange="render()"/> Web-compatible only</label></div><div class="controls" style="margin-top:14px"><button class="secondary" onclick="selectFiltered()">Choose all matching filters</button><button class="secondary" onclick="selectFirst40()">Choose first 1000 matching</button><button class="secondary" onclick="clearSelection()">Clear selection</button><button class="secondary" onclick="copySelectedScope()">Copy selected scope</button><span id="selectionCount" class="tag">0 selected</span></div><div id="selectedTargets" class="controls" style="margin-top:12px"></div><div class="stats" style="margin:15px 0"><div class="stat"><b id="total">0</b><small>Imported assets</small></div><div class="stat"><b id="filtered">0</b><small>Filtered assets</small></div></div><div class="scroll"><table><thead><tr><th>Choose</th><th>Asset / description</th><th>Type</th><th>Max. severity</th><th>Bounty</th><th>Resolved</th></tr></thead><tbody id="rows"></tbody></table></div></section>
|
| 18 |
</div></div><div class="pane" id="pane-settings" hidden><section class="card"><h2>Offline JWT review</h2><div class="field" style="margin-top:14px"><label>Offline JWT metadata inspection (never saved)</label><textarea id="jwt" rows="2" placeholder="Paste a test JWT here"></textarea><button class="secondary" onclick="inspectJwt()">Inspect JWT locally on backend</button><pre id="jwtResults" class="results"></pre></div></section>
|
| 19 |
<div class="grid"><section class="card"><h2>Account profiles (session only)</h2><div class="controls"><div class="field"><label>Account label</label><input id="aname" placeholder="Test account A"/></div><div class="field"><label>Exact host</label><input id="ahost" placeholder="api.example.org"/></div></div><div class="field" style="margin-top:10px"><label>Cookie header (not persisted)</label><input id="acookie" type="password" autocomplete="off" placeholder="session=..."/></div><button style="margin-top:12px" onclick="addAccount()">Add account</button><p class="muted">Do not use other users' credentials. Cookies stay in backend process memory, not disk.</p></section>
|
| 20 |
-
</div><section class="card"><h2>General settings</h2><p class="muted">Limits apply per scan job, not globally across all browser sessions.</p><div class="toolbar"><div class="field"><label>Pipeline order</label><input id="settingsOrder" value="gau, katana, httpx, zap-passive, nuclei, nikto, wpscan, testssl, ffuf, arjun, dalfox, sqlmap"/></div><div class="field"><label>Discovery endpoint budget</label><input id="settingsLimit" type="number" min="1" max="2000" value="100"/></div><div class="field"><label>ZAP total request budget</label><input id="settingsBudget" type="number" min="1" max="2000" value="100"/></div></div><p class="muted">Settings are scoped to this browser tab. Modules execute sequentially in the specified order.</p><button class="secondary" onclick="applySettings()">Apply pipeline settings</button></section><section class="card" id="nucleiSettings"><h2>Nuclei · Template selection</h2>
|
| 21 |
<p class="muted">Combine any bundled profiles, compatible community templates, and custom YAML. This selection applies to new scans only. Community and custom templates must pass the server's GET/HEAD-only validation; dynamic DSL, POST requests, remote interactions, and unsafe paths are blocked.</p>
|
| 22 |
<div class="grid">
|
| 23 |
<div><h3>Bundled templates and presets</h3><div id="nucleiBuiltins" class="template-choices">
|
|
@@ -39,7 +39,7 @@ button.danger{background:#fff0f2;color:#a53551;border:1px solid #e8b8c3}button.d
|
|
| 39 |
<div class="controls"><input type="file" id="nucleiFile" accept=".yaml,.yml,text/yaml" multiple onchange="importNucleiFiles(this.files)" /><button class="secondary" onclick="el('nucleiCustom').value=''">Clear custom YAML</button></div>
|
| 40 |
<textarea id="nucleiCustom" rows="7" spellcheck="false" placeholder="id: your-safe-template info: name: ... severity: info http: - method: GET path: ["{{BaseURL}}/robots.txt"] matchers: ..."></textarea>
|
| 41 |
<div class="controls"><button class="secondary" onclick="validateNucleiSelection()">Validate selection & budget</button><span id="nucleiReady" class="subtle">Selections are included in new scan requests.</span></div></section>
|
| 42 |
-
<section class="card"><h2>GAU · Archive discovery</h2><div class="toolbar"><div class="field"><label for="settingsGauMode">Archive mode</label><select id="settingsGauMode"><option value="balanced" selected>Balanced (OTX + URLScan)</option><option value="expanded">Expanded (also try Common Crawl)</option></select></div></div><p class="muted">Balanced avoids repeated slow archive lookups for hosts with no historical results. Expanded includes Common Crawl, with independent timeouts and cooldowns. A zero-result archive query does not prove that the host has no endpoints.</p></section><section class="card"><h2>Advanced settings · Individual modules</h2><div class="toolbar"><div class="field"><label>HTTPX: URLs per job</label><input id="settingsHttpx" type="number" value="10" min="1" max="100"/></div><div class="field"><label>Katana: maximum depth</label><input id="settingsKatana" type="number" value="1" min="1" max="5"/></div><div class="field"><label>ZAP: max discovered URLs</label><input id="settingsZap" type="number" value="5" min="1" max="100"/></div></div><div class="grid" style="margin-top:14px"><div><h2>FFUF · Content discovery</h2><p class="muted">Bounded 10-path dictionary, one request per second, single-target consent required.</p></div><div><h2>Arjun · Parameter discovery</h2><p class="muted">Bounded per-target parameter discovery; bounded by a process timeout.</p></div><div><h2>Dalfox · XSS review</h2><p class="muted">Single-target XSS review; output requires manual verification.</p></div><div><h2>SQLmap · SQL injection review</h2><p class="muted">Single parameterized target, low-risk boolean tests; no database extraction.</p></div></div><div class="controls" style="margin-top:13px"><label><input id="modFfuf" type="checkbox"/> FFUF</label><label><input id="modArjun" type="checkbox"/> Arjun</label><label><input id="modDalfox" type="checkbox"/> Dalfox</label><label><input id="modSqlmap" type="checkbox"/> SQLmap</label><label><input id="modNikto" type="checkbox"/> Nikto</label><label><input id="modWpscan" type="checkbox"/> WPScan</label><label><input id="modTestssl" type="checkbox"/> testssl.sh</label><button class="secondary" onclick="enableFullPipeline()">Enable all modules</button></div><p class="muted">WPScan runs only after a current-page WordPress check. testssl.sh runs only for HTTPS and uses a limited server-defaults profile. Skipped stages include a reason in job logs. Nikto uses bounded information and configuration checks. WPScan
|
| 43 |
<p id="message" class="muted" role="status" aria-live="polite"></p></main>
|
| 44 |
<script>
|
| 45 |
let assets=[],selectedIds=new Set(),visibleWebIds=[],scanJobs=[],findings=[],currentTab="scope";
|
|
@@ -115,7 +115,7 @@ async function scan(){
|
|
| 115 |
el('nucleiReady').textContent='Validated '+check.templates.length+' templates, estimated '+check.per_target_requests+' requests per target; up to '+check.max_targets_within_budget+' targets per Nuclei job.';
|
| 116 |
}catch(e){return message('Nuclei preflight failed before queuing: '+e.message,true);}
|
| 117 |
}
|
| 118 |
-
const active=modules.some(m=>['ffuf','arjun','dalfox','sqlmap','nikto','wpscan','testssl'].includes(m));if(active&&ids.length>100)return message('Active review allows up to 100 approved targets per batch.',true);if(active&&!el('activeConsent').checked)return message('Confirm active review authorization under Settings.',true);
|
| 119 |
if(!el('policy').checked)return message('Confirm the program policy first.',true);
|
| 120 |
try{
|
| 121 |
let r;
|
|
@@ -233,7 +233,7 @@ function applySettings(){
|
|
| 233 |
}catch(e){message(e.message,true)}
|
| 234 |
}
|
| 235 |
function enabledModulesFrom(raw){
|
| 236 |
-
const names={"gau":"modGau","katana":"modKatana","httpx":"modHttpx","zap-passive":"modZap","nuclei":"modNuclei","ffuf":"modFfuf","arjun":"modArjun","dalfox":"modDalfox","sqlmap":"modSqlmap","nikto":"modNikto","wpscan":"modWpscan","testssl":"modTestssl"};
|
| 237 |
const order=raw.split(",").map(s=>s.trim().toLowerCase()).filter(Boolean);
|
| 238 |
if(order.length!==new Set(order).size||order.some(n=>!names[n]))throw Error("Invalid or duplicate pipeline stage.");
|
| 239 |
const selected=Object.keys(names).filter(n=>el(names[n])?.checked);
|
|
@@ -241,8 +241,8 @@ function enabledModulesFrom(raw){
|
|
| 241 |
return order.filter(n=>el(names[n]).checked);
|
| 242 |
}
|
| 243 |
function enableFullPipeline(){
|
| 244 |
-
for(const id of ["modZap","modGau","modKatana","modHttpx","modNuclei","modFfuf","modArjun","modDalfox","modSqlmap","modNikto","modWpscan","modTestssl"])el(id).checked=true;
|
| 245 |
-
const order="gau, katana, httpx, zap-passive, nuclei, nikto, wpscan, testssl, ffuf, arjun, dalfox, sqlmap";
|
| 246 |
el("stageOrder").value=order;el("settingsOrder").value=order;
|
| 247 |
message("All modules enabled. Runtime applicability checks skip unsuitable stages. Confirm active-testing authorization before queuing.");
|
| 248 |
}
|
|
|
|
| 13 |
<header><div><h1>ASFB <span class="tag">v0.1</span></h1><div class="muted">Bug bounty scope manager · OWASP ZAP passive analysis</div></div><div class="topbar"><button onclick="refresh()">Connect</button></div></header>
|
| 14 |
<div class="notice">Importing scope is not approval to scan. Check each program's automation rules and test only accounts/assets you are authorized to use. Scanning uses a sequential passive queue and requires confirmation of program policy.</div>
|
| 15 |
<nav class="tabs" aria-label="Workspace tabs"><button class="tab active" id="tab-scope" onclick="switchTab('scope')">Scope & scanning</button><button class="tab" id="tab-findings" onclick="switchTab('findings')">Findings <span id="findBadge">0</span></button><button class="tab" id="tab-jobs" onclick="switchTab('jobs')">Scan jobs</button><button class="tab" id="tab-settings" onclick="switchTab('settings')">Settings</button><button class="tab" id="tab-logs" onclick="switchTab('logs')">Logs</button></nav><div class="pane" id="pane-scope"><div class="grid"><section class="card"><h2>1. Import HackerOne scope</h2><div class="controls"><input id="file" type="file" accept=".csv,text/csv"/><button onclick="upload()">Import CSV</button><button id="clearScope" class="danger" disabled onclick="clearScope()">Clear imported scope</button></div><p class="muted">Headers supported: Asset name, Type, Coverage, Max. severity, Bounty, Last update, Resolved Reports, plus common API export variants.</p></section>
|
| 16 |
+
<section class="card"><h2>2. Scan settings</h2><div class="controls" style="margin-bottom:12px"><label><input type="checkbox" id="modZap" checked/> ZAP passive</label><label><input type="checkbox" id="modGau"/> GAU archives</label><label><input type="checkbox" id="modKatana"/> Katana crawler</label><label><input type="checkbox" id="modHttpx"/> HTTPX probe</label><label><input type="checkbox" id="modNuclei"/> Nuclei tech checks</label></div><div class="field"><label>Pipeline order (comma-separated, edit to rearrange)</label><input id="stageOrder" value="gau, katana, httpx, zap-passive, nuclei, nikto, wpscan, testssl, wapiti, retirejs, ffuf, arjun, dalfox, sqlmap"/></div><p class="muted">Katana and Nuclei generate additional requests, independently of the ZAP request setting. Enable only when program policy permits these modules.</p><div class="controls"><div class="field"><label>Custom User-Agent</label><input id="agent" value="ASFB-Research/0.1"/></div><div class="field"><label>Requests (max 10)</label><input id="requests" type="number" min="1" max="10" value="1"/></div></div><div class="controls" style="margin-top:10px"><div class="field"><label>Identity</label><select id="account"><option value="">Anonymous</option></select></div><div class="field"><label>Delay, seconds</label><input id="delay" type="number" value="1.5" min="1" max="30" step=".5"/></div></div><div style="margin-top:15px"><label><input type="checkbox" id="policy"/> I have checked this program's rules and confirm this passive automated testing is permitted.</label></div><p id="selected" class="muted">Select targets in the scope filters below.</p><button id="start" disabled onclick="scan()">Queue selected scans</button><p id="scanHint" class="muted">Select eligible web assets and confirm the program policy.</p></section></div>
|
| 17 |
<section class="card"><h2>Scope assets</h2><p class="muted">Select any imported asset, including manual-review scope entries. Only explicit web-compatible URLs can run HTTP scanner modules; non-web selections stay selected. Up to 1,000 web jobs queued sequentially.</p><div class="controls"><div class="field"><label>Search</label><input id="search" placeholder="Hostname / description" oninput="render()"/></div><div class="field"><label>Minimum max. severity</label><select id="severity" onchange="render()"><option value="all">All</option><option value="medium">Medium+</option><option value="high">High+</option><option value="critical">Critical only</option></select></div><div class="field"><label>Maximum resolved reports</label><input id="reports" type="number" min="0" placeholder="Any" oninput="render()"/></div></div><div class="controls" style="margin-top:13px"><label><input id="bounty" type="checkbox" onchange="render()"/> Reward eligible only</label><label><input id="compatible" type="checkbox" onchange="render()"/> Web-compatible only</label></div><div class="controls" style="margin-top:14px"><button class="secondary" onclick="selectFiltered()">Choose all matching filters</button><button class="secondary" onclick="selectFirst40()">Choose first 1000 matching</button><button class="secondary" onclick="clearSelection()">Clear selection</button><button class="secondary" onclick="copySelectedScope()">Copy selected scope</button><span id="selectionCount" class="tag">0 selected</span></div><div id="selectedTargets" class="controls" style="margin-top:12px"></div><div class="stats" style="margin:15px 0"><div class="stat"><b id="total">0</b><small>Imported assets</small></div><div class="stat"><b id="filtered">0</b><small>Filtered assets</small></div></div><div class="scroll"><table><thead><tr><th>Choose</th><th>Asset / description</th><th>Type</th><th>Max. severity</th><th>Bounty</th><th>Resolved</th></tr></thead><tbody id="rows"></tbody></table></div></section>
|
| 18 |
</div></div><div class="pane" id="pane-settings" hidden><section class="card"><h2>Offline JWT review</h2><div class="field" style="margin-top:14px"><label>Offline JWT metadata inspection (never saved)</label><textarea id="jwt" rows="2" placeholder="Paste a test JWT here"></textarea><button class="secondary" onclick="inspectJwt()">Inspect JWT locally on backend</button><pre id="jwtResults" class="results"></pre></div></section>
|
| 19 |
<div class="grid"><section class="card"><h2>Account profiles (session only)</h2><div class="controls"><div class="field"><label>Account label</label><input id="aname" placeholder="Test account A"/></div><div class="field"><label>Exact host</label><input id="ahost" placeholder="api.example.org"/></div></div><div class="field" style="margin-top:10px"><label>Cookie header (not persisted)</label><input id="acookie" type="password" autocomplete="off" placeholder="session=..."/></div><button style="margin-top:12px" onclick="addAccount()">Add account</button><p class="muted">Do not use other users' credentials. Cookies stay in backend process memory, not disk.</p></section>
|
| 20 |
+
</div><section class="card"><h2>General settings</h2><p class="muted">Limits apply per scan job, not globally across all browser sessions.</p><div class="toolbar"><div class="field"><label>Pipeline order</label><input id="settingsOrder" value="gau, katana, httpx, zap-passive, nuclei, nikto, wpscan, testssl, wapiti, retirejs, ffuf, arjun, dalfox, sqlmap"/></div><div class="field"><label>Discovery endpoint budget</label><input id="settingsLimit" type="number" min="1" max="2000" value="100"/></div><div class="field"><label>ZAP total request budget</label><input id="settingsBudget" type="number" min="1" max="2000" value="100"/></div></div><p class="muted">Settings are scoped to this browser tab. Modules execute sequentially in the specified order.</p><button class="secondary" onclick="applySettings()">Apply pipeline settings</button></section><section class="card" id="nucleiSettings"><h2>Nuclei · Template selection</h2>
|
| 21 |
<p class="muted">Combine any bundled profiles, compatible community templates, and custom YAML. This selection applies to new scans only. Community and custom templates must pass the server's GET/HEAD-only validation; dynamic DSL, POST requests, remote interactions, and unsafe paths are blocked.</p>
|
| 22 |
<div class="grid">
|
| 23 |
<div><h3>Bundled templates and presets</h3><div id="nucleiBuiltins" class="template-choices">
|
|
|
|
| 39 |
<div class="controls"><input type="file" id="nucleiFile" accept=".yaml,.yml,text/yaml" multiple onchange="importNucleiFiles(this.files)" /><button class="secondary" onclick="el('nucleiCustom').value=''">Clear custom YAML</button></div>
|
| 40 |
<textarea id="nucleiCustom" rows="7" spellcheck="false" placeholder="id: your-safe-template info: name: ... severity: info http: - method: GET path: ["{{BaseURL}}/robots.txt"] matchers: ..."></textarea>
|
| 41 |
<div class="controls"><button class="secondary" onclick="validateNucleiSelection()">Validate selection & budget</button><span id="nucleiReady" class="subtle">Selections are included in new scan requests.</span></div></section>
|
| 42 |
+
<section class="card"><h2>GAU · Archive discovery</h2><div class="toolbar"><div class="field"><label for="settingsGauMode">Archive mode</label><select id="settingsGauMode"><option value="balanced" selected>Balanced (OTX + URLScan)</option><option value="expanded">Expanded (also try Common Crawl)</option></select></div></div><p class="muted">Balanced avoids repeated slow archive lookups for hosts with no historical results. Expanded includes Common Crawl, with independent timeouts and cooldowns. A zero-result archive query does not prove that the host has no endpoints.</p></section><section class="card"><h2>Advanced settings · Individual modules</h2><div class="toolbar"><div class="field"><label>HTTPX: URLs per job</label><input id="settingsHttpx" type="number" value="10" min="1" max="100"/></div><div class="field"><label>Katana: maximum depth</label><input id="settingsKatana" type="number" value="1" min="1" max="5"/></div><div class="field"><label>ZAP: max discovered URLs</label><input id="settingsZap" type="number" value="5" min="1" max="100"/></div></div><div class="grid" style="margin-top:14px"><div><h2>FFUF · Content discovery</h2><p class="muted">Bounded 10-path dictionary, one request per second, single-target consent required.</p></div><div><h2>Arjun · Parameter discovery</h2><p class="muted">Bounded per-target parameter discovery; bounded by a process timeout.</p></div><div><h2>Dalfox · XSS review</h2><p class="muted">Single-target XSS review; output requires manual verification.</p></div><div><h2>SQLmap · SQL injection review</h2><p class="muted">Single parameterized target, low-risk boolean tests; no database extraction.</p></div></div><div class="controls" style="margin-top:13px"><label><input id="modFfuf" type="checkbox"/> FFUF</label><label><input id="modArjun" type="checkbox"/> Arjun</label><label><input id="modDalfox" type="checkbox"/> Dalfox</label><label><input id="modSqlmap" type="checkbox"/> SQLmap</label><label><input id="modNikto" type="checkbox"/> Nikto</label><label><input id="modWpscan" type="checkbox"/> WPScan</label><label><input id="modTestssl" type="checkbox"/> testssl.sh</label><label><input id="modWapiti" type="checkbox"/> Wapiti</label><label><input id="modRetirejs" type="checkbox"/> Retire.js</label><button class="secondary" onclick="enableFullPipeline()">Enable all modules</button></div><p class="muted">WPScan runs only after a current-page WordPress check. testssl.sh runs only for HTTPS and uses a limited server-defaults profile. Skipped stages include a reason in job logs. Nikto uses bounded information and configuration checks. All modules work without API keys. WPScan provides inventory without a key; vulnerability database lookups remain optional. Wapiti checks CSP, HTTP headers and cookie flags with GET/HEAD requests. Retire.js samples up to four same-origin or inline scripts and skips pages without usable JavaScript.</p><p><label><input id="activeConsent" type="checkbox"/> I have verified this program authorizes active tests against this selected asset.</label></p><p class="notice">These tools may run in an explicitly authorized batch of up to 100 selected targets. They require separate active-testing confirmation. A public Space still lacks strong access control; only test targets and techniques you are authorized to assess.</p></section></div><div class="pane" id="pane-logs" hidden><section class="card"><div class="controls"><h2>Full scan logs</h2><button class="secondary" onclick="copyLogs()">Copy logs</button><button class="secondary" onclick="loadLogs(true)">Refresh</button></div><pre id="fullLogs" class="results">No logs yet.</pre></section></div><div class="pane" id="pane-findings" hidden><section class="card"><div class="controls" style="justify-content:space-between"><h2>Findings explorer</h2><div class="controls"><button class="secondary" onclick="loadScans()">Refresh detections</button><button class="secondary" onclick="clearFindings()">Clear detections</button><button class="secondary" onclick="fullReport()">Full report JSON</button><button class="secondary" onclick="exportFindings()">Export filtered JSON</button><button class="secondary" onclick="exportCSV()">Export filtered CSV</button></div></div><p class="muted">Heuristic scanner alerts, not confirmed vulnerabilities. Repeated detections on the same URL and rule are grouped.</p><div class="summary" id="findSummary"></div><div id="fileExposurePanel" class="exposure-panel" hidden><div class="controls" style="justify-content:space-between"><strong>Potential file exposures <span id="fileExposureCount"></span></strong><span class="subtle">Tool-reported candidates, not confirmed vulnerabilities</span></div><div id="fileExposureList"></div></div><div class="toolbar" style="margin-top:16px"><div class="field"><label>Search</label><input id="findSearch" placeholder="Rule, URL, details..." oninput="renderFindings()"/></div><div class="field"><label>Severity</label><select id="findSeverity" onchange="renderFindings()"><option value="all">All</option><option>Critical</option><option>High</option><option>Medium</option><option>Low</option><option>Informational</option></select></div><div class="field"><label>Confidence</label><select id="findConfidence" onchange="renderFindings()"><option value="all">All</option><option>High</option><option>Medium</option><option>Low</option></select></div><div class="field"><label>Source</label><select id="findSource" onchange="renderFindings()"><option value="all">All tools</option><option value="zap">ZAP</option><option value="nuclei">Nuclei</option><option value="dalfox">Dalfox</option><option value="nikto">Nikto</option><option value="wpscan">WPScan</option><option value="testssl">testssl.sh</option><option value="wapiti">Wapiti</option><option value="retirejs">Retire.js</option></select></div><div class="field"><label>Host</label><select id="findHost" onchange="renderFindings()"><option value="all">All hosts</option></select></div><div class="field"><label>Sort</label><select id="findSort" onchange="renderFindings()"><option value="severity">Severity first</option><option value="confidence">Confidence first</option><option value="host">Host A-Z</option><option value="count">Most repeated</option></select></div></div><div id="findCount" class="subtle" style="margin-top:13px"></div><div id="findList"></div></section></div><div class="pane" id="pane-jobs" hidden><section class="card"><div class="controls" style="justify-content:space-between"><h2>Scan jobs</h2><div class="controls"><button class="secondary" onclick="loadScans(true)">Refresh jobs</button><button class="secondary" id="pauseUpdates" onclick="toggleAutoRefresh()">Pause live updates</button><button id="cancelAllJobs" class="danger" disabled onclick="cancelAllJobs()">Cancel all jobs</button></div></div><div class="summary" id="jobSummary"></div><div id="scans"></div></section></div>
|
| 43 |
<p id="message" class="muted" role="status" aria-live="polite"></p></main>
|
| 44 |
<script>
|
| 45 |
let assets=[],selectedIds=new Set(),visibleWebIds=[],scanJobs=[],findings=[],currentTab="scope";
|
|
|
|
| 115 |
el('nucleiReady').textContent='Validated '+check.templates.length+' templates, estimated '+check.per_target_requests+' requests per target; up to '+check.max_targets_within_budget+' targets per Nuclei job.';
|
| 116 |
}catch(e){return message('Nuclei preflight failed before queuing: '+e.message,true);}
|
| 117 |
}
|
| 118 |
+
const active=modules.some(m=>['ffuf','arjun','dalfox','sqlmap','nikto','wpscan','testssl','wapiti','retirejs'].includes(m));if(active&&ids.length>100)return message('Active review allows up to 100 approved targets per batch.',true);if(active&&!el('activeConsent').checked)return message('Confirm active review authorization under Settings.',true);
|
| 119 |
if(!el('policy').checked)return message('Confirm the program policy first.',true);
|
| 120 |
try{
|
| 121 |
let r;
|
|
|
|
| 233 |
}catch(e){message(e.message,true)}
|
| 234 |
}
|
| 235 |
function enabledModulesFrom(raw){
|
| 236 |
+
const names={"gau":"modGau","katana":"modKatana","httpx":"modHttpx","zap-passive":"modZap","nuclei":"modNuclei","ffuf":"modFfuf","arjun":"modArjun","dalfox":"modDalfox","sqlmap":"modSqlmap","nikto":"modNikto","wpscan":"modWpscan","testssl":"modTestssl","wapiti":"modWapiti","retirejs":"modRetirejs"};
|
| 237 |
const order=raw.split(",").map(s=>s.trim().toLowerCase()).filter(Boolean);
|
| 238 |
if(order.length!==new Set(order).size||order.some(n=>!names[n]))throw Error("Invalid or duplicate pipeline stage.");
|
| 239 |
const selected=Object.keys(names).filter(n=>el(names[n])?.checked);
|
|
|
|
| 241 |
return order.filter(n=>el(names[n]).checked);
|
| 242 |
}
|
| 243 |
function enableFullPipeline(){
|
| 244 |
+
for(const id of ["modZap","modGau","modKatana","modHttpx","modNuclei","modFfuf","modArjun","modDalfox","modSqlmap","modNikto","modWpscan","modTestssl","modWapiti","modRetirejs"])el(id).checked=true;
|
| 245 |
+
const order="gau, katana, httpx, zap-passive, nuclei, nikto, wpscan, testssl, wapiti, retirejs, ffuf, arjun, dalfox, sqlmap";
|
| 246 |
el("stageOrder").value=order;el("settingsOrder").value=order;
|
| 247 |
message("All modules enabled. Runtime applicability checks skip unsuitable stages. Confirm active-testing authorization before queuing.");
|
| 248 |
}
|
app/main.py
CHANGED
|
@@ -582,7 +582,7 @@ async def run_scan_job(identifier, spec):
|
|
| 582 |
job["alerts"].extend(result["alerts"])
|
| 583 |
elif module in WEB_MODULES:
|
| 584 |
result = await run_web_module(
|
| 585 |
-
module, url, host, spec.user_agent, spec.delay_seconds)
|
| 586 |
job["reviews"].append(result)
|
| 587 |
job["alerts"].extend(result.get("alerts", []))
|
| 588 |
elif module in ACTIVE_MODULES:
|
|
|
|
| 582 |
job["alerts"].extend(result["alerts"])
|
| 583 |
elif module in WEB_MODULES:
|
| 584 |
result = await run_web_module(
|
| 585 |
+
module, url, host, spec.user_agent, spec.delay_seconds, endpoints)
|
| 586 |
job["reviews"].append(result)
|
| 587 |
job["alerts"].extend(result.get("alerts", []))
|
| 588 |
elif module in ACTIVE_MODULES:
|
app/nuclei-templates/directory-listing.yaml
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
id: asfb-directory-listing
|
| 2 |
+
info:
|
| 3 |
+
name: Directory listing page observed
|
| 4 |
+
author: asfb
|
| 5 |
+
severity: info
|
| 6 |
+
description: An index page with directory navigation was observed. This does not establish sensitive file exposure.
|
| 7 |
+
tags: exposure,listing
|
| 8 |
+
http:
|
| 9 |
+
- method: GET
|
| 10 |
+
path:
|
| 11 |
+
- "{{BaseURL}}"
|
| 12 |
+
matchers-condition: and
|
| 13 |
+
matchers:
|
| 14 |
+
- type: status
|
| 15 |
+
status: [200]
|
| 16 |
+
- type: word
|
| 17 |
+
part: header
|
| 18 |
+
words: ["text/html"]
|
| 19 |
+
case-insensitive: true
|
| 20 |
+
- type: regex
|
| 21 |
+
part: body
|
| 22 |
+
regex:
|
| 23 |
+
- '(?i)<title>\s*Index of [^<]*</title>'
|
| 24 |
+
- type: word
|
| 25 |
+
part: body
|
| 26 |
+
words: ["Parent Directory", "href="]
|
| 27 |
+
condition: and
|
app/nuclei-templates/openapi-json.yaml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
id: asfb-openapi-json
|
| 2 |
+
info:
|
| 3 |
+
name: OpenAPI document available
|
| 4 |
+
author: asfb
|
| 5 |
+
severity: info
|
| 6 |
+
description: A structured API specification is accessible at a common path. Public documentation may be intentional and is not an authorization bypass.
|
| 7 |
+
tags: exposure,api,metadata
|
| 8 |
+
http:
|
| 9 |
+
- method: GET
|
| 10 |
+
path:
|
| 11 |
+
- "{{BaseURL}}/openapi.json"
|
| 12 |
+
- "{{BaseURL}}/swagger.json"
|
| 13 |
+
matchers-condition: and
|
| 14 |
+
matchers:
|
| 15 |
+
- type: status
|
| 16 |
+
status: [200]
|
| 17 |
+
- type: regex
|
| 18 |
+
part: body
|
| 19 |
+
regex:
|
| 20 |
+
- '"openapi"\s*:\s*"3\.'
|
| 21 |
+
- '"swagger"\s*:\s*"2\.'
|
| 22 |
+
condition: or
|
| 23 |
+
- type: regex
|
| 24 |
+
part: body
|
| 25 |
+
regex:
|
| 26 |
+
- '"paths"\s*:\s*\{'
|
app/pipeline.py
CHANGED
|
@@ -87,9 +87,10 @@ async def nuclei(url, host, agent, urls=None, default_ids=None,
|
|
| 87 |
# on /app/main.js or /manifest.json can produce misleading or wasted requests.
|
| 88 |
origin = urlsplit(url)._replace(path="/", query="", fragment="").geturl()
|
| 89 |
exposure_profile = any(name.startswith("bounty-ultimate-") for name in selected_ids)
|
| 90 |
-
if exposure_profile:
|
| 91 |
targets = [origin]
|
| 92 |
-
scope_strategy = "origin-only exposure profile"
|
|
|
|
| 93 |
else:
|
| 94 |
def target_priority(candidate):
|
| 95 |
parts = urlsplit(candidate)
|
|
|
|
| 87 |
# on /app/main.js or /manifest.json can produce misleading or wasted requests.
|
| 88 |
origin = urlsplit(url)._replace(path="/", query="", fragment="").geturl()
|
| 89 |
exposure_profile = any(name.startswith("bounty-ultimate-") for name in selected_ids)
|
| 90 |
+
if exposure_profile or "openapi-json" in selected_ids:
|
| 91 |
targets = [origin]
|
| 92 |
+
scope_strategy = ("origin-only exposure profile" if exposure_profile
|
| 93 |
+
else "origin-only API metadata profile")
|
| 94 |
else:
|
| 95 |
def target_priority(candidate):
|
| 96 |
parts = urlsplit(candidate)
|
app/wapiti_guard.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Request guard installed only inside the dedicated Wapiti subprocess."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import ipaddress
|
| 4 |
+
import os
|
| 5 |
+
import socket
|
| 6 |
+
import time
|
| 7 |
+
from urllib.parse import urlsplit
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def origin(value):
|
| 13 |
+
p = urlsplit(str(value))
|
| 14 |
+
if p.scheme not in ("http", "https") or not p.hostname or p.username or p.password:
|
| 15 |
+
raise ValueError("Invalid HTTP origin")
|
| 16 |
+
return p.scheme, p.hostname, p.port if p.port is not None else (443 if p.scheme == "https" else 80)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
async def validate_public(host):
|
| 20 |
+
addresses = await asyncio.to_thread(socket.getaddrinfo, host, None, 0, socket.SOCK_STREAM)
|
| 21 |
+
if not addresses:
|
| 22 |
+
raise ValueError("No DNS addresses")
|
| 23 |
+
if os.environ.get("ALLOW_PRIVATE_TARGETS") != "1":
|
| 24 |
+
if any(not ipaddress.ip_address(row[4][0]).is_global for row in addresses):
|
| 25 |
+
raise ValueError("Non-public DNS address")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class BoundedStream(httpx.AsyncByteStream):
|
| 29 |
+
def __init__(self, wrapped, limit, guard):
|
| 30 |
+
self.wrapped, self.limit, self.guard = wrapped, limit, guard
|
| 31 |
+
|
| 32 |
+
async def __aiter__(self):
|
| 33 |
+
count = 0
|
| 34 |
+
async for chunk in self.wrapped:
|
| 35 |
+
count += len(chunk)
|
| 36 |
+
if count > self.limit:
|
| 37 |
+
self.guard.response_limited = True
|
| 38 |
+
raise httpx.ReadError("ASFB Wapiti response size limit reached")
|
| 39 |
+
yield chunk
|
| 40 |
+
|
| 41 |
+
async def aclose(self):
|
| 42 |
+
await self.wrapped.aclose()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class RequestGuard:
|
| 46 |
+
def __init__(self, target, delay, limit=12, max_bytes=1048576):
|
| 47 |
+
self.target_origin = origin(target)
|
| 48 |
+
self.delay, self.limit, self.max_bytes = max(1.0, float(delay)), limit, max_bytes
|
| 49 |
+
self.requests, self.blocked = 0, 0
|
| 50 |
+
self.limit_reached, self.response_limited = False, False
|
| 51 |
+
self.last_started = None
|
| 52 |
+
self.lock = asyncio.Lock()
|
| 53 |
+
|
| 54 |
+
def wrap(self, original):
|
| 55 |
+
async def send(client, request, **kwargs):
|
| 56 |
+
try:
|
| 57 |
+
allowed = origin(request.url) == self.target_origin and request.method in ("GET", "HEAD")
|
| 58 |
+
except ValueError:
|
| 59 |
+
allowed = False
|
| 60 |
+
if not allowed:
|
| 61 |
+
self.blocked += 1
|
| 62 |
+
raise httpx.RequestError("ASFB Wapiti request outside the allowed origin/method", request=request)
|
| 63 |
+
async with self.lock:
|
| 64 |
+
if self.requests >= self.limit:
|
| 65 |
+
self.limit_reached = True
|
| 66 |
+
raise httpx.RequestError("ASFB Wapiti request budget reached", request=request)
|
| 67 |
+
if self.last_started is not None:
|
| 68 |
+
await asyncio.sleep(max(0.0, self.delay - (time.monotonic() - self.last_started)))
|
| 69 |
+
try:
|
| 70 |
+
await validate_public(self.target_origin[1])
|
| 71 |
+
except (OSError, ValueError) as exc:
|
| 72 |
+
raise httpx.RequestError("ASFB Wapiti DNS validation failed", request=request) from exc
|
| 73 |
+
self.requests += 1
|
| 74 |
+
self.last_started = time.monotonic()
|
| 75 |
+
requested_stream = kwargs.get("stream", False)
|
| 76 |
+
kwargs.update(follow_redirects=False, stream=True)
|
| 77 |
+
response = await original(client, request, **kwargs)
|
| 78 |
+
response.stream = BoundedStream(response.stream, self.max_bytes, self)
|
| 79 |
+
if not requested_stream:
|
| 80 |
+
try:
|
| 81 |
+
await response.aread()
|
| 82 |
+
except BaseException:
|
| 83 |
+
await response.aclose()
|
| 84 |
+
raise
|
| 85 |
+
return response
|
| 86 |
+
return send
|
| 87 |
+
|
| 88 |
+
def report(self):
|
| 89 |
+
return {"requests": self.requests, "blocked": self.blocked,
|
| 90 |
+
"limit_reached": self.limit_reached, "response_limited": self.response_limited}
|
app/wapiti_runner.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run the pinned Wapiti CLI with the ASFB origin/method/budget guard."""
|
| 2 |
+
import json
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
from wapiti_guard import RequestGuard
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def pop_option(name, default=None):
|
| 11 |
+
if name not in sys.argv:
|
| 12 |
+
return default
|
| 13 |
+
index = sys.argv.index(name)
|
| 14 |
+
value = sys.argv[index + 1]
|
| 15 |
+
del sys.argv[index:index + 2]
|
| 16 |
+
return value
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def main():
|
| 20 |
+
stats = pop_option("--asfb-stats")
|
| 21 |
+
delay = float(pop_option("--asfb-delay", "1.5"))
|
| 22 |
+
budget = int(pop_option("--asfb-budget", "12"))
|
| 23 |
+
guard = None
|
| 24 |
+
if "-u" in sys.argv:
|
| 25 |
+
target = sys.argv[sys.argv.index("-u") + 1]
|
| 26 |
+
guard = RequestGuard(target, delay, budget)
|
| 27 |
+
httpx.AsyncClient.send = guard.wrap(httpx.AsyncClient.send)
|
| 28 |
+
try:
|
| 29 |
+
from wapitiCore.main.wapiti import wapiti_asyncio_wrapper
|
| 30 |
+
wapiti_asyncio_wrapper()
|
| 31 |
+
finally:
|
| 32 |
+
if stats and guard:
|
| 33 |
+
Path(stats).write_text(json.dumps(guard.report()), encoding="utf-8")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
if __name__ == "__main__":
|
| 37 |
+
main()
|
app/web_modules.py
CHANGED
|
@@ -14,7 +14,7 @@ from . import config
|
|
| 14 |
from .pipeline import execute, in_host
|
| 15 |
from .scope import public_host
|
| 16 |
|
| 17 |
-
MODULES = {"nikto", "wpscan", "testssl"}
|
| 18 |
|
| 19 |
|
| 20 |
def same_origin(candidate, target):
|
|
@@ -188,9 +188,14 @@ def command(module, target, host, agent, report, delay):
|
|
| 188 |
raise ValueError("Unknown web module")
|
| 189 |
|
| 190 |
|
| 191 |
-
async def run_web_module(module, url, host, agent, delay=1.5):
|
| 192 |
if module not in MODULES or not in_host(url, host):
|
| 193 |
raise ValueError("Invalid module or out-of-scope web target")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
target, evidence = url, ""
|
| 195 |
if module == "testssl" and urlsplit(url).scheme != "https":
|
| 196 |
return {"status": "skipped", "alerts": [], "target": url,
|
|
|
|
| 14 |
from .pipeline import execute, in_host
|
| 15 |
from .scope import public_host
|
| 16 |
|
| 17 |
+
MODULES = {"nikto", "wpscan", "testssl", "wapiti", "retirejs"}
|
| 18 |
|
| 19 |
|
| 20 |
def same_origin(candidate, target):
|
|
|
|
| 188 |
raise ValueError("Unknown web module")
|
| 189 |
|
| 190 |
|
| 191 |
+
async def run_web_module(module, url, host, agent, delay=1.5, discovered=None):
|
| 192 |
if module not in MODULES or not in_host(url, host):
|
| 193 |
raise ValueError("Invalid module or out-of-scope web target")
|
| 194 |
+
if module in ("wapiti", "retirejs"):
|
| 195 |
+
from . import client_modules
|
| 196 |
+
if module == "retirejs":
|
| 197 |
+
return await client_modules.retirejs(url, host, agent, delay, discovered or [])
|
| 198 |
+
return await client_modules.wapiti(url, host, agent, delay)
|
| 199 |
target, evidence = url, ""
|
| 200 |
if module == "testssl" and urlsplit(url).scheme != "https":
|
| 201 |
return {"status": "skipped", "alerts": [], "target": url,
|
docs/MODULE_ROADMAP.md
CHANGED
|
@@ -14,16 +14,27 @@ All three require the existing active-testing confirmation, appear in job logs,
|
|
| 14 |
|
| 15 |
Use **Settings → Enable all modules** to select every currently implemented stage. This preserves the separate authorization checkbox. The server checks applicability when each job runs, regardless of how modules were selected.
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
## Requested next groups
|
| 20 |
|
| 21 |
| Requested feature | State | Intended input / applicability gate |
|
| 22 |
| --- | --- | --- |
|
| 23 |
-
| More [Nuclei templates](https://docs.projectdiscovery.io/templates/introduction) |
|
| 24 |
-
| [Gemini Nuclei template generation](https://ai.google.dev/gemini-api/docs/structured-output) |
|
| 25 |
-
| [Wapiti](https://github.com/wapiti-scanner/wapiti) |
|
| 26 |
-
| [Retire.js](https://github.com/RetireJS/retire.js) |
|
| 27 |
| [TruffleHog](https://github.com/trufflesecurity/trufflehog) | Planned | Explicit repository/artifact input on remote infrastructure. Redacted secret findings; verification behavior must be explicit. |
|
| 28 |
| [OWASP Amass](https://github.com/owasp-amass/amass) | Planned | Passive domain discovery first. Newly discovered subdomains are inventory and do not automatically become authorized scan targets. |
|
| 29 |
| [OWASP dep-scan](https://github.com/owasp-dep-scan/dep-scan) | Planned | Source, SBOM or container artifact; a URL-only job is inapplicable. |
|
|
|
|
| 14 |
|
| 15 |
Use **Settings → Enable all modules** to select every currently implemented stage. This preserves the separate authorization checkbox. The server checks applicability when each job runs, regardless of how modules were selected.
|
| 16 |
|
| 17 |
+
All implemented modules work without API keys. WPScan runs its inventory profile without a key and clearly reports unavailable vulnerability database coverage. A WPSCAN_API_TOKEN Space secret remains optional for users who already have one; it is never needed to run the pipeline.
|
| 18 |
+
|
| 19 |
+
## Second group: keyless additions
|
| 20 |
+
|
| 21 |
+
| Module / check | State | Runtime applicability and limits |
|
| 22 |
+
| --- | --- | --- |
|
| 23 |
+
| [Retire.js 6.1.0](https://github.com/RetireJS/retire.js/blob/6.1.0/node/README.md) | Implemented | Samples current-page inline scripts and same-origin script URLs, including JS inventory from earlier discovery stages. Maximum 4 files, 512 KiB per file, no redirects. Skips if no usable JavaScript is available. Runs the scanner on HF temporary files as data; never executes target JavaScript. Uses an image-bundled upstream advisory snapshot and no runtime API/key. Findings retain their original source URLs. Snapshot freshness and bounded sampling limit coverage. |
|
| 24 |
+
| [Wapiti 3.3.2](https://github.com/wapiti-scanner/wapiti/wiki) | Implemented, limited profile | Browser-free CSP, HTTP-header and cookie-flag checks. Exact selected URL scope, guarded exact origin, GET/HEAD only, 12-request hard limit, configured request delay, no redirects, 25-second scan / 40-second process deadline. No injection modules, form submissions, endpoint callbacks or automatic bug reports. Separate Python 3.12 environment matches upstream requirements. |
|
| 25 |
+
| Directory listing Nuclei template | Implemented, opt-in | HTML index title plus directory-navigation markers and HTTP 200. Informational observation; does not prove sensitive file exposure. |
|
| 26 |
+
| OpenAPI JSON Nuclei template | Implemented, opt-in | Two common JSON paths, version and paths-object markers, HTTP 200. Runs at the origin once even when discovery found many asset paths. Public API documentation may be intentional. |
|
| 27 |
+
|
| 28 |
+
Both modules appear in Settings, Enable all modules, source filters and stage logs. Sampling failures and guard limits are reported explicitly. Tests include the actual installed Retire.js scanner on a synthetic JS fixture and the actual installed Wapiti engine behind a mocked HTTP transport; they send no live-target traffic.
|
| 29 |
|
| 30 |
## Requested next groups
|
| 31 |
|
| 32 |
| Requested feature | State | Intended input / applicability gate |
|
| 33 |
| --- | --- | --- |
|
| 34 |
+
| More [Nuclei templates](https://docs.projectdiscovery.io/templates/introduction) | Two added; further expansion WIP | Reviewed HTTP templates, technology gates, exact-origin constraints, existing request budgets and validation. |
|
| 35 |
+
| [Gemini Nuclei template generation](https://ai.google.dev/gemini-api/docs/structured-output) | Deferred under keyless preference | Hosted Gemini generation needs API credentials, so it is not part of the keyless pipeline. Any future optional integration would use a separate draft-generation action with a Space secret and configurable model. Generated templates must pass existing template restrictions and Nuclei validation before explicit selection. Do not auto-run generated YAML. |
|
| 36 |
+
| [Wapiti](https://github.com/wapiti-scanner/wapiti) | Limited profile implemented | Further profiles need separately reviewed request/method limits. |
|
| 37 |
+
| [Retire.js](https://github.com/RetireJS/retire.js) | Implemented for web JavaScript | Source/SBOM upload workflows remain future work. |
|
| 38 |
| [TruffleHog](https://github.com/trufflesecurity/trufflehog) | Planned | Explicit repository/artifact input on remote infrastructure. Redacted secret findings; verification behavior must be explicit. |
|
| 39 |
| [OWASP Amass](https://github.com/owasp-amass/amass) | Planned | Passive domain discovery first. Newly discovered subdomains are inventory and do not automatically become authorized scan targets. |
|
| 40 |
| [OWASP dep-scan](https://github.com/owasp-dep-scan/dep-scan) | Planned | Source, SBOM or container artifact; a URL-only job is inapplicable. |
|
tests/retire_offline_smoke.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// No target JavaScript is executed. Retire scans this synthetic fixture as data.
|
| 2 |
+
const fs = require("fs");
|
| 3 |
+
const os = require("os");
|
| 4 |
+
const path = require("path");
|
| 5 |
+
const cp = require("child_process");
|
| 6 |
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "asfb-retire-smoke-"));
|
| 7 |
+
try {
|
| 8 |
+
const assets = path.join(root, "assets");
|
| 9 |
+
fs.mkdirSync(assets);
|
| 10 |
+
fs.writeFileSync(path.join(assets, "jquery-1.6.1.js"), "/* Synthetic file-name detector fixture. */");
|
| 11 |
+
const report = path.join(root, "report.json");
|
| 12 |
+
const result = cp.spawnSync("/usr/local/bin/node", ["/opt/retire/lib/cli.js", "--path", assets,
|
| 13 |
+
"--jsrepo", "/opt/retire-db/jsrepository-v5.json", "--outputformat", "json",
|
| 14 |
+
"--outputpath", report, "--exitwith", "13"], {encoding:"utf8", timeout:20000});
|
| 15 |
+
if (result.status !== 13) throw Error("Expected Retire vulnerability exit code 13: " + result.stderr);
|
| 16 |
+
const data = JSON.parse(fs.readFileSync(report, "utf8"));
|
| 17 |
+
if (!data.data.some(f => f.results.some(c => c.component === "jquery" && c.vulnerabilities?.length))) {
|
| 18 |
+
throw Error("The installed Retire scanner did not report the known fixture");
|
| 19 |
+
}
|
| 20 |
+
if (data.errors.length) throw Error(JSON.stringify(data.errors));
|
| 21 |
+
process.stdout.write("Retire.js installed-engine offline smoke passed\n");
|
| 22 |
+
} finally {
|
| 23 |
+
fs.rmSync(root, {recursive:true, force:true});
|
| 24 |
+
}
|
tests/test_client_modules.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from unittest.mock import patch
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from app import client_modules as tools, config, main, nuclei_catalog, pipeline, web_modules
|
| 10 |
+
from test_worker_pipeline import run_job
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_script_inventory_keeps_same_origin_and_javascript_inline_only():
|
| 14 |
+
parser = tools.ScriptInventory("https://example.org/")
|
| 15 |
+
parser.feed('<script src="/app.js"></script><script src="https://outside.org/a.js"></script>'
|
| 16 |
+
'<script src="https://example.org:8443/a.js"></script>'
|
| 17 |
+
'<script>/*! jQuery v1.6.1 */</script><script type="application/ld+json">{}</script>')
|
| 18 |
+
assert parser.urls == ["https://example.org/app.js"]
|
| 19 |
+
assert len(parser.inline) == 1
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_retire_no_javascript_skips_without_starting_node():
|
| 23 |
+
async def fetch(*args): return b"<html>No scripts</html>", "text/html"
|
| 24 |
+
with patch.object(tools, "fetch_sample", fetch), patch.object(tools, "execute") as execute:
|
| 25 |
+
result = asyncio.run(tools.retirejs("https://example.org/", "example.org", "Test", 1.5, []))
|
| 26 |
+
assert result["status"] == "skipped"
|
| 27 |
+
assert not execute.called
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_retire_fetch_failure_does_not_claim_a_clean_scan():
|
| 31 |
+
async def fetch(*args): raise httpx.ReadTimeout("mock")
|
| 32 |
+
with patch.object(tools, "fetch_sample", fetch), patch.object(tools, "execute") as execute:
|
| 33 |
+
result = asyncio.run(tools.retirejs("https://example.org/", "example.org", "Test", 1.5, []))
|
| 34 |
+
assert result["status"] == "skipped"
|
| 35 |
+
assert "ReadTimeout" in result["reason"]
|
| 36 |
+
assert not execute.called
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_retire_samples_bounded_files_uses_bundled_database_and_maps_findings():
|
| 40 |
+
fetches = []
|
| 41 |
+
async def fetch(url, *args):
|
| 42 |
+
fetches.append(url)
|
| 43 |
+
if url == "https://example.org/":
|
| 44 |
+
scripts = "".join(f'<script src="/jquery-{i}.js"></script>' for i in range(8))
|
| 45 |
+
return scripts.encode(), "text/html"
|
| 46 |
+
return b"/* sample */", "application/javascript"
|
| 47 |
+
async def execute(args, timeout):
|
| 48 |
+
assert "--includeOsv" not in args
|
| 49 |
+
assert args[args.index("--jsrepo") + 1] == "/opt/retire-db/jsrepository-v5.json"
|
| 50 |
+
root = Path(args[args.index("--path") + 1])
|
| 51 |
+
files = sorted(root.rglob("*.js"))
|
| 52 |
+
assert len(files) == config.RETIRE_MAX_FILES
|
| 53 |
+
assert files[0].name == "jquery-0.js"
|
| 54 |
+
report = {"data": [{"file": str(files[0]), "results": [
|
| 55 |
+
{"component": "jquery", "version": "1.6.1", "vulnerabilities": [
|
| 56 |
+
{"severity": "high", "identifiers": {"CVE": ["CVE-TEST"], "summary": "Mock candidate"}}]}]}],
|
| 57 |
+
"errors": []}
|
| 58 |
+
Path(args[args.index("--outputpath") + 1]).write_text(json.dumps(report))
|
| 59 |
+
return ["[diagnostic] Process exit code: 13"], "failed"
|
| 60 |
+
async def sleep(*args): pass
|
| 61 |
+
with patch.object(tools, "fetch_sample", fetch), patch.object(tools, "execute", execute), \
|
| 62 |
+
patch.object(tools.asyncio, "sleep", sleep):
|
| 63 |
+
result = asyncio.run(tools.retirejs("https://example.org/", "example.org", "Test", 1.5,
|
| 64 |
+
["https://outside.org/out.js"]))
|
| 65 |
+
assert result["status"] == "partial" # More JS existed than the configured sample.
|
| 66 |
+
assert result["sampled_files"] == 4
|
| 67 |
+
assert result["alerts"][0]["url"] == "https://example.org/jquery-0.js"
|
| 68 |
+
assert result["alerts"][0]["risk"] == "High"
|
| 69 |
+
assert len(fetches) == 5
|
| 70 |
+
assert "Process exit code: 13" not in result["reason"]
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_retire_unknown_report_path_is_never_assigned_to_the_target(tmp_path):
|
| 74 |
+
known = str((tmp_path / "known.js").resolve())
|
| 75 |
+
report = {"data": [{"file": str(tmp_path / "outside.js"), "results": [
|
| 76 |
+
{"component": "x", "version": "1", "vulnerabilities": [{"severity": "high"}]}]}]}
|
| 77 |
+
assert tools.parse_retire(report, {known: "https://example.org/a.js"}) == []
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@pytest.mark.parametrize("guard_stats,expected", [
|
| 81 |
+
({"requests": 2}, "completed"),
|
| 82 |
+
({"requests": 12, "limit_reached": True}, "partial"),
|
| 83 |
+
({"requests": 1, "blocked": 1}, "partial"),
|
| 84 |
+
])
|
| 85 |
+
def test_wapiti_cli_profile_and_coverage(guard_stats, expected):
|
| 86 |
+
async def execute(args, timeout):
|
| 87 |
+
assert args[args.index("-m") + 1] == "csp,http_headers,cookieflags"
|
| 88 |
+
assert args[args.index("--scope") + 1] == "url"
|
| 89 |
+
assert args[args.index("--headless") + 1] == "no"
|
| 90 |
+
assert "--no-bugreport" in args
|
| 91 |
+
Path(args[args.index("-o") + 1]).write_text(json.dumps({
|
| 92 |
+
"vulnerabilities": {"Header": [{"method": "GET", "path": "/",
|
| 93 |
+
"level": 1, "info": "missing", "module": "http_headers"}]}}))
|
| 94 |
+
Path(args[args.index("--asfb-stats") + 1]).write_text(json.dumps(guard_stats))
|
| 95 |
+
return [], "completed"
|
| 96 |
+
with patch.object(tools, "execute", execute):
|
| 97 |
+
result = asyncio.run(tools.wapiti("https://example.org/", "example.org", "Test", 1.5))
|
| 98 |
+
assert result["status"] == expected
|
| 99 |
+
assert result["alerts"][0]["source"] == "wapiti"
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_wapiti_missing_report_is_failed():
|
| 103 |
+
async def execute(*args): return [], "completed"
|
| 104 |
+
with patch.object(tools, "execute", execute):
|
| 105 |
+
result = asyncio.run(tools.wapiti("https://example.org/", "example.org", "Test", 1.5))
|
| 106 |
+
assert result["status"] == "failed"
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_wapiti_parser_filters_other_origins_and_methods():
|
| 110 |
+
report = {"vulnerabilities": {"Mock": [
|
| 111 |
+
{"method": "GET", "path": "https://outside.org/", "info": "outside"},
|
| 112 |
+
{"method": "POST", "path": "/", "info": "wrong method"},
|
| 113 |
+
{"method": "GET", "path": "/", "info": "kept", "level": 1}]}}
|
| 114 |
+
findings = tools.parse_wapiti(report, "https://example.org/")
|
| 115 |
+
assert len(findings) == 1 and findings[0]["description"] == "kept"
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_new_module_dispatch_receives_discovery_inventory():
|
| 119 |
+
seen = []
|
| 120 |
+
async def retire(*args):
|
| 121 |
+
seen.extend(args[-1])
|
| 122 |
+
return {"status": "skipped", "alerts": [], "reason": "No JS"}
|
| 123 |
+
with patch.object(tools, "retirejs", retire):
|
| 124 |
+
result = asyncio.run(web_modules.run_web_module(
|
| 125 |
+
"retirejs", "https://example.org/", "example.org", "Test",
|
| 126 |
+
discovered=["https://example.org/a.js"]))
|
| 127 |
+
assert result["status"] == "skipped"
|
| 128 |
+
assert seen == ["https://example.org/a.js"]
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_worker_new_modules_and_skip_are_isolated():
|
| 132 |
+
run, job = run_job(["retirejs", "wapiti", "httpx"])
|
| 133 |
+
async def module(name, *args):
|
| 134 |
+
return {"status": "skipped" if name == "retirejs" else "completed", "alerts": [], "output": name}
|
| 135 |
+
async def probe(*args): return {"status": "completed", "probes": []}
|
| 136 |
+
with patch.object(main, "public_host"), patch.object(main, "run_web_module", module), \
|
| 137 |
+
patch.object(main, "probe", probe):
|
| 138 |
+
asyncio.run(run())
|
| 139 |
+
assert job["status"] == "completed"
|
| 140 |
+
assert job["pipeline"]["retirejs"] == "skipped"
|
| 141 |
+
assert job["pipeline"]["httpx"] == "completed"
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_optional_nuclei_templates_validate_and_do_not_change_defaults():
|
| 145 |
+
for name in ("directory-listing", "openapi-json"):
|
| 146 |
+
assert name in nuclei_catalog.builtins()
|
| 147 |
+
assert name not in config.NUCLEI_DEFAULT_IDS
|
| 148 |
+
content = nuclei_catalog.bundled_content(name)
|
| 149 |
+
nuclei_catalog.validate_template(content)
|
| 150 |
+
assert nuclei_catalog.estimate_requests(content) <= 2
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def test_openapi_fixed_paths_run_only_at_origin():
|
| 154 |
+
async def execute(args, deadline):
|
| 155 |
+
targets = Path(args[args.index("-l") + 1]).read_text().splitlines()
|
| 156 |
+
assert targets == ["https://example.org/"]
|
| 157 |
+
return [], "completed"
|
| 158 |
+
with patch.object(pipeline, "execute", execute):
|
| 159 |
+
result = asyncio.run(pipeline.nuclei("https://example.org/", "example.org", "Test",
|
| 160 |
+
["https://example.org/app.js"], default_ids=["openapi-json"]))
|
| 161 |
+
assert result["targets_checked"] == 1
|
tests/test_wapiti_guard.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from unittest.mock import patch
|
| 3 |
+
|
| 4 |
+
import httpx
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from app import wapiti_guard as module
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
async def no_dns(*args): pass
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@pytest.mark.parametrize("url,method", [
|
| 14 |
+
("https://outside.org/", "GET"),
|
| 15 |
+
("https://example.org:8443/", "GET"),
|
| 16 |
+
("http://example.org/", "GET"),
|
| 17 |
+
("https://example.org/", "POST"),
|
| 18 |
+
])
|
| 19 |
+
def test_guard_blocks_unapproved_origin_and_methods(url, method):
|
| 20 |
+
async def scenario():
|
| 21 |
+
guard = module.RequestGuard("https://example.org/", 1.5)
|
| 22 |
+
async def original(*args, **kwargs): raise AssertionError("must not send")
|
| 23 |
+
with pytest.raises(httpx.RequestError):
|
| 24 |
+
await guard.wrap(original)(object(), httpx.Request(method, url))
|
| 25 |
+
assert guard.blocked == 1 and guard.requests == 0
|
| 26 |
+
asyncio.run(scenario())
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_guard_disables_redirects_and_limits_number_of_requests():
|
| 30 |
+
async def scenario():
|
| 31 |
+
guard = module.RequestGuard("https://example.org/", 1.5, limit=1)
|
| 32 |
+
async def original(client, request, **kwargs):
|
| 33 |
+
assert kwargs["follow_redirects"] is False
|
| 34 |
+
assert kwargs["stream"] is True
|
| 35 |
+
return httpx.Response(302, headers={"location": "https://outside.org/"}, content=b"", request=request)
|
| 36 |
+
request = httpx.Request("GET", "https://example.org/")
|
| 37 |
+
with patch.object(module, "validate_public", no_dns):
|
| 38 |
+
response = await guard.wrap(original)(object(), request, follow_redirects=True)
|
| 39 |
+
assert response.status_code == 302
|
| 40 |
+
with pytest.raises(httpx.RequestError):
|
| 41 |
+
await guard.wrap(original)(object(), request)
|
| 42 |
+
assert guard.requests == 1 and guard.limit_reached
|
| 43 |
+
asyncio.run(scenario())
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_guard_stops_large_stream():
|
| 47 |
+
async def scenario():
|
| 48 |
+
class Stream(httpx.AsyncByteStream):
|
| 49 |
+
async def __aiter__(self):
|
| 50 |
+
yield b"0123456789"
|
| 51 |
+
async def aclose(self): pass
|
| 52 |
+
guard = module.RequestGuard("https://example.org/", 1.5, max_bytes=3)
|
| 53 |
+
async def original(client, request, **kwargs):
|
| 54 |
+
return httpx.Response(200, stream=Stream(), request=request)
|
| 55 |
+
with patch.object(module, "validate_public", no_dns):
|
| 56 |
+
with pytest.raises(httpx.ReadError):
|
| 57 |
+
await guard.wrap(original)(object(), httpx.Request("GET", "https://example.org/"))
|
| 58 |
+
assert guard.response_limited
|
| 59 |
+
asyncio.run(scenario())
|
tests/wapiti_offline_smoke.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Exercise the real installed Wapiti engine using a mocked HTTP transport."""
|
| 2 |
+
import json
|
| 3 |
+
import socket
|
| 4 |
+
import sys
|
| 5 |
+
import tempfile
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import httpx
|
| 9 |
+
|
| 10 |
+
sys.path.insert(0, "/app/app")
|
| 11 |
+
import wapiti_guard
|
| 12 |
+
import wapiti_runner
|
| 13 |
+
|
| 14 |
+
calls = []
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async def no_dns(*args):
|
| 18 |
+
pass
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def fake_send(client, request, **kwargs):
|
| 22 |
+
calls.append((str(request.url), request.method))
|
| 23 |
+
assert str(request.url).startswith("https://example.invalid/")
|
| 24 |
+
assert request.method in ("GET", "HEAD")
|
| 25 |
+
assert kwargs["follow_redirects"] is False
|
| 26 |
+
return httpx.Response(200, content=b"<html><title>Offline fixture</title></html>",
|
| 27 |
+
headers={"content-type": "text/html", "set-cookie": "fixture=value; Path=/"},
|
| 28 |
+
request=request)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
wapiti_guard.validate_public = no_dns
|
| 32 |
+
httpx.AsyncClient.send = fake_send
|
| 33 |
+
socket.gethostbyname = lambda *args: "93.184.216.34"
|
| 34 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 35 |
+
root = Path(tmp)
|
| 36 |
+
report, stats = root / "report.json", root / "stats.json"
|
| 37 |
+
sys.argv = ["wapiti_runner.py", "--asfb-stats", str(stats), "--asfb-delay", "1",
|
| 38 |
+
"--asfb-budget", "12", "-u", "https://example.invalid/", "--scope", "url",
|
| 39 |
+
"-m", "csp,http_headers,cookieflags", "--headless", "no", "--tasks", "1",
|
| 40 |
+
"-d", "0", "--max-scan-time", "15", "--max-attack-time", "3",
|
| 41 |
+
"--store-session", str(root / "session"), "--store-config", str(root / "config"),
|
| 42 |
+
"--no-bugreport", "-f", "json", "-o", str(report)]
|
| 43 |
+
wapiti_runner.main()
|
| 44 |
+
result = json.loads(report.read_text())
|
| 45 |
+
assert result["vulnerabilities"], result
|
| 46 |
+
assert any(result["vulnerabilities"].values()), result
|
| 47 |
+
assert calls
|
| 48 |
+
assert json.loads(stats.read_text())["requests"] > 0
|
| 49 |
+
print("Wapiti installed-engine offline smoke passed")
|