pyzgrab2 1.0.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.
pyzgrab2/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ from .client import ZGrab2
2
+ from .exceptions import (
3
+ ZGrab2ConfigurationError,
4
+ ZGrab2Error,
5
+ ZGrab2NotFoundError,
6
+ ZGrab2OutputError,
7
+ ZGrab2ProcessError,
8
+ ZGrab2TimeoutError,
9
+ )
10
+ from .models import ScanResult, Target
11
+ from .options import HTTPOptions, ScanOptions, SSHOptions, TLSOptions
12
+
13
+ __all__ = [
14
+ 'HTTPOptions',
15
+ 'SSHOptions',
16
+ 'ScanOptions',
17
+ 'ScanResult',
18
+ 'TLSOptions',
19
+ 'Target',
20
+ 'ZGrab2',
21
+ 'ZGrab2ConfigurationError',
22
+ 'ZGrab2Error',
23
+ 'ZGrab2NotFoundError',
24
+ 'ZGrab2OutputError',
25
+ 'ZGrab2ProcessError',
26
+ 'ZGrab2TimeoutError',
27
+ ]
pyzgrab2/_process.py ADDED
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ import threading
6
+ from collections.abc import Iterable, Iterator, Mapping, Sequence
7
+ from pathlib import Path
8
+
9
+ from .exceptions import ZGrab2ProcessError, ZGrab2TimeoutError
10
+
11
+
12
+ def run_zgrab2(
13
+ executable: str | Path,
14
+ arguments: Sequence[str],
15
+ input_lines: Iterable[str],
16
+ *,
17
+ timeout: float | None = None,
18
+ cwd: str | Path | None = None,
19
+ env: Mapping[str, str] | None = None,
20
+ ) -> Iterator[str]:
21
+ '''Run zgrab2 and yield non-blank stdout lines as they arrive.'''
22
+ if timeout is not None and timeout <= 0:
23
+ raise ValueError('timeout must be greater than zero')
24
+
25
+ command = [str(executable), *arguments]
26
+ process_env = None if env is None else {**os.environ, **env}
27
+ try:
28
+ process = subprocess.Popen(
29
+ command,
30
+ stdin=subprocess.PIPE,
31
+ stdout=subprocess.PIPE,
32
+ stderr=subprocess.PIPE,
33
+ text=True,
34
+ encoding='utf-8',
35
+ bufsize=1,
36
+ cwd=cwd,
37
+ env=process_env,
38
+ )
39
+ except OSError as error:
40
+ raise ZGrab2ProcessError(
41
+ f'Could not start zgrab2: {error}', command=command,
42
+ ) from error
43
+
44
+ if process.stdin is None or process.stdout is None or process.stderr is None:
45
+ process.kill()
46
+ process.wait()
47
+ raise ZGrab2ProcessError('Could not create pipes for zgrab2', command=command)
48
+
49
+ stdin = process.stdin
50
+ stdout = process.stdout
51
+ stderr_pipe = process.stderr
52
+ stderr_lines: list[str] = []
53
+ writer_errors: list[Exception] = []
54
+ timed_out = threading.Event()
55
+
56
+ def write_input() -> None:
57
+ try:
58
+ for line in input_lines:
59
+ stdin.write(line.rstrip('\r\n') + '\n')
60
+ stdin.flush()
61
+ except BrokenPipeError:
62
+ pass
63
+ except Exception as error: # noqa: BLE001 - preserve producer failures
64
+ writer_errors.append(error)
65
+ finally:
66
+ stdin.close()
67
+
68
+ def read_stderr() -> None:
69
+ stderr_lines.extend(stderr_pipe)
70
+
71
+ def expire() -> None:
72
+ timed_out.set()
73
+ if process.poll() is None:
74
+ process.terminate()
75
+
76
+ writer_thread = threading.Thread(target=write_input, name='pyzgrab2-stdin', daemon=True)
77
+ stderr_thread = threading.Thread(target=read_stderr, name='pyzgrab2-stderr', daemon=True)
78
+ timer = threading.Timer(timeout, expire) if timeout is not None else None
79
+ writer_thread.start()
80
+ stderr_thread.start()
81
+ if timer is not None:
82
+ timer.start()
83
+
84
+ try:
85
+ for line in stdout:
86
+ if line.strip():
87
+ yield line
88
+
89
+ writer_thread.join()
90
+ stderr_thread.join()
91
+ return_code = process.wait()
92
+ stderr = ''.join(stderr_lines).strip()
93
+
94
+ if timed_out.is_set():
95
+ raise ZGrab2TimeoutError(
96
+ f'zgrab2 exceeded timeout of {timeout} seconds',
97
+ command=command,
98
+ returncode=return_code,
99
+ stderr=stderr,
100
+ )
101
+ if writer_errors:
102
+ raise ZGrab2ProcessError(
103
+ f'Failed while writing targets to zgrab2: {writer_errors[0]}',
104
+ command=command,
105
+ returncode=return_code,
106
+ stderr=stderr,
107
+ ) from writer_errors[0]
108
+ if return_code != 0:
109
+ message = f'zgrab2 exited with status {return_code}'
110
+ if stderr:
111
+ message += f': {stderr}'
112
+ raise ZGrab2ProcessError(
113
+ message, command=command, returncode=return_code, stderr=stderr,
114
+ )
115
+ finally:
116
+ if timer is not None:
117
+ timer.cancel()
118
+ if process.poll() is None:
119
+ process.terminate()
120
+ try:
121
+ process.wait(timeout=5)
122
+ except subprocess.TimeoutExpired:
123
+ process.kill()
124
+ process.wait()
125
+ writer_thread.join(timeout=1)
126
+ stderr_thread.join(timeout=1)
pyzgrab2/client.py ADDED
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import json
5
+ import shutil
6
+ import subprocess
7
+ from collections.abc import Iterable, Iterator, Mapping
8
+ from pathlib import Path
9
+
10
+ from ._process import run_zgrab2
11
+ from .exceptions import (
12
+ ZGrab2ConfigurationError,
13
+ ZGrab2NotFoundError,
14
+ ZGrab2OutputError,
15
+ ZGrab2ProcessError,
16
+ )
17
+ from .models import ScanResult, Target
18
+ from .options import HTTPOptions, ScanOptions, SSHOptions, TLSOptions
19
+
20
+ TargetInput = str | Target
21
+
22
+
23
+ class ZGrab2:
24
+ '''Python interface to a local zgrab2 executable.'''
25
+
26
+ def __init__(self, executable: str | Path = 'zgrab2') -> None:
27
+ executable_path = shutil.which(str(executable))
28
+ if executable_path is None:
29
+ raise ZGrab2NotFoundError(f'Could not find the zgrab2 executable: {executable}')
30
+ self.executable = executable_path
31
+
32
+ def scan(
33
+ self,
34
+ targets: Iterable[TargetInput],
35
+ *,
36
+ module: str,
37
+ port: int | None = None,
38
+ module_args: Iterable[str] = (),
39
+ options: ScanOptions | None = None,
40
+ timeout: float | None = None,
41
+ cwd: str | Path | None = None,
42
+ env: Mapping[str, str] | None = None,
43
+ ) -> Iterator[ScanResult]:
44
+ '''Stream results from any zgrab2 module.'''
45
+ if not module or module.startswith('-'):
46
+ raise ZGrab2ConfigurationError('module must be a non-empty command name')
47
+ common = options or ScanOptions()
48
+ if port is not None and common.port is not None and port != common.port:
49
+ raise ZGrab2ConfigurationError('port conflicts with options.port')
50
+ if port is not None:
51
+ common = ScanOptions(
52
+ port=port,
53
+ senders=common.senders,
54
+ connections_per_host=common.connections_per_host,
55
+ connect_timeout=common.connect_timeout,
56
+ target_timeout=common.target_timeout,
57
+ read_limit_per_host=common.read_limit_per_host,
58
+ dns_resolvers=common.dns_resolvers,
59
+ dns_resolution_timeout=common.dns_resolution_timeout,
60
+ server_rate_limit=common.server_rate_limit,
61
+ resolve_ipv4=common.resolve_ipv4,
62
+ resolve_ipv6=common.resolve_ipv6,
63
+ debug=common.debug,
64
+ flush=common.flush,
65
+ verbose=common.verbose,
66
+ trigger=common.trigger,
67
+ extra_args=common.extra_args,
68
+ )
69
+ arguments = [module, *common.to_args(), *module_args]
70
+ input_lines = (self._normalize_target(target).to_csv_line() for target in targets)
71
+
72
+ for line_number, line in enumerate(
73
+ run_zgrab2(
74
+ executable=self.executable,
75
+ arguments=arguments,
76
+ input_lines=input_lines,
77
+ timeout=timeout,
78
+ cwd=cwd,
79
+ env=env,
80
+ ),
81
+ start=1,
82
+ ):
83
+ try:
84
+ raw = json.loads(line)
85
+ except (json.JSONDecodeError, UnicodeDecodeError) as error:
86
+ raise ZGrab2OutputError(
87
+ f"Invalid JSON on zgrab2 output line {line_number}",
88
+ ) from error
89
+ if not isinstance(raw, dict):
90
+ raise ZGrab2OutputError(
91
+ f"Expected a JSON object on zgrab2 output line {line_number}",
92
+ )
93
+ yield self._parse_result(raw, module, line_number=line_number)
94
+
95
+ def http(
96
+ self,
97
+ targets: Iterable[TargetInput],
98
+ *,
99
+ http: HTTPOptions | None = None,
100
+ options: ScanOptions | None = None,
101
+ timeout: float | None = None,
102
+ ) -> Iterator[ScanResult]:
103
+ '''Scan targets with zgrab2's HTTP module.'''
104
+ return self.scan(
105
+ targets,
106
+ module='http',
107
+ module_args=(http or HTTPOptions()).to_args(),
108
+ options=options,
109
+ timeout=timeout,
110
+ )
111
+
112
+ def tls(
113
+ self,
114
+ targets: Iterable[TargetInput],
115
+ *,
116
+ tls: TLSOptions | None = None,
117
+ options: ScanOptions | None = None,
118
+ timeout: float | None = None,
119
+ ) -> Iterator[ScanResult]:
120
+ '''Scan targets with zgrab2's TLS module.'''
121
+ return self.scan(
122
+ targets,
123
+ module='tls',
124
+ module_args=(tls or TLSOptions()).to_args(),
125
+ options=options,
126
+ timeout=timeout,
127
+ )
128
+
129
+ def ssh(
130
+ self,
131
+ targets: Iterable[TargetInput],
132
+ *,
133
+ ssh: SSHOptions | None = None,
134
+ options: ScanOptions | None = None,
135
+ timeout: float | None = None,
136
+ ) -> Iterator[ScanResult]:
137
+ '''Scan targets with zgrab2's SSH module.'''
138
+ return self.scan(
139
+ targets,
140
+ module='ssh',
141
+ module_args=(ssh or SSHOptions()).to_args(),
142
+ options=options,
143
+ timeout=timeout,
144
+ )
145
+
146
+
147
+ def module_help(self, module: str) -> str:
148
+ '''Return zgrab2's help text for a module.'''
149
+ if not module or module.startswith('-'):
150
+ raise ZGrab2ConfigurationError('module must be a non-empty command name')
151
+ return self._run_info_command(module, '--help')
152
+
153
+ def _run_info_command(self, *arguments: str) -> str:
154
+ command = [self.executable, *arguments]
155
+ try:
156
+ completed = subprocess.run(
157
+ command, capture_output=True, text=True, encoding='utf-8', check=False,
158
+ )
159
+ except OSError as error:
160
+ raise ZGrab2ProcessError(
161
+ f'Could not start zgrab2: {error}', command=command,
162
+ ) from error
163
+ if completed.returncode != 0:
164
+ stderr = completed.stderr.strip()
165
+ message = f"zgrab2 exited with status {completed.returncode}"
166
+ if stderr:
167
+ message += f": {stderr}"
168
+ raise ZGrab2ProcessError(
169
+ message,
170
+ command=command,
171
+ returncode=completed.returncode,
172
+ stderr=stderr,
173
+ )
174
+ return completed.stdout
175
+
176
+ @staticmethod
177
+ def _normalize_target(target: TargetInput) -> Target:
178
+ if isinstance(target, Target):
179
+ return target
180
+ try:
181
+ ipaddress.ip_network(target, strict=False)
182
+ except ValueError:
183
+ return Target(domain=target)
184
+ return Target(ip=target)
185
+
186
+ @staticmethod
187
+ def _parse_result(
188
+ raw: dict[str, object],
189
+ module: str,
190
+ *,
191
+ line_number: int | None = None,
192
+ ) -> ScanResult:
193
+ data = raw.get('data', {})
194
+ if not isinstance(data, dict):
195
+ raise ZGrab2OutputError(_unexpected_output('data must be an object', line_number))
196
+ module_output = data.get(module, {})
197
+ if module_output is None:
198
+ module_output = {}
199
+ if not isinstance(module_output, dict):
200
+ raise ZGrab2OutputError(
201
+ _unexpected_output(f"data.{module} must be an object", line_number),
202
+ )
203
+ return ScanResult(
204
+ ip=_optional_string(raw.get('ip'), 'ip', line_number),
205
+ domain=_optional_string(raw.get('domain'), 'domain', line_number),
206
+ tag=_optional_string(raw.get('tag'), 'tag', line_number),
207
+ module=module,
208
+ status=_optional_string(module_output.get('status'), 'status', line_number),
209
+ data=module_output,
210
+ raw=raw,
211
+ )
212
+
213
+
214
+ def _optional_string(value: object, field: str, line_number: int | None) -> str | None:
215
+ if value is None:
216
+ return None
217
+ if not isinstance(value, str):
218
+ raise ZGrab2OutputError(_unexpected_output(f'{field} must be a string', line_number))
219
+ return value
220
+
221
+
222
+ def _unexpected_output(message: str, line_number: int | None) -> str:
223
+ suffix = f" on zgrab2 output line {line_number}" if line_number is not None else ''
224
+ return f"Unexpected zgrab2 output{suffix}: {message}"
pyzgrab2/exceptions.py ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+
5
+
6
+ class ZGrab2Error(Exception):
7
+ '''Base exception for pyzgrab2.'''
8
+
9
+
10
+ class ZGrab2NotFoundError(ZGrab2Error):
11
+ '''The zgrab2 executable could not be found.'''
12
+
13
+
14
+ class ZGrab2ConfigurationError(ZGrab2Error, ValueError):
15
+ '''The requested scan configuration is invalid.'''
16
+
17
+
18
+ class ZGrab2ProcessError(ZGrab2Error):
19
+ '''The zgrab2 process exited unsuccessfully.'''
20
+
21
+ def __init__(
22
+ self,
23
+ message: str,
24
+ *,
25
+ command: Sequence[str] = (),
26
+ returncode: int | None = None,
27
+ stderr: str = '',
28
+ ) -> None:
29
+ super().__init__(message)
30
+ self.command = tuple(command)
31
+ self.returncode = returncode
32
+ self.stderr = stderr
33
+
34
+
35
+ class ZGrab2TimeoutError(ZGrab2ProcessError, TimeoutError):
36
+ '''The zgrab2 process exceeded its allowed runtime.'''
37
+
38
+
39
+ class ZGrab2OutputError(ZGrab2Error):
40
+ '''zgrab2 produced invalid or unexpected JSON output.'''
pyzgrab2/models.py ADDED
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import io
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+ from typing import Any
9
+
10
+ from .exceptions import ZGrab2ConfigurationError
11
+
12
+
13
+ def _validate_port(port: int | None) -> None:
14
+ if port is not None and not 1 <= port <= 65535:
15
+ raise ZGrab2ConfigurationError('Port must be between 1 and 65535')
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class Target:
20
+ '''A zgrab2 input target in ``IP,DOMAIN,TAG,PORT`` form.'''
21
+
22
+ ip: str | None = None
23
+ domain: str | None = None
24
+ tag: str | None = None
25
+ port: int | None = None
26
+
27
+ def __post_init__(self) -> None:
28
+ if not self.ip and not self.domain:
29
+ raise ZGrab2ConfigurationError('Target requires an IP address, CIDR, or domain')
30
+
31
+ for value in (self.ip, self.domain, self.tag):
32
+ if value is not None and ('\n' in value or '\r' in value):
33
+ raise ZGrab2ConfigurationError('Target fields cannot contain newlines')
34
+
35
+ _validate_port(self.port)
36
+
37
+ def to_csv_line(self) -> str:
38
+ output = io.StringIO(newline='')
39
+ csv.writer(output, lineterminator='').writerow(
40
+ (self.ip or '', self.domain or '', self.tag or '', self.port or ''),
41
+ )
42
+ return output.getvalue()
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class ScanResult:
47
+ '''One normalized zgrab2 JSON-lines result.'''
48
+
49
+ ip: str | None
50
+ domain: str | None
51
+ module: str
52
+ status: str | None
53
+ data: Mapping[str, Any] = field(default_factory=dict)
54
+ raw: Mapping[str, Any] = field(default_factory=dict)
55
+ tag: str | None = None
56
+
57
+ @property
58
+ def protocol(self) -> str | None:
59
+ value = self.data.get('protocol')
60
+ return value if isinstance(value, str) else None
61
+
62
+ @property
63
+ def port(self) -> int | None:
64
+ value = self.data.get('port')
65
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
66
+
67
+ @property
68
+ def result(self) -> Mapping[str, Any] | None:
69
+ value = self.data.get('result')
70
+ return value if isinstance(value, Mapping) else None
71
+
72
+ @property
73
+ def error(self) -> str | None:
74
+ value = self.data.get('error')
75
+ return value if isinstance(value, str) else None
76
+
77
+ @property
78
+ def timestamp(self) -> datetime | None:
79
+ value = self.data.get('timestamp')
80
+ if not isinstance(value, str):
81
+ return None
82
+ try:
83
+ return datetime.fromisoformat(value.replace('Z', '+00:00'))
84
+ except ValueError:
85
+ return None