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 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&#10;info:&#10; name: ...&#10; severity: info&#10;http:&#10; - method: GET&#10; path: [&#34;{{BaseURL}}/robots.txt&#34;]&#10; matchers: ..."></textarea>
41
  <div class="controls"><button class="secondary" onclick="validateNucleiSelection()">Validate selection &amp; 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 vulnerability lookups require a WPSCAN_API_TOKEN Space secret; without it, inventory still works.</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></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,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&#10;info:&#10; name: ...&#10; severity: info&#10;http:&#10; - method: GET&#10; path: [&#34;{{BaseURL}}/robots.txt&#34;]&#10; matchers: ..."></textarea>
41
  <div class="controls"><button class="secondary" onclick="validateNucleiSelection()">Validate selection &amp; 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
- WPScan inventory works without a vulnerability API token. For vulnerability database lookups, optionally set **WPSCAN_API_TOKEN** as a Hugging Face Space **secret**. It is read from the tool's documented environment variable, never inserted into command arguments or returned report metadata. Metadata is fetched during the remote image build; scans use --no-update and isolated temporary cookie/cache paths. See [WPScan's license](https://github.com/wpscanteam/wpscan/blob/v4.2.0/LICENSE) for its usage terms.
 
 
 
 
 
 
 
 
 
 
 
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) | Planned, WIP | Reviewed HTTP templates, technology gates, exact-origin constraints, existing request budgets and validation. |
24
- | [Gemini Nuclei template generation](https://ai.google.dev/gemini-api/docs/structured-output) | Planned | 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. |
25
- | [Wapiti](https://github.com/wapiti-scanner/wapiti) | Planned | Explicit web asset, capped crawl/attack modules, selected methods, exact-origin policy. |
26
- | [Retire.js](https://github.com/RetireJS/retire.js) | Planned | Bounded same-origin JavaScript files or uploaded source; not arbitrary domain text passed to a filesystem scanner. |
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")