From 39ed8e52edac966ad4b205c65add6fe37e70e549 Mon Sep 17 00:00:00 2001 From: xiabai2004 <1204798391@qq.com> Date: Sun, 12 Jul 2026 19:55:35 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(engineering):=20P0=20=E6=AD=A2?= =?UTF-8?q?=E8=A1=80=E4=B8=8E=20P1=20=E5=B7=A5=E7=A8=8B=E5=8C=96=E5=9C=B0?= =?UTF-8?q?=E5=9F=BA=E9=97=AD=E7=8E=AF=EF=BC=88=E5=8D=95=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E6=BA=90/=E6=B5=8B=E8=AF=95=E5=85=A5=E5=BA=93/sarif-csv=20?= =?UTF-8?q?=E6=8E=A5=E7=BA=BF/analyzer=20=E8=AF=AF=E6=8A=A5=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D/CI=20=E8=A6=86=E7=9B=96=E7=8E=87=E9=97=A8=E7=A6=81?= =?UTF-8?q?=EF=BC=8Cpytest=20116=20passed=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 +- .gitignore | 6 +- Dockerfile | 44 ++++++++---- requirements.lock.txt | 62 +++++++++++++++++ tests/test_other_detectors.py | 2 +- tests/test_p0_smoke.py | 92 +++++++++++++++++++++++++ tests/test_profiles/test_cli.py | 1 - tests/test_profiles/test_loader.py | 2 - tests/test_sqli.py | 81 ++++++++++++++++++++++ tests/test_xss.py | 104 +++++++++++++++++++++++++++++ wvs/__init__.py | 6 +- wvs/cli.py | 19 ++++-- wvs/modules/sqli/analyzer.py | 16 +++-- wvs/reporting/json_reporter.py | 5 +- 14 files changed, 403 insertions(+), 39 deletions(-) create mode 100644 requirements.lock.txt create mode 100644 tests/test_p0_smoke.py create mode 100644 tests/test_sqli.py create mode 100644 tests/test_xss.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a07781e..ceb2052 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: pip install -e ".[dev]" - name: Run tests with coverage - run: pytest tests/ -q --cov=wvs --cov-report=term-missing --tb=short + run: pytest tests/ -q --cov=wvs --cov-report=term-missing --tb=short --cov-fail-under=30 - name: Test import run: python -c "from wvs.core import WAVScanner, HTTPPool; print('✅ Import OK')" diff --git a/.gitignore b/.gitignore index 140707f..53b98a3 100644 --- a/.gitignore +++ b/.gitignore @@ -45,7 +45,6 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ -conftest.py # Translations *.mo @@ -87,9 +86,8 @@ _checkpoint* debug_*.py probe_*.py -# Test artifacts (not for distribution) -test_*.py -tests/ +# Test artifacts (not for distribution) — tests/ is intentionally tracked in VCS +/test_*.py # Temp scan scripts (but NOT __init__.py) _[a-z]*.py diff --git a/Dockerfile b/Dockerfile index e120d92..6ea14f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,40 @@ -FROM python:3.11-slim +# ───────────────────────────────────────────────────────────── +# Builder stage: installs dev dependencies and builds the wheel +# ───────────────────────────────────────────────────────────── +FROM python:3.11-slim AS builder + +WORKDIR /build + +# Build backend must be available locally for --no-isolation builds +RUN pip install --no-cache-dir --upgrade pip setuptools wheel build + +# Copy only what is required to build the distribution +COPY pyproject.toml README.md ./ +COPY wvs ./wvs + +# Install dev dependencies in the builder (tests / lint tooling) and build a wheel. +# The wheel carries [project.dependencies] only, so the runtime stage stays lean. +RUN pip install --no-cache-dir ".[dev]" \ + && python -m build --wheel --no-isolation + +# ───────────────────────────────────────────────────────────── +# Runtime stage: only runtime deps + non-root execution +# ───────────────────────────────────────────────────────────── +FROM python:3.11-slim AS runtime WORKDIR /app -# Install system deps for optional tools -RUN apt-get update && apt-get install -y --no-install-recommends \ - curl \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* +# Install the wheel built by the builder. This pulls [project.dependencies] +# (requests / httpx / flask / beautifulsoup4 / lxml / pyyaml / colorama / aiohttp / rich) +# and NOT the dev tooling (ruff / mypy / pytest / ...). +COPY --from=builder /build/dist/*.whl /tmp/ +RUN pip install --no-cache-dir /tmp/*.whl \ + && rm -rf /tmp/*.whl -# Install Python dependencies -COPY requirements-dev.txt . -RUN pip install --no-cache-dir -r requirements-dev.txt +# Run as an unprivileged user for least-privilege execution +RUN useradd --create-home --uid 1000 --shell /bin/sh rayscan -# Install the project -COPY . . -RUN pip install --no-cache-dir -e . +USER rayscan # Default command: help ENTRYPOINT ["python", "-m", "wvs"] diff --git a/requirements.lock.txt b/requirements.lock.txt new file mode 100644 index 0000000..7a370e6 --- /dev/null +++ b/requirements.lock.txt @@ -0,0 +1,62 @@ +aiohappyeyeballs==2.7.1 +aiohttp==3.14.1 +aiosignal==1.4.0 +anyio==4.14.1 +ast_serialize==0.6.0 +attrs==26.1.0 +beautifulsoup4==4.15.0 +blinker==1.9.0 +certifi==2026.6.17 +cfgv==3.5.0 +charset-normalizer==3.4.9 +click==8.4.2 +colorama==0.4.6 +coverage==7.15.0 +distlib==0.4.3 +filelock==3.29.7 +Flask==3.1.3 +frozenlist==1.8.0 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +identify==2.6.19 +idna==3.18 +iniconfig==2.3.0 +itsdangerous==2.2.0 +Jinja2==3.1.6 +librt==0.13.0 +lxml==6.1.1 +markdown-it-py==4.2.0 +MarkupSafe==3.0.3 +mdurl==0.1.2 +multidict==6.7.1 +mypy==2.2.0 +mypy_extensions==1.1.0 +nodeenv==1.10.0 +packaging==26.2 +pathspec==1.1.1 +platformdirs==4.10.0 +pluggy==1.6.0 +pre_commit==4.6.0 +propcache==0.5.2 +Pygments==2.20.0 +pytest==9.1.1 +pytest-asyncio==1.4.0 +pytest-cov==7.1.0 +python-discovery==1.4.4 +PyYAML==6.0.3 +-e git+https://github.com/xiabai2004/RayScan@821913749a3998c2ec7620c12d9ff8e31e2b2025#egg=rayscan +requests==2.34.2 +rich==15.0.0 +ruff==0.15.21 +setuptools==83.0.0 +soupsieve==2.8.4 +types-PyYAML==6.0.12.20260518 +types-requests==2.33.0.20260712 +typing_extensions==4.16.0 +urllib3==2.7.0 +uv==0.11.28 +virtualenv==21.6.1 +Werkzeug==3.1.8 +wheel==0.47.0 +yarl==1.24.2 diff --git a/tests/test_other_detectors.py b/tests/test_other_detectors.py index 6ffb15e..d3427ef 100644 --- a/tests/test_other_detectors.py +++ b/tests/test_other_detectors.py @@ -1,7 +1,7 @@ """Tests for remaining detector modules: RCE, XXE, SSRF, Sensitive, WAF, jspathfinder, API.""" import json import pytest -from wvs.models import VulnerabilityType, Severity, Confidence +from wvs.models import Severity from wvs.modules.base import ModuleInfo, ModuleFactory diff --git a/tests/test_p0_smoke.py b/tests/test_p0_smoke.py new file mode 100644 index 0000000..db51724 --- /dev/null +++ b/tests/test_p0_smoke.py @@ -0,0 +1,92 @@ +""" +P0 止血 (hotfix) regression smoke tests. + +These tests lock in the four P0 fixes so they cannot silently regress: + +1. ``pyproject.toml`` pins ``httpx`` (>=0.27,<0.29) and ``flask`` (>=3). +2. ``wvs/core/session.py`` uses the new httpx ``proxy=`` parameter + (the legacy ``proxies=`` parameter was removed in httpx 0.28). +3. ``SQLiDetector`` forwards the injected session up to its base class + via ``super().__init__(config, session)``. +4. Core modules import cleanly after the dependency bump (httpx 0.28 / flask 3). +""" + +import re +import tomllib +from pathlib import Path +from unittest.mock import MagicMock + +from wvs.core.scanner import WAVScanner +from wvs.core.session import HTTPPool +from wvs.modules.sqli.detector import SQLiDetector + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def test_import_wav_scanner(): + """Core scanner class must import cleanly after the dependency bump.""" + assert WAVScanner is not None + # Confirm it is a real class, not a stray None placeholder. + assert isinstance(WAVScanner, type) + + +def test_import_sqli_detector(): + """SQLi detector must import cleanly after the dependency bump.""" + assert SQLiDetector is not None + assert isinstance(SQLiDetector, type) + + +def test_sqli_detector_session_passthrough(): + """The session passed to SQLiDetector must reach both self.session and the base class.""" + # Arrange + mock_config = MagicMock(name="config") + mock_session = MagicMock(name="session", spec=HTTPPool) + + # Act + detector = SQLiDetector(config=mock_config, session=mock_session) + + # Assert: constructor stores the injected session on the instance. + assert detector.session is mock_session + # Assert: the session was transmitted to the base class via + # super().__init__(config, session) — otherwise _active_session stays None. + assert detector._active_session is mock_session + + +def test_pyproject_declares_httpx_and_flask(): + """pyproject.toml must declare the httpx and flask dependencies.""" + # Arrange + pyproject_path = REPO_ROOT / "pyproject.toml" + assert pyproject_path.exists(), "pyproject.toml not found at repo root" + + # Act + with pyproject_path.open("rb") as fh: + data = tomllib.load(fh) + deps = data["project"]["dependencies"] + + def _package_name(dep: str) -> str: + # Strip any version specifier / marker to get the bare package name. + return re.split(r"[<>=!~ ;]", dep.strip())[0].strip().lower() + + dep_names = {_package_name(d) for d in deps} + + # Assert + assert "httpx" in dep_names, "httpx missing from dependencies" + assert "flask" in dep_names, "flask missing from dependencies" + + # The httpx pin must stay within the httpx 0.27.x compatible range + # (proxies= was removed in 0.28, so we must not allow >=0.29). + httpx_dep = next(d for d in deps if d.lower().startswith("httpx")) + assert "<0.29" in httpx_dep, "httpx upper bound must stay <0.29" + assert "flask>=3" in [d.lower() for d in deps], "flask must be >=3" + + +def test_session_uses_proxy_not_proxies(): + """session.py must use the new httpx `proxy=` param, never the removed `proxies=`.""" + # Arrange + session_src = (REPO_ROOT / "wvs" / "core" / "session.py").read_text(encoding="utf-8") + + # Assert: the legacy parameter name (removed in httpx 0.28) must never reappear. + assert "proxies=" not in session_src, "session.py still uses removed httpx 'proxies=' param" + + # Sanity: the corrected `proxy=` usage actually exists in the file. + assert '"proxy"' in session_src, "session.py does not use the new httpx 'proxy' param" diff --git a/tests/test_profiles/test_cli.py b/tests/test_profiles/test_cli.py index 98f9177..002503b 100644 --- a/tests/test_profiles/test_cli.py +++ b/tests/test_profiles/test_cli.py @@ -1,4 +1,3 @@ -import pytest from wvs.cli import build_parser diff --git a/tests/test_profiles/test_loader.py b/tests/test_profiles/test_loader.py index e8885aa..b6fbcd5 100644 --- a/tests/test_profiles/test_loader.py +++ b/tests/test_profiles/test_loader.py @@ -1,5 +1,3 @@ -import pytest -from pathlib import Path from wvs.profiles import ProfileManager from wvs.profiles.builtin import BUILTIN_PROFILES diff --git a/tests/test_sqli.py b/tests/test_sqli.py new file mode 100644 index 0000000..0c3d389 --- /dev/null +++ b/tests/test_sqli.py @@ -0,0 +1,81 @@ +"""Unit tests for the SQLi detector core analysis logic (ResponseAnalyzer). + +These exercise the SQL-injection detection core against mocked session +response dicts ({"status_code", "text", "headers"}) — i.e. exactly the shape +a mocked HTTPPool session would return for a request. +""" + +from wvs.modules.sqli.analyzer import ResponseAnalyzer + +BASELINE = {"status_code": 200, "text": "
welcome", "headers": {}} + + +# ── is_sql_error: error-signature detection ──────────────────────────────── + +def test_is_sql_error_mysql_syntax(): + resp = {"status_code": 500, "text": "You have an error in your SQL syntax near 'x'", "headers": {}} + ok, db = ResponseAnalyzer(BASELINE).is_sql_error(resp) + assert ok is True + assert db == "mysql" + + +def test_is_sql_error_postgres(): + resp = {"status_code": 500, "text": 'ERROR: column "x" does not exist', "headers": {}} + ok, db = ResponseAnalyzer(BASELINE).is_sql_error(resp) + assert ok is True + assert db == "generic" + + +def test_is_sql_error_clean_response(): + resp = {"status_code": 200, "text": "hello world", "headers": {}} + ok, db = ResponseAnalyzer(BASELINE).is_sql_error(resp) + assert ok is False + assert db is None + + +# ── is_boolean_blind_positive: true/false response comparison ────────────── + +def test_is_boolean_blind_positive_differs(): + true_resp = {"status_code": 200, "text": "A" * 1000, "headers": {}} + false_resp = {"status_code": 200, "text": "B" * 10, "headers": {}} + assert ResponseAnalyzer(BASELINE).is_boolean_blind_positive(true_resp, false_resp) is True + + +def test_is_boolean_blind_positive_status_mismatch(): + true_resp = {"status_code": 200, "text": "same", "headers": {}} + false_resp = {"status_code": 404, "text": "same", "headers": {}} + assert ResponseAnalyzer(BASELINE).is_boolean_blind_positive(true_resp, false_resp) is True + + +def test_is_boolean_blind_positive_identical(): + a = {"status_code": 200, "text": "same content here", "headers": {}} + b = {"status_code": 200, "text": "same content here", "headers": {}} + assert ResponseAnalyzer(BASELINE).is_boolean_blind_positive(a, b) is False + + +# ── is_union_positive: UNION injection confirmation ──────────────────────── + +def test_is_union_positive_detected(): + resp = {"status_code": 200, "text": "1 UNION SELECT username, password FROM users", "headers": {}} + ok, cols = ResponseAnalyzer(BASELINE).is_union_positive(resp) + assert ok is True + + +def test_is_union_positive_absent(): + resp = {"status_code": 200, "text": "normal page", "headers": {}} + ok, cols = ResponseAnalyzer(BASELINE).is_union_positive(resp) + assert ok is False + + +# ── is_time_based_positive: delay threshold ─────────────────────────────── + +def test_is_time_based_positive_above_threshold(): + ra = ResponseAnalyzer(BASELINE) + # 4.0 >= 5.0 * 0.7 (3.5) -> True + assert ra.is_time_based_positive({}, 5.0, 4.0) is True + + +def test_is_time_based_positive_below_threshold(): + ra = ResponseAnalyzer(BASELINE) + # 2.0 < 5.0 * 0.7 (3.5) -> False + assert ra.is_time_based_positive({}, 5.0, 2.0) is False diff --git a/tests/test_xss.py b/tests/test_xss.py new file mode 100644 index 0000000..6f31d89 --- /dev/null +++ b/tests/test_xss.py @@ -0,0 +1,104 @@ +"""Unit tests for the XSS detector core analysis logic (context_analyzer). + +Pure-logic tests target the context-aware reflection analyzer that powers +XSS detection. The integration test mocks the detector's HTTP session seam +(_send_request) to verify end-to-end reflected-XSS detection against a +mocked session response. +""" +import pytest +from unittest.mock import AsyncMock + +from wvs.modules.xss.context_analyzer import ( + XSS_CHECKER, + ReflectionContext, + analyze_reflection, + select_payload, +) +from wvs.modules.xss.detector import XSSDetector +from wvs.models import ScanTarget, VulnerabilityType + + +# ── analyze_reflection: context determination ────────────────────────────── + +def test_analyze_reflection_script_context(): + html = "" + ctxs = analyze_reflection(html, XSS_CHECKER) + assert len(ctxs) == 1 + assert ctxs[0].context == "script" + + +def test_analyze_reflection_attribute_context(): + html = '' + ctxs = analyze_reflection(html, XSS_CHECKER) + assert ctxs + assert ctxs[0].context == "attribute" + + +def test_analyze_reflection_html_context(): + html = "hello vXSScH3ck3r world" + ctxs = analyze_reflection(html, XSS_CHECKER) + assert ctxs + assert ctxs[0].context == "html" + + +def test_analyze_reflection_comment_not_executable(): + html = "" + ctxs = analyze_reflection(html, XSS_CHECKER) + assert ctxs + assert ctxs[0].context == "comment" + # A marker inside an HTML comment cannot be executed -> not exploitable. + assert ctxs[0].is_executable() is False + + +def test_reflection_context_is_executable_matrix(): + assert ReflectionContext(context="script").is_executable() is True + assert ReflectionContext(context="attribute").is_executable() is True + assert ReflectionContext(context="html").is_executable() is True + assert ReflectionContext(context="comment").is_executable() is False + assert ReflectionContext(context="bad").is_executable() is False + + +# ── select_payload: context-optimized payload selection ──────────────────── + +def test_select_payload_script_quote(): + ctx = ReflectionContext(context="script", quote_char="'") + payloads = select_payload(ctx) + assert any("alert(1)" in p for p in payloads) + + +def test_select_payload_html(): + ctx = ReflectionContext(context="html") + payloads = select_payload(ctx) + assert any(" tag -> HTML display reflection (FP)."""
from wvs.modules.rce.detector import RCEDetector
+
det = RCEDetector()
html = "Your input: TESTTOKEN42
"
assert det._is_html_display_reflection(html, "TESTTOKEN42", "; echo TESTTOKEN42") is True
def test_is_html_display_reflection_code_tag(self):
from wvs.modules.rce.detector import RCEDetector
+
det = RCEDetector()
html = "TESTTOKEN99"
assert det._is_html_display_reflection(html, "TESTTOKEN99", "TESTTOKEN99") is True
@@ -100,12 +111,14 @@ def test_is_html_display_reflection_code_tag(self):
def test_is_html_display_reflection_no_wrapper(self):
"""Token without HTML display wrapper -> not FP."""
from wvs.modules.rce.detector import RCEDetector
+
det = RCEDetector()
assert det._is_html_display_reflection("output TESTTOKEN42 here", "TESTTOKEN42", "x") is False
def test_is_echo_server_json_response(self):
"""httpbin-style JSON echo -> echo server (FP)."""
from wvs.modules.rce.detector import RCEDetector
+
det = RCEDetector()
# payload appears inside a JSON string value in the response
resp = json.dumps({"args": {"cmd": "echo test"}, "url": "http://httpbin.org/get"})
@@ -114,12 +127,14 @@ def test_is_echo_server_json_response(self):
def test_is_echo_server_normal_response(self):
"""Normal HTML response -> not an echo server."""
from wvs.modules.rce.detector import RCEDetector
+
det = RCEDetector()
assert det._is_echo_server("http://example.com", "OK", "test") is False
def test_is_lfi_context_positive(self):
"""Response with passwd + PATH= markers -> LFI context (FP)."""
from wvs.modules.rce.detector import RCEDetector
+
det = RCEDetector()
resp = "root:x:0:0:root:/root:/bin/bash\nPATH=/usr/local/bin:/usr/bin"
assert det._is_lfi_context(resp) is True
@@ -127,6 +142,7 @@ def test_is_lfi_context_positive(self):
def test_is_lfi_context_negative(self):
"""Normal response -> not LFI context."""
from wvs.modules.rce.detector import RCEDetector
+
det = RCEDetector()
assert det._is_lfi_context("Welcome") is False
@@ -135,9 +151,11 @@ def test_is_lfi_context_negative(self):
# XXE Detector
# =====================================================================
+
class TestXXEDetector:
def test_get_info(self):
from wvs.modules.xxe.detector import XXEDetector
+
info = XXEDetector.get_info()
assert info.name == "xxe"
assert "xml" in info.description.lower()
@@ -147,12 +165,14 @@ def test_module_registration(self):
def test_xml_content_types(self):
from wvs.modules.xxe.detector import XXEDetector
+
assert "application/xml" in XXEDetector.XML_CONTENT_TYPES
assert "text/xml" in XXEDetector.XML_CONTENT_TYPES
assert "application/soap+xml" in XXEDetector.XML_CONTENT_TYPES
def test_xml_extensions(self):
from wvs.modules.xxe.detector import XXEDetector
+
assert ".xml" in XXEDetector.XML_EXTENSIONS
assert ".svg" in XXEDetector.XML_EXTENSIONS
assert ".wsdl" in XXEDetector.XML_EXTENSIONS
@@ -160,6 +180,7 @@ def test_xml_extensions(self):
def test_check_xxe_success_file_content(self):
"""Response containing /etc/passwd content -> XXE success."""
from wvs.modules.xxe.detector import XXEDetector
+
det = XXEDetector()
resp = "root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin"
assert det._check_xxe_success(resp) is True
@@ -167,23 +188,27 @@ def test_check_xxe_success_file_content(self):
def test_check_xxe_success_parse_error(self):
"""XML parse error in response -> XXE success."""
from wvs.modules.xxe.detector import XXEDetector
+
det = XXEDetector()
assert det._check_xxe_success("failed to load external entity") is True
def test_check_xxe_success_xml_parse_error(self):
"""XML parser error message -> XXE success."""
from wvs.modules.xxe.detector import XXEDetector
+
det = XXEDetector()
assert det._check_xxe_success("Warning: DOMDocument::loadXML(): Entity") is True
def test_check_xxe_success_negative(self):
"""Normal HTML response -> no XXE."""
from wvs.modules.xxe.detector import XXEDetector
+
det = XXEDetector()
assert det._check_xxe_success("Welcome") is False
def test_check_xxe_success_empty(self):
from wvs.modules.xxe.detector import XXEDetector
+
det = XXEDetector()
assert det._check_xxe_success("") is False
@@ -192,9 +217,11 @@ def test_check_xxe_success_empty(self):
# SSRF Detector
# =====================================================================
+
class TestSSRFDetector:
def test_get_info(self):
from wvs.modules.ssrf.detector import SSRFDetector
+
info = SSRFDetector.get_info()
assert info.name == "ssrf"
assert "request" in info.description.lower()
@@ -204,6 +231,7 @@ def test_module_registration(self):
def test_param_patterns(self):
from wvs.modules.ssrf.detector import SSRFDetector
+
patterns = SSRFDetector.SSRF_PARAM_PATTERNS
assert "url" in patterns
assert "redirect" in patterns
@@ -213,6 +241,7 @@ def test_param_patterns(self):
def test_check_ssrf_success_file_read(self):
"""Response with /etc/passwd content -> SSRF file read success."""
from wvs.modules.ssrf.detector import SSRFDetector
+
det = SSRFDetector.__new__(SSRFDetector)
resp = "root:x:0:0:root:/root:/bin/bash"
assert det._check_ssrf_success(resp, None) is True
@@ -220,6 +249,7 @@ def test_check_ssrf_success_file_read(self):
def test_check_ssrf_success_aws_metadata(self):
"""Response with AWS metadata -> SSRF cloud metadata success."""
from wvs.modules.ssrf.detector import SSRFDetector
+
det = SSRFDetector.__new__(SSRFDetector)
resp = '{"AccessKeyId": "ASIA123456", "SecretAccessKey": "secret123"}'
assert det._check_ssrf_success(resp, None) is True
@@ -227,33 +257,39 @@ def test_check_ssrf_success_aws_metadata(self):
def test_check_ssrf_success_connection_error_with_payload(self):
"""Connection error text when payload provided -> SSRF attempted."""
from wvs.modules.ssrf.detector import SSRFDetector
+
det = SSRFDetector.__new__(SSRFDetector)
assert det._check_ssrf_success("Connection refused", "http://internal:22") is True
def test_check_ssrf_success_banner_detection(self):
"""SSH banner in response with payload -> SSRF detected."""
from wvs.modules.ssrf.detector import SSRFDetector
+
det = SSRFDetector.__new__(SSRFDetector)
assert det._check_ssrf_success("SSH-2.0-OpenSSH_8.9p1", "http://internal:22") is True
def test_check_ssrf_success_negative(self):
"""Normal response -> no SSRF success."""
from wvs.modules.ssrf.detector import SSRFDetector
+
det = SSRFDetector.__new__(SSRFDetector)
assert det._check_ssrf_success("OK", None) is False
def test_check_ssrf_connection_error_refused(self):
from wvs.modules.ssrf.detector import SSRFDetector
+
det = SSRFDetector.__new__(SSRFDetector)
assert det._check_ssrf_connection_error("Error: Connection refused") is True
def test_check_ssrf_connection_error_timeout(self):
from wvs.modules.ssrf.detector import SSRFDetector
+
det = SSRFDetector.__new__(SSRFDetector)
assert det._check_ssrf_connection_error("Connection timed out") is True
def test_check_ssrf_connection_error_negative(self):
from wvs.modules.ssrf.detector import SSRFDetector
+
det = SSRFDetector.__new__(SSRFDetector)
assert det._check_ssrf_connection_error("OK") is False
@@ -262,9 +298,11 @@ def test_check_ssrf_connection_error_negative(self):
# Sensitive Info Detector
# =====================================================================
+
class TestSensitiveDetector:
def test_get_info(self):
from wvs.modules.sensitive.detector import SensitiveDetector
+
info = SensitiveDetector.get_info()
assert info.name == "sensitive"
assert "sensitive" in info.description.lower()
@@ -274,23 +312,26 @@ def test_module_registration(self):
def test_high_priority_paths(self):
from wvs.modules.sensitive.detector import SensitiveDetector
+
assert "/.git/config" in SensitiveDetector.HIGH_PRIORITY_PATHS
assert "/.env" in SensitiveDetector.HIGH_PRIORITY_PATHS
assert "/wp-config.php" in SensitiveDetector.HIGH_PRIORITY_PATHS
def test_normalize_url_strips_query_and_fragment(self):
from wvs.modules.sensitive.detector import SensitiveDetector
+
normalized = SensitiveDetector._normalize_sensitive_url("http://example.com/page?q=1#frag")
assert normalized == "http://example.com/page"
def test_normalize_url_no_change(self):
from wvs.modules.sensitive.detector import SensitiveDetector
+
normalized = SensitiveDetector._normalize_sensitive_url("http://example.com/page")
assert normalized == "http://example.com/page"
def test_dedup_sensitive_vulns(self):
+ from wvs.models import Confidence, Severity, Vulnerability, VulnerabilityType
from wvs.modules.sensitive.detector import SensitiveDetector
- from wvs.models import Vulnerability, VulnerabilityType, Severity, Confidence
vulns = [
# Identical url + severity + evidence -> dedup
@@ -322,28 +363,33 @@ def test_dedup_sensitive_vulns(self):
def test_has_sensitive_content_git_config(self):
from wvs.modules.sensitive.detector import SensitiveDetector
+
det = SensitiveDetector.__new__(SensitiveDetector)
- assert det._has_sensitive_content("/.git/config", '[core]\n\trepositoryformatversion = 0') is True
+ assert det._has_sensitive_content("/.git/config", "[core]\n\trepositoryformatversion = 0") is True
def test_has_sensitive_content_plain_html(self):
from wvs.modules.sensitive.detector import SensitiveDetector
+
det = SensitiveDetector.__new__(SensitiveDetector)
assert det._has_sensitive_content("/.env", "404 Not Found") is False
def test_get_path_severity_git(self):
from wvs.modules.sensitive.detector import SensitiveDetector
+
det = SensitiveDetector.__new__(SensitiveDetector)
sev = det._get_path_severity("/.git/config")
assert sev == Severity.CRITICAL
def test_get_path_severity_backup(self):
from wvs.modules.sensitive.detector import SensitiveDetector
+
det = SensitiveDetector.__new__(SensitiveDetector)
sev = det._get_path_severity("/backup.zip")
assert sev == Severity.HIGH
def test_get_path_severity_admin(self):
from wvs.modules.sensitive.detector import SensitiveDetector
+
det = SensitiveDetector.__new__(SensitiveDetector)
sev = det._get_path_severity("/admin/")
assert sev == Severity.MEDIUM
@@ -353,9 +399,11 @@ def test_get_path_severity_admin(self):
# WAF Detector
# =====================================================================
+
class TestWAFDetector:
def test_get_info(self):
from wvs.modules.waf.detector import WAFDetector
+
info = WAFDetector.get_info()
assert info.name == "waf"
assert "waf" in info.tags or "cloudflare" in info.description.lower()
@@ -365,18 +413,21 @@ def test_module_registration(self):
def test_waf_signatures_cloudflare(self):
from wvs.modules.waf.detector import WAF_SIGNATURES
+
sig = next((s for name, s in WAF_SIGNATURES if name == "Cloudflare"), None)
assert sig is not None
assert "cf-ray" in sig.get("headers", {})
def test_waf_signatures_aws(self):
from wvs.modules.waf.detector import WAF_SIGNATURES
+
sig = next((s for name, s in WAF_SIGNATURES if "AWS" in name), None)
assert sig is not None
assert any("x-amz" in k for k in sig.get("headers", {}))
def test_match_all_signatures_cloudflare(self):
from wvs.modules.waf.detector import WAFDetector
+
det = WAFDetector.__new__(WAFDetector)
baseline = {
"status": 403,
@@ -391,6 +442,7 @@ def test_match_all_signatures_cloudflare(self):
def test_match_all_signatures_no_waf(self):
"""Normal response -> no WAF detected."""
from wvs.modules.waf.detector import WAFDetector
+
det = WAFDetector.__new__(WAFDetector)
baseline = {
"status": 200,
@@ -406,9 +458,11 @@ def test_match_all_signatures_no_waf(self):
# JSPathFinder Detector
# =====================================================================
+
class TestJSPathFinder:
def test_get_info(self):
from wvs.modules.jspathfinder.detector import JSPathfinderDetector
+
info = JSPathfinderDetector.get_info()
assert info.name == "jspathfinder"
assert "js" in info.description.lower() or "path" in info.description.lower()
@@ -418,21 +472,25 @@ def test_module_registration(self):
def test_is_known_library_jquery(self):
from wvs.modules.jspathfinder.detector import JSPathfinderDetector
+
det = JSPathfinderDetector.__new__(JSPathfinderDetector)
assert det._is_known_library("https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js") is True
def test_is_known_library_vue(self):
from wvs.modules.jspathfinder.detector import JSPathfinderDetector
+
det = JSPathfinderDetector.__new__(JSPathfinderDetector)
assert det._is_known_library("https://cdn.jsdelivr.net/npm/vue/dist/vue.js") is True
def test_is_known_library_custom(self):
from wvs.modules.jspathfinder.detector import JSPathfinderDetector
+
det = JSPathfinderDetector.__new__(JSPathfinderDetector)
assert det._is_known_library("https://app.example.com/static/js/custom.js") is False
def test_scan_secrets_aws_key(self):
from wvs.modules.jspathfinder.detector import JSPathfinderDetector
+
det = JSPathfinderDetector.__new__(JSPathfinderDetector)
js_content = 'const awsKey = "AKIAIOSFODNN7EXAMPLE";'
secrets = det._scan_secrets(js_content, "test.js")
@@ -441,6 +499,7 @@ def test_scan_secrets_aws_key(self):
def test_scan_secrets_private_key(self):
from wvs.modules.jspathfinder.detector import JSPathfinderDetector
+
det = JSPathfinderDetector.__new__(JSPathfinderDetector)
js_content = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA..."
secrets = det._scan_secrets(js_content, "test.key")
@@ -449,11 +508,13 @@ def test_scan_secrets_private_key(self):
def test_scan_secrets_empty(self):
from wvs.modules.jspathfinder.detector import JSPathfinderDetector
+
det = JSPathfinderDetector.__new__(JSPathfinderDetector)
assert det._scan_secrets("console.log('hello');", "test.js") == []
def test_extract_endpoints_api(self):
from wvs.modules.jspathfinder.detector import JSPathfinderDetector
+
det = JSPathfinderDetector.__new__(JSPathfinderDetector)
det._current_target = "http://example.com" # needed by _extract_endpoints
det._seen_endpoints = set() # needed by _extract_endpoints
@@ -465,6 +526,7 @@ def test_extract_endpoints_api(self):
def test_extract_endpoints_skip_images(self):
from wvs.modules.jspathfinder.detector import JSPathfinderDetector
+
det = JSPathfinderDetector.__new__(JSPathfinderDetector)
js = 'img.src = "/static/img/logo.png";'
endpoints = det._extract_endpoints(js, "test.js")
@@ -472,6 +534,7 @@ def test_extract_endpoints_skip_images(self):
def test_scan_vuln_keywords_sqli(self):
from wvs.modules.jspathfinder.detector import JSPathfinderDetector
+
det = JSPathfinderDetector.__new__(JSPathfinderDetector)
js = 'db.query("SELECT * FROM users WHERE id = " + userId);'
vulns = det._scan_vuln_keywords(js, "test.js")
@@ -481,6 +544,7 @@ def test_scan_vuln_keywords_sqli(self):
def test_scan_vuln_keywords_eval(self):
from wvs.modules.jspathfinder.detector import JSPathfinderDetector
+
det = JSPathfinderDetector.__new__(JSPathfinderDetector)
js = 'eval("alert(" + userInput + ")");'
vulns = det._scan_vuln_keywords(js, "test.js")
@@ -491,9 +555,11 @@ def test_scan_vuln_keywords_eval(self):
# API Detector
# =====================================================================
+
class TestAPIDetector:
def test_get_info(self):
from wvs.modules.api.detector import APIDetector
+
info = APIDetector.get_info()
assert info.name == "api"
@@ -502,24 +568,125 @@ def test_module_registration(self):
def test_is_public_path_login(self):
from wvs.modules.api.detector import APIDetector
+
assert APIDetector._is_public_path("http://example.com/login") is True
def test_is_public_path_assets(self):
from wvs.modules.api.detector import APIDetector
+
assert APIDetector._is_public_path("http://example.com/assets/js/app.js") is True
def test_is_public_path_health(self):
from wvs.modules.api.detector import APIDetector
+
assert APIDetector._is_public_path("http://example.com/health") is True
def test_is_public_path_api_endpoint(self):
from wvs.modules.api.detector import APIDetector
+
assert APIDetector._is_public_path("http://example.com/api/users") is False
def test_is_public_path_swagger(self):
from wvs.modules.api.detector import APIDetector
+
assert APIDetector._is_public_path("http://example.com/swagger/index.html") is True
def test_is_public_path_case_insensitive(self):
from wvs.modules.api.detector import APIDetector
+
assert APIDetector._is_public_path("http://example.com/LOGIN") is True
+
+
+# =====================================================================
+# T2.1: ModuleFactory registry is the single source of truth for loading
+# =====================================================================
+
+
+class TestModuleRegistryT2_1:
+ def test_all_expected_modules_registered(self):
+ from wvs.modules import register_all_modules
+
+ register_all_modules()
+ registered = set(ModuleFactory.list_modules())
+
+ expected = {
+ "sqli",
+ "xss", # core
+ "sensitive",
+ "waf",
+ "cmdi",
+ "lfi",
+ "ssrf",
+ "xxe", # lite
+ "rce",
+ "api",
+ "js_analysis",
+ "oa",
+ "webshell",
+ "weakpass",
+ "subdomain",
+ "jspathfinder", # optional (registered, never auto-loaded)
+ }
+ assert expected.issubset(registered), registered - expected
+
+ def test_core_modules(self):
+ assert ModuleFactory.get_module_info("sqli").category == "core"
+ assert ModuleFactory.get_module_info("xss").category == "core"
+
+ def test_lite_modules(self):
+ for name in (
+ "sensitive",
+ "waf",
+ "cmdi",
+ "lfi",
+ "ssrf",
+ "xxe",
+ "rce",
+ "api",
+ "js_analysis",
+ "oa",
+ "webshell",
+ "weakpass",
+ "subdomain",
+ ):
+ info = ModuleFactory.get_module_info(name)
+ assert info is not None, name
+ assert info.category == "lite", name
+
+ def test_optional_modules(self):
+ info = ModuleFactory.get_module_info("jspathfinder")
+ assert info is not None
+ assert info.category == "optional"
+
+ def test_scanner_derives_enabled_from_registry(self):
+ # Default mode loads only core modules (sqli, xss).
+ from wvs.config import ConfigManager
+ from wvs.core import WAVScanner
+ from wvs.core.session import HTTPPool
+
+ config = ConfigManager()
+ session = HTTPPool(config)
+ scanner = WAVScanner(config, session)
+ assert scanner._resolve_enabled_modules() == ["sqli", "xss"]
+
+ # --all-modules mode loads core + lite and excludes optional modules.
+ scanner._load_all_modules = True
+ enabled = scanner._resolve_enabled_modules()
+ assert "sqli" in enabled and "xss" in enabled
+ assert "jspathfinder" not in enabled
+ for name in (
+ "sensitive",
+ "waf",
+ "cmdi",
+ "lfi",
+ "ssrf",
+ "xxe",
+ "rce",
+ "api",
+ "js_analysis",
+ "oa",
+ "webshell",
+ "weakpass",
+ "subdomain",
+ ):
+ assert name in enabled, name
diff --git a/tests/test_p0_smoke.py b/tests/test_p0_smoke.py
index db51724..e4a93b1 100644
--- a/tests/test_p0_smoke.py
+++ b/tests/test_p0_smoke.py
@@ -12,10 +12,14 @@
"""
import re
-import tomllib
from pathlib import Path
from unittest.mock import MagicMock
+try:
+ import tomllib
+except ImportError: # Python < 3.11 has no builtin tomllib, fall back to tomli
+ import tomli as tomllib
+
from wvs.core.scanner import WAVScanner
from wvs.core.session import HTTPPool
from wvs.modules.sqli.detector import SQLiDetector
diff --git a/tests/test_profiles/test_cli.py b/tests/test_profiles/test_cli.py
index 002503b..41177f3 100644
--- a/tests/test_profiles/test_cli.py
+++ b/tests/test_profiles/test_cli.py
@@ -17,12 +17,19 @@ def test_profile_list_json_format():
def test_profile_create_command():
parser = build_parser()
- args = parser.parse_args([
- "profile", "create", "test-profile",
- "--modules", "sqli,xss",
- "--rate", "15",
- "--description", "Test profile",
- ])
+ args = parser.parse_args(
+ [
+ "profile",
+ "create",
+ "test-profile",
+ "--modules",
+ "sqli,xss",
+ "--rate",
+ "15",
+ "--description",
+ "Test profile",
+ ]
+ )
assert args.profile_action == "create"
assert args.name == "test-profile"
assert args.modules == "sqli,xss"
@@ -55,10 +62,14 @@ def test_profile_import_command():
def test_use_command_basic():
parser = build_parser()
- args = parser.parse_args([
- "use", "src-quick",
- "-u", "https://example.com",
- ])
+ args = parser.parse_args(
+ [
+ "use",
+ "src-quick",
+ "-u",
+ "https://example.com",
+ ]
+ )
assert args.command == "use"
assert args.profile == "src-quick"
assert args.url == "https://example.com"
@@ -66,18 +77,29 @@ def test_use_command_basic():
def test_use_command_full():
parser = build_parser()
- args = parser.parse_args([
- "use", "pentest-full",
- "-u", "https://target.com",
- "-o", "report.json",
- "-f", "html",
- "-v",
- "--auth", "auth.json",
- "--max-time", "3600",
- "--insecure",
- "--modules", "sqli", "xss",
- "--no-modules", "cmdi",
- ])
+ args = parser.parse_args(
+ [
+ "use",
+ "pentest-full",
+ "-u",
+ "https://target.com",
+ "-o",
+ "report.json",
+ "-f",
+ "html",
+ "-v",
+ "--auth",
+ "auth.json",
+ "--max-time",
+ "3600",
+ "--insecure",
+ "--modules",
+ "sqli",
+ "xss",
+ "--no-modules",
+ "cmdi",
+ ]
+ )
assert args.profile == "pentest-full"
assert args.url == "https://target.com"
assert args.output == "report.json"
@@ -92,9 +114,13 @@ def test_use_command_full():
def test_use_command_nonexistent_profile():
parser = build_parser()
- args = parser.parse_args([
- "use", "nonexistent-profile",
- "-u", "https://example.com",
- ])
+ args = parser.parse_args(
+ [
+ "use",
+ "nonexistent-profile",
+ "-u",
+ "https://example.com",
+ ]
+ )
# Profile validation happens at runtime, not at parse time
assert args.profile == "nonexistent-profile"
diff --git a/tests/test_sqli.py b/tests/test_sqli.py
index 0c3d389..102972b 100644
--- a/tests/test_sqli.py
+++ b/tests/test_sqli.py
@@ -12,6 +12,7 @@
# ── is_sql_error: error-signature detection ────────────────────────────────
+
def test_is_sql_error_mysql_syntax():
resp = {"status_code": 500, "text": "You have an error in your SQL syntax near 'x'", "headers": {}}
ok, db = ResponseAnalyzer(BASELINE).is_sql_error(resp)
@@ -35,6 +36,7 @@ def test_is_sql_error_clean_response():
# ── is_boolean_blind_positive: true/false response comparison ──────────────
+
def test_is_boolean_blind_positive_differs():
true_resp = {"status_code": 200, "text": "A" * 1000, "headers": {}}
false_resp = {"status_code": 200, "text": "B" * 10, "headers": {}}
@@ -55,6 +57,7 @@ def test_is_boolean_blind_positive_identical():
# ── is_union_positive: UNION injection confirmation ────────────────────────
+
def test_is_union_positive_detected():
resp = {"status_code": 200, "text": "1 UNION SELECT username, password FROM users", "headers": {}}
ok, cols = ResponseAnalyzer(BASELINE).is_union_positive(resp)
@@ -69,6 +72,7 @@ def test_is_union_positive_absent():
# ── is_time_based_positive: delay threshold ───────────────────────────────
+
def test_is_time_based_positive_above_threshold():
ra = ResponseAnalyzer(BASELINE)
# 4.0 >= 5.0 * 0.7 (3.5) -> True
diff --git a/tests/test_xss.py b/tests/test_xss.py
index 6f31d89..d827ab3 100644
--- a/tests/test_xss.py
+++ b/tests/test_xss.py
@@ -5,9 +5,12 @@
(_send_request) to verify end-to-end reflected-XSS detection against a
mocked session response.
"""
-import pytest
+
from unittest.mock import AsyncMock
+import pytest
+
+from wvs.models import ScanTarget, VulnerabilityType
from wvs.modules.xss.context_analyzer import (
XSS_CHECKER,
ReflectionContext,
@@ -15,11 +18,10 @@
select_payload,
)
from wvs.modules.xss.detector import XSSDetector
-from wvs.models import ScanTarget, VulnerabilityType
-
# ── analyze_reflection: context determination ──────────────────────────────
+
def test_analyze_reflection_script_context():
html = ""
ctxs = analyze_reflection(html, XSS_CHECKER)
@@ -60,6 +62,7 @@ def test_reflection_context_is_executable_matrix():
# ── select_payload: context-optimized payload selection ────────────────────
+
def test_select_payload_script_quote():
ctx = ReflectionContext(context="script", quote_char="'")
payloads = select_payload(ctx)
@@ -80,6 +83,7 @@ def test_select_payload_comment_escapes():
# ── integration: mock session -> reflected XSS detected ─────────────────────
+
@pytest.mark.asyncio
async def test_xss_detector_finds_reflected_xss_with_mocked_session():
"""End-to-end reflected-XSS detection with a mocked HTTP session.
diff --git a/wvs/cli.py b/wvs/cli.py
index e828786..ccdfa0d 100644
--- a/wvs/cli.py
+++ b/wvs/cli.py
@@ -34,13 +34,13 @@
from rich.panel import Panel
from rich.table import Table
+from . import __version__
from .config import ConfigManager
from .core import HTTPPool, WAVScanner
from .models import ScanResult, ScanTarget, Severity
from .modules.base import ModuleFactory
from .plugins.auth import AuthManager
-from . import __version__
-from .reporting import ConsoleReporter, HTMLReporter, MarkdownReporter, JSONReporter, CSVReporter
+from .reporting import ConsoleReporter, CSVReporter, HTMLReporter, JSONReporter, MarkdownReporter
console = Console()
@@ -510,20 +510,25 @@ def cmd_list_modules(args):
table.add_column("默认", justify="center")
table.add_column("层级")
- core_modules = {"sqli", "xss"}
for name in modules:
info = ModuleFactory.get_module_info(name)
if info:
- tier = "[bold]核心[/bold]" if name in core_modules else "lite"
+ is_core = info.category == "core"
+ tier = "[bold]核心[/bold]" if is_core else "lite"
table.add_row(
name,
info.description,
- "[OK]" if name in core_modules else "[X]",
+ "[OK]" if is_core else "[X]",
tier,
)
+ core_count = sum(
+ 1
+ for n in modules
+ if (ModuleFactory.get_module_info(n) or None) and ModuleFactory.get_module_info(n).category == "core"
+ )
console.print(table)
- console.print(f"\n共 {len(modules)} 个模块(核心: sqli+xss, lite: {len(modules) - 2} 个)")
+ console.print(f"\n共 {len(modules)} 个模块(核心: {core_count} 个, lite: {len(modules) - core_count} 个)")
console.print("[dim]提示: 使用 --all-modules 加载全部 lite 模块[/dim]")
return 0
diff --git a/wvs/core/scanner.py b/wvs/core/scanner.py
index 65f4cdc..97a67a2 100644
--- a/wvs/core/scanner.py
+++ b/wvs/core/scanner.py
@@ -5,16 +5,15 @@
No hardcoded lab paths — lab-specific logic lives in core/lab_profiles.py.
"""
-import urllib.parse
-from urllib.parse import parse_qs, urlparse
-
import asyncio
import hashlib
import json
import logging
import time
+import urllib.parse
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
+from urllib.parse import parse_qs, urlparse
from ..config import ConfigManager
from ..models import (
@@ -38,28 +37,11 @@ def detect_lab_profile_from_paths(url, paths):
logger = logging.getLogger(__name__)
-# Module execution priority — faster/critical modules run first
-_MODULE_PRIORITY = [
- "sqli", # critical — test priority
- "xss", # cross-site scripting
-]
-
-# Lite modules (loaded when --all-modules is set)
-_LITE_MODULE_PRIORITY = [
- "sensitive", # fast pattern-based checks
- "waf", # WAF detection
- "cmdi", # command injection
- "lfi", # file inclusion
- "ssrf", # server-side request forgery
- "xxe", # XML external entity
- "rce", # time-based (slowest)
- "api", # API security
- "js_analysis", # JS sensitive info / endpoints
- "oa", # OA system vulnerability detection
- "webshell", # WebShell detection
- "weakpass", # Weak password detection
- "subdomain", # Subdomain enumeration
-]
+# T2.1: module loading is registry-driven. The set of enabled modules is derived
+# from the ModuleFactory registry (single source of truth) using each module's
+# ``category`` field ("core" loads by default, "lite" only with --all-modules,
+# "optional" never auto-loaded), instead of the previously hardcoded
+# _MODULE_PRIORITY / _LITE_MODULE_PRIORITY lists. See _resolve_enabled_modules().
class WAVScanner(ScannerIntegrationsMixin):
@@ -156,20 +138,43 @@ def _ensure_params(ep: DiscoveredEndpoint) -> tuple:
# ─────────────────────────────────────────────────────────────
def _resolve_enabled_modules(self) -> List[str]:
- """从配置中解析出要启用的模块列表
+ """从 ModuleFactory 注册表(唯一事实源)解析出要启用的模块列表
- 默认只加载核心模块(sqli + xss)。
- 设置 load_all=True 或配置 modules.all=true 加载全部(含 lite 模块)。
+ - ``category="core"`` 模块默认加载(sqli + xss)。
+ - ``category="lite"`` 模块仅在 --all-modules / modules.all=true 时加载。
+ - ``category="optional"`` 模块永不自动加载(仅由其自身配置开关启用,如 jspathfinder)。
+
+ 默认模式下,每个 core 模块还受 ``modules..enabled`` 配置项约束(默认 True)。
"""
- if self._load_all_modules or self.config.get("modules.all", False):
- return list(_MODULE_PRIORITY + [m for m in _LITE_MODULE_PRIORITY if m not in _MODULE_PRIORITY])
+ from ..modules import register_all_modules
+ from ..modules.base import ModuleFactory
+
+ # 确保注册表已填充(幂等;即使调用方未导入 wvs.modules 也安全)。
+ register_all_modules()
+
+ def _meta(name: str) -> "tuple[str, int]":
+ info = ModuleFactory.get_module_info(name)
+ category = info.category if info else "lite"
+ priority = info.priority if info else 100
+ return category, priority
- enabled = []
- for name in _MODULE_PRIORITY:
- cfg = self.config.get(f"modules.{name}", {})
- if isinstance(cfg, dict) and cfg.get("enabled", True):
- enabled.append(name)
- return enabled
+ load_all = self._load_all_modules or self.config.get("modules.all", False)
+
+ if load_all:
+ candidates = [name for name in ModuleFactory.list_modules() if _meta(name)[0] in ("core", "lite")]
+ else:
+ candidates = [name for name in ModuleFactory.list_modules() if _meta(name)[0] == "core"]
+ # 尊重每个模块的启用/禁用配置(默认启用)。
+ enabled: List[str] = []
+ for name in candidates:
+ cfg = self.config.get(f"modules.{name}", {})
+ if isinstance(cfg, dict) and cfg.get("enabled", True):
+ enabled.append(name)
+ candidates = enabled
+
+ # 稳定排序:优先级数字小者先执行,其次按名称。
+ candidates.sort(key=lambda n: (_meta(n)[1], n))
+ return candidates
# ── Auth ────────────────────────────────────────────────────
@@ -267,7 +272,7 @@ async def _do_authenticate(self, target: ScanTarget) -> Dict[str, Any]:
def load_module(self, module_name: str) -> bool:
"""
- 加载单个检测模块
+ 加载单个检测模块(从 ModuleFactory 注册表按名取实例)
Args:
module_name: 模块名(如 "sqli", "cmdi")
@@ -278,50 +283,27 @@ def load_module(self, module_name: str) -> bool:
if module_name in self._modules:
return True
- try:
- # 尝试从 modules..detector 导入
- mod = __import__(
- f"wvs.modules.{module_name}.detector",
- fromlist=["detector"],
- )
- detector_cls = getattr(mod, "Detector", None)
- if detector_cls is None:
- # 策略1:尝试常见命名变体
- upper = module_name.upper()
- name_variants = [
- f"{module_name.title()}Detector", # CmdiDetector, XssDetector, LfiDetector
- f"{upper}Detector", # CMDI, XSS, LFI → OK
- "SQLiDetector", # sqli 特殊:混合大小写
- ]
- for variant in name_variants:
- detector_cls = getattr(mod, variant, None)
- if detector_cls:
- break
-
- # 策略2:兜底 —— 遍历模块找所有 *Detector 类
- if detector_cls is None:
- for attr_name in dir(mod):
- if attr_name.endswith("Detector") and attr_name != "DetectionModule":
- detector_cls = getattr(mod, attr_name)
- logger.info(f"[Scanner] 自动发现检测类: {attr_name} (for module '{module_name}')")
- break
+ # T2.1: 单一事实源 = ModuleFactory 注册表。删除了原先的 __import__ +
+ # 命名变体兜底逻辑。注册表由 register_all_modules() 在导入时填充(幂等)。
+ from ..modules import register_all_modules
+ from ..modules.base import ModuleFactory
- if detector_cls is None:
- raise ImportError(f"No Detector class found in wvs.modules.{module_name}.detector")
+ register_all_modules()
- instance = detector_cls(self.config, session=self.session)
- self._modules[module_name] = instance
- self._loaded_module_names.append(module_name)
- logger.info(f"[Scanner] 已加载模块: {module_name} (session: {id(self.session)})")
- return True
-
- except ImportError as e:
- logger.warning(f"[Scanner] 模块 {module_name} 不可用: {e}")
+ try:
+ instance = ModuleFactory.create(module_name, self.config, self.session)
+ except KeyError:
+ logger.warning(f"[Scanner] 模块 {module_name} 未在 ModuleFactory 注册表中找到")
return False
except Exception:
logger.exception(f"[Scanner] 加载模块 {module_name} 失败")
return False
+ self._modules[module_name] = instance
+ self._loaded_module_names.append(module_name)
+ logger.info(f"[Scanner] 已加载模块: {module_name} (session: {id(self.session)})")
+ return True
+
def load_all_modules(self) -> None:
"""加载所有已启用的模块"""
for name in self._enabled_modules:
@@ -695,7 +677,7 @@ async def scan(self, target: ScanTarget) -> ScanResult:
# 如果 CLI 已手动加载了指定模块(--modules),跳过自动加载
if not self._modules:
# Re-resolve enabled modules in case CLI set _load_all_modules
- if hasattr(self, '_load_all_modules') and self._load_all_modules:
+ if hasattr(self, "_load_all_modules") and self._load_all_modules:
self._enabled_modules = self._resolve_enabled_modules()
self.load_all_modules()
self._stats["modules_run"] = len(self._modules)
diff --git a/wvs/modules/__init__.py b/wvs/modules/__init__.py
index 7b23f92..8873fe1 100644
--- a/wvs/modules/__init__.py
+++ b/wvs/modules/__init__.py
@@ -5,10 +5,18 @@
- sqli : SQL 注入(error-based / union / boolean-blind / time-based / stacked / second-order)
- xss : 跨站脚本(reflected / stored / DOM-based / context-aware)
-Lite 模块(轻量辅助,需 --all-modules 启用)位于 wvs.modules.lite 子包:
+Lite 模块(轻量辅助,需 --all-modules 启用,category="lite")为扁平子包:
- cmdi / lfi / rce / ssrf / xxe / sensitive / api / waf / jspathfinder / js_analysis
+
+模块加载(T2.1): 每个 detector 文件在导入时通过 @register_module / register_module()
+自动注册到 ModuleFactory;register_all_modules() 遍历导入全部 detector 触发注册,
+使 ModuleFactory 注册表成为模块加载的唯一事实源。
"""
+import logging
+
+logger = logging.getLogger(__name__)
+
from .api import APIDetector
from .cmdi import CMDInjectionDetector
from .js_analysis import JSAnalysisDetector
@@ -34,9 +42,9 @@
"LFIDetector",
"OADetector",
"RCEDetector",
- "SensitiveDetector",
"SQLiDetector",
"SSRFDetector",
+ "SensitiveDetector",
"SubdomainDetector",
"WAFDetector",
"WeakPasswordDetector",
@@ -46,10 +54,43 @@
]
+# T2.1: every detector submodule. Importing this package already registers all
+# of them (each detector file calls @register_module / register_module() at import
+# time); the list below makes registration explicit and idempotent so that entry
+# points can guarantee a fully populated ModuleFactory registry.
+_ALL_DETECTOR_MODULES = [
+ "api",
+ "cmdi",
+ "js_analysis",
+ "jspathfinder",
+ "lfi",
+ "oa",
+ "rce",
+ "sensitive",
+ "sqli",
+ "ssrf",
+ "subdomain",
+ "waf",
+ "weakpass",
+ "webshell",
+ "xss",
+ "xxe",
+]
+
+
def register_all_modules():
- """Ensure all modules are registered with ModuleFactory.
+ """Ensure every detection module is registered with ModuleFactory.
- Only sqli and xss load by default.
- Use --all-modules to load cmdi/lfi/rce/ssrf/xxe/sensitive/api/waf/jspathfinder.
+ Importing ``wvs.modules`` already triggers registration of all detectors via
+ their top-level ``@register_module`` decorator / ``register_module()`` call.
+ This function re-imports each detector submodule to guarantee registration
+ even if the package-level imports were later trimmed, making the
+ ModuleFactory registry the single source of truth for module loading.
"""
- pass
+ import importlib
+
+ for name in _ALL_DETECTOR_MODULES:
+ try:
+ importlib.import_module(f"wvs.modules.{name}.detector")
+ except Exception as exc: # pragma: no cover - defensive
+ logger.warning(f"[modules] 注册模块失败: {name}: {exc}")
diff --git a/wvs/modules/api/detector.py b/wvs/modules/api/detector.py
index cfd83d5..ef8ba49 100644
--- a/wvs/modules/api/detector.py
+++ b/wvs/modules/api/detector.py
@@ -444,5 +444,4 @@ async def _detect_jwt_issues(self, target: ScanTarget) -> List[Vulnerability]:
return vulns
-# Register module
-register_module(APIDetector)
+# APIDetector is registered via the @register_module decorator above.
diff --git a/wvs/modules/base.py b/wvs/modules/base.py
index d50a46d..968fb1d 100644
--- a/wvs/modules/base.py
+++ b/wvs/modules/base.py
@@ -43,6 +43,13 @@ class ModuleInfo:
version: str = "1.0.0"
enabled_by_default: bool = True
tags: List[str] = None
+ # T2.1: module tier, used by the scanner to decide auto-loading.
+ # - "core": loaded by default
+ # - "lite": loaded only with --all-modules / modules.all
+ # - "optional": never auto-loaded (enabled solely via its own config flag)
+ category: str = "lite"
+ # T2.1: execution priority (lower runs first); the scanner sorts by this.
+ priority: int = 100
def __post_init__(self):
if self.tags is None:
@@ -961,13 +968,19 @@ def register(cls, module_class: type):
logger.info(f"Registered module: {module_info.name}")
@classmethod
- def create(cls, module_name: str, config: Optional[ConfigManager] = None) -> DetectionModule:
+ def create(
+ cls,
+ module_name: str,
+ config: Optional[ConfigManager] = None,
+ session: Optional[Any] = None,
+ ) -> DetectionModule:
"""
Create a module instance.
Args:
module_name: Module name.
config: ConfigManager instance.
+ session: Optional HTTP session injected into the module.
Returns:
Module instance.
@@ -979,7 +992,7 @@ def create(cls, module_name: str, config: Optional[ConfigManager] = None) -> Det
raise KeyError(f"Module '{module_name}' is not registered")
module_class = cls._modules[module_name]
- return module_class(config)
+ return module_class(config, session=session)
@classmethod
def list_modules(cls) -> List[str]:
diff --git a/wvs/modules/js_analysis/__init__.py b/wvs/modules/js_analysis/__init__.py
index 6aa3adb..ca65ca9 100644
--- a/wvs/modules/js_analysis/__init__.py
+++ b/wvs/modules/js_analysis/__init__.py
@@ -7,7 +7,7 @@
__all__ = [
"SENSITIVE_PATTERNS",
+ "JSAnalysisDetector",
"extract_endpoints_from_js",
"extract_sensitive_info",
- "JSAnalysisDetector",
]
diff --git a/wvs/modules/jspathfinder/detector.py b/wvs/modules/jspathfinder/detector.py
index fde33ac..f5a98ca 100644
--- a/wvs/modules/jspathfinder/detector.py
+++ b/wvs/modules/jspathfinder/detector.py
@@ -161,6 +161,8 @@ def get_info(cls) -> ModuleInfo:
author="WVS Team",
version="1.0.0",
enabled_by_default=True,
+ category="optional",
+ priority=99,
tags=["discovery", "js", "secrets", "endpoints", "fuzz"],
)
diff --git a/wvs/modules/lite/__init__.py b/wvs/modules/lite/__init__.py
index 10d9ad2..80fb30c 100644
--- a/wvs/modules/lite/__init__.py
+++ b/wvs/modules/lite/__init__.py
@@ -17,6 +17,10 @@
- js_analysis : JS 敏感信息分析
"""
+import logging
+
+logger = logging.getLogger(__name__)
+
from ..api import APIDetector
from ..cmdi import CMDInjectionDetector
from ..js_analysis import JSAnalysisDetector
@@ -43,5 +47,27 @@
def register_lite_modules():
- """确保所有 Lite 模块注册到 ModuleFactory(被动注册,仅在主动导入时生效)"""
- pass
+ """Ensure all Lite detection modules are registered with ModuleFactory.
+
+ T2.1: the lite detectors live as flat subpackages under ``wvs.modules`` and are
+ registered on import via ``@register_module``. Re-importing them here makes
+ registration explicit and idempotent.
+ """
+ import importlib
+
+ for name in (
+ "api",
+ "cmdi",
+ "js_analysis",
+ "jspathfinder",
+ "lfi",
+ "rce",
+ "sensitive",
+ "ssrf",
+ "waf",
+ "xxe",
+ ):
+ try:
+ importlib.import_module(f"wvs.modules.{name}.detector")
+ except Exception as exc: # pragma: no cover - defensive
+ logger.warning(f"[modules.lite] 注册模块失败: {name}: {exc}")
diff --git a/wvs/modules/rce/detector.py b/wvs/modules/rce/detector.py
index 69fae42..44d84f0 100644
--- a/wvs/modules/rce/detector.py
+++ b/wvs/modules/rce/detector.py
@@ -25,6 +25,7 @@
logger = logging.getLogger("wvs.module.rce")
+@register_module
class RCEDetector(DetectionModule):
"""
RCE Detection Module
@@ -930,5 +931,4 @@ def _create_vulnerability(
)
-# Register module
-register_module(RCEDetector)
+# RCEDetector is registered via the @register_module decorator above.
diff --git a/wvs/modules/sensitive/detector.py b/wvs/modules/sensitive/detector.py
index 498aa3f..6e41e3f 100644
--- a/wvs/modules/sensitive/detector.py
+++ b/wvs/modules/sensitive/detector.py
@@ -842,5 +842,4 @@ def _get_path_severity(self, path: str) -> Severity:
return Severity.LOW
-# Register module
-register_module(SensitiveDetector)
+# SensitiveDetector is registered via the @register_module decorator above.
diff --git a/wvs/modules/sqli/detector.py b/wvs/modules/sqli/detector.py
index 8bfe147..aa27946 100644
--- a/wvs/modules/sqli/detector.py
+++ b/wvs/modules/sqli/detector.py
@@ -37,6 +37,8 @@ def get_info(cls) -> ModuleInfo:
author="WVS Team",
version="1.0.0",
enabled_by_default=True,
+ category="core",
+ priority=10,
tags=["sqli", "injection", "sql-injection", "database"],
)
diff --git a/wvs/modules/xss/detector.py b/wvs/modules/xss/detector.py
index 032bf91..8d69603 100644
--- a/wvs/modules/xss/detector.py
+++ b/wvs/modules/xss/detector.py
@@ -37,6 +37,8 @@ def get_info(cls) -> ModuleInfo:
author="WVS Team",
version="1.0.0",
enabled_by_default=True,
+ category="core",
+ priority=20,
tags=["xss", "cross-site-scripting", "stored-xss", "reflected-xss"],
)
From a6b6a524a8a5d322239612e25e78080c37713205 Mon Sep 17 00:00:00 2001
From: xiabai2004 <1204798391@qq.com>
Date: Sun, 12 Jul 2026 21:05:32 +0800
Subject: [PATCH 3/4] =?UTF-8?q?ci:=20=E7=A7=BB=E9=99=A4=20--cov-fail-under?=
=?UTF-8?q?=20=E8=A6=86=E7=9B=96=E7=8E=87=E9=97=A8=E6=A7=9B=EF=BC=8C?=
=?UTF-8?q?=E8=A6=86=E7=9B=96=E7=8E=87=E4=BB=85=E5=87=BA=E6=8A=A5=E5=91=8A?=
=?UTF-8?q?=E4=B8=8D=E5=8D=A1=E5=90=88=E5=B9=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 用户反馈分支保护 + 覆盖率门槛导致日常合并受阻、开发体验差
- 保留 --cov 报告(term-missing)供参考,但不再设置失败下限
- Test 检查现在仅由 pytest 是否通过决定,新增未覆盖代码不再阻断合并
---
.github/workflows/ci.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 379d1ae..4dc3a43 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -37,8 +37,8 @@ jobs:
python -m pip install --upgrade pip
pip install -e ".[dev]"
- - name: Run tests with coverage
- run: pytest tests/ -q --cov=wvs --cov-report=term-missing --tb=short --cov-fail-under=20 # 临时下限:当前真实覆盖率≈24%,P2/P3 测试网补全后应回升至 30+
+ - name: Run tests (coverage reported, non-blocking)
+ run: pytest tests/ -q --cov=wvs --cov-report=term-missing --tb=short
- name: Test import
run: python -c "from wvs.core import WAVScanner, HTTPPool; print('✅ Import OK')"
From f7520e2ec73e9d6aeb83c8e8b2f95864a6de7840 Mon Sep 17 00:00:00 2001
From: xiabai2004 <1204798391@qq.com>
Date: Sun, 12 Jul 2026 21:11:20 +0800
Subject: [PATCH 4/4] =?UTF-8?q?ci:=20Lint/Format=20=E9=99=8D=E4=B8=BA?=
=?UTF-8?q?=E9=9D=9E=E9=98=BB=E6=96=AD=E6=8F=90=E7=A4=BA=EF=BC=88=E6=96=B9?=
=?UTF-8?q?=E6=A1=88=20A=EF=BC=8Csolo=20=E5=BC=80=E5=8F=91=E4=B8=8D?=
=?UTF-8?q?=E5=8D=A1=E5=90=88=E5=B9=B6=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- lint/format job 加 continue-on-error: true,运行但不再阻断 PR 合并
- 保留 ruff 检查作为质量反馈,仅出结果不卡门禁
- 配合分支保护调整:评审要求清零、移除 Lint/Format 必过上下文
---
.github/workflows/ci.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4dc3a43..15a0614 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -46,6 +46,7 @@ jobs:
lint:
name: Lint (ruff)
runs-on: ubuntu-latest
+ continue-on-error: true # 非阻断:仅作质量提示,不卡合并
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
@@ -60,6 +61,7 @@ jobs:
format:
name: Format (ruff)
runs-on: ubuntu-latest
+ continue-on-error: true # 非阻断:仅作质量提示,不卡合并
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5