Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: Run tests with coverage
- name: Run tests (coverage reported, non-blocking)
run: pytest tests/ -q --cov=wvs --cov-report=term-missing --tb=short

- name: Test import
Expand All @@ -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
Expand All @@ -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
Expand Down
6 changes: 2 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ coverage.xml
*.py,cover
.hypothesis/
.pytest_cache/
conftest.py

# Translations
*.mo
Expand Down Expand Up @@ -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
Expand Down
44 changes: 32 additions & 12 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ dev = [
"pre-commit>=3.0.0",
"types-requests",
"types-PyYAML",
"tomli; python_version < \"3.11\"",
]
speedup = [
"orjson>=3.8.0",
Expand Down
62 changes: 62 additions & 0 deletions requirements.lock.txt
Original file line number Diff line number Diff line change
@@ -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
201 changes: 201 additions & 0 deletions tests/test_module_factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
"""
P2-T2.1 回归测试:模块加载统一(ModuleFactory 注册表为唯一事实源)。

补充 tests/test_other_detectors.py::TestModuleRegistryT2_1 未覆盖的关键路径:

1. ModuleFactory 注册机制(@register_module 装饰器 / register)能从所有 detector 收集模块。
2. scanner.load_module(name) 按名从注册表取实例 —— 验证删除原 __import__ 动态兜底后
仍能正确加载(无 ImportError,未知模块被优雅拒绝而非崩溃)。
3. lite 模式(--all-modules / _load_all_modules)仍正常工作。
4. CLI 模块加载行为向后兼容(--modules 按名 / --all-modules / --no-modules 禁用)。
"""

import inspect

import pytest

from wvs.config import ConfigManager
from wvs.core import WAVScanner
from wvs.core.session import HTTPPool
from wvs.modules import register_all_modules
from wvs.modules.base import (
DetectionModule,
ModuleFactory,
ModuleInfo,
register_module,
)

# 核对现有注册列表:sqli/xss/cmdi/lfi/rce/ssrf/xxe/waf/api/sensitive/jspathfinder 等
EXPECTED_CORE = {"sqli", "xss"}
EXPECTED_LITE = {
"sensitive",
"waf",
"cmdi",
"lfi",
"ssrf",
"xxe",
"rce",
"api",
"js_analysis",
"oa",
"webshell",
"weakpass",
"subdomain",
}
EXPECTED_OPTIONAL = {"jspathfinder"}
EXPECTED_ALL = EXPECTED_CORE | EXPECTED_LITE | EXPECTED_OPTIONAL


@pytest.fixture
def registered():
"""确保注册表已填充(幂等)并返回模块名列表。"""
register_all_modules()
return ModuleFactory.list_modules()


@pytest.fixture
def scanner():
return WAVScanner(ConfigManager(), HTTPPool(ConfigManager()))


class TestModuleFactoryRegistry:
def test_registry_collects_all_detectors(self, registered):
registered_set = set(registered)
missing = EXPECTED_ALL - registered_set
assert not missing, f"未注册的预期模块: {missing}"

def test_registry_contains_teamlead_checklist(self, registered):
for name in (
"sqli",
"xss",
"cmdi",
"lfi",
"rce",
"ssrf",
"xxe",
"waf",
"api",
"sensitive",
"jspathfinder",
):
assert name in registered, name

def test_register_module_decorator_registers(self):
"""@register_module 装饰器机制:定义新模块应立即可见。"""
before = set(ModuleFactory.list_modules())

@register_module
class _ProbeModule(DetectionModule):
@classmethod
def get_info(cls):
return ModuleInfo(name="qa_probe", description="T2.1 probe", category="optional")

async def _scan_impl(self, target):
return []

try:
assert "qa_probe" in ModuleFactory.list_modules()
assert "qa_probe" not in before
assert ModuleFactory.get_module_info("qa_probe").name == "qa_probe"
finally:
ModuleFactory._modules.pop("qa_probe", None)

def test_register_rejects_non_module(self):
with pytest.raises(TypeError):
ModuleFactory.register(int) # int 不是 DetectionModule 子类

def test_create_returns_instances(self, registered):
for name in sorted(EXPECTED_ALL):
inst = ModuleFactory.create(name)
assert isinstance(inst, DetectionModule)
assert inst.get_info().name == name


class TestScannerLoadByRegistry:
"""验证删 __import__ 兜底后,scanner.load_module 仍按名从注册表加载(无 ImportError)。"""

def test_load_module_by_name(self, scanner):
for name in sorted(EXPECTED_ALL):
assert scanner.load_module(name) is True
assert name in scanner._modules
assert isinstance(scanner._modules[name], DetectionModule)

def test_load_module_unknown_returns_false(self, scanner):
# 删除 __import__ 兜底后,未知模块应被 KeyError 捕获并返回 False(不抛 ImportError)。
assert scanner.load_module("does_not_exist_xyz") is False
assert "does_not_exist_xyz" not in scanner._modules

def test_load_module_idempotent(self, scanner):
assert scanner.load_module("sqli") is True
assert scanner.load_module("sqli") is True
assert scanner._loaded_module_names.count("sqli") == 1

def test_load_module_raises_no_import_error(self, scanner):
# 回归重点:真实模块不得抛 ImportError(原实现依赖 __import__ 动态兜底)。
for name in sorted(EXPECTED_ALL):
try:
ok = scanner.load_module(name)
except ImportError as exc: # pragma: no cover
pytest.fail(f"load_module('{name}') 抛 ImportError: {exc}")
assert ok is True, name

def test_load_module_uses_registry_not_import_fallback(self):
# 直接断言源码级回归:load_module 必须走注册表,不再有 __import__ 动态兜底。
src = inspect.getsource(WAVScanner.load_module)
assert "ModuleFactory.create" in src
assert "register_all_modules" in src
# 原动态兜底逻辑应已删除(注释中出现 '__import__' 字样不算实际调用)。
assert "importlib.import_module" not in src
assert "__import__(" not in src


class TestLiteMode:
def test_all_modules_resolves_to_core_plus_lite(self):
scanner = WAVScanner(ConfigManager(), HTTPPool(ConfigManager()))
scanner._load_all_modules = True
enabled = scanner._resolve_enabled_modules()
for name in EXPECTED_LITE:
assert name in enabled, name
# optional 模块从不自动加载
assert "jspathfinder" not in enabled

def test_scanner_loads_lite_end_to_end(self):
# 模拟 scan() 的真实流程:先按 _load_all_modules 重新 _resolve_enabled_modules 再 load_all_modules。
scanner = WAVScanner(ConfigManager(), HTTPPool(ConfigManager()))
scanner._load_all_modules = True
scanner._enabled_modules = scanner._resolve_enabled_modules()
scanner.load_all_modules()
for name in EXPECTED_LITE:
assert name in scanner._modules, name
assert isinstance(scanner._modules[name], DetectionModule)


class TestCLIBackwardCompat:
"""CLI 模块加载行为向后兼容(对应 wvs/cli.py 的 --modules / --all-modules / --no-modules)。"""

def test_modules_arg_loads_by_names(self, scanner):
# 对应 CLI: --modules sqli,xss,cmdi
for m in ["sqli", "xss", "cmdi"]:
scanner.load_module(m)
assert set(scanner._modules.keys()) == {"sqli", "xss", "cmdi"}

def test_disabled_modules_filter(self, scanner):
# 对应 CLI: --all-modules 后再 --no-modules cmdi,waf(从已加载集合中剔除)。
scanner._load_all_modules = True
scanner._enabled_modules = scanner._resolve_enabled_modules()
scanner.load_all_modules()

disable_set = {"cmdi", "waf"}
for mod_name in list(scanner._modules.keys()):
if mod_name in disable_set:
del scanner._modules[mod_name]

assert "cmdi" not in scanner._modules
assert "waf" not in scanner._modules
assert "sqli" in scanner._modules

def test_profile_modules_load_by_name(self, scanner):
# 对应 CLI: profile 指定模块时按名加载(不再依赖路径解析)。
for mod in ["ssrf", "xxe"]:
scanner.load_module(mod)
assert {"ssrf", "xxe"} <= set(scanner._modules.keys())
Loading
Loading