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.
Files changed (34) hide show
  1. dissect/target/helpers/configutil.py +5 -5
  2. dissect/target/helpers/record.py +12 -6
  3. dissect/target/helpers/regutil.py +28 -11
  4. dissect/target/helpers/utils.py +20 -1
  5. dissect/target/loaders/itunes.py +5 -3
  6. dissect/target/plugins/apps/browser/iexplore.py +7 -3
  7. dissect/target/plugins/general/network.py +1 -1
  8. dissect/target/plugins/general/plugins.py +1 -1
  9. dissect/target/plugins/os/unix/_os.py +1 -1
  10. dissect/target/plugins/os/unix/bsd/osx/network.py +2 -1
  11. dissect/target/plugins/os/unix/esxi/_os.py +34 -32
  12. dissect/target/plugins/os/unix/etc/etc.py +12 -6
  13. dissect/target/plugins/os/unix/linux/fortios/_keys.py +7914 -1114
  14. dissect/target/plugins/os/unix/linux/fortios/_os.py +109 -22
  15. dissect/target/plugins/os/unix/linux/network.py +338 -0
  16. dissect/target/plugins/os/unix/linux/network_managers.py +1 -1
  17. dissect/target/plugins/os/unix/log/auth.py +6 -37
  18. dissect/target/plugins/os/unix/log/helpers.py +46 -0
  19. dissect/target/plugins/os/unix/log/messages.py +24 -15
  20. dissect/target/plugins/os/unix/trash.py +13 -2
  21. dissect/target/plugins/os/windows/activitiescache.py +32 -30
  22. dissect/target/plugins/os/windows/catroot.py +14 -5
  23. dissect/target/plugins/os/windows/lnk.py +13 -7
  24. dissect/target/plugins/os/windows/network.py +9 -5
  25. dissect/target/plugins/os/windows/notifications.py +40 -38
  26. dissect/target/plugins/os/windows/regf/cit.py +20 -7
  27. dissect/target/tools/diff.py +990 -0
  28. {dissect.target-3.20.dev64.dist-info → dissect.target-3.20.2.dev11.dist-info}/METADATA +73 -73
  29. {dissect.target-3.20.dev64.dist-info → dissect.target-3.20.2.dev11.dist-info}/RECORD +34 -31
  30. {dissect.target-3.20.dev64.dist-info → dissect.target-3.20.2.dev11.dist-info}/WHEEL +1 -1
  31. {dissect.target-3.20.dev64.dist-info → dissect.target-3.20.2.dev11.dist-info}/entry_points.txt +1 -0
  32. {dissect.target-3.20.dev64.dist-info → dissect.target-3.20.2.dev11.dist-info}/COPYRIGHT +0 -0
  33. {dissect.target-3.20.dev64.dist-info → dissect.target-3.20.2.dev11.dist-info}/LICENSE +0 -0
  34. {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 KERNEL_KEY_MAP
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
- key, iv = key_iv_for_kernel_hash(kernel_hash)
99
- rfs_fh = decrypt_rootfs(rootfs.open(), key, iv)
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) -> tuple[bytes, bytes]:
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 = bytes.fromhex(KERNEL_KEY_MAP.get(kernel_hash, ""))
490
- if len(key) == 32:
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
- return _kdf_7_4_x(key)
493
- elif len(key) == 48:
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[:32], key[32:]
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 decrypt_rootfs(fh: BinaryIO, key: bytes, iv: bytes) -> BinaryIO:
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: ChaCha20 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
- # First 8 bytes = counter, last 8 bytes = nonce
529
- # PyCryptodome interally divides this seek by 64 to get a (position, offset) tuple
530
- # We're interested in updating the position in the ChaCha20 internal state, so to make
531
- # PyCryptodome "OpenSSL-compatible" we have to multiply the counter by 64
532
- cipher = ChaCha20.new(key=key, nonce=iv[8:])
533
- cipher.seek(int.from_bytes(iv[:8], "little") * 64)
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[4:32] + key_data[:4]).digest()
552
- iv = hashlib.sha256(key_data[5:32] + key_data[:5]).digest()[:16]
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, "daemon") and record.daemon == "dhclient" and "bound to" in line:
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))