diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..150afb3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.build/ +.env +.venv +.git +.gitignore +.dist/ +**/.mypy_cache/ +**/.pytest_cache/ +**/.ruff_cache/ +**/.DS_Store \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..380b1ec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY . /app +RUN pip install --no-cache-dir . + +ENTRYPOINT ["svscan"] +CMD ["--help"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6802bc4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..23de295 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +PYTHON ?= python3 +PIP ?= $(PYTHON) -m pip + +.PHONY: install dev uninstall help build docker-build docker-run + +help: + @echo "Targets: install, dev, uninstall, build, docker-build, docker-run" + +install: + $(PIP) install . + +dev: + $(PIP) install -e . + +uninstall: + -$(PIP) uninstall -y svscan + +build: + $(PYTHON) -m build + +docker-build: + docker build -t svscan:latest . + +docker-run: + docker run --rm svscan:latest --help \ No newline at end of file diff --git a/README.md b/README.md index 2996366..9729d26 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,73 @@ -# NetNinjaScan -Сканер сети и уязвимостей +# NetNinjaScan — модульный сканер сети и уязвимостей + +Небольшой консольный аналог Nessus/OpenVAS/NetScanPro: быстрый сетевой сканер (TCP connect), лёгкое фингерпринтинг‑сканирование и локальная база сигнатур уязвимостей. + +## Возможности (MVP) +- Сканер сети: асинхронный TCP connect, CIDR/hostname/IP, настраиваемые порты и параллелизм +- Сканер уязвимостей: базовый фингерпринтинг HTTP/SSH/FTP/SMTP и сопоставление с локальной БД (`svscan/data/vuln_db.json`) +- Удобный CLI с подкомандами: `svscan net scan`, `svscan vuln scan` +- Простая установка: Docker и Python пакет + +## Установка + +### Вариант 1: Docker (самый быстрый) +```bash +docker build -t svscan:latest . +docker run --rm svscan:latest --help +``` + +### Вариант 2: pipx (рекомендуется для локальной установки) +После публикации на GitHub: +```bash +pipx install git+https://github.com//svscan +svscan --help +``` +Локально из исходников: +```bash +pipx install /workspace +svscan --help +``` + +### Вариант 3: pip (в пользовательскую директорию) +```bash +python3 -m pip install -e . --user +~/.local/bin/svscan --help +``` + +## Примеры использования +- Скан сети (диапазон): +```bash +svscan net scan 192.168.1.0/24 -p 22,80,443 --json +``` +- Скан уязвимостей (фингерпринтинг + локальные сигнатуры): +```bash +svscan vuln scan example.com -p 21,22,25,80,443 -o report.json +``` + +## Архитектура +- `svscan/cli.py` — CLI (Typer), подкоманды `net` и `vuln` +- `svscan/scanners/network_scanner.py` — сетевой сканер (async TCP connect), парсинг целей/портов +- `svscan/scanners/vuln_scanner.py` — фингерпринтинг (HTTP/SSH/FTP/SMTP) + сопоставление с БД +- `svscan/data/vuln_db.json` — минимальная локальная БД сигнатур +- Пакетирование: `pyproject.toml` (entrypoint `svscan`) +- Контейнеризация: `Dockerfile` + +Модульность: код сканера сети и уязвимостей расположен в отдельных папках. Легко добавлять новые модули (например, `svscan/scanners/udp_scanner.py`). + +## Как развивать +- Новые протоколы: SMB, RDP, TLS (шифросьюты), SNMP, Redis, MySQL/PostgreSQL +- UDP‑сканирование, ОС‑детектирование, баннер‑граббинг с эвристиками, HTTP‑краулер +- Экспорт: JSON/SARIF/JUnit, интеграции Jira/GitLab +- Планировщик, кэш, продолжение прерванных сканов, лимиты скорости +- Автообновление БД уязвимостей из NVD/OSV/CISA KEV, расчёт CVSS +- TUI/Web‑UI, REST API, плагинная система (SDK для правил/сценариев) + +## Монетизация (идеи) +- Freemium: базовый скан бесплатный; Pro — расширенные плагины, Web‑UI, планировщик, отчёты, интеграции +- Подписка на обновления сигнатур/отчётов (обогащение данными, приоритетные обновления) +- Коммерческая поддержка/SLAs, аудит, консультации +- Marketplace плагинов (корпоративные правила, интеграции SIEM/SOAR) +- Cloud SaaS‑версия: сканы через управляемую облачную очередь/агенты + +## Дисклеймер +Используйте инструмент только на ресурсах, на тест‑стендах или при наличии письменного разрешения. Ответственность не лежит на egorTriceps. Ответственность за использование лежит только на пользователе. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c617ac5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "svscan" +version = "0.1.0" +description = "Modular security scanner (network + vulnerability) with fast, easy install." +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [ + { name = "SVScan Authors", email = "maintainers@example.com" } +] +keywords = ["security", "scanner", "network", "vulnerability", "cli"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Topic :: Security", + "Intended Audience :: System Administrators", + "Intended Audience :: Developers" +] +dependencies = [ + "typer>=0.12.3" +] + +[project.urls] +Homepage = "https://github.com/example/svscan" +Issues = "https://github.com/example/svscan/issues" + +[project.scripts] +svscan = "svscan.cli:app" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["."] +include = ["svscan*"] + +[tool.setuptools.package-data] +"svscan.data" = ["*.json"] \ No newline at end of file diff --git a/svscan.egg-info/PKG-INFO b/svscan.egg-info/PKG-INFO new file mode 100644 index 0000000..75a20de --- /dev/null +++ b/svscan.egg-info/PKG-INFO @@ -0,0 +1,23 @@ +Metadata-Version: 2.4 +Name: svscan +Version: 0.1.0 +Summary: Modular security scanner (network + vulnerability) with fast, easy install. +Author-email: SVScan Authors +License: MIT +Project-URL: Homepage, https://github.com/example/svscan +Project-URL: Issues, https://github.com/example/svscan/issues +Keywords: security,scanner,network,vulnerability,cli +Classifier: Programming Language :: Python :: 3 +Classifier: License :: OSI Approved :: MIT License +Classifier: Operating System :: OS Independent +Classifier: Topic :: Security +Classifier: Intended Audience :: System Administrators +Classifier: Intended Audience :: Developers +Requires-Python: >=3.10 +Description-Content-Type: text/markdown +License-File: LICENSE +Requires-Dist: typer>=0.12.3 +Dynamic: license-file + +# NetNinjaScan +Сканер сети и уязвимостей diff --git a/svscan.egg-info/SOURCES.txt b/svscan.egg-info/SOURCES.txt new file mode 100644 index 0000000..703b3f8 --- /dev/null +++ b/svscan.egg-info/SOURCES.txt @@ -0,0 +1,21 @@ +LICENSE +README.md +pyproject.toml +svscan/__init__.py +svscan/cli.py +svscan/version.py +svscan.egg-info/PKG-INFO +svscan.egg-info/SOURCES.txt +svscan.egg-info/dependency_links.txt +svscan.egg-info/entry_points.txt +svscan.egg-info/requires.txt +svscan.egg-info/top_level.txt +svscan/data/__init__.py +svscan/data/vuln_db.json +svscan/reporting/__init__.py +svscan/reporting/serialize.py +svscan/scanners/__init__.py +svscan/scanners/network_scanner.py +svscan/scanners/vuln_scanner.py +svscan/utils/__init__.py +svscan/utils/targets.py \ No newline at end of file diff --git a/svscan.egg-info/dependency_links.txt b/svscan.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/svscan.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/svscan.egg-info/entry_points.txt b/svscan.egg-info/entry_points.txt new file mode 100644 index 0000000..7d47d17 --- /dev/null +++ b/svscan.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +svscan = svscan.cli:app diff --git a/svscan.egg-info/requires.txt b/svscan.egg-info/requires.txt new file mode 100644 index 0000000..ffe491a --- /dev/null +++ b/svscan.egg-info/requires.txt @@ -0,0 +1 @@ +typer>=0.12.3 diff --git a/svscan.egg-info/top_level.txt b/svscan.egg-info/top_level.txt new file mode 100644 index 0000000..8c6f5cc --- /dev/null +++ b/svscan.egg-info/top_level.txt @@ -0,0 +1 @@ +svscan diff --git a/svscan/__init__.py b/svscan/__init__.py new file mode 100644 index 0000000..14a0d54 --- /dev/null +++ b/svscan/__init__.py @@ -0,0 +1 @@ +from .version import VERSION as __version__ \ No newline at end of file diff --git a/svscan/__pycache__/__init__.cpython-313.pyc b/svscan/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..8ecb268 Binary files /dev/null and b/svscan/__pycache__/__init__.cpython-313.pyc differ diff --git a/svscan/__pycache__/cli.cpython-313.pyc b/svscan/__pycache__/cli.cpython-313.pyc new file mode 100644 index 0000000..0bef0be Binary files /dev/null and b/svscan/__pycache__/cli.cpython-313.pyc differ diff --git a/svscan/__pycache__/version.cpython-313.pyc b/svscan/__pycache__/version.cpython-313.pyc new file mode 100644 index 0000000..21eb246 Binary files /dev/null and b/svscan/__pycache__/version.cpython-313.pyc differ diff --git a/svscan/cli.py b/svscan/cli.py new file mode 100644 index 0000000..641f083 --- /dev/null +++ b/svscan/cli.py @@ -0,0 +1,91 @@ +import asyncio +import json +from typing import List, Optional + +import typer + +from svscan.version import VERSION +from svscan.scanners.network_scanner import ( + scan_targets, + parse_ports, +) +from svscan.scanners.vuln_scanner import run_vuln_scan + +app = typer.Typer(no_args_is_help=True, help="SVScan: modular network and vulnerability scanner") + +net_app = typer.Typer(help="Network scanning commands") +vuln_app = typer.Typer(help="Vulnerability scanning commands") + +app.add_typer(net_app, name="net") +app.add_typer(vuln_app, name="vuln") + + +@app.command() +def version() -> None: + """Show version information.""" + typer.echo(f"svscan {VERSION}") + + +@net_app.command("scan") +def net_scan( + targets: List[str] = typer.Argument(..., help="Targets: IPs, hostnames, or CIDRs (e.g. 192.168.1.0/24)"), + ports: str = typer.Option("22,80,443", "--ports", "-p", help="Comma-separated ports"), + timeout: float = typer.Option(0.5, help="Per-connection timeout (seconds)"), + concurrency: int = typer.Option(200, help="Max concurrent connections"), + output: Optional[str] = typer.Option(None, "--output", "-o", help="Write results to file (json)"), + as_json: bool = typer.Option(False, "--json", help="Print JSON to stdout"), +) -> None: + """Run a TCP connect network scan.""" + port_list = parse_ports(ports) + results = asyncio.run(scan_targets(targets, port_list, timeout=timeout, concurrency=concurrency)) + + if as_json or output: + data = json.dumps(results, indent=2) + if output: + with open(output, "w", encoding="utf-8") as f: + f.write(data) + if as_json: + typer.echo(data) + else: + for host_result in results: + typer.echo(f"Host: {host_result['host']}") + open_ports = host_result.get("open_ports", []) + if not open_ports: + typer.echo(" No open ports found") + else: + for port in open_ports: + typer.echo(f" Port {port}") + + +@vuln_app.command("scan") +def vuln_scan( + targets: List[str] = typer.Argument(..., help="Targets: IPs, hostnames, or CIDRs"), + ports: str = typer.Option("22,80,443", "--ports", "-p", help="Comma-separated ports"), + timeout: float = typer.Option(0.8, help="Per-connection timeout (seconds)"), + concurrency: int = typer.Option(200, help="Max concurrent connections"), + output: Optional[str] = typer.Option(None, "--output", "-o", help="Write results to file (json)"), + as_json: bool = typer.Option(False, "--json", help="Print JSON to stdout"), +) -> None: + """Run a vulnerability scan (network scan + lightweight fingerprinting + DB match).""" + port_list = parse_ports(ports) + results = asyncio.run(run_vuln_scan(targets, port_list, timeout=timeout, concurrency=concurrency)) + + if as_json or output: + data = json.dumps(results, indent=2) + if output: + with open(output, "w", encoding="utf-8") as f: + f.write(data) + if as_json: + typer.echo(data) + else: + for host in results: + typer.echo(f"Host: {host['host']}") + if not host.get("findings"): + typer.echo(" No vulnerabilities found (based on local signatures)") + continue + for finding in host["findings"]: + typer.echo( + f" [{finding['severity'].upper()}] {finding['id']} on {finding['service']}\n" + f" Evidence: {finding.get('evidence', '')}\n" + f" Description: {finding.get('description', '')}" + ) \ No newline at end of file diff --git a/svscan/data/__init__.py b/svscan/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/svscan/data/vuln_db.json b/svscan/data/vuln_db.json new file mode 100644 index 0000000..7502e67 --- /dev/null +++ b/svscan/data/vuln_db.json @@ -0,0 +1,34 @@ +[ + { + "id": "CVE-2021-41773", + "service": "http", + "match": { "type": "header_regex", "header": "server", "pattern": "Apache/2\\.4\\.49" }, + "severity": "high", + "description": "Apache HTTP Server 2.4.49 path traversal and RCE in default configs.", + "references": ["https://httpd.apache.org/security/vulnerabilities_24.html#CVE-2021-41773"] + }, + { + "id": "CVE-2016-0777", + "service": "ssh", + "match": { "type": "banner_regex", "pattern": "OpenSSH_7\\.[01]" }, + "severity": "medium", + "description": "OpenSSH roaming vulnerability leads to potential key leak.", + "references": ["https://www.openssh.com/txt/release-7.1p2"] + }, + { + "id": "CVE-2019-10149", + "service": "smtp", + "match": { "type": "banner_regex", "pattern": "Exim 4\\.92" }, + "severity": "high", + "description": "Exim 4.92 local privilege escalation via string expansion flaw.", + "references": ["https://nvd.nist.gov/vuln/detail/CVE-2019-10149"] + }, + { + "id": "CVE-2011-2523", + "service": "ftp", + "match": { "type": "banner_regex", "pattern": "vsFTPd 2\\.3\\.4" }, + "severity": "critical", + "description": "vsftpd 2.3.4 backdoor allows remote code execution.", + "references": ["https://nvd.nist.gov/vuln/detail/CVE-2011-2523"] + } +] \ No newline at end of file diff --git a/svscan/reporting/__init__.py b/svscan/reporting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/svscan/reporting/serialize.py b/svscan/reporting/serialize.py new file mode 100644 index 0000000..8cd8276 --- /dev/null +++ b/svscan/reporting/serialize.py @@ -0,0 +1,6 @@ +import json +from typing import Any + + +def to_json(data: Any) -> str: + return json.dumps(data, indent=2) \ No newline at end of file diff --git a/svscan/scanners/__init__.py b/svscan/scanners/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/svscan/scanners/__pycache__/__init__.cpython-313.pyc b/svscan/scanners/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..15b5d0a Binary files /dev/null and b/svscan/scanners/__pycache__/__init__.cpython-313.pyc differ diff --git a/svscan/scanners/__pycache__/network_scanner.cpython-313.pyc b/svscan/scanners/__pycache__/network_scanner.cpython-313.pyc new file mode 100644 index 0000000..ed2cb94 Binary files /dev/null and b/svscan/scanners/__pycache__/network_scanner.cpython-313.pyc differ diff --git a/svscan/scanners/__pycache__/vuln_scanner.cpython-313.pyc b/svscan/scanners/__pycache__/vuln_scanner.cpython-313.pyc new file mode 100644 index 0000000..d2d22ef Binary files /dev/null and b/svscan/scanners/__pycache__/vuln_scanner.cpython-313.pyc differ diff --git a/svscan/scanners/network_scanner.py b/svscan/scanners/network_scanner.py new file mode 100644 index 0000000..2a43e8c --- /dev/null +++ b/svscan/scanners/network_scanner.py @@ -0,0 +1,113 @@ +import asyncio +import socket +from dataclasses import dataclass +from ipaddress import ip_network, ip_address +from typing import Iterable, List, Dict, Any + + +DEFAULT_COMMON_PORTS = [22, 80, 443] + + +@dataclass +class HostScanResult: + host: str + open_ports: List[int] + + +def parse_ports(ports_arg: str) -> List[int]: + """Parse comma-separated ports into a sorted unique list of ints.""" + ports: List[int] = [] + for part in ports_arg.split(","): + part = part.strip() + if not part: + continue + if "-" in part: + start_str, end_str = part.split("-", 1) + start = int(start_str) + end = int(end_str) + ports.extend(range(start, end + 1)) + else: + ports.append(int(part)) + return sorted(set(ports)) + + +def expand_targets(targets: Iterable[str]) -> List[str]: + """Expand IPs/hostnames/CIDRs into a list of IP strings. + + Hostnames are resolved to a single IP; CIDRs are expanded (IPv4 only by default). + """ + result: List[str] = [] + for t in targets: + t = t.strip() + if not t: + continue + try: + # Try CIDR expansion + if "/" in t: + net = ip_network(t, strict=False) + for ip in net.hosts(): + result.append(str(ip)) + continue + # Try IP literal + _ = ip_address(t) + result.append(t) + except ValueError: + # Resolve hostname + try: + resolved = socket.gethostbyname(t) + result.append(resolved) + except socket.gaierror: + # Skip unresolved + pass + # De-duplicate preserving order + seen = set() + unique: List[str] = [] + for ip in result: + if ip in seen: + continue + seen.add(ip) + unique.append(ip) + return unique + + +async def _probe_port(host: str, port: int, timeout: float) -> bool: + loop = asyncio.get_running_loop() + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setblocking(False) + await asyncio.wait_for(loop.sock_connect(sock, (host, port)), timeout=timeout) + return True + except Exception: + return False + + +async def _scan_host(host: str, ports: List[int], timeout: float, sem: asyncio.Semaphore) -> HostScanResult: + open_ports: List[int] = [] + + async def probe(p: int) -> None: + async with sem: + is_open = await _probe_port(host, p, timeout) + if is_open: + open_ports.append(p) + + await asyncio.gather(*(probe(p) for p in ports)) + open_ports.sort() + return HostScanResult(host=host, open_ports=open_ports) + + +async def scan_targets( + targets: Iterable[str], + ports: List[int] = None, + timeout: float = 0.5, + concurrency: int = 200, +) -> List[Dict[str, Any]]: + """Scan targets for open TCP ports using async connect. + + Returns a list of dicts for easy JSON serialization. + """ + if ports is None: + ports = DEFAULT_COMMON_PORTS + ips = expand_targets(targets) + sem = asyncio.Semaphore(concurrency) + results = await asyncio.gather(*(_scan_host(ip, ports, timeout, sem) for ip in ips)) + return [{"host": r.host, "open_ports": r.open_ports} for r in results] \ No newline at end of file diff --git a/svscan/scanners/vuln_scanner.py b/svscan/scanners/vuln_scanner.py new file mode 100644 index 0000000..4ea58f5 --- /dev/null +++ b/svscan/scanners/vuln_scanner.py @@ -0,0 +1,169 @@ +import asyncio +import json +import re +import socket +import contextlib +from dataclasses import dataclass +from typing import Dict, List, Any, Optional, Tuple + +from importlib import resources + +from svscan.scanners.network_scanner import scan_targets + + +@dataclass +class ServiceFingerprint: + service: str + port: int + evidence: str + meta: Dict[str, Any] + + +async def _read_banner(host: str, port: int, timeout: float) -> Optional[str]: + try: + reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=timeout) + try: + # Give the service a moment to speak first (SSH/FTP often do) + await asyncio.sleep(0.1) + data = await asyncio.wait_for(reader.read(256), timeout=timeout) + banner = data.decode(errors="ignore").strip() + return banner if banner else None + finally: + writer.close() + with contextlib.suppress(Exception): + await writer.wait_closed() + except Exception: + return None + + +async def _http_server_header(host: str, port: int, timeout: float) -> Optional[Tuple[str, Dict[str, str]]]: + request = f"GET / HTTP/1.0\r\nHost: {host}\r\nUser-Agent: svscan/0.1\r\n\r\n".encode() + try: + reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=timeout) + try: + writer.write(request) + await writer.drain() + data = await asyncio.wait_for(reader.read(2048), timeout=timeout) + raw = data.decode(errors="ignore") + headers: Dict[str, str] = {} + lines = raw.split("\r\n") + for line in lines[1:]: + if not line: + break + if ":" in line: + k, v = line.split(":", 1) + headers[k.strip().lower()] = v.strip() + server = headers.get("server") + if server: + return server, headers + finally: + writer.close() + with contextlib.suppress(Exception): + await writer.wait_closed() + except Exception: + return None + return None + + +async def fingerprint_services(host: str, open_ports: List[int], timeout: float) -> List[ServiceFingerprint]: + tasks: List[asyncio.Task] = [] + results: List[ServiceFingerprint] = [] + + async def fp_http(port: int) -> None: + res = await _http_server_header(host, port, timeout) + if res is not None: + server, headers = res + results.append(ServiceFingerprint(service="http", port=port, evidence=server, meta={"headers": headers})) + + async def fp_ssh(port: int) -> None: + banner = await _read_banner(host, port, timeout) + if banner: + results.append(ServiceFingerprint(service="ssh", port=port, evidence=banner, meta={})) + + async def fp_ftp(port: int) -> None: + banner = await _read_banner(host, port, timeout) + if banner: + results.append(ServiceFingerprint(service="ftp", port=port, evidence=banner, meta={})) + + async def fp_smtp(port: int) -> None: + banner = await _read_banner(host, port, timeout) + if banner: + results.append(ServiceFingerprint(service="smtp", port=port, evidence=banner, meta={})) + + for p in open_ports: + if p in (80, 8080, 8000): + tasks.append(asyncio.create_task(fp_http(p))) + elif p == 22: + tasks.append(asyncio.create_task(fp_ssh(p))) + elif p == 21: + tasks.append(asyncio.create_task(fp_ftp(p))) + elif p == 25: + tasks.append(asyncio.create_task(fp_smtp(p))) + + if tasks: + await asyncio.gather(*tasks) + return results + + +def load_vuln_db() -> List[Dict[str, Any]]: + with resources.files("svscan.data").joinpath("vuln_db.json").open("r", encoding="utf-8") as f: + return json.load(f) + + +def match_vulnerabilities(fingerprints: List[ServiceFingerprint], db: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + for fp in fingerprints: + for rule in db: + if rule.get("service") != fp.service: + continue + match = rule.get("match", {}) + mtype = match.get("type") + pattern = match.get("pattern") + if not mtype or not pattern: + continue + try: + regex = re.compile(pattern, re.IGNORECASE) + except re.error: + continue + + evidence_value: Optional[str] = None + if mtype == "banner_regex": + evidence_value = fp.evidence + elif mtype == "header_regex": + header_name = match.get("header", "server").lower() + headers = fp.meta.get("headers", {}) if fp.meta else {} + evidence_value = headers.get(header_name) + else: + continue + + if evidence_value and regex.search(evidence_value): + findings.append( + { + "id": rule.get("id"), + "severity": rule.get("severity", "info"), + "description": rule.get("description", ""), + "references": rule.get("references", []), + "service": fp.service, + "port": fp.port, + "evidence": evidence_value, + } + ) + return findings + + +async def run_vuln_scan( + targets: List[str], + ports: List[int], + timeout: float = 0.8, + concurrency: int = 200, +) -> List[Dict[str, Any]]: + net_results = await scan_targets(targets, ports, timeout=timeout, concurrency=concurrency) + db = load_vuln_db() + + out: List[Dict[str, Any]] = [] + for host in net_results: + open_ports = host.get("open_ports", []) + fps = await fingerprint_services(host["host"], open_ports, timeout) + findings = match_vulnerabilities(fps, db) + out.append({"host": host["host"], "open_ports": open_ports, "findings": findings}) + return out \ No newline at end of file diff --git a/svscan/utils/__init__.py b/svscan/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/svscan/utils/targets.py b/svscan/utils/targets.py new file mode 100644 index 0000000..9418113 --- /dev/null +++ b/svscan/utils/targets.py @@ -0,0 +1,7 @@ +from typing import List + +from svscan.scanners.network_scanner import expand_targets as expand_targets_from_scanner + + +def expand_targets(targets: List[str]) -> List[str]: + return expand_targets_from_scanner(targets) \ No newline at end of file diff --git a/svscan/version.py b/svscan/version.py new file mode 100644 index 0000000..58c8455 --- /dev/null +++ b/svscan/version.py @@ -0,0 +1 @@ +VERSION = "0.1.0" \ No newline at end of file