dissect.target 3.20.dev45__py3-none-any.whl → 3.20.dev47__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.
@@ -1,62 +1,373 @@
1
+ from __future__ import annotations
2
+
3
+ import itertools
4
+ import logging
1
5
  import re
6
+ from abc import ABC, abstractmethod
7
+ from datetime import datetime
8
+ from functools import lru_cache
2
9
  from itertools import chain
3
- from typing import Iterator
10
+ from pathlib import Path
11
+ from typing import Any, Iterator
4
12
 
13
+ from dissect.target import Target
5
14
  from dissect.target.exceptions import UnsupportedPluginError
6
- from dissect.target.helpers.record import TargetRecordDescriptor
15
+ from dissect.target.helpers.fsutil import open_decompress
16
+ from dissect.target.helpers.record import DynamicDescriptor, TargetRecordDescriptor
7
17
  from dissect.target.helpers.utils import year_rollover_helper
8
- from dissect.target.plugin import Plugin, export
9
-
10
- AuthLogRecord = TargetRecordDescriptor(
11
- "linux/log/auth",
12
- [
13
- ("datetime", "ts"),
14
- ("string", "message"),
15
- ("path", "source"),
16
- ],
18
+ from dissect.target.plugin import Plugin, alias, export
19
+
20
+ log = logging.getLogger(__name__)
21
+
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
+
34
+ # Generic regular expressions
35
+ RE_IPV4_ADDRESS = re.compile(
36
+ r"""
37
+ ((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3} # First three octets
38
+ (25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?) # Last octet
39
+ """,
40
+ re.VERBOSE,
17
41
  )
42
+ RE_USER = re.compile(r"for ([^\s]+)")
43
+
44
+
45
+ class BaseService(ABC):
46
+ @classmethod
47
+ @abstractmethod
48
+ def parse(cls, message: str) -> dict[str, any]:
49
+ pass
50
+
51
+
52
+ class SudoService(BaseService):
53
+ """Parsing of sudo service messages in the auth log."""
54
+
55
+ RE_SUDO_COMMAND = re.compile(
56
+ r"""
57
+ TTY=(?P<tty>\w+\/\w+)\s;\s # The TTY -> TTY=pts/0 ;
58
+ PWD=(?P<pwd>[\/\w]+)\s;\s # The current working directory -> PWD="/home/user" ;
59
+ USER=(?P<effective_user>\w+)\s;\s # The effective user -> USER=root ;
60
+ COMMAND=(?P<command>.+)$ # The command -> COMMAND=/usr/bin/whoami
61
+ """,
62
+ re.VERBOSE,
63
+ )
64
+
65
+ @classmethod
66
+ def parse(cls, message: str) -> dict[str, str]:
67
+ """Parse auth log message from sudo."""
68
+ if not (match := cls.RE_SUDO_COMMAND.search(message)):
69
+ return {}
70
+
71
+ additional_fields = {}
72
+ for key, value in match.groupdict().items():
73
+ additional_fields[key] = value
74
+
75
+ return additional_fields
76
+
77
+
78
+ class SshdService(BaseService):
79
+ """Class for parsing sshd messages in the auth log."""
80
+
81
+ RE_SSHD_PORTREGEX = re.compile(r"port\s(\d+)")
82
+ RE_USER = re.compile(r"for\s([^\s]+)")
83
+
84
+ @classmethod
85
+ def parse(cls, message: str) -> dict[str, str | int]:
86
+ """Parse message from sshd"""
87
+ additional_fields = {}
88
+ if ip_address := RE_IPV4_ADDRESS.search(message):
89
+ field_name = "host_ip" if "listening" in message else "remote_ip"
90
+ additional_fields[field_name] = ip_address.group(0)
91
+ if port := cls.RE_SSHD_PORTREGEX.search(message):
92
+ additional_fields["port"] = int(port.group(1))
93
+ if user := cls.RE_USER.search(message):
94
+ additional_fields["user"] = user.group(1)
95
+ # Accepted publickey for test_user from 8.8.8.8 IP port 12345 ssh2: RSA SHA256:123456789asdfghjklertzuio
96
+ if "Accepted publickey" in message:
97
+ ssh_protocol, encryption_algo, key_info = message.split()[-3:]
98
+ hash_algo, key_hash = key_info.split(":")
99
+ additional_fields["ssh_protocol"] = ssh_protocol.strip(":")
100
+ additional_fields["encryption_algorithm"] = encryption_algo
101
+ additional_fields["hash_algorithm"] = hash_algo
102
+ additional_fields["key_hash"] = key_hash
103
+ if (failed := "Failed" in message) or "Accepted" in message:
104
+ action_type = "failed" if failed else "accepted"
105
+ additional_fields["action"] = f"{action_type} authentication"
106
+ additional_fields["authentication_type"] = "password" if "password" in message else "publickey"
107
+
108
+ return additional_fields
109
+
110
+
111
+ class SystemdLogindService(BaseService):
112
+ """Class for parsing systemd-logind messages in the auth log."""
113
+
114
+ RE_SYSTEMD_LOGIND_WATCHING = re.compile(
115
+ r"""
116
+ (?P<action>Watching\ssystem\sbuttons)\s # Action is "Watching system buttons"
117
+ on\s(?P<device>[^\s]+)\s # The device the button is related to -> /dev/input/event0
118
+ \((?P<device_name>.*?)\) # The device (button) name -> (Power button)
119
+ """,
120
+ re.VERBOSE,
121
+ )
122
+
123
+ @classmethod
124
+ def parse(cls, message: str):
125
+ """Parse auth log message from systemd-logind."""
126
+ additional_fields = {}
127
+ # Example: Nov 14 07:14:09 ubuntu-1 systemd-logind[4]: Removed session 4.
128
+ if "Removed" in message:
129
+ additional_fields["action"] = "removed session"
130
+ additional_fields["session"] = message.split()[-1].strip(".")
131
+ elif "Watching" in message and (match := cls.RE_SYSTEMD_LOGIND_WATCHING.search(message)):
132
+ for key, value in match.groupdict().items():
133
+ additional_fields[key] = value
134
+ # Example: New session 4 of user sampleuser.
135
+ elif "New session" in message:
136
+ parts = message.removeprefix("New session ").split()
137
+ additional_fields["action"] = "new session"
138
+ additional_fields["session"] = parts[0]
139
+ additional_fields["user"] = parts[-1].strip(".")
140
+ # Example: Session 4 logged out. Waiting for processes to exit.
141
+ elif "logged out" in message:
142
+ session = message.removeprefix("Session ").split(maxsplit=1)[0]
143
+ additional_fields["action"] = "logged out session"
144
+ additional_fields["session"] = session
145
+ # Example: New seat seat0.
146
+ elif "New seat" in message:
147
+ seat = message.split()[-1].strip(".")
148
+ additional_fields["action"] = "new seat"
149
+ additional_fields["seat"] = seat
150
+
151
+ return additional_fields
152
+
153
+
154
+ class SuService(BaseService):
155
+ """Class for parsing su messages in the auth log."""
156
+
157
+ RE_SU_BY = re.compile(r"by\s([^\s]+)")
158
+ RE_SU_ON = re.compile(r"on\s([^\s]+)")
159
+ RE_SU_COMMAND = re.compile(r"'(.*?)'")
160
+
161
+ @classmethod
162
+ def parse(cls, message: str) -> dict[str, str]:
163
+ additional_fields = {}
164
+ if user := RE_USER.search(message):
165
+ additional_fields["user"] = user.group(1)
166
+ if by := cls.RE_SU_BY.search(message):
167
+ additional_fields["by"] = by.group(1)
168
+ if on := cls.RE_SU_ON.search(message):
169
+ additional_fields["device"] = on.group(1)
170
+ if command := cls.RE_SU_COMMAND.search(message):
171
+ additional_fields["command"] = command.group(1)
172
+ if (failed := "failed" in message) or "Successful" in message:
173
+ additional_fields["su_result"] = "failed" if failed else "success"
174
+
175
+ return additional_fields
176
+
177
+
178
+ class PkexecService(BaseService):
179
+ """Class for parsing pkexec messages in the auth log."""
180
+
181
+ RE_PKEXEC_COMMAND = re.compile(
182
+ r"""
183
+ (?P<user>\S+?):\sExecuting\scommand\s # Starts with actual user -> user:
184
+ \[USER=(?P<effective_user>[^\]]+)\]\s # The impersonated user -> [USER=root]
185
+ \[TTY=(?P<tty>[^\]]+)\]\s # The tty -> [TTY=unknown]
186
+ \[CWD=(?P<cwd>[^\]]+)\]\s # Current working directory -> [CWD=/home/user]
187
+ \[COMMAND=(?P<command>[^\]]+)\] # Command -> [COMMAND=/usr/lib/example]
188
+ """,
189
+ re.VERBOSE,
190
+ )
191
+
192
+ @classmethod
193
+ def parse(cls, message: str) -> dict[str, str]:
194
+ """Parse auth log message from pkexec"""
195
+ additional_fields = {}
196
+ if exec_cmd := cls.RE_PKEXEC_COMMAND.search(message):
197
+ additional_fields["action"] = "executing command"
198
+ for key, value in exec_cmd.groupdict().items():
199
+ if value and value.isdigit():
200
+ value = int(value)
201
+ additional_fields[key] = value
18
202
 
19
- _TS_REGEX = r"^[A-Za-z]{3}\s*[0-9]{1,2}\s[0-9]{1,2}:[0-9]{2}:[0-9]{2}"
20
- RE_TS = re.compile(_TS_REGEX)
21
- RE_TS_AND_HOSTNAME = re.compile(_TS_REGEX + r"\s\S+\s")
203
+ return additional_fields
204
+
205
+
206
+ class PamUnixService(BaseService):
207
+ RE_PAM_UNIX = re.compile(
208
+ r"""
209
+ pam_unix\([^\s]+:session\):\s(?P<action>session\s\w+)\s # Session action, usually opened or closed
210
+ for\suser\s(?P<user>[^\s\(]+)(?:\(uid=(?P<user_uid>\d+)\))? # User may contain uid like: root(uid=0)
211
+ (?:\sby\s\(uid=(?P<by_uid>\d+)\))?$ # Opened action also contains by
212
+ """,
213
+ re.VERBOSE,
214
+ )
215
+
216
+ @classmethod
217
+ def parse(cls, message):
218
+ """Parse auth log message from pluggable authentication modules (PAM)."""
219
+ if not (match := cls.RE_PAM_UNIX.search(message)):
220
+ return {}
221
+
222
+ additional_fields = {}
223
+ for key, value in match.groupdict().items():
224
+ if value and value.isdigit():
225
+ value = int(value)
226
+ additional_fields[key] = value
227
+
228
+ return additional_fields
229
+
230
+
231
+ class AuthLogRecordBuilder:
232
+ """Class for dynamically creating auth log records."""
233
+
234
+ RECORD_NAME = "linux/log/auth"
235
+ SERVICES: dict[str, BaseService] = {
236
+ "su": SuService,
237
+ "sudo": SudoService,
238
+ "sshd": SshdService,
239
+ "systemd-logind": SystemdLogindService,
240
+ "pkexec": PkexecService,
241
+ }
242
+
243
+ def __init__(self, target: Target):
244
+ self._create_event_descriptor = lru_cache(4096)(self._create_event_descriptor)
245
+ self.target = target
246
+
247
+ def _parse_additional_fields(self, service: str | None, message: str) -> dict[str, Any]:
248
+ """Parse additional fields in the message based on the service."""
249
+ if "pam_unix(" in message:
250
+ return PamUnixService.parse(message)
251
+
252
+ if service not in self.SERVICES:
253
+ self.target.log.debug("Service %s is not recognised, no additional fields could be parsed", service)
254
+ return {}
255
+
256
+ try:
257
+ return self.SERVICES[service].parse(message)
258
+ except Exception as e:
259
+ self.target.log.warning("Parsing additional fields in message '%s' for service %s failed", message, service)
260
+ self.target.log.debug("", exc_info=e)
261
+ raise e
262
+
263
+ def build_record(self, ts: datetime, source: Path, line: str) -> TargetRecordDescriptor:
264
+ """Builds an ``AuthLog`` event record."""
265
+
266
+ record_fields = [
267
+ ("datetime", "ts"),
268
+ ("path", "source"),
269
+ ("string", "service"),
270
+ ("varint", "pid"),
271
+ ("string", "message"),
272
+ ]
273
+
274
+ record_values = {
275
+ "ts": ts,
276
+ "message": line,
277
+ "service": None,
278
+ "pid": None,
279
+ "source": source,
280
+ "_target": self.target,
281
+ }
282
+
283
+ match = RE_LINE.search(line)
284
+ if match:
285
+ record_values.update(match.groupdict())
286
+
287
+ for key, value in self._parse_additional_fields(record_values["service"], line).items():
288
+ record_type = "string"
289
+ if isinstance(value, int):
290
+ record_type = "varint"
291
+
292
+ record_fields.append((record_type, key))
293
+ record_values[key] = value
294
+
295
+ # tuple conversion here is needed for lru_cache
296
+ desc = self._create_event_descriptor(tuple(record_fields))
297
+ return desc(**record_values)
298
+
299
+ def _create_event_descriptor(self, record_fields) -> TargetRecordDescriptor:
300
+ return TargetRecordDescriptor(self.RECORD_NAME, record_fields)
22
301
 
23
302
 
24
303
  class AuthPlugin(Plugin):
25
- """Unix auth log plugin."""
304
+ """Unix authentication log plugin."""
305
+
306
+ def __init__(self, target: Target):
307
+ super().__init__(target)
308
+ self._auth_log_builder = AuthLogRecordBuilder(target)
26
309
 
27
310
  def check_compatible(self) -> None:
28
311
  var_log = self.target.fs.path("/var/log")
29
312
  if not any(var_log.glob("auth.log*")) and not any(var_log.glob("secure*")):
30
313
  raise UnsupportedPluginError("No auth log files found")
31
314
 
32
- @export(record=AuthLogRecord)
33
- def securelog(self) -> Iterator[AuthLogRecord]:
34
- """Return contents of /var/log/auth.log* and /var/log/secure*."""
35
- return self.authlog()
315
+ @alias("securelog")
316
+ @export(record=DynamicDescriptor(["datetime", "path", "string"]))
317
+ def authlog(self) -> Iterator[Any]:
318
+ """Yield contents of ``/var/log/auth.log*`` and ``/var/log/secure*`` files.
319
+
320
+ Order of returned events is not guaranteed to be chronological because of year
321
+ rollover detection efforts for log files without a year in the timestamp.
322
+
323
+ The following timestamp formats are recognised automatically. This plugin
324
+ assumes that no custom ``date_format`` template is set in ``syslog-ng`` or ``systemd``
325
+ configuration (defaults to ``M d H:M:S``).
326
+
327
+ ISO formatted authlog entries are parsed as can be found in Ubuntu 24.04 and later.
36
328
 
37
- @export(record=AuthLogRecord)
38
- def authlog(self) -> Iterator[AuthLogRecord]:
39
- """Return contents of /var/log/auth.log* and /var/log/secure*."""
329
+ .. code-block:: text
40
330
 
41
- # Assuming no custom date_format template is set in syslog-ng or systemd (M d H:M:S)
42
- # CentOS format: Jan 12 13:37:00 hostname daemon: message
43
- # Debian format: Jan 12 13:37:00 hostname daemon[pid]: pam_unix(daemon:session): message
331
+ CentOS format: Jan 12 13:37:00 hostname daemon: message
332
+ Debian format: Jan 12 13:37:00 hostname daemon[pid]: pam_unix(daemon:session): message
333
+ Ubuntu 24.04: 2024-01-12T13:37:00.000000+02:00 hostname daemon[pid]: pam_unix(daemon:session): message
334
+
335
+ Resources:
336
+ - https://help.ubuntu.com/community/LinuxLogFiles
337
+ """
44
338
 
45
339
  tzinfo = self.target.datetime.tzinfo
46
340
 
47
341
  var_log = self.target.fs.path("/var/log")
48
342
  for auth_file in chain(var_log.glob("auth.log*"), var_log.glob("secure*")):
49
- for ts, line in year_rollover_helper(auth_file, RE_TS, "%b %d %H:%M:%S", tzinfo):
50
- ts_and_hostname = re.search(RE_TS_AND_HOSTNAME, line)
51
- if not ts_and_hostname:
52
- self.target.log.warning("No timstamp and hostname found on one of the lines in %s.", auth_file)
53
- self.target.log.debug("Skipping this line: %s", line)
54
- continue
55
-
56
- message = line.replace(ts_and_hostname.group(0), "").strip()
57
- yield AuthLogRecord(
58
- ts=ts,
59
- message=message,
60
- source=auth_file,
61
- _target=self.target,
62
- )
343
+ if is_iso_fmt(auth_file):
344
+ iterable = iso_readlines(auth_file)
345
+ else:
346
+ iterable = year_rollover_helper(auth_file, RE_TS, "%b %d %H:%M:%S", tzinfo)
347
+
348
+ for ts, line in iterable:
349
+ 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))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dissect.target
3
- Version: 3.20.dev45
3
+ Version: 3.20.dev47
4
4
  Summary: This module ties all other Dissect modules together, it provides a programming API and command line tools which allow easy access to various data sources inside disk images or file collections (a.k.a. targets)
5
5
  Author-email: Dissect Team <dissect@fox-it.com>
6
6
  License: Affero General Public License v3
@@ -268,7 +268,7 @@ dissect/target/plugins/os/unix/locate/plocate.py,sha256=0p7ibPPrDlQuJNjJbV4rU0fJ
268
268
  dissect/target/plugins/os/unix/log/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
269
269
  dissect/target/plugins/os/unix/log/atop.py,sha256=zjG5eKS-X0mpBXs-Sg2f7RfQvtjt0T8JcteNd9DB_ok,16361
270
270
  dissect/target/plugins/os/unix/log/audit.py,sha256=rZwxC90Q0FOB5BZxplTJwCTIp0hdVpaps1e3C1fRYaM,3754
271
- dissect/target/plugins/os/unix/log/auth.py,sha256=tYe54PGaJtLdBbM425_8VHJFvLqj6aqHuu6ZrQhqEKM,2417
271
+ dissect/target/plugins/os/unix/log/auth.py,sha256=9NJvlo7Vbsp_ENJFpKd04PH_sUuOy6ueSBwQqY0MtKo,14546
272
272
  dissect/target/plugins/os/unix/log/journal.py,sha256=xe8p8MM_95uYjFNzNSP5IsoIthJtxwFEDicYR42RYAI,17681
273
273
  dissect/target/plugins/os/unix/log/lastlog.py,sha256=Wr3-2n1-GwckN9mSx-yM55N6_L0PQyx6TGHoEvuc6c0,2515
274
274
  dissect/target/plugins/os/unix/log/messages.py,sha256=XtjZ0a2budgQm_K5JT3fMf7JcjuD0AelcD3zOFN2xpI,5732
@@ -378,10 +378,10 @@ dissect/target/volumes/luks.py,sha256=OmCMsw6rCUXG1_plnLVLTpsvE1n_6WtoRUGQbpmu1z
378
378
  dissect/target/volumes/lvm.py,sha256=wwQVR9I3G9YzmY6UxFsH2Y4MXGBcKL9aayWGCDTiWMU,2269
379
379
  dissect/target/volumes/md.py,sha256=7ShPtusuLGaIv27SvEETtgsuoQyAa4iAAeOR1NEaajI,1689
380
380
  dissect/target/volumes/vmfs.py,sha256=-LoUbn9WNwTtLi_4K34uV_-wDw2W5hgaqxZNj4UmqAQ,1730
381
- dissect.target-3.20.dev45.dist-info/COPYRIGHT,sha256=m-9ih2RVhMiXHI2bf_oNSSgHgkeIvaYRVfKTwFbnJPA,301
382
- dissect.target-3.20.dev45.dist-info/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
383
- dissect.target-3.20.dev45.dist-info/METADATA,sha256=h2ywkvsL6FJ-kD4V9jjhxVVxdXnDlucQJPtL5oAmG-c,12897
384
- dissect.target-3.20.dev45.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
385
- dissect.target-3.20.dev45.dist-info/entry_points.txt,sha256=BWuxAb_6AvUAQpIQOQU0IMTlaF6TDht2AIZK8bHd-zE,492
386
- dissect.target-3.20.dev45.dist-info/top_level.txt,sha256=Mn-CQzEYsAbkxrUI0TnplHuXnGVKzxpDw_po_sXpvv4,8
387
- dissect.target-3.20.dev45.dist-info/RECORD,,
381
+ dissect.target-3.20.dev47.dist-info/COPYRIGHT,sha256=m-9ih2RVhMiXHI2bf_oNSSgHgkeIvaYRVfKTwFbnJPA,301
382
+ dissect.target-3.20.dev47.dist-info/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
383
+ dissect.target-3.20.dev47.dist-info/METADATA,sha256=28qUe6AIKvwvWI58yI9VPiy1U86ihuVGMhzR-avNrtc,12897
384
+ dissect.target-3.20.dev47.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
385
+ dissect.target-3.20.dev47.dist-info/entry_points.txt,sha256=BWuxAb_6AvUAQpIQOQU0IMTlaF6TDht2AIZK8bHd-zE,492
386
+ dissect.target-3.20.dev47.dist-info/top_level.txt,sha256=Mn-CQzEYsAbkxrUI0TnplHuXnGVKzxpDw_po_sXpvv4,8
387
+ dissect.target-3.20.dev47.dist-info/RECORD,,