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 ADDED
@@ -0,0 +1,2 @@
1
+ __all__ = ["__version__"]
2
+ __version__ = "0.3.0"
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 = ""
10
+ GREEN = ""
11
+ YELLOW = ""
12
+ BLUE = ""
13
+ MAGENTA = ""
14
+ CYAN = ""
15
+ RESET = ""
16
+ BOLD = ""
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
+ }
@@ -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