|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | | -from typing import TYPE_CHECKING, BinaryIO |
| 3 | +from dataclasses import asdict, dataclass |
| 4 | +from typing import TYPE_CHECKING |
4 | 5 |
|
5 | 6 | from dissect.cstruct import cstruct |
6 | | -from dissect.util import ts |
| 7 | +from dissect.database.sqlite3 import SQLite3 |
| 8 | +from dissect.util.ts import from_unix |
7 | 9 |
|
8 | | -from dissect.target.exceptions import FileNotFoundError, UnsupportedPluginError |
| 10 | +from dissect.target.exceptions import UnsupportedPluginError |
9 | 11 | from dissect.target.helpers.record import TargetRecordDescriptor |
10 | 12 | from dissect.target.plugin import Plugin, export |
11 | 13 |
|
12 | 14 | if TYPE_CHECKING: |
13 | 15 | from collections.abc import Iterator |
| 16 | + from datetime import datetime |
| 17 | + from pathlib import Path |
| 18 | + |
| 19 | + from dissect.target.target import Target |
14 | 20 |
|
15 | 21 | LastLogRecord = TargetRecordDescriptor( |
16 | 22 | "linux/log/lastlog", |
|
20 | 26 | ("string", "ut_user"), # name |
21 | 27 | ("string", "ut_host"), # source |
22 | 28 | ("string", "ut_tty"), # port |
| 29 | + ("string", "ut_service"), |
| 30 | + ("path", "source"), |
23 | 31 | ], |
24 | 32 | ) |
25 | 33 |
|
|
44 | 52 | c_lastlog = cstruct().load(lastlog_def) |
45 | 53 |
|
46 | 54 |
|
| 55 | +@dataclass |
| 56 | +class LastLogEntry: |
| 57 | + ts: datetime |
| 58 | + uid: int |
| 59 | + ut_user: str | None |
| 60 | + ut_tty: str | None |
| 61 | + ut_host: str | None |
| 62 | + ut_service: str | None # lastlog2 specific |
| 63 | + |
| 64 | + |
47 | 65 | class LastLogFile: |
48 | | - def __init__(self, fh: BinaryIO): |
49 | | - self.fh = fh |
| 66 | + """Lastlog sparse file iterator. |
| 67 | +
|
| 68 | + References: |
| 69 | + - https://github.com/linux-pam/linux-pam/tree/master/modules/pam_lastlog |
| 70 | + """ |
| 71 | + |
| 72 | + def __init__(self, path: Path, users: dict | None): |
| 73 | + self.path = path |
| 74 | + self.fh = path.open() |
| 75 | + self.users = users |
50 | 76 |
|
51 | | - def __iter__(self): |
| 77 | + def __iter__(self) -> Iterator[LastLogEntry]: |
| 78 | + idx = -1 |
52 | 79 | while True: |
53 | 80 | try: |
54 | | - yield c_lastlog.entry(self.fh) |
55 | | - except EOFError: # noqa: PERF203 |
| 81 | + idx += 1 |
| 82 | + entry = c_lastlog.entry(self.fh) |
| 83 | + user = self.users.get(idx) |
| 84 | + |
| 85 | + if entry.ll_time.tv_sec == 0: |
| 86 | + continue |
| 87 | + |
| 88 | + yield LastLogEntry( |
| 89 | + ts=from_unix(entry.ll_time.tv_sec), |
| 90 | + uid=idx, |
| 91 | + ut_user=user, |
| 92 | + ut_tty=entry.ut_user.decode().strip("\x00") or None, |
| 93 | + ut_host=entry.ut_host.decode(errors="ignore").strip("\x00") or None, |
| 94 | + ut_service=None, |
| 95 | + ) |
| 96 | + |
| 97 | + except EOFError: |
56 | 98 | break |
57 | 99 |
|
58 | 100 |
|
| 101 | +class LastLogDb: |
| 102 | + """Lastlog2 database file iterator. |
| 103 | +
|
| 104 | + References: |
| 105 | + - https://github.com/util-linux/util-linux/tree/master/liblastlog2 |
| 106 | + - https://github.com/util-linux/util-linux/tree/master/pam_lastlog2 |
| 107 | + """ |
| 108 | + |
| 109 | + def __init__(self, path: Path, users: dict | None): |
| 110 | + self.path = path |
| 111 | + self.db = SQLite3(path) |
| 112 | + self.users: dict[str, int] = {v: k for k, v in users.items()} |
| 113 | + |
| 114 | + def __iter__(self) -> Iterator[LastLogEntry]: |
| 115 | + if not (table := self.db.table("Lastlog2")): |
| 116 | + return None |
| 117 | + |
| 118 | + for row in table.rows(): |
| 119 | + yield LastLogEntry( |
| 120 | + ts=from_unix(row.Time), |
| 121 | + uid=self.users.get(row.Name), |
| 122 | + ut_user=row.Name, |
| 123 | + ut_tty=row.TTY, |
| 124 | + ut_host=row.RemoteHost or None, |
| 125 | + ut_service=row.Service, |
| 126 | + ) |
| 127 | + |
| 128 | + |
59 | 129 | class LastLogPlugin(Plugin): |
60 | | - """Unix lastlog plugin.""" |
| 130 | + """UNIX lastlog plugin.""" |
| 131 | + |
| 132 | + def __init__(self, target: Target): |
| 133 | + super().__init__(target) |
| 134 | + |
| 135 | + self.paths = list(self.target.fs.path("/").glob("var/log/lastlog*")) + list( |
| 136 | + self.target.fs.path("/").glob("var/lib/lastlog/lastlog2*") |
| 137 | + ) |
61 | 138 |
|
62 | 139 | def check_compatible(self) -> None: |
63 | | - lastlog = self.target.fs.path("/var/log/lastlog") |
64 | | - if not lastlog.exists(): |
65 | | - raise UnsupportedPluginError("No lastlog file found") |
| 140 | + if not self.paths: |
| 141 | + raise UnsupportedPluginError("No lastlog file(s) found on target") |
66 | 142 |
|
67 | 143 | @export(record=LastLogRecord) |
68 | 144 | def lastlog(self) -> Iterator[LastLogRecord]: |
69 | | - """Return last logins information from /var/log/lastlog. |
| 145 | + """Return login information from ``/var/log/lastlog`` and ``/var/lib/lastlog/lastlog2.db`` files. |
70 | 146 |
|
71 | | - The lastlog file contains the most recent logins of all users on a Unix based operating system. |
| 147 | + Lastlog files contain the most recent logins of all users on a UNIX based operating system. |
| 148 | +
|
| 149 | + Newer UNIX distributions use ``lastlog2`` and ``liblastlog2`` which use SQLite3 database files. |
72 | 150 |
|
73 | 151 | References: |
74 | 152 | - https://www.tutorialspoint.com/unix_commands/lastlog.htm |
75 | 153 | """ |
76 | | - try: |
77 | | - lastlog = self.target.fs.open("/var/log/lastlog") |
78 | | - except FileNotFoundError: |
79 | | - return |
80 | | - |
81 | | - users = {} |
82 | | - for user in self.target.users(): |
83 | | - users[user.uid] = user.name |
| 154 | + seen = set() |
| 155 | + users: dict[int, str] = ( |
| 156 | + {user.uid: user.name for user in self.target.users()} if self.target.has_function("users") else {} |
| 157 | + ) |
84 | 158 |
|
85 | | - log = LastLogFile(lastlog) |
86 | | - |
87 | | - for idx, entry in enumerate(log): |
88 | | - # if ts=0 the uid has never logged in before |
89 | | - if entry.ut_host.strip(b"\x00") == b"" or entry.ll_time.tv_sec == 0: |
| 159 | + for path in self.paths: |
| 160 | + if any(seen_path.samefile(path) for seen_path in seen): |
90 | 161 | continue |
91 | 162 |
|
92 | | - yield LastLogRecord( |
93 | | - ts=ts.from_unix(entry.ll_time.tv_sec), |
94 | | - uid=idx, |
95 | | - ut_user=users.get(idx), |
96 | | - ut_tty=entry.ut_user.decode().strip("\x00"), |
97 | | - ut_host=entry.ut_host.decode(errors="ignore").strip("\x00"), |
98 | | - _target=self.target, |
99 | | - ) |
| 163 | + iterator = LastLogDb if path.suffix == ".db" else LastLogFile |
| 164 | + for entry in iterator(path, users): |
| 165 | + yield LastLogRecord( |
| 166 | + **asdict(entry), |
| 167 | + source=path, |
| 168 | + _target=self.target, |
| 169 | + ) |
0 commit comments