From dacbcadd0b03c7842195b292b174dcc190aeae7b Mon Sep 17 00:00:00 2001 From: Zawadi Date: Tue, 18 Jul 2023 11:14:41 +0200 Subject: [PATCH 1/8] Add cPanel lastlogin parser --- .../plugins/apps/webhosting/__init__.py | 0 .../target/plugins/apps/webhosting/cpanel.py | 68 +++++++++++++++++++ .../plugins/apps/webhosting/cpanel/lastlogin | 5 ++ tests/test_plugins_apps_webhosting_cpanel.py | 26 +++++++ 4 files changed, 99 insertions(+) create mode 100644 dissect/target/plugins/apps/webhosting/__init__.py create mode 100644 dissect/target/plugins/apps/webhosting/cpanel.py create mode 100644 tests/data/plugins/apps/webhosting/cpanel/lastlogin create mode 100644 tests/test_plugins_apps_webhosting_cpanel.py diff --git a/dissect/target/plugins/apps/webhosting/__init__.py b/dissect/target/plugins/apps/webhosting/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/dissect/target/plugins/apps/webhosting/cpanel.py b/dissect/target/plugins/apps/webhosting/cpanel.py new file mode 100644 index 000000000..2d4976e96 --- /dev/null +++ b/dissect/target/plugins/apps/webhosting/cpanel.py @@ -0,0 +1,68 @@ +from datetime import datetime +from typing import Iterator + +from dissect.target.helpers.record import TargetRecordDescriptor +from dissect.target.plugin import Plugin, export + +CpanelLastloginRecord = TargetRecordDescriptor( + "application/log/cpanel/lastlogin", + [ + ("datetime", "ts"), + ("string", "user"), + ("net.ipaddress", "remote_ip"), + ], +) + +CPANEL_LASTLOGIN = ".lastlogin" +CPANEL_LOGS_PATH = "/usr/local/cpanel/logs" + + +class CpanelPlugin(Plugin): + # TODO: Parse other log files https://support.cartika.com/portal/en/kb/articles/whm-cpanel-log-files-and-locations + __namespace__ = "cpanel" + + def check_compatible(self) -> None: + return bool(self.target.fs.path(CPANEL_LOGS_PATH).exists()) + + @export(record=CpanelLastloginRecord) + def lastlogin(self) -> Iterator[CpanelLastloginRecord]: + """Return the content of the cPanel lastlogin file. + + The lastlogin files tracks successful cPanel interface logons. New logon events are only tracked + if the IP-address of the logon changes. + + References: + - https://forums.cpanel.net/threads/cpanel-control-panel-last-login-clarification.579221/ + - https://forums.cpanel.net/threads/lastlogin.707557/ + """ + for user_details in self.target.user_details.all_with_home(): + if (lastlogin := user_details.home_path.joinpath(CPANEL_LASTLOGIN)).exists(): + try: + for index, line in enumerate(lastlogin.open("rt")): + if not line: + continue + + line = line.strip().split() + + # In certain cases two log lines are part of the same line + if len(line) != 5 or len(line[4]) != 5: + self.target.log.warning( + "The cPanel lastlogin line number %s is malformed: %s", index + 1, lastlogin + ) + continue + + remote_ip, _, date, time, utc_offset = line + + timestamp = datetime.strptime(f"{date} {time} {utc_offset}", "%Y-%m-%d %H:%M:%S %z") + + yield CpanelLastloginRecord( + ts=timestamp, + user=user_details.user.name, + remote_ip=remote_ip, + _target=self.target, + ) + + except Exception: + self.target.log.warning( + "An error occurred parsing cPanel lastlogin line number %i in file: %s", index + 1, lastlogin + ) diff --git a/tests/data/plugins/apps/webhosting/cpanel/lastlogin b/tests/data/plugins/apps/webhosting/cpanel/lastlogin new file mode 100644 index 000000000..052494726 --- /dev/null +++ b/tests/data/plugins/apps/webhosting/cpanel/lastlogin @@ -0,0 +1,5 @@ +8.8.8.8 # 2023-06-27 14:22:13 +0100 +8.8.8.8 # 2023-06-28 14:13:37 +0200 +8.8.8.8 # 2023-06-28 14:13:37 +02008.8.8.8 # 2023-06-28 14:13:37 +0200 +8.8.8.8 # 2016-10-04 16:40:39 -0500 +8.8.8.8 # 2016-10-27 14:33:05 -0500 diff --git a/tests/test_plugins_apps_webhosting_cpanel.py b/tests/test_plugins_apps_webhosting_cpanel.py new file mode 100644 index 000000000..c0a09c89d --- /dev/null +++ b/tests/test_plugins_apps_webhosting_cpanel.py @@ -0,0 +1,26 @@ +from datetime import datetime, timezone + +from dissect.target.plugins.apps.webhosting.cpanel import CpanelPlugin + +from ._utils import absolute_path + + +def test_cpanel_plugin(target_unix, fs_unix): + data_file = absolute_path("data/plugins/apps/webhosting/cpanel/lastlogin") + fs_unix.map_file("/home/test/.lastlogin", data_file) + + fs_unix.makedirs("/usr/local/cpanel/logs") + + passwd_file = absolute_path("data/unix/configs/passwd") + fs_unix.map_file("/etc/passwd", passwd_file) + + target_unix.add_plugin(CpanelPlugin) + + results = list(target_unix.cpanel.lastlogin()) + + record = results[0] + + assert len(results) == 4 + assert record.ts == datetime(2023, 6, 27, 13, 22, 13, tzinfo=timezone.utc) + assert record.user == "test" + assert record.remote_ip == "8.8.8.8" From aa179240aad745e061106b0524e18e3354ec74fe Mon Sep 17 00:00:00 2001 From: Zawadi Date: Fri, 21 Jul 2023 10:11:46 +0200 Subject: [PATCH 2/8] Apply suggestions code review Capitalize CPanel and raise error if plugin is incompatible --- dissect/target/plugins/apps/webhosting/cpanel.py | 14 ++++++++------ tests/test_plugins_apps_webhosting_cpanel.py | 4 ++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/dissect/target/plugins/apps/webhosting/cpanel.py b/dissect/target/plugins/apps/webhosting/cpanel.py index 2d4976e96..da946553d 100644 --- a/dissect/target/plugins/apps/webhosting/cpanel.py +++ b/dissect/target/plugins/apps/webhosting/cpanel.py @@ -1,10 +1,11 @@ from datetime import datetime from typing import Iterator +from dissect.target.exceptions import UnsupportedPluginError from dissect.target.helpers.record import TargetRecordDescriptor from dissect.target.plugin import Plugin, export -CpanelLastloginRecord = TargetRecordDescriptor( +CPanelLastloginRecord = TargetRecordDescriptor( "application/log/cpanel/lastlogin", [ ("datetime", "ts"), @@ -17,15 +18,16 @@ CPANEL_LOGS_PATH = "/usr/local/cpanel/logs" -class CpanelPlugin(Plugin): +class CPanelPlugin(Plugin): # TODO: Parse other log files https://support.cartika.com/portal/en/kb/articles/whm-cpanel-log-files-and-locations __namespace__ = "cpanel" def check_compatible(self) -> None: - return bool(self.target.fs.path(CPANEL_LOGS_PATH).exists()) + if not self.target.fs.path(CPANEL_LOGS_PATH).exists(): + raise UnsupportedPluginError("No cPanel log path found") - @export(record=CpanelLastloginRecord) - def lastlogin(self) -> Iterator[CpanelLastloginRecord]: + @export(record=CPanelLastloginRecord) + def lastlogin(self) -> Iterator[CPanelLastloginRecord]: """Return the content of the cPanel lastlogin file. The lastlogin files tracks successful cPanel interface logons. New logon events are only tracked @@ -55,7 +57,7 @@ def lastlogin(self) -> Iterator[CpanelLastloginRecord]: timestamp = datetime.strptime(f"{date} {time} {utc_offset}", "%Y-%m-%d %H:%M:%S %z") - yield CpanelLastloginRecord( + yield CPanelLastloginRecord( ts=timestamp, user=user_details.user.name, remote_ip=remote_ip, diff --git a/tests/test_plugins_apps_webhosting_cpanel.py b/tests/test_plugins_apps_webhosting_cpanel.py index c0a09c89d..dbd5698ff 100644 --- a/tests/test_plugins_apps_webhosting_cpanel.py +++ b/tests/test_plugins_apps_webhosting_cpanel.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from dissect.target.plugins.apps.webhosting.cpanel import CpanelPlugin +from dissect.target.plugins.apps.webhosting.cpanel import CPanelPlugin from ._utils import absolute_path @@ -14,7 +14,7 @@ def test_cpanel_plugin(target_unix, fs_unix): passwd_file = absolute_path("data/unix/configs/passwd") fs_unix.map_file("/etc/passwd", passwd_file) - target_unix.add_plugin(CpanelPlugin) + target_unix.add_plugin(CPanelPlugin) results = list(target_unix.cpanel.lastlogin()) From f7db22fec676dc309ab2f050f01b69e21f280993 Mon Sep 17 00:00:00 2001 From: Zawadi Date: Fri, 21 Jul 2023 10:15:19 +0200 Subject: [PATCH 3/8] Use `target_unix_users` --- tests/test_plugins_apps_webhosting_cpanel.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/tests/test_plugins_apps_webhosting_cpanel.py b/tests/test_plugins_apps_webhosting_cpanel.py index dbd5698ff..cbcb61c36 100644 --- a/tests/test_plugins_apps_webhosting_cpanel.py +++ b/tests/test_plugins_apps_webhosting_cpanel.py @@ -5,22 +5,19 @@ from ._utils import absolute_path -def test_cpanel_plugin(target_unix, fs_unix): +def test_cpanel_plugin(target_unix_users, fs_unix): data_file = absolute_path("data/plugins/apps/webhosting/cpanel/lastlogin") - fs_unix.map_file("/home/test/.lastlogin", data_file) + fs_unix.map_file("/home/user/.lastlogin", data_file) fs_unix.makedirs("/usr/local/cpanel/logs") - passwd_file = absolute_path("data/unix/configs/passwd") - fs_unix.map_file("/etc/passwd", passwd_file) + target_unix_users.add_plugin(CPanelPlugin) - target_unix.add_plugin(CPanelPlugin) - - results = list(target_unix.cpanel.lastlogin()) + results = list(target_unix_users.cpanel.lastlogin()) record = results[0] assert len(results) == 4 assert record.ts == datetime(2023, 6, 27, 13, 22, 13, tzinfo=timezone.utc) - assert record.user == "test" + assert record.user == "user" assert record.remote_ip == "8.8.8.8" From 8467ee511b05f825ab82e955ac41ea398d9dfdf8 Mon Sep 17 00:00:00 2001 From: Zawadi Done Date: Fri, 4 Aug 2023 11:12:25 +0200 Subject: [PATCH 4/8] Update dissect/target/plugins/apps/webhosting/cpanel.py Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com> --- dissect/target/plugins/apps/webhosting/cpanel.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dissect/target/plugins/apps/webhosting/cpanel.py b/dissect/target/plugins/apps/webhosting/cpanel.py index da946553d..8e03d8948 100644 --- a/dissect/target/plugins/apps/webhosting/cpanel.py +++ b/dissect/target/plugins/apps/webhosting/cpanel.py @@ -41,10 +41,11 @@ def lastlogin(self) -> Iterator[CPanelLastloginRecord]: if (lastlogin := user_details.home_path.joinpath(CPANEL_LASTLOGIN)).exists(): try: for index, line in enumerate(lastlogin.open("rt")): + line = line.strip() if not line: continue - line = line.strip().split() + line = line.split() # In certain cases two log lines are part of the same line if len(line) != 5 or len(line[4]) != 5: From 80becf840d062a45ed0fa5ee821ac977651b4bfd Mon Sep 17 00:00:00 2001 From: Zawadi Done Date: Fri, 4 Aug 2023 11:48:03 +0200 Subject: [PATCH 5/8] Use regex --- .../target/plugins/apps/webhosting/cpanel.py | 31 ++++++++++--------- tests/test_plugins_apps_webhosting_cpanel.py | 2 +- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/dissect/target/plugins/apps/webhosting/cpanel.py b/dissect/target/plugins/apps/webhosting/cpanel.py index 8e03d8948..d8e4f81de 100644 --- a/dissect/target/plugins/apps/webhosting/cpanel.py +++ b/dissect/target/plugins/apps/webhosting/cpanel.py @@ -1,3 +1,4 @@ +import re from datetime import datetime from typing import Iterator @@ -16,6 +17,7 @@ CPANEL_LASTLOGIN = ".lastlogin" CPANEL_LOGS_PATH = "/usr/local/cpanel/logs" +CPANEL_PATTERN = re.compile(r"([^\s]+) # ([0-9]{4}-[0-9]{2}-[0-9]{2}) ([0-9]{2}:[0-9]{2}:[0-9]{2}) ([+-][0-9]{4})") class CPanelPlugin(Plugin): @@ -45,25 +47,24 @@ def lastlogin(self) -> Iterator[CPanelLastloginRecord]: if not line: continue - line = line.split() + events = CPANEL_PATTERN.findall(line) - # In certain cases two log lines are part of the same line - if len(line) != 5 or len(line[4]) != 5: + if events: + for event in events: + remote_ip, date, time, utc_offset = event + + timestamp = datetime.strptime(f"{date} {time} {utc_offset}", "%Y-%m-%d %H:%M:%S %z") + + yield CPanelLastloginRecord( + ts=timestamp, + user=user_details.user.name, + remote_ip=remote_ip, + _target=self.target, + ) + else: self.target.log.warning( "The cPanel lastlogin line number %s is malformed: %s", index + 1, lastlogin ) - continue - - remote_ip, _, date, time, utc_offset = line - - timestamp = datetime.strptime(f"{date} {time} {utc_offset}", "%Y-%m-%d %H:%M:%S %z") - - yield CPanelLastloginRecord( - ts=timestamp, - user=user_details.user.name, - remote_ip=remote_ip, - _target=self.target, - ) except Exception: self.target.log.warning( diff --git a/tests/test_plugins_apps_webhosting_cpanel.py b/tests/test_plugins_apps_webhosting_cpanel.py index cbcb61c36..6e8bc0ab7 100644 --- a/tests/test_plugins_apps_webhosting_cpanel.py +++ b/tests/test_plugins_apps_webhosting_cpanel.py @@ -17,7 +17,7 @@ def test_cpanel_plugin(target_unix_users, fs_unix): record = results[0] - assert len(results) == 4 + assert len(results) == 6 assert record.ts == datetime(2023, 6, 27, 13, 22, 13, tzinfo=timezone.utc) assert record.user == "user" assert record.remote_ip == "8.8.8.8" From 5e158692463a0813cfe3a47236a399fe00d050a1 Mon Sep 17 00:00:00 2001 From: Zawadi Done Date: Fri, 4 Aug 2023 12:52:25 +0200 Subject: [PATCH 6/8] Apply suggestions code review --- dissect/target/plugins/apps/webhosting/cpanel.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dissect/target/plugins/apps/webhosting/cpanel.py b/dissect/target/plugins/apps/webhosting/cpanel.py index d8e4f81de..67ab56c7e 100644 --- a/dissect/target/plugins/apps/webhosting/cpanel.py +++ b/dissect/target/plugins/apps/webhosting/cpanel.py @@ -17,7 +17,9 @@ CPANEL_LASTLOGIN = ".lastlogin" CPANEL_LOGS_PATH = "/usr/local/cpanel/logs" -CPANEL_PATTERN = re.compile(r"([^\s]+) # ([0-9]{4}-[0-9]{2}-[0-9]{2}) ([0-9]{2}:[0-9]{2}:[0-9]{2}) ([+-][0-9]{4})") +CPANEL_LASTLOGIN_PATTERN = re.compile( + r"([^\s]+) # ([0-9]{4}-[0-9]{2}-[0-9]{2}) ([0-9]{2}:[0-9]{2}:[0-9]{2}) ([+-][0-9]{4})" +) class CPanelPlugin(Plugin): @@ -47,9 +49,7 @@ def lastlogin(self) -> Iterator[CPanelLastloginRecord]: if not line: continue - events = CPANEL_PATTERN.findall(line) - - if events: + if events := CPANEL_LASTLOGIN_PATTERN.findall(line): for event in events: remote_ip, date, time, utc_offset = event From 9d770725fb8f04fbf648ce9bc5f51349ef49373a Mon Sep 17 00:00:00 2001 From: Zawadi Done Date: Mon, 7 Aug 2023 19:15:37 +0200 Subject: [PATCH 7/8] Add type hints --- tests/test_plugins_apps_webhosting_cpanel.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_plugins_apps_webhosting_cpanel.py b/tests/test_plugins_apps_webhosting_cpanel.py index 6e8bc0ab7..dc1d9a7f3 100644 --- a/tests/test_plugins_apps_webhosting_cpanel.py +++ b/tests/test_plugins_apps_webhosting_cpanel.py @@ -1,11 +1,13 @@ from datetime import datetime, timezone +from dissect.target import Target +from dissect.target.filesystem import VirtualFilesystem from dissect.target.plugins.apps.webhosting.cpanel import CPanelPlugin from ._utils import absolute_path -def test_cpanel_plugin(target_unix_users, fs_unix): +def test_cpanel_plugin(target_unix_users: Target, fs_unix: VirtualFilesystem): data_file = absolute_path("data/plugins/apps/webhosting/cpanel/lastlogin") fs_unix.map_file("/home/user/.lastlogin", data_file) From 8c84a4bd96ddd4ed40647ea90d8c844af66e6c22 Mon Sep 17 00:00:00 2001 From: Zawadi Done Date: Mon, 7 Aug 2023 19:20:59 +0200 Subject: [PATCH 8/8] Type hint return of function --- tests/test_plugins_apps_webhosting_cpanel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_plugins_apps_webhosting_cpanel.py b/tests/test_plugins_apps_webhosting_cpanel.py index dc1d9a7f3..ae07faab2 100644 --- a/tests/test_plugins_apps_webhosting_cpanel.py +++ b/tests/test_plugins_apps_webhosting_cpanel.py @@ -7,7 +7,7 @@ from ._utils import absolute_path -def test_cpanel_plugin(target_unix_users: Target, fs_unix: VirtualFilesystem): +def test_cpanel_plugin(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None: data_file = absolute_path("data/plugins/apps/webhosting/cpanel/lastlogin") fs_unix.map_file("/home/user/.lastlogin", data_file)