From d061e6443a5f80ead1ea7842fda2b9dc378fd120 Mon Sep 17 00:00:00 2001 From: wbi Date: Thu, 12 Feb 2026 15:51:44 +0100 Subject: [PATCH 1/8] Quick anacron plugins (#1543). TODO later test + more tests --- dissect/target/plugins/os/unix/anacronjobs.py | 159 ++++++++++++++++++ dissect/target/plugins/os/unix/cronjobs.py | 10 +- 2 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 dissect/target/plugins/os/unix/anacronjobs.py diff --git a/dissect/target/plugins/os/unix/anacronjobs.py b/dissect/target/plugins/os/unix/anacronjobs.py new file mode 100644 index 0000000000..e22e92d64b --- /dev/null +++ b/dissect/target/plugins/os/unix/anacronjobs.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import datetime +import re +import shlex +from typing import TYPE_CHECKING + +from dissect.util import ts + +from dissect.target.exceptions import UnsupportedPluginError +from dissect.target.helpers.record import ( + TargetRecordDescriptor, +) +from dissect.target.plugin import Plugin, export + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + + from dissect.target.target import Target + +AnacronjobRecord = TargetRecordDescriptor( + "unix/anacronjob", + [ + ("string", "period_name"), + ("varint", "delay"), + ("string", "job_identify"), + ("string", "command"), + ("datetime", "ts_first_exec"), # /var/spool/anacron/job_identify birth time + ("datetime", "ts_last_exec"), # based on /var/spool/anacron/job_identify content and last modification time + ("path", "source"), + ], +) + +EnvironmentVariableRecord = TargetRecordDescriptor( + "unix/environmentvariable", + [ + ("string", "key"), + ("string", "value"), + ("path", "source"), + ], +) + +RE_ANACRONJOB = re.compile( + r""" + ^ + (?P\S+) + \s+ + (?P\S+) + \s+ + (?P\S+) + \s+ + (?P.+) + $ + """, + re.VERBOSE, +) +RE_ENVVAR = re.compile(r"^(?P[a-zA-Z_]+[a-zA-Z[0-9_])\s?=\s?(?P.*)") + + +class AnacronjobPlugin(Plugin): + """Unix anacron plugin.""" + + def __init__(self, target: Target): + super().__init__(target) + self.ancrontabs = list(self.get_paths()) + + def check_compatible(self) -> None: + if not self.ancrontabs: + raise UnsupportedPluginError("No crontab(s) found on target") + + def _get_paths(self) -> Iterator[Path]: + if (file := self.target.fs.path("/etc/anacrontab")).exists(): + yield file + + @export(record=[AnacronjobRecord, EnvironmentVariableRecord]) + def anacronjobs(self) -> Iterator[AnacronjobRecord | EnvironmentVariableRecord]: + """Yield anacron jobs, and their configured environment variables on a Unix system + + A anacron job is a scheduled task/command on a Unix based system. Adversaries may use anacronjobs to gain + persistence on the system. "Unlike cron, it does not assume that the machine is running continuously. + Hence, it can be used on machines that aren't running 24 hours a day, to control regular jobs as daily, + weekly, and monthly jobs." + + References: + - https://linux.die.net/man/5/anacrontab + - https://linux.die.net/man/8/anacron + """ + + for file in self.ancrontabs: + # Cronjobs in user crontab files do not have a user field specified. + + for line in file.open("rt"): + line = line.strip() + ts_first_exec = None + ts_last_exec = None + if line.startswith("#") or not line: + continue + + if match := RE_ANACRONJOB.search(line): + match = match.groupdict() + job_identify = match.get("job_identify", None) + command = match.get("command", None) + if (ts_file := self.target.fs.path(f"/var/spool/anacron/{job_identify}")).exists(): + ts_file_stat = ts_file.stat() + ts_first_exec = ( + None + if getattr(ts_file_stat, "st_birthtime", None) is None + else ts.from_unix(getattr(ts_file_stat, "st_birthtime", None)) + ) + ts_last_exec = ts.from_unix(ts_file_stat.st_mtime) + anacron_ts_value = ts_file.read_text(errors="backslashreplace").strip() + if ts_first_exec and ts_first_exec.strftime("%Y%m%d") > anacron_ts_value: + # incoherent value, maybe related to ts modification/data loss during transfer + ts_first_exec = None + if ts_last_exec.strftime("%Y%m%d") != anacron_ts_value: + # incoherent value, maybe related to ts modification/data loss during transfer + ts_last_exec = datetime.datetime.strptime(anacron_ts_value, "%Y%m%d") # noqa: DTZ007 + yield AnacronjobRecord( + period_name=match.get("period_name", None), + delay=match.get("delay", None), + job_identify=job_identify, + command=command, + ts_first_exec=ts_first_exec, + ts_last_exec=ts_last_exec, + source=file, + _target=self.target, + ) + if command: + splited = shlex.split(command) + # Anacron often use run-parts or nice run-parts to run a list of script in a directory + if ( + "run-parts" in splited[:2] + and (run_part_dir := self.target.fs.path(splited[-1])).exists() + and run_part_dir.is_dir() + ): + for f in run_part_dir.iterdir(): + yield AnacronjobRecord( + period_name=match.get("period_name", None), + delay=match.get("delay", None), + job_identify=job_identify, + command=f, + ts_first_exec=ts_first_exec, + ts_last_exec=ts_last_exec, + source=file, + _target=self.target, + ) + + # Some cron implementations allow for environment variables to be set inside crontab files. + elif match := RE_ENVVAR.search(line): + match = match.groupdict() + yield EnvironmentVariableRecord( + **match, + source=file, + _target=self.target, + ) + + else: + self.target.log.warning("Unable to match anacronjob line in %s: '%s'", file, line) diff --git a/dissect/target/plugins/os/unix/cronjobs.py b/dissect/target/plugins/os/unix/cronjobs.py index 6090d8d799..64628bcbe6 100644 --- a/dissect/target/plugins/os/unix/cronjobs.py +++ b/dissect/target/plugins/os/unix/cronjobs.py @@ -71,20 +71,17 @@ class CronjobPlugin(Plugin): "/usr/local/etc/cron.d", # FreeBSD ) - CRONTAB_FILES = ( - "/etc/crontab", - "/etc/anacrontab", - ) + CRONTAB_FILES = ("/etc/crontab",) def __init__(self, target: Target): super().__init__(target) - self.crontabs = list(self.find_crontabs()) + self.crontabs = list(self.get_paths()) def check_compatible(self) -> None: if not self.crontabs: raise UnsupportedPluginError("No crontab(s) found on target") - def find_crontabs(self) -> Iterator[Path]: + def _get_paths(self) -> Iterator[Path]: for crontab_dir in self.CRONTAB_DIRS: if not (dir := self.target.fs.path(crontab_dir)).exists(): continue @@ -109,7 +106,6 @@ def cronjobs(self) -> Iterator[CronjobRecord | EnvironmentVariableRecord]: - https://linux.die.net/man/1/crontab - https://linux.die.net/man/5/crontab - https://en.wikipedia.org/wiki/Cron - - https://linux.die.net/man/8/anacron - https://manpages.ubuntu.com/manpages/oracular/en/man5/crontab.5.html - https://www.gnu.org/software/mcron/manual/mcron.html#Guile-Syntax """ From d444c42f22425a4eb5d386cf8dcf2c6a24c6322a Mon Sep 17 00:00:00 2001 From: wbi Date: Mon, 16 Feb 2026 17:08:50 +0100 Subject: [PATCH 2/8] Add tests --- dissect/target/plugins/os/unix/anacronjobs.py | 48 +++---- tests/plugins/os/unix/test_anacronjobs.py | 128 ++++++++++++++++++ 2 files changed, 152 insertions(+), 24 deletions(-) create mode 100644 tests/plugins/os/unix/test_anacronjobs.py diff --git a/dissect/target/plugins/os/unix/anacronjobs.py b/dissect/target/plugins/os/unix/anacronjobs.py index e22e92d64b..2c0e468671 100644 --- a/dissect/target/plugins/os/unix/anacronjobs.py +++ b/dissect/target/plugins/os/unix/anacronjobs.py @@ -12,6 +12,7 @@ TargetRecordDescriptor, ) from dissect.target.plugin import Plugin, export +from dissect.target.plugins.os.unix.cronjobs import EnvironmentVariableRecord if TYPE_CHECKING: from collections.abc import Iterator @@ -23,7 +24,7 @@ "unix/anacronjob", [ ("string", "period_name"), - ("varint", "delay"), + ("varint", "delay_in_minutes"), ("string", "job_identify"), ("string", "command"), ("datetime", "ts_first_exec"), # /var/spool/anacron/job_identify birth time @@ -32,15 +33,6 @@ ], ) -EnvironmentVariableRecord = TargetRecordDescriptor( - "unix/environmentvariable", - [ - ("string", "key"), - ("string", "value"), - ("path", "source"), - ], -) - RE_ANACRONJOB = re.compile( r""" ^ @@ -55,40 +47,47 @@ """, re.VERBOSE, ) -RE_ENVVAR = re.compile(r"^(?P[a-zA-Z_]+[a-zA-Z[0-9_])\s?=\s?(?P.*)") + +# Spaces around VAR are removed. No spaces around VALUE are allowed (unless you want them to be part of the value). + +RE_ENVVAR = re.compile(r"^\s*(?P[a-zA-Z_]+[a-zA-Z[0-9_])\s*=(?P.*)") class AnacronjobPlugin(Plugin): """Unix anacron plugin.""" + ANACRONTAB_FILES = ( + "/etc/anacrontab", # Linux + "/usr/local/etc/anacrontab" # FreeBSD + ) + def __init__(self, target: Target): super().__init__(target) self.ancrontabs = list(self.get_paths()) def check_compatible(self) -> None: if not self.ancrontabs: - raise UnsupportedPluginError("No crontab(s) found on target") + raise UnsupportedPluginError("No anacrontab found on target") def _get_paths(self) -> Iterator[Path]: - if (file := self.target.fs.path("/etc/anacrontab")).exists(): - yield file + for anacrontab_file in self.ANACRONTAB_FILES: + if (file := self.target.fs.path(anacrontab_file)).exists(): + yield file @export(record=[AnacronjobRecord, EnvironmentVariableRecord]) def anacronjobs(self) -> Iterator[AnacronjobRecord | EnvironmentVariableRecord]: """Yield anacron jobs, and their configured environment variables on a Unix system - A anacron job is a scheduled task/command on a Unix based system. Adversaries may use anacronjobs to gain - persistence on the system. "Unlike cron, it does not assume that the machine is running continuously. - Hence, it can be used on machines that aren't running 24 hours a day, to control regular jobs as daily, - weekly, and monthly jobs." + An anacron job is a scheduled task/command on a Unix based system. Adversaries may use anacronjobs to gain + persistence on the system. This plugins also iterate over files executed using run-parts References: - https://linux.die.net/man/5/anacrontab + - https://man.freebsd.org/cgi/man.cgi?anacron(8) - https://linux.die.net/man/8/anacron """ for file in self.ancrontabs: - # Cronjobs in user crontab files do not have a user field specified. for line in file.open("rt"): line = line.strip() @@ -111,14 +110,14 @@ def anacronjobs(self) -> Iterator[AnacronjobRecord | EnvironmentVariableRecord]: ts_last_exec = ts.from_unix(ts_file_stat.st_mtime) anacron_ts_value = ts_file.read_text(errors="backslashreplace").strip() if ts_first_exec and ts_first_exec.strftime("%Y%m%d") > anacron_ts_value: - # incoherent value, maybe related to ts modification/data loss during transfer + # incoherent value, maybe related to ts modification/data loss during collection ts_first_exec = None if ts_last_exec.strftime("%Y%m%d") != anacron_ts_value: - # incoherent value, maybe related to ts modification/data loss during transfer + # incoherent value, maybe related to ts modification/data loss during collection ts_last_exec = datetime.datetime.strptime(anacron_ts_value, "%Y%m%d") # noqa: DTZ007 yield AnacronjobRecord( period_name=match.get("period_name", None), - delay=match.get("delay", None), + delay_in_minutes=match.get("delay", None), job_identify=job_identify, command=command, ts_first_exec=ts_first_exec, @@ -129,6 +128,7 @@ def anacronjobs(self) -> Iterator[AnacronjobRecord | EnvironmentVariableRecord]: if command: splited = shlex.split(command) # Anacron often use run-parts or nice run-parts to run a list of script in a directory + # Last part of the command is the name of the folder if ( "run-parts" in splited[:2] and (run_part_dir := self.target.fs.path(splited[-1])).exists() @@ -137,7 +137,7 @@ def anacronjobs(self) -> Iterator[AnacronjobRecord | EnvironmentVariableRecord]: for f in run_part_dir.iterdir(): yield AnacronjobRecord( period_name=match.get("period_name", None), - delay=match.get("delay", None), + delay_in_minutes=match.get("delay", None), job_identify=job_identify, command=f, ts_first_exec=ts_first_exec, @@ -146,7 +146,7 @@ def anacronjobs(self) -> Iterator[AnacronjobRecord | EnvironmentVariableRecord]: _target=self.target, ) - # Some cron implementations allow for environment variables to be set inside crontab files. + # Anacron allows for Environment assignment elif match := RE_ENVVAR.search(line): match = match.groupdict() yield EnvironmentVariableRecord( diff --git a/tests/plugins/os/unix/test_anacronjobs.py b/tests/plugins/os/unix/test_anacronjobs.py new file mode 100644 index 0000000000..483ac27af5 --- /dev/null +++ b/tests/plugins/os/unix/test_anacronjobs.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import stat +import textwrap +import types +from io import BytesIO +from typing import TYPE_CHECKING + +from flow.record.fieldtypes import datetime as dt + +from dissect.target.filesystem import VirtualFile +from dissect.target.helpers import fsutil +from dissect.target.plugins.os.unix.anacronjobs import AnacronjobPlugin, AnacronjobRecord, EnvironmentVariableRecord + +if TYPE_CHECKING: + from dissect.target.filesystem import VirtualFilesystem + from dissect.target.target import Target + + +ANACRONTAB_DEFAULT = """ + # environment variables + SHELL=/bin/sh + PATH=/sbin:/bin:/usr/sbin:/usr/bin + MAILTO=root + RANDOM_DELAY=30 + # the jobs will be started during the following hours only + START_HOURS_RANGE=3-22 + # delay will be 5 minutes + RANDOM_DELAY for cron.daily + 1 5 cron.daily nice run-parts /etc/cron.daily + 7 0 cron.weekly nice run-parts /etc/cron.weekly + @monthly 0 cron.monthly nice run-parts /etc/cron.monthly +""" + + +def test_unix_anacrontab(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None: + fs_unix.map_file_fh("/etc/anacrontab", BytesIO(textwrap.dedent(ANACRONTAB_DEFAULT).encode())) + fs_unix.map_file_fh("/var/spool/anacron/cron.daily", BytesIO(b"20260206\n")) + # File to tests enumeration of files references in run-parts commands + fs_unix.map_file_fh("/etc/cron.daily/logrotate", BytesIO(b"#!/bin/sh\n/usr/sbin/logrotate\n")) + + # Virtual file for cron.weekly, to tests if ts_first_exec/ts_last_exec + # is set according to file birth and modification time + weekly_virtual_file = VirtualFile(fs_unix, "/var/spool/anacron/cron.weekly", BytesIO(b"20260119\n")) + + def mocked_lstat(self: VirtualFile) -> fsutil.stat_result: + size = getattr(self.entry, "size", 0) + file_addr = fsutil.generate_addr(self.path, alt_separator=self.fs.alt_separator) + r = fsutil.stat_result( + [ + stat.S_IFREG, + file_addr, + id(self.fs), + 1, + 0, + 0, + size, + int(dt("2026-01-19 03:01:01+00:00").timestamp()), + int(dt("2026-01-19 02:12:17+00:00").timestamp()), + int(dt("2026-01-19 02:12:17+00:00").timestamp()), + ] + ) + r.st_birthtime = int(dt("2025-04-15 15:08:04+00:00").timestamp()) + r.st_birthtime_ns = int(dt("2025-04-15 15:08:04+00:00").timestamp()) * 1e9 + + return r + + weekly_virtual_file.lstat = types.MethodType(mocked_lstat, weekly_virtual_file) + fs_unix.map_file_entry("/var/spool/anacron/cron.weekly", weekly_virtual_file) + + target_unix_users.add_plugin(AnacronjobPlugin) + + results = list(target_unix_users.anacronjobs()) + + assert len(results) == 9 + + anacronjob_records = [r for r in results if isinstance(r, type(AnacronjobRecord()))] + environmentvariable_records = [r for r in results if isinstance(r, type(EnvironmentVariableRecord()))] + + assert len(anacronjob_records) == 4 + assert len(environmentvariable_records) == 5 + + assert anacronjob_records[0].period_name == "1" + assert anacronjob_records[0].delay_in_minutes == 5 + assert anacronjob_records[0].job_identify == "cron.daily" + assert anacronjob_records[0].command == "nice run-parts /etc/cron.daily" + assert anacronjob_records[0].ts_first_exec is None + assert anacronjob_records[0].ts_last_exec == dt("2026-02-06 00:00:00+00:00") + + assert anacronjob_records[1].period_name == "1" + assert anacronjob_records[1].delay_in_minutes == 5 + assert anacronjob_records[1].job_identify == "cron.daily" + assert anacronjob_records[1].command == "/etc/cron.daily/logrotate" + assert anacronjob_records[1].ts_first_exec is None + # If ts associated to timestamps files are not consistent with date in file, fallback to file content date + assert anacronjob_records[1].ts_last_exec == dt("2026-02-06 00:00:00+00:00") + + assert anacronjob_records[2].period_name == "7" + assert anacronjob_records[2].delay_in_minutes == 0 + assert anacronjob_records[2].job_identify == "cron.weekly" + assert anacronjob_records[2].command == "nice run-parts /etc/cron.weekly" + assert anacronjob_records[2].ts_first_exec == dt("2025-04-15 15:08:04+00:00") + assert anacronjob_records[2].ts_last_exec == dt("2026-01-19 02:12:17+00:00") + + assert anacronjob_records[3].period_name == "@monthly" + assert anacronjob_records[3].delay_in_minutes == 0 + assert anacronjob_records[3].job_identify == "cron.monthly" + assert anacronjob_records[3].command == "nice run-parts /etc/cron.monthly" + assert anacronjob_records[3].ts_first_exec is None + assert anacronjob_records[3].ts_last_exec is None + + assert environmentvariable_records[0].key == "SHELL" + assert environmentvariable_records[0].value == "/bin/sh" + + assert environmentvariable_records[1].key == "PATH" + assert environmentvariable_records[1].value == "/sbin:/bin:/usr/sbin:/usr/bin" + + assert environmentvariable_records[4].key == "START_HOURS_RANGE" + assert environmentvariable_records[4].value == "3-22" + + +def test_freebsd_anacrontab(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None: + """test that anacrontab path on freebsd is also identified""" + + fs_unix.map_file_fh("/usr/local/etc/anacrontab", BytesIO(textwrap.dedent(ANACRONTAB_DEFAULT).encode())) + target_unix_users.add_plugin(AnacronjobPlugin) + + results = list(target_unix_users.anacronjobs()) + assert len(results) == 8 From cbb13c88f0cde7160b38eb756bcca7214d8403b6 Mon Sep 17 00:00:00 2001 From: wbi Date: Mon, 16 Feb 2026 17:09:13 +0100 Subject: [PATCH 3/8] Format --- dissect/target/plugins/os/unix/anacronjobs.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dissect/target/plugins/os/unix/anacronjobs.py b/dissect/target/plugins/os/unix/anacronjobs.py index 2c0e468671..709f70215e 100644 --- a/dissect/target/plugins/os/unix/anacronjobs.py +++ b/dissect/target/plugins/os/unix/anacronjobs.py @@ -58,7 +58,7 @@ class AnacronjobPlugin(Plugin): ANACRONTAB_FILES = ( "/etc/anacrontab", # Linux - "/usr/local/etc/anacrontab" # FreeBSD + "/usr/local/etc/anacrontab", # FreeBSD ) def __init__(self, target: Target): @@ -88,7 +88,6 @@ def anacronjobs(self) -> Iterator[AnacronjobRecord | EnvironmentVariableRecord]: """ for file in self.ancrontabs: - for line in file.open("rt"): line = line.strip() ts_first_exec = None From 3e5713c6b7eade991b50f73ea1a65aed13c1e96c Mon Sep 17 00:00:00 2001 From: wbi Date: Tue, 30 Jun 2026 15:50:56 +0200 Subject: [PATCH 4/8] Fix tests + remove first exec field (not really reliable) --- dissect/target/plugins/os/unix/anacronjobs.py | 21 +++++++------------ tests/plugins/os/unix/test_anacronjobs.py | 2 +- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/dissect/target/plugins/os/unix/anacronjobs.py b/dissect/target/plugins/os/unix/anacronjobs.py index 709f70215e..b464b1bc60 100644 --- a/dissect/target/plugins/os/unix/anacronjobs.py +++ b/dissect/target/plugins/os/unix/anacronjobs.py @@ -48,7 +48,9 @@ re.VERBOSE, ) -# Spaces around VAR are removed. No spaces around VALUE are allowed (unless you want them to be part of the value). + +# From man : +# ``Spaces around VAR are removed. No spaces around VALUE are allowed (unless you want them to be part of the value).`` RE_ENVVAR = re.compile(r"^\s*(?P[a-zA-Z_]+[a-zA-Z[0-9_])\s*=(?P.*)") @@ -63,10 +65,10 @@ class AnacronjobPlugin(Plugin): def __init__(self, target: Target): super().__init__(target) - self.ancrontabs = list(self.get_paths()) + self.anacrontabs = list(self.get_paths()) def check_compatible(self) -> None: - if not self.ancrontabs: + if not self.anacrontabs: raise UnsupportedPluginError("No anacrontab found on target") def _get_paths(self) -> Iterator[Path]: @@ -76,7 +78,7 @@ def _get_paths(self) -> Iterator[Path]: @export(record=[AnacronjobRecord, EnvironmentVariableRecord]) def anacronjobs(self) -> Iterator[AnacronjobRecord | EnvironmentVariableRecord]: - """Yield anacron jobs, and their configured environment variables on a Unix system + """Yield anacron jobs, and their configured environment variables on a Unix system. An anacron job is a scheduled task/command on a Unix based system. Adversaries may use anacronjobs to gain persistence on the system. This plugins also iterate over files executed using run-parts @@ -86,8 +88,7 @@ def anacronjobs(self) -> Iterator[AnacronjobRecord | EnvironmentVariableRecord]: - https://man.freebsd.org/cgi/man.cgi?anacron(8) - https://linux.die.net/man/8/anacron """ - - for file in self.ancrontabs: + for file in self.anacrontabs: for line in file.open("rt"): line = line.strip() ts_first_exec = None @@ -101,16 +102,8 @@ def anacronjobs(self) -> Iterator[AnacronjobRecord | EnvironmentVariableRecord]: command = match.get("command", None) if (ts_file := self.target.fs.path(f"/var/spool/anacron/{job_identify}")).exists(): ts_file_stat = ts_file.stat() - ts_first_exec = ( - None - if getattr(ts_file_stat, "st_birthtime", None) is None - else ts.from_unix(getattr(ts_file_stat, "st_birthtime", None)) - ) ts_last_exec = ts.from_unix(ts_file_stat.st_mtime) anacron_ts_value = ts_file.read_text(errors="backslashreplace").strip() - if ts_first_exec and ts_first_exec.strftime("%Y%m%d") > anacron_ts_value: - # incoherent value, maybe related to ts modification/data loss during collection - ts_first_exec = None if ts_last_exec.strftime("%Y%m%d") != anacron_ts_value: # incoherent value, maybe related to ts modification/data loss during collection ts_last_exec = datetime.datetime.strptime(anacron_ts_value, "%Y%m%d") # noqa: DTZ007 diff --git a/tests/plugins/os/unix/test_anacronjobs.py b/tests/plugins/os/unix/test_anacronjobs.py index 483ac27af5..dd7f8dcf05 100644 --- a/tests/plugins/os/unix/test_anacronjobs.py +++ b/tests/plugins/os/unix/test_anacronjobs.py @@ -2,9 +2,9 @@ import stat import textwrap -import types from io import BytesIO from typing import TYPE_CHECKING +from unittest.mock import Mock from flow.record.fieldtypes import datetime as dt From 3d08b34a5bdd46a10052bcedd22b0317f5df17a6 Mon Sep 17 00:00:00 2001 From: wbi Date: Tue, 30 Jun 2026 15:51:01 +0200 Subject: [PATCH 5/8] Fix tests + remove first exec field (not really reliable) --- tests/plugins/os/unix/test_anacronjobs.py | 49 +++++++++-------------- 1 file changed, 18 insertions(+), 31 deletions(-) diff --git a/tests/plugins/os/unix/test_anacronjobs.py b/tests/plugins/os/unix/test_anacronjobs.py index dd7f8dcf05..ecca358b11 100644 --- a/tests/plugins/os/unix/test_anacronjobs.py +++ b/tests/plugins/os/unix/test_anacronjobs.py @@ -38,33 +38,24 @@ def test_unix_anacrontab(target_unix_users: Target, fs_unix: VirtualFilesystem) # File to tests enumeration of files references in run-parts commands fs_unix.map_file_fh("/etc/cron.daily/logrotate", BytesIO(b"#!/bin/sh\n/usr/sbin/logrotate\n")) - # Virtual file for cron.weekly, to tests if ts_first_exec/ts_last_exec + # Virtual file # is set according to file birth and modification time weekly_virtual_file = VirtualFile(fs_unix, "/var/spool/anacron/cron.weekly", BytesIO(b"20260119\n")) - - def mocked_lstat(self: VirtualFile) -> fsutil.stat_result: - size = getattr(self.entry, "size", 0) - file_addr = fsutil.generate_addr(self.path, alt_separator=self.fs.alt_separator) - r = fsutil.stat_result( - [ - stat.S_IFREG, - file_addr, - id(self.fs), - 1, - 0, - 0, - size, - int(dt("2026-01-19 03:01:01+00:00").timestamp()), - int(dt("2026-01-19 02:12:17+00:00").timestamp()), - int(dt("2026-01-19 02:12:17+00:00").timestamp()), - ] - ) - r.st_birthtime = int(dt("2025-04-15 15:08:04+00:00").timestamp()) - r.st_birthtime_ns = int(dt("2025-04-15 15:08:04+00:00").timestamp()) * 1e9 - - return r - - weekly_virtual_file.lstat = types.MethodType(mocked_lstat, weekly_virtual_file) + weekly_virtual_file.lstat = Mock() + weekly_virtual_file.lstat.return_value = fsutil.stat_result( + [ + stat.S_IFREG, + 0, + 0, + 1, + 0, + 0, + 9, + int(dt("2026-01-19 03:01:01+00:00").timestamp()), + int(dt("2026-01-19 02:12:17+00:00").timestamp()), + int(dt("2026-01-19 02:12:17+00:00").timestamp()), + ] + ) fs_unix.map_file_entry("/var/spool/anacron/cron.weekly", weekly_virtual_file) target_unix_users.add_plugin(AnacronjobPlugin) @@ -83,14 +74,12 @@ def mocked_lstat(self: VirtualFile) -> fsutil.stat_result: assert anacronjob_records[0].delay_in_minutes == 5 assert anacronjob_records[0].job_identify == "cron.daily" assert anacronjob_records[0].command == "nice run-parts /etc/cron.daily" - assert anacronjob_records[0].ts_first_exec is None assert anacronjob_records[0].ts_last_exec == dt("2026-02-06 00:00:00+00:00") assert anacronjob_records[1].period_name == "1" assert anacronjob_records[1].delay_in_minutes == 5 assert anacronjob_records[1].job_identify == "cron.daily" assert anacronjob_records[1].command == "/etc/cron.daily/logrotate" - assert anacronjob_records[1].ts_first_exec is None # If ts associated to timestamps files are not consistent with date in file, fallback to file content date assert anacronjob_records[1].ts_last_exec == dt("2026-02-06 00:00:00+00:00") @@ -98,14 +87,13 @@ def mocked_lstat(self: VirtualFile) -> fsutil.stat_result: assert anacronjob_records[2].delay_in_minutes == 0 assert anacronjob_records[2].job_identify == "cron.weekly" assert anacronjob_records[2].command == "nice run-parts /etc/cron.weekly" - assert anacronjob_records[2].ts_first_exec == dt("2025-04-15 15:08:04+00:00") assert anacronjob_records[2].ts_last_exec == dt("2026-01-19 02:12:17+00:00") assert anacronjob_records[3].period_name == "@monthly" assert anacronjob_records[3].delay_in_minutes == 0 assert anacronjob_records[3].job_identify == "cron.monthly" assert anacronjob_records[3].command == "nice run-parts /etc/cron.monthly" - assert anacronjob_records[3].ts_first_exec is None + assert anacronjob_records[3].ts_last_exec is None assert environmentvariable_records[0].key == "SHELL" @@ -119,8 +107,7 @@ def mocked_lstat(self: VirtualFile) -> fsutil.stat_result: def test_freebsd_anacrontab(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None: - """test that anacrontab path on freebsd is also identified""" - + """Test that anacrontab path on freebsd is also identified.""" fs_unix.map_file_fh("/usr/local/etc/anacrontab", BytesIO(textwrap.dedent(ANACRONTAB_DEFAULT).encode())) target_unix_users.add_plugin(AnacronjobPlugin) From 55c4ca6797df56d9ea9783e5e2802fb0acdfa900 Mon Sep 17 00:00:00 2001 From: wbi Date: Tue, 30 Jun 2026 16:00:15 +0200 Subject: [PATCH 6/8] remove first exec --- dissect/target/plugins/os/unix/anacronjobs.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dissect/target/plugins/os/unix/anacronjobs.py b/dissect/target/plugins/os/unix/anacronjobs.py index b464b1bc60..abae708e83 100644 --- a/dissect/target/plugins/os/unix/anacronjobs.py +++ b/dissect/target/plugins/os/unix/anacronjobs.py @@ -27,7 +27,6 @@ ("varint", "delay_in_minutes"), ("string", "job_identify"), ("string", "command"), - ("datetime", "ts_first_exec"), # /var/spool/anacron/job_identify birth time ("datetime", "ts_last_exec"), # based on /var/spool/anacron/job_identify content and last modification time ("path", "source"), ], From 99d6578183c744d251b82898491c7ef91f8a3917 Mon Sep 17 00:00:00 2001 From: wbi Date: Tue, 30 Jun 2026 16:01:15 +0200 Subject: [PATCH 7/8] rm ts_first_exec --- dissect/target/plugins/os/unix/anacronjobs.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/dissect/target/plugins/os/unix/anacronjobs.py b/dissect/target/plugins/os/unix/anacronjobs.py index abae708e83..85efdacc2d 100644 --- a/dissect/target/plugins/os/unix/anacronjobs.py +++ b/dissect/target/plugins/os/unix/anacronjobs.py @@ -90,7 +90,6 @@ def anacronjobs(self) -> Iterator[AnacronjobRecord | EnvironmentVariableRecord]: for file in self.anacrontabs: for line in file.open("rt"): line = line.strip() - ts_first_exec = None ts_last_exec = None if line.startswith("#") or not line: continue @@ -111,7 +110,6 @@ def anacronjobs(self) -> Iterator[AnacronjobRecord | EnvironmentVariableRecord]: delay_in_minutes=match.get("delay", None), job_identify=job_identify, command=command, - ts_first_exec=ts_first_exec, ts_last_exec=ts_last_exec, source=file, _target=self.target, @@ -131,7 +129,6 @@ def anacronjobs(self) -> Iterator[AnacronjobRecord | EnvironmentVariableRecord]: delay_in_minutes=match.get("delay", None), job_identify=job_identify, command=f, - ts_first_exec=ts_first_exec, ts_last_exec=ts_last_exec, source=file, _target=self.target, From df7b5dcb96f933d2bc2d7e7e06964f14a1c7409a Mon Sep 17 00:00:00 2001 From: wbi Date: Tue, 30 Jun 2026 16:29:49 +0200 Subject: [PATCH 8/8] Add test related to source + target --- tests/plugins/os/unix/test_anacronjobs.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/plugins/os/unix/test_anacronjobs.py b/tests/plugins/os/unix/test_anacronjobs.py index ecca358b11..39237f595e 100644 --- a/tests/plugins/os/unix/test_anacronjobs.py +++ b/tests/plugins/os/unix/test_anacronjobs.py @@ -75,6 +75,8 @@ def test_unix_anacrontab(target_unix_users: Target, fs_unix: VirtualFilesystem) assert anacronjob_records[0].job_identify == "cron.daily" assert anacronjob_records[0].command == "nice run-parts /etc/cron.daily" assert anacronjob_records[0].ts_last_exec == dt("2026-02-06 00:00:00+00:00") + assert anacronjob_records[0].source == "/etc/anacrontab" + assert anacronjob_records[0].hostname == "localhost" assert anacronjob_records[1].period_name == "1" assert anacronjob_records[1].delay_in_minutes == 5 @@ -82,6 +84,8 @@ def test_unix_anacrontab(target_unix_users: Target, fs_unix: VirtualFilesystem) assert anacronjob_records[1].command == "/etc/cron.daily/logrotate" # If ts associated to timestamps files are not consistent with date in file, fallback to file content date assert anacronjob_records[1].ts_last_exec == dt("2026-02-06 00:00:00+00:00") + assert anacronjob_records[1].source == "/etc/anacrontab" + assert anacronjob_records[1].hostname == "localhost" assert anacronjob_records[2].period_name == "7" assert anacronjob_records[2].delay_in_minutes == 0 @@ -96,8 +100,11 @@ def test_unix_anacrontab(target_unix_users: Target, fs_unix: VirtualFilesystem) assert anacronjob_records[3].ts_last_exec is None + # Test env variable assert environmentvariable_records[0].key == "SHELL" assert environmentvariable_records[0].value == "/bin/sh" + assert environmentvariable_records[0].source == "/etc/anacrontab" + assert environmentvariable_records[0].hostname == "localhost" assert environmentvariable_records[1].key == "PATH" assert environmentvariable_records[1].value == "/sbin:/bin:/usr/sbin:/usr/bin"