driftmux 1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- driftmux/__init__.py +2 -0
- driftmux/cli.py +150 -0
- driftmux/engine.py +196 -0
- driftmux/logging.py +51 -0
- driftmux/models.py +96 -0
- driftmux/parsers/nmap.py +79 -0
- driftmux/planner.py +147 -0
- driftmux/renderers/console.py +29 -0
- driftmux/scanners/nmap.py +130 -0
- driftmux/scanners/nuclei.py +170 -0
- driftmux/scanners/nvd.py +380 -0
- driftmux/scanners/passive_cve.py +90 -0
- driftmux/scanners/plecost.py +133 -0
- driftmux/utils.py +97 -0
- driftmux-1.0.dist-info/METADATA +255 -0
- driftmux-1.0.dist-info/RECORD +20 -0
- driftmux-1.0.dist-info/WHEEL +5 -0
- driftmux-1.0.dist-info/entry_points.txt +2 -0
- driftmux-1.0.dist-info/licenses/LICENSE +201 -0
- driftmux-1.0.dist-info/top_level.txt +1 -0
driftmux/__init__.py
ADDED
driftmux/cli.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import pyfiglet
|
|
7
|
+
|
|
8
|
+
from driftmux.engine import DriftmuxEngine, ScanConfig
|
|
9
|
+
from driftmux.renderers.console import render_console
|
|
10
|
+
from driftmux.utils import ensure_dir, read_hosts, write_csv, write_json, write_markdown
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def banner() -> None:
|
|
14
|
+
click.echo(pyfiglet.figlet_format("Driftmux"))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
|
|
18
|
+
@click.option("--host-file", type=click.Path(exists=True, dir_okay=False), help="File with one host per line")
|
|
19
|
+
@click.option("--host", help="Single host or URL to scan")
|
|
20
|
+
@click.option("--ports", help="Port selection for nmap, for example 1-1000 or 80,443,6443")
|
|
21
|
+
@click.option("--nmap-script", help="Nmap NSE scripts to run, for example vulners or default,vulners")
|
|
22
|
+
@click.option(
|
|
23
|
+
"--format",
|
|
24
|
+
"output_format",
|
|
25
|
+
type=click.Choice(["text", "json", "csv", "markdown"], case_sensitive=False),
|
|
26
|
+
default="json",
|
|
27
|
+
)
|
|
28
|
+
@click.option("--timeout", type=int, default=600, show_default=True, help="Timeout per external scanner")
|
|
29
|
+
@click.option("--http-only", is_flag=True, help="Prefer HTTP targets for web scanners")
|
|
30
|
+
@click.option("--https-only", is_flag=True, help="Prefer HTTPS targets for web scanners")
|
|
31
|
+
@click.option("--output-dir", default="reports", show_default=True, help="Directory for generated reports")
|
|
32
|
+
@click.option("--log-dir", default="logs", show_default=True, help="Directory for scan logs")
|
|
33
|
+
@click.option("--deep-wordpress", is_flag=True, help="Enable Plecost deep mode for WordPress")
|
|
34
|
+
@click.option(
|
|
35
|
+
"--profile",
|
|
36
|
+
"nuclei_profile",
|
|
37
|
+
type=click.Choice(["passive", "fast", "deep"], case_sensitive=False),
|
|
38
|
+
default="fast",
|
|
39
|
+
show_default=True,
|
|
40
|
+
help="Scan profile. passive disables Nuclei; fast/deep run Nuclei.",
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--vuln-backend",
|
|
44
|
+
type=click.Choice(["none", "nvd"], case_sensitive=False),
|
|
45
|
+
default="none",
|
|
46
|
+
show_default=True,
|
|
47
|
+
help="Passive vulnerability backend.",
|
|
48
|
+
)
|
|
49
|
+
@click.option(
|
|
50
|
+
"--min-cvss",
|
|
51
|
+
type=float,
|
|
52
|
+
default=0.0,
|
|
53
|
+
show_default=True,
|
|
54
|
+
help="Minimum CVSS score for passive vulnerability findings.",
|
|
55
|
+
)
|
|
56
|
+
@click.option(
|
|
57
|
+
"--nvd-api-key",
|
|
58
|
+
default=None,
|
|
59
|
+
help="NVD API key. If omitted, Driftmux also checks the NVD_API_KEY environment variable.",
|
|
60
|
+
)
|
|
61
|
+
@click.option(
|
|
62
|
+
"--nvd-cache",
|
|
63
|
+
default="~/.cache/driftmux/nvd.sqlite",
|
|
64
|
+
show_default=True,
|
|
65
|
+
help="SQLite cache path for NVD responses.",
|
|
66
|
+
)
|
|
67
|
+
@click.option(
|
|
68
|
+
"--nvd-cache-ttl-hours",
|
|
69
|
+
type=int,
|
|
70
|
+
default=168,
|
|
71
|
+
show_default=True,
|
|
72
|
+
help="How long cached NVD responses remain valid.",
|
|
73
|
+
)
|
|
74
|
+
def main(
|
|
75
|
+
host_file: str | None,
|
|
76
|
+
host: str | None,
|
|
77
|
+
ports: str | None,
|
|
78
|
+
nmap_script: str | None,
|
|
79
|
+
output_format: str,
|
|
80
|
+
timeout: int,
|
|
81
|
+
http_only: bool,
|
|
82
|
+
https_only: bool,
|
|
83
|
+
output_dir: str,
|
|
84
|
+
log_dir: str,
|
|
85
|
+
deep_wordpress: bool,
|
|
86
|
+
nuclei_profile: str,
|
|
87
|
+
vuln_backend: str,
|
|
88
|
+
min_cvss: float,
|
|
89
|
+
nvd_api_key: str | None,
|
|
90
|
+
nvd_cache: str,
|
|
91
|
+
nvd_cache_ttl_hours: int,
|
|
92
|
+
) -> None:
|
|
93
|
+
if not host and not host_file:
|
|
94
|
+
raise click.UsageError("Provide --host or --host-file")
|
|
95
|
+
|
|
96
|
+
if http_only and https_only:
|
|
97
|
+
raise click.UsageError("Use only one of --http-only or --https-only")
|
|
98
|
+
|
|
99
|
+
banner()
|
|
100
|
+
|
|
101
|
+
scheme = "auto"
|
|
102
|
+
|
|
103
|
+
if http_only:
|
|
104
|
+
scheme = "http"
|
|
105
|
+
elif https_only:
|
|
106
|
+
scheme = "https"
|
|
107
|
+
|
|
108
|
+
config = ScanConfig(
|
|
109
|
+
ports=ports,
|
|
110
|
+
nmap_script=nmap_script,
|
|
111
|
+
timeout=timeout,
|
|
112
|
+
web_scheme=scheme,
|
|
113
|
+
nuclei_profile=nuclei_profile.lower(),
|
|
114
|
+
output_format=output_format.lower(),
|
|
115
|
+
output_dir=output_dir,
|
|
116
|
+
log_dir=log_dir,
|
|
117
|
+
deep_wordpress=deep_wordpress,
|
|
118
|
+
vuln_backend=vuln_backend.lower(),
|
|
119
|
+
min_cvss=min_cvss,
|
|
120
|
+
nvd_api_key=nvd_api_key,
|
|
121
|
+
nvd_cache=nvd_cache,
|
|
122
|
+
nvd_cache_ttl_hours=nvd_cache_ttl_hours,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
hosts = read_hosts(host_file=host_file, host=host)
|
|
126
|
+
|
|
127
|
+
engine = DriftmuxEngine(config)
|
|
128
|
+
results = engine.scan_hosts(hosts)
|
|
129
|
+
|
|
130
|
+
ensure_dir(output_dir)
|
|
131
|
+
|
|
132
|
+
out_base = Path(output_dir) / "driftmux-report"
|
|
133
|
+
|
|
134
|
+
if config.output_format == "json":
|
|
135
|
+
path = write_json(out_base.with_suffix(".json"), results)
|
|
136
|
+
elif config.output_format == "csv":
|
|
137
|
+
path = write_csv(out_base.with_suffix(".csv"), results)
|
|
138
|
+
elif config.output_format == "markdown":
|
|
139
|
+
path = write_markdown(out_base.with_suffix(".md"), results)
|
|
140
|
+
else:
|
|
141
|
+
path = None
|
|
142
|
+
|
|
143
|
+
render_console(results)
|
|
144
|
+
|
|
145
|
+
if path:
|
|
146
|
+
click.echo(f"\nSaved report to {path}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
main()
|
driftmux/engine.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Iterable
|
|
5
|
+
|
|
6
|
+
from driftmux.planner import build_scan_plan
|
|
7
|
+
from driftmux.models import HostScanResult
|
|
8
|
+
from driftmux.scanners.nmap import NmapScanner
|
|
9
|
+
from driftmux.scanners.nuclei import NucleiScanner
|
|
10
|
+
from driftmux.scanners.nvd import NvdConfig, NvdCveScanner
|
|
11
|
+
from driftmux.scanners.plecost import PlecostScanner
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
WEB_PORTS = {
|
|
15
|
+
80,
|
|
16
|
+
81,
|
|
17
|
+
443,
|
|
18
|
+
444,
|
|
19
|
+
591,
|
|
20
|
+
593,
|
|
21
|
+
8000,
|
|
22
|
+
8008,
|
|
23
|
+
8080,
|
|
24
|
+
8081,
|
|
25
|
+
8088,
|
|
26
|
+
8443,
|
|
27
|
+
8888,
|
|
28
|
+
9443,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_web_candidate(service) -> bool:
|
|
33
|
+
svc = (getattr(service, "service", "") or "").lower()
|
|
34
|
+
product = (getattr(service, "product", "") or "").lower()
|
|
35
|
+
port = int(getattr(service, "port", 0) or 0)
|
|
36
|
+
|
|
37
|
+
if "http" in svc or "https" in svc:
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
if any(x in product for x in ("apache", "nginx", "tomcat", "jetty", "nextcloud")):
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
if port in WEB_PORTS:
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def guess_scheme(service, configured_scheme: str) -> str:
|
|
50
|
+
if configured_scheme in {"http", "https"}:
|
|
51
|
+
return configured_scheme
|
|
52
|
+
|
|
53
|
+
port = int(getattr(service, "port", 0) or 0)
|
|
54
|
+
|
|
55
|
+
if port in {443, 8443, 9443}:
|
|
56
|
+
return "https"
|
|
57
|
+
|
|
58
|
+
if port in {80, 81, 8000, 8008, 8080, 8081, 8088, 8888}:
|
|
59
|
+
return "http"
|
|
60
|
+
|
|
61
|
+
return "auto"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(slots=True)
|
|
65
|
+
class ScanConfig:
|
|
66
|
+
ports: str | None = None
|
|
67
|
+
nmap_script: str | None = None
|
|
68
|
+
timeout: int = 120
|
|
69
|
+
web_scheme: str = "auto"
|
|
70
|
+
nuclei_profile: str = "fast"
|
|
71
|
+
output_format: str = "json"
|
|
72
|
+
output_dir: str = "reports"
|
|
73
|
+
log_dir: str = "logs"
|
|
74
|
+
deep_wordpress: bool = False
|
|
75
|
+
|
|
76
|
+
# NVD backend
|
|
77
|
+
vuln_backend: str = "none"
|
|
78
|
+
min_cvss: float = 0.0
|
|
79
|
+
nvd_api_key: str | None = None
|
|
80
|
+
nvd_cache: str = "~/.cache/driftmux/nvd.sqlite"
|
|
81
|
+
nvd_cache_ttl_hours: int = 168
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class DriftmuxEngine:
|
|
85
|
+
def __init__(self, config: ScanConfig):
|
|
86
|
+
self.config = config
|
|
87
|
+
|
|
88
|
+
self.nmap = NmapScanner(
|
|
89
|
+
timeout=config.timeout,
|
|
90
|
+
ports=config.ports,
|
|
91
|
+
nmap_script=config.nmap_script,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
self.nvd = NvdCveScanner(
|
|
95
|
+
NvdConfig(
|
|
96
|
+
enabled=config.vuln_backend == "nvd",
|
|
97
|
+
api_key=config.nvd_api_key,
|
|
98
|
+
min_cvss=config.min_cvss,
|
|
99
|
+
cache_path=config.nvd_cache,
|
|
100
|
+
cache_ttl_hours=config.nvd_cache_ttl_hours,
|
|
101
|
+
timeout=max(20, min(config.timeout, 60)),
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
self.nuclei = NucleiScanner(
|
|
106
|
+
timeout=max(config.timeout, 180),
|
|
107
|
+
profile=getattr(config, "nuclei_profile", "fast"),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
self.plecost = PlecostScanner(
|
|
111
|
+
timeout=max(config.timeout, 180),
|
|
112
|
+
mode=config.web_scheme,
|
|
113
|
+
deep=config.deep_wordpress,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def scan_host(self, host: str) -> HostScanResult:
|
|
117
|
+
discovery = self.nmap.scan(host)
|
|
118
|
+
|
|
119
|
+
final = HostScanResult(
|
|
120
|
+
host=host,
|
|
121
|
+
services=discovery.services.copy(),
|
|
122
|
+
findings=discovery.findings.copy(),
|
|
123
|
+
errors=discovery.errors.copy(),
|
|
124
|
+
metadata=discovery.metadata.copy(),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
final.metadata["config"] = {
|
|
128
|
+
"ports": self.config.ports,
|
|
129
|
+
"nmap_script": self.config.nmap_script,
|
|
130
|
+
"timeout": self.config.timeout,
|
|
131
|
+
"web_scheme": self.config.web_scheme,
|
|
132
|
+
"nuclei_profile": self.config.nuclei_profile,
|
|
133
|
+
"vuln_backend": self.config.vuln_backend,
|
|
134
|
+
"min_cvss": self.config.min_cvss,
|
|
135
|
+
"deep_wordpress": self.config.deep_wordpress,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if self.config.vuln_backend == "nvd":
|
|
139
|
+
nvd_result = self.nvd.scan(host, discovery.services)
|
|
140
|
+
final.findings.extend(nvd_result.findings)
|
|
141
|
+
final.errors.extend(nvd_result.errors)
|
|
142
|
+
final.metadata.update(nvd_result.metadata)
|
|
143
|
+
|
|
144
|
+
if self.config.nuclei_profile != "passive":
|
|
145
|
+
self._run_nuclei(host, discovery, final)
|
|
146
|
+
|
|
147
|
+
self._run_plecost(host, discovery, final)
|
|
148
|
+
|
|
149
|
+
return final
|
|
150
|
+
|
|
151
|
+
def _run_nuclei(
|
|
152
|
+
self,
|
|
153
|
+
host: str,
|
|
154
|
+
discovery: HostScanResult,
|
|
155
|
+
final: HostScanResult,
|
|
156
|
+
) -> None:
|
|
157
|
+
plan = build_scan_plan(
|
|
158
|
+
host=host,
|
|
159
|
+
services=discovery.services,
|
|
160
|
+
passive_findings=final.findings,
|
|
161
|
+
scheme=self.config.web_scheme,
|
|
162
|
+
profile=self.config.nuclei_profile,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
nuclei_result = self.nuclei.scan_many(
|
|
166
|
+
host=host,
|
|
167
|
+
targets=plan.nuclei_targets,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
final.findings.extend(nuclei_result.findings)
|
|
171
|
+
final.errors.extend(nuclei_result.errors)
|
|
172
|
+
|
|
173
|
+
def _run_plecost(
|
|
174
|
+
self,
|
|
175
|
+
host: str,
|
|
176
|
+
discovery: HostScanResult,
|
|
177
|
+
final: HostScanResult,
|
|
178
|
+
) -> None:
|
|
179
|
+
wp_done = False
|
|
180
|
+
|
|
181
|
+
for service in discovery.services:
|
|
182
|
+
if not wp_done and self.plecost.maybe_wordpress(host, service):
|
|
183
|
+
plecost_result = self.plecost.scan(host, service)
|
|
184
|
+
final.findings.extend(plecost_result.findings)
|
|
185
|
+
final.errors.extend(plecost_result.errors)
|
|
186
|
+
final.metadata.update(plecost_result.metadata)
|
|
187
|
+
wp_done = True
|
|
188
|
+
|
|
189
|
+
if not wp_done and self.plecost.maybe_wordpress(host):
|
|
190
|
+
plecost_result = self.plecost.scan(host)
|
|
191
|
+
final.findings.extend(plecost_result.findings)
|
|
192
|
+
final.errors.extend(plecost_result.errors)
|
|
193
|
+
final.metadata.update(plecost_result.metadata)
|
|
194
|
+
|
|
195
|
+
def scan_hosts(self, hosts: Iterable[str]) -> list[HostScanResult]:
|
|
196
|
+
return [self.scan_host(host) for host in hosts]
|
driftmux/logging.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class COLOR:
|
|
9
|
+
RED = "[91m"
|
|
10
|
+
GREEN = "[92m"
|
|
11
|
+
YELLOW = "[93m"
|
|
12
|
+
BLUE = "[94m"
|
|
13
|
+
MAGENTA = "[95m"
|
|
14
|
+
CYAN = "[96m"
|
|
15
|
+
RESET = "[0m"
|
|
16
|
+
BOLD = "[1m"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_logger(host: str, output_dir: str = "logs", level: int = logging.INFO) -> logging.Logger:
|
|
20
|
+
logger_name = f"driftmux.{host}"
|
|
21
|
+
logger = logging.getLogger(logger_name)
|
|
22
|
+
logger.setLevel(level)
|
|
23
|
+
logger.propagate = False
|
|
24
|
+
|
|
25
|
+
if logger.handlers:
|
|
26
|
+
return logger
|
|
27
|
+
|
|
28
|
+
log_dir = Path(output_dir)
|
|
29
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
safe_host = host.replace(":", "_").replace("/", "_")
|
|
31
|
+
logfile = log_dir / f"{safe_host}.log"
|
|
32
|
+
|
|
33
|
+
formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
|
|
34
|
+
|
|
35
|
+
file_handler = logging.FileHandler(logfile, encoding="utf-8")
|
|
36
|
+
file_handler.setFormatter(formatter)
|
|
37
|
+
logger.addHandler(file_handler)
|
|
38
|
+
|
|
39
|
+
console_handler = logging.StreamHandler()
|
|
40
|
+
console_handler.setFormatter(formatter)
|
|
41
|
+
logger.addHandler(console_handler)
|
|
42
|
+
|
|
43
|
+
return logger
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def close_logger(logger: Optional[logging.Logger]) -> None:
|
|
47
|
+
if logger is None:
|
|
48
|
+
return
|
|
49
|
+
for handler in list(logger.handlers):
|
|
50
|
+
handler.close()
|
|
51
|
+
logger.removeHandler(handler)
|
driftmux/models.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field, asdict
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
SEVERITY_ORDER = {"critical": 5, "high": 4, "medium": 3, "low": 2, "info": 1, "unknown": 0}
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class ToolError:
|
|
10
|
+
scanner: str
|
|
11
|
+
message: str
|
|
12
|
+
host: Optional[str] = None
|
|
13
|
+
details: Optional[str] = None
|
|
14
|
+
|
|
15
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
16
|
+
return asdict(self)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class OpenPort:
|
|
21
|
+
port: int
|
|
22
|
+
protocol: str
|
|
23
|
+
state: str
|
|
24
|
+
service: str
|
|
25
|
+
product: str = ""
|
|
26
|
+
version: str = ""
|
|
27
|
+
extrainfo: str = ""
|
|
28
|
+
tunnel: str = ""
|
|
29
|
+
cpes: List[str] = field(default_factory=list)
|
|
30
|
+
classifications: List[str] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
def endpoint(self) -> str:
|
|
33
|
+
return f"{self.port}/{self.protocol}"
|
|
34
|
+
|
|
35
|
+
def detected_version(self) -> str:
|
|
36
|
+
return self.version or self.extrainfo or "unknown"
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
39
|
+
return asdict(self)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True)
|
|
43
|
+
class Finding:
|
|
44
|
+
scanner: str
|
|
45
|
+
host: str
|
|
46
|
+
title: str
|
|
47
|
+
severity: str = "info"
|
|
48
|
+
description: str = ""
|
|
49
|
+
evidence: str = ""
|
|
50
|
+
confidence: str = "medium"
|
|
51
|
+
port: Optional[int] = None
|
|
52
|
+
service: Optional[str] = None
|
|
53
|
+
detected_version: Optional[str] = None
|
|
54
|
+
reference: Optional[str] = None
|
|
55
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
def normalized_severity(self) -> str:
|
|
58
|
+
value = (self.severity or "unknown").lower()
|
|
59
|
+
return value if value in SEVERITY_ORDER else "unknown"
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
62
|
+
payload = asdict(self)
|
|
63
|
+
payload["severity"] = self.normalized_severity()
|
|
64
|
+
return payload
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(slots=True)
|
|
68
|
+
class HostScanResult:
|
|
69
|
+
host: str
|
|
70
|
+
services: List[OpenPort] = field(default_factory=list)
|
|
71
|
+
findings: List[Finding] = field(default_factory=list)
|
|
72
|
+
errors: List[ToolError] = field(default_factory=list)
|
|
73
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
74
|
+
|
|
75
|
+
def add_error(self, scanner: str, message: str, details: Optional[str] = None) -> None:
|
|
76
|
+
self.errors.append(ToolError(scanner=scanner, host=self.host, message=message, details=details))
|
|
77
|
+
|
|
78
|
+
def max_severity(self) -> str:
|
|
79
|
+
if not self.findings:
|
|
80
|
+
return "unknown"
|
|
81
|
+
return max((f.normalized_severity() for f in self.findings), key=lambda x: SEVERITY_ORDER.get(x, 0))
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
84
|
+
return {
|
|
85
|
+
"host": self.host,
|
|
86
|
+
"metadata": self.metadata,
|
|
87
|
+
"summary": {
|
|
88
|
+
"services": len(self.services),
|
|
89
|
+
"findings": len(self.findings),
|
|
90
|
+
"errors": len(self.errors),
|
|
91
|
+
"max_severity": self.max_severity(),
|
|
92
|
+
},
|
|
93
|
+
"services": [s.to_dict() for s in self.services],
|
|
94
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
95
|
+
"errors": [e.to_dict() for e in self.errors],
|
|
96
|
+
}
|
driftmux/parsers/nmap.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import xml.etree.ElementTree as ET
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from driftmux.models import Finding, HostScanResult, OpenPort
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NmapXmlParser:
|
|
10
|
+
@staticmethod
|
|
11
|
+
def parse(xml_text: str, host: str) -> HostScanResult:
|
|
12
|
+
result = HostScanResult(host=host)
|
|
13
|
+
root = ET.fromstring(xml_text)
|
|
14
|
+
for host_node in root.findall("host"):
|
|
15
|
+
for port in host_node.findall("ports/port"):
|
|
16
|
+
state_el = port.find("state")
|
|
17
|
+
state = state_el.get("state", "unknown") if state_el is not None else "unknown"
|
|
18
|
+
if state != "open":
|
|
19
|
+
continue
|
|
20
|
+
service_el = port.find("service")
|
|
21
|
+
service = OpenPort(
|
|
22
|
+
port=int(port.get("portid", 0)),
|
|
23
|
+
protocol=port.get("protocol", "tcp"),
|
|
24
|
+
state=state,
|
|
25
|
+
service=(service_el.get("name", "unknown") if service_el is not None else "unknown"),
|
|
26
|
+
product=(service_el.get("product", "") if service_el is not None else ""),
|
|
27
|
+
version=(service_el.get("version", "") if service_el is not None else ""),
|
|
28
|
+
extrainfo=(service_el.get("extrainfo", "") if service_el is not None else ""),
|
|
29
|
+
tunnel=(service_el.get("tunnel", "") if service_el is not None else ""),
|
|
30
|
+
cpes=[
|
|
31
|
+
cpe.text.strip()
|
|
32
|
+
for cpe in port.findall("service/cpe")
|
|
33
|
+
if cpe.text and cpe.text.strip()
|
|
34
|
+
],
|
|
35
|
+
)
|
|
36
|
+
service.classifications = classify_service(service)
|
|
37
|
+
result.services.append(service)
|
|
38
|
+
|
|
39
|
+
for script_el in port.findall("script"):
|
|
40
|
+
result.findings.append(
|
|
41
|
+
Finding(
|
|
42
|
+
scanner="nmap",
|
|
43
|
+
host=host,
|
|
44
|
+
title=f"Nmap script result: {script_el.get('id', 'script')}",
|
|
45
|
+
severity="info",
|
|
46
|
+
description=script_el.get("output", ""),
|
|
47
|
+
evidence=script_el.get("output", ""),
|
|
48
|
+
confidence="medium",
|
|
49
|
+
port=service.port,
|
|
50
|
+
service=service.service,
|
|
51
|
+
detected_version=service.version or None,
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def classify_service(service: OpenPort) -> List[str]:
|
|
58
|
+
text = " ".join(
|
|
59
|
+
part.lower() for part in [service.service, service.product, service.version, service.extrainfo, " ".join(service.cpes)] if part
|
|
60
|
+
)
|
|
61
|
+
labels: list[str] = []
|
|
62
|
+
if any(token in text for token in ["http", "https", "ssl/http", "apache", "nginx", "iis"]):
|
|
63
|
+
labels.append("http")
|
|
64
|
+
if "apache" in text:
|
|
65
|
+
labels.append("apache-httpd")
|
|
66
|
+
if "nginx" in text:
|
|
67
|
+
labels.append("nginx")
|
|
68
|
+
if any(token in text for token in ["kubernetes", "kube-apiserver", "kubelet", "etcd", "openshift"]):
|
|
69
|
+
labels.append("kubernetes")
|
|
70
|
+
if "wordpress" in text:
|
|
71
|
+
labels.append("wordpress")
|
|
72
|
+
if service.service.lower() == "ssh" or "openssh" in text:
|
|
73
|
+
labels.append("ssh")
|
|
74
|
+
# preserve order
|
|
75
|
+
dedup: list[str] = []
|
|
76
|
+
for label in labels:
|
|
77
|
+
if label not in dedup:
|
|
78
|
+
dedup.append(label)
|
|
79
|
+
return dedup
|