dissect.target 3.20.dev64__py3-none-any.whl → 3.20.2.dev11__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.
- 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))
|