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 +27 -0
- pyzgrab2/_process.py +126 -0
- pyzgrab2/client.py +224 -0
- pyzgrab2/exceptions.py +40 -0
- pyzgrab2/models.py +85 -0
- pyzgrab2/options.py +233 -0
- pyzgrab2/py.typed +0 -0
- pyzgrab2-1.0.0.dist-info/METADATA +395 -0
- pyzgrab2-1.0.0.dist-info/RECORD +11 -0
- pyzgrab2-1.0.0.dist-info/WHEEL +4 -0
- pyzgrab2-1.0.0.dist-info/licenses/LICENSE +674 -0
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
|