dissect.target 3.20.dev64__py3-none-any.whl → 3.20.2.dev11__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- dissect/target/helpers/configutil.py +5 -5
- dissect/target/helpers/record.py +12 -6
- dissect/target/helpers/regutil.py +28 -11
- dissect/target/helpers/utils.py +20 -1
- dissect/target/loaders/itunes.py +5 -3
- dissect/target/plugins/apps/browser/iexplore.py +7 -3
- dissect/target/plugins/general/network.py +1 -1
- dissect/target/plugins/general/plugins.py +1 -1
- dissect/target/plugins/os/unix/_os.py +1 -1
- dissect/target/plugins/os/unix/bsd/osx/network.py +2 -1
- dissect/target/plugins/os/unix/esxi/_os.py +34 -32
- dissect/target/plugins/os/unix/etc/etc.py +12 -6
- dissect/target/plugins/os/unix/linux/fortios/_keys.py +7914 -1114
- dissect/target/plugins/os/unix/linux/fortios/_os.py +109 -22
- dissect/target/plugins/os/unix/linux/network.py +338 -0
- dissect/target/plugins/os/unix/linux/network_managers.py +1 -1
- dissect/target/plugins/os/unix/log/auth.py +6 -37
- dissect/target/plugins/os/unix/log/helpers.py +46 -0
- dissect/target/plugins/os/unix/log/messages.py +24 -15
- dissect/target/plugins/os/unix/trash.py +13 -2
- dissect/target/plugins/os/windows/activitiescache.py +32 -30
- dissect/target/plugins/os/windows/catroot.py +14 -5
- dissect/target/plugins/os/windows/lnk.py +13 -7
- dissect/target/plugins/os/windows/network.py +9 -5
- dissect/target/plugins/os/windows/notifications.py +40 -38
- dissect/target/plugins/os/windows/regf/cit.py +20 -7
- dissect/target/tools/diff.py +990 -0
- {dissect.target-3.20.dev64.dist-info → dissect.target-3.20.2.dev11.dist-info}/METADATA +73 -73
- {dissect.target-3.20.dev64.dist-info → dissect.target-3.20.2.dev11.dist-info}/RECORD +34 -31
- {dissect.target-3.20.dev64.dist-info → dissect.target-3.20.2.dev11.dist-info}/WHEEL +1 -1
- {dissect.target-3.20.dev64.dist-info → dissect.target-3.20.2.dev11.dist-info}/entry_points.txt +1 -0
- {dissect.target-3.20.dev64.dist-info → dissect.target-3.20.2.dev11.dist-info}/COPYRIGHT +0 -0
- {dissect.target-3.20.dev64.dist-info → dissect.target-3.20.2.dev11.dist-info}/LICENSE +0 -0
- {dissect.target-3.20.dev64.dist-info → dissect.target-3.20.2.dev11.dist-info}/top_level.txt +0 -0
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import gzip
|
4
4
|
import hashlib
|
5
|
+
import struct
|
5
6
|
from base64 import b64decode
|
6
7
|
from datetime import datetime
|
7
8
|
from io import BytesIO
|
@@ -17,11 +18,17 @@ from dissect.target.helpers.fsutil import open_decompress
|
|
17
18
|
from dissect.target.helpers.record import TargetRecordDescriptor, UnixUserRecord
|
18
19
|
from dissect.target.plugin import OperatingSystem, export
|
19
20
|
from dissect.target.plugins.os.unix.linux._os import LinuxPlugin
|
20
|
-
from dissect.target.plugins.os.unix.linux.fortios._keys import
|
21
|
+
from dissect.target.plugins.os.unix.linux.fortios._keys import (
|
22
|
+
KERNEL_KEY_MAP,
|
23
|
+
AesKey,
|
24
|
+
ChaCha20Key,
|
25
|
+
ChaCha20Seed,
|
26
|
+
)
|
21
27
|
from dissect.target.target import Target
|
22
28
|
|
23
29
|
try:
|
24
30
|
from Crypto.Cipher import AES, ChaCha20
|
31
|
+
from Crypto.Util import Counter
|
25
32
|
|
26
33
|
HAS_CRYPTO = True
|
27
34
|
except ImportError:
|
@@ -95,8 +102,11 @@ class FortiOSPlugin(LinuxPlugin):
|
|
95
102
|
# The rootfs.gz file could be encrypted.
|
96
103
|
try:
|
97
104
|
kernel_hash = get_kernel_hash(sysvol)
|
98
|
-
|
99
|
-
|
105
|
+
target.log.info("Kernel hash: %s", kernel_hash)
|
106
|
+
key = key_iv_for_kernel_hash(kernel_hash)
|
107
|
+
target.log.info("Trying to decrypt_rootfs using key: %r", key)
|
108
|
+
rfs_fh = decrypt_rootfs(rootfs.open(), key)
|
109
|
+
target.log.info("Decrypted fh: %r", rfs_fh)
|
100
110
|
vfs = TarFilesystem(rfs_fh, tarinfo=cpio.CpioInfo)
|
101
111
|
except RuntimeError:
|
102
112
|
target.log.warning("Could not decrypt rootfs.gz. Missing `pycryptodome` dependency.")
|
@@ -471,7 +481,7 @@ def decrypt_password(input: str) -> str:
|
|
471
481
|
return "ENC:" + input
|
472
482
|
|
473
483
|
|
474
|
-
def key_iv_for_kernel_hash(kernel_hash: str) ->
|
484
|
+
def key_iv_for_kernel_hash(kernel_hash: str) -> AesKey | ChaCha20Key:
|
475
485
|
"""Return decryption key and IV for a specific sha256 kernel hash.
|
476
486
|
|
477
487
|
The decryption key and IV are used to decrypt the ``rootfs.gz`` file.
|
@@ -486,17 +496,96 @@ def key_iv_for_kernel_hash(kernel_hash: str) -> tuple[bytes, bytes]:
|
|
486
496
|
ValueError: When no decryption keys are available for the given kernel hash.
|
487
497
|
"""
|
488
498
|
|
489
|
-
key =
|
490
|
-
if
|
499
|
+
key = KERNEL_KEY_MAP.get(kernel_hash)
|
500
|
+
if isinstance(key, ChaCha20Seed):
|
491
501
|
# FortiOS 7.4.x uses a KDF to derive the key and IV
|
492
|
-
|
493
|
-
|
502
|
+
key, iv = _kdf_7_4_x(key.key)
|
503
|
+
return ChaCha20Key(key, iv)
|
504
|
+
elif isinstance(key, ChaCha20Key):
|
494
505
|
# FortiOS 7.0.13 and 7.0.14 uses a static key and IV
|
495
|
-
return key
|
506
|
+
return key
|
507
|
+
elif isinstance(key, AesKey):
|
508
|
+
# FortiOS 7.0.16, 7.2.9, 7.4.4, 7.6.0 and higher uses AES-CTR with a custom CTR increment
|
509
|
+
return key
|
496
510
|
raise ValueError(f"No known decryption keys for kernel hash: {kernel_hash}")
|
497
511
|
|
498
512
|
|
499
|
-
def
|
513
|
+
def chacha20_decrypt(fh: BinaryIO, key: ChaCha20Key) -> bytes:
|
514
|
+
"""Decrypt file using ChaCha20 with given ChaCha20Key.
|
515
|
+
|
516
|
+
Args:
|
517
|
+
fh: File-like object to the encrypted rootfs.gz file.
|
518
|
+
key: ChaCha20Key.
|
519
|
+
|
520
|
+
Returns:
|
521
|
+
Decrypted bytes.
|
522
|
+
"""
|
523
|
+
|
524
|
+
# First 8 bytes = counter, last 8 bytes = nonce
|
525
|
+
# PyCryptodome interally divides this seek by 64 to get a (position, offset) tuple
|
526
|
+
# We're interested in updating the position in the ChaCha20 internal state, so to make
|
527
|
+
# PyCryptodome "OpenSSL-compatible" we have to multiply the counter by 64
|
528
|
+
cipher = ChaCha20.new(key=key.key, nonce=key.iv[8:])
|
529
|
+
cipher.seek(int.from_bytes(key.iv[:8], "little") * 64)
|
530
|
+
return cipher.decrypt(fh.read())
|
531
|
+
|
532
|
+
|
533
|
+
def calculate_counter_increment(iv: bytes) -> int:
|
534
|
+
"""Calculate the custom FortiGate CTR increment from IV.
|
535
|
+
|
536
|
+
Args:
|
537
|
+
iv: 16 bytes IV.
|
538
|
+
|
539
|
+
Returns:
|
540
|
+
Custom CTR increment.
|
541
|
+
"""
|
542
|
+
increment = 0
|
543
|
+
for i in range(16):
|
544
|
+
increment ^= (iv[i] & 15) ^ ((iv[i] >> 4) & 0xFF)
|
545
|
+
return max(increment, 1)
|
546
|
+
|
547
|
+
|
548
|
+
def aes_decrypt(fh: BinaryIO, key: AesKey) -> bytes:
|
549
|
+
"""Decrypt file using a custom AES CTR increment with given AesKey.
|
550
|
+
|
551
|
+
Args:
|
552
|
+
fh: File-like object to the encrypted rootfs.gz file.
|
553
|
+
key: AesKey.
|
554
|
+
|
555
|
+
Returns:
|
556
|
+
Decrypted bytes.
|
557
|
+
"""
|
558
|
+
|
559
|
+
data = bytearray(fh.read())
|
560
|
+
|
561
|
+
# Calculate custom CTR increment from IV
|
562
|
+
increment = calculate_counter_increment(key.iv)
|
563
|
+
advance_block = (b"\x69" * 16) * (increment - 1)
|
564
|
+
|
565
|
+
# AES counter is little-endian and has a prefix
|
566
|
+
prefix, counter = struct.unpack("<8sQ", key.iv)
|
567
|
+
ctr = Counter.new(
|
568
|
+
64,
|
569
|
+
prefix=prefix,
|
570
|
+
initial_value=counter,
|
571
|
+
little_endian=True,
|
572
|
+
allow_wraparound=True,
|
573
|
+
)
|
574
|
+
cipher = AES.new(key.key, mode=AES.MODE_CTR, counter=ctr)
|
575
|
+
|
576
|
+
nblocks, nleft = divmod(len(data), 16)
|
577
|
+
for i in range(nblocks):
|
578
|
+
offset = i * 16
|
579
|
+
data[offset : offset + 16] = cipher.decrypt(data[offset : offset + 16])
|
580
|
+
cipher.decrypt(advance_block) # custom advance the counter
|
581
|
+
|
582
|
+
if nleft:
|
583
|
+
data[nblocks * 16 :] = cipher.decrypt(data[nblocks * 16 :])
|
584
|
+
|
585
|
+
return data
|
586
|
+
|
587
|
+
|
588
|
+
def decrypt_rootfs(fh: BinaryIO, key: ChaCha20Key | AesKey) -> BinaryIO:
|
500
589
|
"""Attempt to decrypt an encrypted ``rootfs.gz`` file with given key and IV.
|
501
590
|
|
502
591
|
FortiOS releases as of 7.4.1 / 2023-08-31, have ChaCha20 encrypted ``rootfs.gz`` files.
|
@@ -511,8 +600,7 @@ def decrypt_rootfs(fh: BinaryIO, key: bytes, iv: bytes) -> BinaryIO:
|
|
511
600
|
|
512
601
|
Args:
|
513
602
|
fh: File-like object to the encrypted rootfs.gz file.
|
514
|
-
key:
|
515
|
-
iv: ChaCha20 iv.
|
603
|
+
key: ChaCha20Key or AesKey.
|
516
604
|
|
517
605
|
Returns:
|
518
606
|
File-like object to the decrypted rootfs.gz file.
|
@@ -525,13 +613,12 @@ def decrypt_rootfs(fh: BinaryIO, key: bytes, iv: bytes) -> BinaryIO:
|
|
525
613
|
if not HAS_CRYPTO:
|
526
614
|
raise RuntimeError("Missing pycryptodome dependency")
|
527
615
|
|
528
|
-
|
529
|
-
|
530
|
-
|
531
|
-
|
532
|
-
|
533
|
-
|
534
|
-
result = cipher.decrypt(fh.read())
|
616
|
+
result = b""
|
617
|
+
if isinstance(key, ChaCha20Key):
|
618
|
+
result = chacha20_decrypt(fh, key)
|
619
|
+
elif isinstance(key, AesKey):
|
620
|
+
result = aes_decrypt(fh, key)
|
621
|
+
result = result[:-256] # strip off the 256 byte footer
|
535
622
|
|
536
623
|
if result[0:2] != b"\x1f\x8b":
|
537
624
|
raise ValueError("Failed to decrypt: No gzip magic header found.")
|
@@ -539,7 +626,7 @@ def decrypt_rootfs(fh: BinaryIO, key: bytes, iv: bytes) -> BinaryIO:
|
|
539
626
|
return BytesIO(result)
|
540
627
|
|
541
628
|
|
542
|
-
def _kdf_7_4_x(key_data: str | bytes) -> tuple[bytes, bytes]:
|
629
|
+
def _kdf_7_4_x(key_data: str | bytes, offset_key: int = 4, offset_iv: int = 5) -> tuple[bytes, bytes]:
|
543
630
|
"""Derive 32 byte key and 16 byte IV from 32 byte seed.
|
544
631
|
|
545
632
|
As the IV needs to be 16 bytes, we return the first 16 bytes of the sha256 hash.
|
@@ -548,8 +635,8 @@ def _kdf_7_4_x(key_data: str | bytes) -> tuple[bytes, bytes]:
|
|
548
635
|
if isinstance(key_data, str):
|
549
636
|
key_data = bytes.fromhex(key_data)
|
550
637
|
|
551
|
-
key = hashlib.sha256(key_data[
|
552
|
-
iv = hashlib.sha256(key_data[
|
638
|
+
key = hashlib.sha256(key_data[offset_key:32] + key_data[:offset_key]).digest()
|
639
|
+
iv = hashlib.sha256(key_data[offset_iv:32] + key_data[:offset_iv]).digest()[:16]
|
553
640
|
return key, iv
|
554
641
|
|
555
642
|
|
@@ -0,0 +1,338 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import re
|
4
|
+
from dataclasses import dataclass, field
|
5
|
+
from datetime import datetime, timezone
|
6
|
+
from ipaddress import ip_address, ip_interface
|
7
|
+
from typing import TYPE_CHECKING, Any, Iterator, Literal, NamedTuple
|
8
|
+
|
9
|
+
from dissect.target.helpers import configutil
|
10
|
+
from dissect.target.helpers.record import UnixInterfaceRecord
|
11
|
+
from dissect.target.helpers.utils import to_list
|
12
|
+
from dissect.target.plugins.general.network import NetworkPlugin
|
13
|
+
|
14
|
+
if TYPE_CHECKING:
|
15
|
+
from ipaddress import IPv4Address, IPv4Interface, IPv6Address, IPv6Interface
|
16
|
+
|
17
|
+
from dissect.target import Target
|
18
|
+
from dissect.target.target import TargetPath
|
19
|
+
|
20
|
+
NetAddress = IPv4Address | IPv6Address
|
21
|
+
NetInterface = IPv4Interface | IPv6Interface
|
22
|
+
|
23
|
+
|
24
|
+
class LinuxNetworkPlugin(NetworkPlugin):
|
25
|
+
"""Linux network interface plugin."""
|
26
|
+
|
27
|
+
def _interfaces(self) -> Iterator[UnixInterfaceRecord]:
|
28
|
+
"""Try all available network configuration managers and aggregate the results."""
|
29
|
+
for manager_cls in MANAGERS:
|
30
|
+
manager: LinuxNetworkConfigParser = manager_cls(self.target)
|
31
|
+
yield from manager.interfaces()
|
32
|
+
|
33
|
+
|
34
|
+
VlanIdByInterface = dict[str, set[int]]
|
35
|
+
|
36
|
+
|
37
|
+
class LinuxNetworkConfigParser:
|
38
|
+
def __init__(self, target: Target):
|
39
|
+
self._target = target
|
40
|
+
|
41
|
+
def _config_files(self, config_paths: list[str], glob: str) -> list[TargetPath]:
|
42
|
+
"""Returns all configuration files in config_paths matching the given extension."""
|
43
|
+
all_files = []
|
44
|
+
for config_path in config_paths:
|
45
|
+
paths = self._target.fs.path(config_path).glob(glob)
|
46
|
+
all_files.extend(config_file for config_file in paths if config_file.is_file())
|
47
|
+
|
48
|
+
return sorted(all_files, key=lambda p: p.stem)
|
49
|
+
|
50
|
+
def interfaces(self) -> Iterator[UnixInterfaceRecord]:
|
51
|
+
"""Parse network interfaces from configuration files."""
|
52
|
+
yield from ()
|
53
|
+
|
54
|
+
|
55
|
+
class NetworkManagerConfigParser(LinuxNetworkConfigParser):
|
56
|
+
"""NetworkManager configuration parser.
|
57
|
+
|
58
|
+
NetworkManager configuration files are generally in an INI-like format.
|
59
|
+
Note that Red Hat and Fedora deprecated ifcfg files.
|
60
|
+
Documentation: https://networkmanager.dev/docs/api/latest/nm-settings-keyfile.html
|
61
|
+
"""
|
62
|
+
|
63
|
+
config_paths: list[str] = [
|
64
|
+
"/etc/NetworkManager/system-connections/",
|
65
|
+
"/usr/lib/NetworkManager/system-connections/",
|
66
|
+
"/run/NetworkManager/system-connections/",
|
67
|
+
]
|
68
|
+
|
69
|
+
@dataclass
|
70
|
+
class ParserContext:
|
71
|
+
source: str
|
72
|
+
uuid: str | None = None
|
73
|
+
last_connected: datetime | None = None
|
74
|
+
name: str | None = None
|
75
|
+
mac_address: str | None = None
|
76
|
+
type: str = ""
|
77
|
+
dns: set[NetAddress] = field(default_factory=set)
|
78
|
+
ip_interfaces: set[NetInterface] = field(default_factory=set)
|
79
|
+
gateways: set[NetAddress] = field(default_factory=set)
|
80
|
+
dhcp_ipv4: bool = False
|
81
|
+
dhcp_ipv6: bool = False
|
82
|
+
vlan: set[int] = field(default_factory=set)
|
83
|
+
|
84
|
+
def to_record(self) -> UnixInterfaceRecord:
|
85
|
+
return UnixInterfaceRecord(
|
86
|
+
source=self.source,
|
87
|
+
last_connected=self.last_connected,
|
88
|
+
name=self.name,
|
89
|
+
mac=[self.mac_address] if self.mac_address else [],
|
90
|
+
type=self.type,
|
91
|
+
dhcp_ipv4=self.dhcp_ipv4,
|
92
|
+
dhcp_ipv6=self.dhcp_ipv6,
|
93
|
+
dns=list(self.dns),
|
94
|
+
ip=[interface.ip for interface in self.ip_interfaces],
|
95
|
+
network=[interface.network for interface in self.ip_interfaces],
|
96
|
+
gateway=list(self.gateways),
|
97
|
+
vlan=list(self.vlan),
|
98
|
+
configurator="NetworkManager",
|
99
|
+
)
|
100
|
+
|
101
|
+
def interfaces(self) -> Iterator[UnixInterfaceRecord]:
|
102
|
+
connections: list[NetworkManagerConfigParser.ParserContext] = []
|
103
|
+
vlan_id_by_interface: VlanIdByInterface = {}
|
104
|
+
|
105
|
+
for connection_file_path in self._config_files(self.config_paths, "*"):
|
106
|
+
try:
|
107
|
+
config = configutil.parse(connection_file_path, hint="ini")
|
108
|
+
context = self.ParserContext(source=connection_file_path)
|
109
|
+
common_section: dict[str, str] = config.get("connection", {})
|
110
|
+
context.type = common_section.get("type", "")
|
111
|
+
sub_type: dict[str, str] = config.get(context.type, {})
|
112
|
+
|
113
|
+
if context.type == "vlan":
|
114
|
+
self._parse_vlan(sub_type, vlan_id_by_interface)
|
115
|
+
continue
|
116
|
+
|
117
|
+
for ip_version in ["ipv4", "ipv6"]:
|
118
|
+
ip_section: dict[str, str] = config.get(ip_version, {})
|
119
|
+
for key, value in ip_section.items():
|
120
|
+
self._parse_ip_section_key(key, value, context, ip_version)
|
121
|
+
|
122
|
+
context.name = common_section.get("interface-name")
|
123
|
+
context.mac_address = sub_type.get("mac-address")
|
124
|
+
context.uuid = common_section.get("uuid")
|
125
|
+
context.source = str(connection_file_path)
|
126
|
+
context.last_connected = self._parse_lastconnected(common_section.get("timestamp", ""))
|
127
|
+
|
128
|
+
connections.append(context)
|
129
|
+
|
130
|
+
except Exception as e:
|
131
|
+
self._target.log.warning("Error parsing network config file %s", connection_file_path)
|
132
|
+
self._target.log.debug("", exc_info=e)
|
133
|
+
|
134
|
+
for connection in connections:
|
135
|
+
vlan_ids_from_interface = vlan_id_by_interface.get(connection.name, set())
|
136
|
+
connection.vlan.update(vlan_ids_from_interface)
|
137
|
+
|
138
|
+
vlan_ids_from_uuid = vlan_id_by_interface.get(connection.uuid, set())
|
139
|
+
connection.vlan.update(vlan_ids_from_uuid)
|
140
|
+
|
141
|
+
yield connection.to_record()
|
142
|
+
|
143
|
+
def _parse_route(self, route: str) -> NetAddress | None:
|
144
|
+
"""Parse a route and return gateway IP address."""
|
145
|
+
if (elements := route.split(",")) and len(elements) > 1:
|
146
|
+
return ip_address(elements[1])
|
147
|
+
|
148
|
+
return None
|
149
|
+
|
150
|
+
def _parse_lastconnected(self, last_connected: str) -> datetime | None:
|
151
|
+
"""Parse last connected timestamp."""
|
152
|
+
if not last_connected:
|
153
|
+
return None
|
154
|
+
|
155
|
+
return datetime.fromtimestamp(int(last_connected), timezone.utc)
|
156
|
+
|
157
|
+
def _parse_ip_section_key(
|
158
|
+
self, key: str, value: str, context: ParserContext, ip_version: Literal["ipv4", "ipv6"]
|
159
|
+
) -> None:
|
160
|
+
if not (trimmed := value.strip()):
|
161
|
+
return
|
162
|
+
|
163
|
+
if key == "dns":
|
164
|
+
context.dns.update(ip_address(addr) for addr in trimmed.split(";") if addr)
|
165
|
+
elif key.startswith("address"):
|
166
|
+
# Undocumented: single gateway on address line. Observed when running:
|
167
|
+
# nmcli connection add type ethernet ... ip4 192.168.2.138/24 gw4 192.168.2.1
|
168
|
+
ip, *gateway = trimmed.split(",", 1)
|
169
|
+
context.ip_interfaces.add(ip_interface(ip))
|
170
|
+
if gateway:
|
171
|
+
context.gateways.add(ip_address(gateway[0]))
|
172
|
+
elif key.startswith("gateway"):
|
173
|
+
context.gateways.add(ip_address(trimmed))
|
174
|
+
elif key == "method":
|
175
|
+
if ip_version == "ipv4":
|
176
|
+
context.dhcp_ipv4 = trimmed == "auto"
|
177
|
+
elif ip_version == "ipv6":
|
178
|
+
context.dhcp_ipv6 = trimmed == "auto"
|
179
|
+
elif key.startswith("route"):
|
180
|
+
if gateway := self._parse_route(value):
|
181
|
+
context.gateways.add(gateway)
|
182
|
+
|
183
|
+
def _parse_vlan(self, sub_type: dict[str, Any], vlan_id_by_interface: VlanIdByInterface) -> None:
|
184
|
+
parent_interface = sub_type.get("parent")
|
185
|
+
vlan_id = sub_type.get("id")
|
186
|
+
if not parent_interface or not vlan_id:
|
187
|
+
return
|
188
|
+
|
189
|
+
ids = vlan_id_by_interface.setdefault(parent_interface, set())
|
190
|
+
ids.add(int(vlan_id))
|
191
|
+
|
192
|
+
|
193
|
+
class SystemdNetworkConfigParser(LinuxNetworkConfigParser):
|
194
|
+
"""Systemd network configuration parser.
|
195
|
+
|
196
|
+
Systemd network configuration files are generally in an INI-like format with some quirks.
|
197
|
+
Note that drop-in directories are not yet supported.
|
198
|
+
|
199
|
+
Documentation: https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html
|
200
|
+
"""
|
201
|
+
|
202
|
+
config_paths: list[str] = [
|
203
|
+
"/etc/systemd/network/",
|
204
|
+
"/run/systemd/network/",
|
205
|
+
"/usr/lib/systemd/network/",
|
206
|
+
"/usr/local/lib/systemd/network/",
|
207
|
+
]
|
208
|
+
|
209
|
+
class DhcpConfig(NamedTuple):
|
210
|
+
ipv4: bool
|
211
|
+
ipv6: bool
|
212
|
+
|
213
|
+
# Can be enclosed in brackets for IPv6. Can also have port, iface name, and SNI, which we ignore.
|
214
|
+
# Example: [1111:2222::3333]:9953%ifname#example.com
|
215
|
+
dns_ip_patttern = re.compile(
|
216
|
+
r"(?P<withoutBrackets>(?:\d{1,3}\.){3}\d{1,3})|\[(?P<withBrackets>\[?[0-9a-fA-F:]+\]?)\]"
|
217
|
+
)
|
218
|
+
|
219
|
+
def interfaces(self) -> Iterator:
|
220
|
+
virtual_networks = self._parse_virtual_networks()
|
221
|
+
yield from self._parse_networks(virtual_networks)
|
222
|
+
|
223
|
+
def _parse_virtual_networks(self) -> VlanIdByInterface:
|
224
|
+
"""Parse virtual network configurations from systemd network configuration files."""
|
225
|
+
|
226
|
+
virtual_networks: VlanIdByInterface = {}
|
227
|
+
for config_file in self._config_files(self.config_paths, "*.netdev"):
|
228
|
+
try:
|
229
|
+
virtual_network_config = configutil.parse(config_file, hint="systemd")
|
230
|
+
net_dev_section: dict[str, str] = virtual_network_config.get("NetDev", {})
|
231
|
+
if net_dev_section.get("Kind") != "vlan":
|
232
|
+
continue
|
233
|
+
|
234
|
+
vlan_id = virtual_network_config.get("VLAN", {}).get("Id")
|
235
|
+
if (name := net_dev_section.get("Name")) and vlan_id:
|
236
|
+
vlan_ids = virtual_networks.setdefault(name, set())
|
237
|
+
vlan_ids.add(int(vlan_id))
|
238
|
+
except Exception as e:
|
239
|
+
self._target.log.warning("Error parsing virtual network config file %s", config_file)
|
240
|
+
self._target.log.debug("", exc_info=e)
|
241
|
+
|
242
|
+
return virtual_networks
|
243
|
+
|
244
|
+
def _parse_networks(self, virtual_networks: VlanIdByInterface) -> Iterator[UnixInterfaceRecord]:
|
245
|
+
"""Parse network configurations from systemd network configuration files."""
|
246
|
+
for config_file in self._config_files(self.config_paths, "*.network"):
|
247
|
+
try:
|
248
|
+
config = configutil.parse(config_file, hint="systemd")
|
249
|
+
|
250
|
+
match_section: dict[str, str] = config.get("Match", {})
|
251
|
+
network_section: dict[str, str] = config.get("Network", {})
|
252
|
+
link_section: dict[str, str] = config.get("Link", {})
|
253
|
+
|
254
|
+
ip_interfaces: set[NetInterface] = set()
|
255
|
+
gateways: set[NetAddress] = set()
|
256
|
+
dns: set[NetAddress] = set()
|
257
|
+
mac_addresses: set[str] = set()
|
258
|
+
|
259
|
+
if link_mac := link_section.get("MACAddress"):
|
260
|
+
mac_addresses.add(link_mac)
|
261
|
+
mac_addresses.update(match_section.get("MACAddress", "").split())
|
262
|
+
mac_addresses.update(match_section.get("PermanentMACAddress", "").split())
|
263
|
+
|
264
|
+
dns_value = to_list(network_section.get("DNS", []))
|
265
|
+
dns.update(self._parse_dns_ip(dns_ip) for dns_ip in dns_value)
|
266
|
+
|
267
|
+
address_value = to_list(network_section.get("Address", []))
|
268
|
+
ip_interfaces.update(ip_interface(addr) for addr in address_value)
|
269
|
+
|
270
|
+
gateway_value = to_list(network_section.get("Gateway", []))
|
271
|
+
gateways.update(ip_address(gateway) for gateway in gateway_value)
|
272
|
+
|
273
|
+
vlan_ids: set[int] = set()
|
274
|
+
vlan_names = to_list(network_section.get("VLAN", []))
|
275
|
+
for vlan_name in vlan_names:
|
276
|
+
if ids := virtual_networks.get(vlan_name):
|
277
|
+
vlan_ids.update(ids)
|
278
|
+
|
279
|
+
# There are possibly multiple route sections, but they are collapsed into one by the parser.
|
280
|
+
route_section: dict[str, Any] = config.get("Route", {})
|
281
|
+
gateway_values = to_list(route_section.get("Gateway", []))
|
282
|
+
gateways.update(filter(None, map(self._parse_gateway, gateway_values)))
|
283
|
+
|
284
|
+
dhcp_ipv4, dhcp_ipv6 = self._parse_dhcp(network_section.get("DHCP"))
|
285
|
+
|
286
|
+
yield UnixInterfaceRecord(
|
287
|
+
source=str(config_file),
|
288
|
+
type=match_section.get("Type"),
|
289
|
+
enabled=None, # Unknown, dependent on run-time state
|
290
|
+
dhcp_ipv4=dhcp_ipv4,
|
291
|
+
dhcp_ipv6=dhcp_ipv6,
|
292
|
+
name=match_section.get("Name"),
|
293
|
+
dns=list(dns),
|
294
|
+
mac=list(mac_addresses),
|
295
|
+
ip=[interface.ip for interface in ip_interfaces],
|
296
|
+
network=[interface.network for interface in ip_interfaces],
|
297
|
+
gateway=list(gateways),
|
298
|
+
vlan=list(vlan_ids),
|
299
|
+
configurator="systemd-networkd",
|
300
|
+
)
|
301
|
+
except Exception as e:
|
302
|
+
self._target.log.warning("Error parsing network config file %s", config_file)
|
303
|
+
self._target.log.debug("", exc_info=e)
|
304
|
+
|
305
|
+
def _parse_dns_ip(self, address: str) -> NetAddress:
|
306
|
+
"""Parse DNS address from systemd network configuration file.
|
307
|
+
|
308
|
+
The optional brackets and port number make this hard to parse.
|
309
|
+
See https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html and search for DNS.
|
310
|
+
"""
|
311
|
+
|
312
|
+
if match := self.dns_ip_patttern.search(address):
|
313
|
+
return ip_address(match.group("withoutBrackets") or match.group("withBrackets"))
|
314
|
+
|
315
|
+
raise ValueError(f"Invalid DNS address format: {address}")
|
316
|
+
|
317
|
+
def _parse_dhcp(self, value: str | None) -> DhcpConfig:
|
318
|
+
"""Parse DHCP value from systemd network configuration file to a named tuple (ipv4, ipv6)."""
|
319
|
+
|
320
|
+
if value is None or value == "no":
|
321
|
+
return self.DhcpConfig(ipv4=False, ipv6=False)
|
322
|
+
elif value == "yes":
|
323
|
+
return self.DhcpConfig(ipv4=True, ipv6=True)
|
324
|
+
elif value == "ipv4":
|
325
|
+
return self.DhcpConfig(ipv4=True, ipv6=False)
|
326
|
+
elif value == "ipv6":
|
327
|
+
return self.DhcpConfig(ipv4=False, ipv6=True)
|
328
|
+
|
329
|
+
raise ValueError(f"Invalid DHCP value: {value}")
|
330
|
+
|
331
|
+
def _parse_gateway(self, value: str | None) -> NetAddress | None:
|
332
|
+
if (not value) or (value in {"_dhcp4", "_ipv6ra"}):
|
333
|
+
return None
|
334
|
+
|
335
|
+
return ip_address(value)
|
336
|
+
|
337
|
+
|
338
|
+
MANAGERS = [NetworkManagerConfigParser, SystemdNetworkConfigParser]
|
@@ -567,7 +567,7 @@ def parse_unix_dhcp_log_messages(target: Target, iter_all: bool = False) -> set[
|
|
567
567
|
continue
|
568
568
|
|
569
569
|
# Debian and CentOS dhclient
|
570
|
-
if hasattr(record, "
|
570
|
+
if hasattr(record, "service") and record.service == "dhclient" and "bound to" in line:
|
571
571
|
ip = line.split("bound to")[1].split(" ")[1].strip()
|
572
572
|
ips.add(ip)
|
573
573
|
continue
|
@@ -1,6 +1,5 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
import itertools
|
4
3
|
import logging
|
5
4
|
import re
|
6
5
|
from abc import ABC, abstractmethod
|
@@ -12,24 +11,18 @@ from typing import Any, Iterator
|
|
12
11
|
|
13
12
|
from dissect.target import Target
|
14
13
|
from dissect.target.exceptions import UnsupportedPluginError
|
15
|
-
from dissect.target.helpers.fsutil import open_decompress
|
16
14
|
from dissect.target.helpers.record import DynamicDescriptor, TargetRecordDescriptor
|
17
15
|
from dissect.target.helpers.utils import year_rollover_helper
|
18
16
|
from dissect.target.plugin import Plugin, alias, export
|
17
|
+
from dissect.target.plugins.os.unix.log.helpers import (
|
18
|
+
RE_LINE,
|
19
|
+
RE_TS,
|
20
|
+
is_iso_fmt,
|
21
|
+
iso_readlines,
|
22
|
+
)
|
19
23
|
|
20
24
|
log = logging.getLogger(__name__)
|
21
25
|
|
22
|
-
RE_TS = re.compile(r"^[A-Za-z]{3}\s*\d{1,2}\s\d{1,2}:\d{2}:\d{2}")
|
23
|
-
RE_TS_ISO = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}\+\d{2}:\d{2}")
|
24
|
-
RE_LINE = re.compile(
|
25
|
-
r"""
|
26
|
-
\d{2}:\d{2}\s # First match on the similar ending of the different timestamps
|
27
|
-
(?P<hostname>\S+)\s # The hostname
|
28
|
-
(?P<service>\S+?)(\[(?P<pid>\d+)\])?: # The service with optionally the PID between brackets
|
29
|
-
\s*(?P<message>.+?)\s*$ # The log message stripped from spaces left and right
|
30
|
-
""",
|
31
|
-
re.VERBOSE,
|
32
|
-
)
|
33
26
|
|
34
27
|
# Generic regular expressions
|
35
28
|
RE_IPV4_ADDRESS = re.compile(
|
@@ -347,27 +340,3 @@ class AuthPlugin(Plugin):
|
|
347
340
|
|
348
341
|
for ts, line in iterable:
|
349
342
|
yield self._auth_log_builder.build_record(ts, auth_file, line)
|
350
|
-
|
351
|
-
|
352
|
-
def iso_readlines(file: Path) -> Iterator[tuple[datetime, str]]:
|
353
|
-
"""Iterator reading the provided auth log file in ISO format. Mimics ``year_rollover_helper`` behaviour."""
|
354
|
-
with open_decompress(file, "rt") as fh:
|
355
|
-
for line in fh:
|
356
|
-
if not (match := RE_TS_ISO.match(line)):
|
357
|
-
log.warning("No timestamp found in one of the lines in %s!", file)
|
358
|
-
log.debug("Skipping line: %s", line)
|
359
|
-
continue
|
360
|
-
|
361
|
-
try:
|
362
|
-
ts = datetime.strptime(match[0], "%Y-%m-%dT%H:%M:%S.%f%z")
|
363
|
-
except ValueError as e:
|
364
|
-
log.warning("Unable to parse ISO timestamp in line: %s", line)
|
365
|
-
log.debug("", exc_info=e)
|
366
|
-
continue
|
367
|
-
|
368
|
-
yield ts, line
|
369
|
-
|
370
|
-
|
371
|
-
def is_iso_fmt(file: Path) -> bool:
|
372
|
-
"""Determine if the provided auth log file uses new ISO format logging or not."""
|
373
|
-
return any(itertools.islice(iso_readlines(file), 0, 2))
|
@@ -0,0 +1,46 @@
|
|
1
|
+
import itertools
|
2
|
+
import logging
|
3
|
+
import re
|
4
|
+
from datetime import datetime
|
5
|
+
from pathlib import Path
|
6
|
+
from typing import Iterator
|
7
|
+
|
8
|
+
from dissect.target.helpers.fsutil import open_decompress
|
9
|
+
|
10
|
+
log = logging.getLogger(__name__)
|
11
|
+
|
12
|
+
RE_TS = re.compile(r"^[A-Za-z]{3}\s*\d{1,2}\s\d{1,2}:\d{2}:\d{2}")
|
13
|
+
RE_TS_ISO = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}\+\d{2}:\d{2}")
|
14
|
+
RE_LINE = re.compile(
|
15
|
+
r"""
|
16
|
+
\d{2}:\d{2}\s # First match on the similar ending of the different timestamps
|
17
|
+
(?:\S+)\s # The hostname, but do not capture it
|
18
|
+
(?P<service>\S+?)(\[(?P<pid>\d+)\])?: # The service / daemon with optionally the PID between brackets
|
19
|
+
\s*(?P<message>.+?)\s*$ # The log message stripped from spaces left and right
|
20
|
+
""",
|
21
|
+
re.VERBOSE,
|
22
|
+
)
|
23
|
+
|
24
|
+
|
25
|
+
def iso_readlines(file: Path) -> Iterator[tuple[datetime, str]]:
|
26
|
+
"""Iterator reading the provided log file in ISO format. Mimics ``year_rollover_helper`` behaviour."""
|
27
|
+
with open_decompress(file, "rt") as fh:
|
28
|
+
for line in fh:
|
29
|
+
if not (match := RE_TS_ISO.match(line)):
|
30
|
+
log.warning("No timestamp found in one of the lines in %s!", file)
|
31
|
+
log.debug("Skipping line: %s", line)
|
32
|
+
continue
|
33
|
+
|
34
|
+
try:
|
35
|
+
ts = datetime.strptime(match[0], "%Y-%m-%dT%H:%M:%S.%f%z")
|
36
|
+
except ValueError as e:
|
37
|
+
log.warning("Unable to parse ISO timestamp in line: %s", line)
|
38
|
+
log.debug("", exc_info=e)
|
39
|
+
continue
|
40
|
+
|
41
|
+
yield ts, line
|
42
|
+
|
43
|
+
|
44
|
+
def is_iso_fmt(file: Path) -> bool:
|
45
|
+
"""Determine if the provided log file uses ISO 8601 timestamp format logging or not."""
|
46
|
+
return any(itertools.islice(iso_readlines(file), 0, 2))
|