diff --git a/dissect/target/helpers/record.py b/dissect/target/helpers/record.py index 3cf1eee1f..c680866cd 100644 --- a/dissect/target/helpers/record.py +++ b/dissect/target/helpers/record.py @@ -185,3 +185,24 @@ def DynamicDescriptor(types): # noqa ("boolean", "dhcp"), ], ) + + +COMMON_APPLICATION_FIELDS = [ + ("datetime", "ts_modified"), + ("datetime", "ts_installed"), + ("string", "name"), + ("string", "version"), + ("string", "author"), + ("string", "type"), + ("path", "path"), +] + +UnixApplicationRecord = TargetRecordDescriptor( + "unix/application", + COMMON_APPLICATION_FIELDS, +) + +WindowsApplicationRecord = TargetRecordDescriptor( + "windows/application", + COMMON_APPLICATION_FIELDS, +) diff --git a/dissect/target/helpers/regutil.py b/dissect/target/helpers/regutil.py index 048767208..24dbf1beb 100644 --- a/dissect/target/helpers/regutil.py +++ b/dissect/target/helpers/regutil.py @@ -310,6 +310,7 @@ def __init__(self, hive: RegistryHive, path: str, class_name: Optional[str] = No self._class_name = class_name self._values: dict[str, RegistryValue] = {} self._subkeys: dict[str, RegistryKey] = {} + self._timestamp: datetime = None self.top: RegistryKey = None super().__init__(hive=hive) @@ -339,11 +340,19 @@ def path(self) -> str: return self._path @property - def timestamp(self) -> datetime: + def timestamp(self) -> datetime | None: if self.top: return self.top.timestamp + + if self._timestamp: + return self._timestamp + return None + @timestamp.setter + def timestamp(self, ts: datetime) -> None: + self._timestamp = ts + def subkey(self, subkey: str) -> RegistryKey: try: return self._subkeys[subkey.lower()] diff --git a/dissect/target/plugins/os/unix/applications.py b/dissect/target/plugins/os/unix/applications.py new file mode 100644 index 000000000..b1331110a --- /dev/null +++ b/dissect/target/plugins/os/unix/applications.py @@ -0,0 +1,78 @@ +from typing import Iterator + +from dissect.target.exceptions import UnsupportedPluginError +from dissect.target.helpers import configutil +from dissect.target.helpers.fsutil import TargetPath +from dissect.target.helpers.record import UnixApplicationRecord +from dissect.target.plugin import Plugin, export +from dissect.target.target import Target + + +class UnixApplicationsPlugin(Plugin): + """Unix Applications plugin.""" + + SYSTEM_PATHS = [ + "/usr/share/applications/", + "/usr/local/share/applications/", + "/var/lib/snapd/desktop/applications/", + "/var/lib/flatpak/exports/share/applications/", + ] + + USER_PATHS = [ + ".local/share/applications/", + ] + + SYSTEM_APPS = ("org.gnome.",) + + def __init__(self, target: Target): + super().__init__(target) + self.desktop_files = list(self._find_desktop_files()) + + def _find_desktop_files(self) -> Iterator[TargetPath]: + for dir in self.SYSTEM_PATHS: + for file in self.target.fs.path(dir).glob("*.desktop"): + yield file + + for user_details in self.target.user_details.all_with_home(): + for dir in self.USER_PATHS: + for file in user_details.home_path.joinpath(dir).glob("*.desktop"): + yield file + + def check_compatible(self) -> None: + if not self.desktop_files: + raise UnsupportedPluginError("No application .desktop files found") + + @export(record=UnixApplicationRecord) + def applications(self) -> Iterator[UnixApplicationRecord]: + """Yield installed Unix GUI applications from GNOME and XFCE. + + Resources: + - https://wiki.archlinux.org/title/Desktop_entries + - https://specifications.freedesktop.org/desktop-entry-spec/latest/ + - https://unix.stackexchange.com/questions/582928/where-gnome-apps-are-installed + + Yields ``UnixApplicationRecord`` records with the following fields: + + .. code-block:: text + + ts_modified (datetime): timestamp when the installation was modified + ts_installed (datetime): timestamp when the application was installed on the system + name (string): name of the application + version (string): version of the application + author (string): author of the application + type (string): type of the application, either user or system + path (string): path to the desktop file entry of the application + """ + for file in self.desktop_files: + config = configutil.parse(file, hint="ini").get("Desktop Entry") or {} + stat = file.lstat() + + yield UnixApplicationRecord( + ts_modified=stat.st_mtime, + ts_installed=stat.st_btime if hasattr(stat, "st_btime") else None, + name=config.get("Name"), + version=config.get("Version"), + path=config.get("Exec"), + type="system" if config.get("Icon", "").startswith(self.SYSTEM_APPS) else "user", + _target=self.target, + ) diff --git a/dissect/target/plugins/os/unix/linux/debian/snap.py b/dissect/target/plugins/os/unix/linux/debian/snap.py new file mode 100644 index 000000000..76a7de702 --- /dev/null +++ b/dissect/target/plugins/os/unix/linux/debian/snap.py @@ -0,0 +1,79 @@ +from typing import Iterator + +from dissect.target.exceptions import UnsupportedPluginError +from dissect.target.filesystems.squashfs import SquashFSFilesystem +from dissect.target.helpers import configutil +from dissect.target.helpers.fsutil import TargetPath +from dissect.target.helpers.record import UnixApplicationRecord +from dissect.target.plugin import Plugin, alias, export +from dissect.target.target import Target + + +class SnapPlugin(Plugin): + """Canonical Linux Snapcraft plugin.""" + + PATHS = [ + "/var/lib/snapd/snaps", + ] + + def __init__(self, target: Target): + super().__init__(target) + self.installs = list(self._find_installs()) + + def check_compatible(self) -> None: + if not configutil.HAS_YAML: + raise UnsupportedPluginError("Missing required dependency ruamel.yaml") + + if not self.installs: + raise UnsupportedPluginError("No snapd install folder(s) found") + + def _find_installs(self) -> Iterator[TargetPath]: + for str_path in self.PATHS: + if (path := self.target.fs.path(str_path)).exists(): + yield path + + @export(record=UnixApplicationRecord) + @alias("snaps") + def snap(self) -> Iterator[UnixApplicationRecord]: + """Yields installed Canonical Linux Snapcraft (snaps) applications on the target system. + + Reads information from installed SquashFS ``*.snap`` files found in ``/var/lib/snapd/snaps``. + Logs of the ``snapd`` daemon can be parsed using the ``journal`` or ``syslog`` plugins. + + Resources: + - https://github.com/canonical/snapcraft + - https://en.wikipedia.org/wiki/Snap_(software) + + Yields ``UnixApplicationRecord`` records with the following fields: + + .. code-block:: text + + ts_modified (datetime): timestamp when the installation was modified + name (string): name of the application + version (string): version of the application + path (string): path to the application snap file + """ + + for install_path in self.installs: + for snap in install_path.glob("*.snap"): + try: + squashfs = SquashFSFilesystem(snap.open()) + + except (ValueError, NotImplementedError) as e: + self.target.log.warning("Unable to open snap file %s", snap) + self.target.log.debug("", exc_info=e) + continue + + if not (meta := squashfs.path("meta/snap.yaml")).exists(): + self.target.log.warning("Snap %s has no meta/snap.yaml file") + continue + + meta_data = configutil.parse(meta, hint="yaml") + + yield UnixApplicationRecord( + ts_modified=meta.lstat().st_mtime, + name=meta_data.get("name"), + version=meta_data.get("version"), + path=snap, + _target=self.target, + ) diff --git a/dissect/target/plugins/os/unix/packagemanager.py b/dissect/target/plugins/os/unix/packagemanager.py index 6cc0058eb..e29554cee 100644 --- a/dissect/target/plugins/os/unix/packagemanager.py +++ b/dissect/target/plugins/os/unix/packagemanager.py @@ -6,7 +6,7 @@ from dissect.target.plugin import NamespacePlugin PackageManagerLogRecord = TargetRecordDescriptor( - "unix/log/packagemanager", + "unix/packagemanager/log", [ ("datetime", "ts"), ("string", "package_manager"), diff --git a/dissect/target/plugins/os/windows/regf/applications.py b/dissect/target/plugins/os/windows/regf/applications.py new file mode 100644 index 000000000..abcb1444c --- /dev/null +++ b/dissect/target/plugins/os/windows/regf/applications.py @@ -0,0 +1,62 @@ +from datetime import datetime +from typing import Iterator + +from dissect.target.exceptions import UnsupportedPluginError +from dissect.target.helpers.record import WindowsApplicationRecord +from dissect.target.plugin import Plugin, export +from dissect.target.target import Target + + +class WindowsApplicationsPlugin(Plugin): + """Windows Applications plugin.""" + + def __init__(self, target: Target): + super().__init__(target) + self.keys = list(self.target.registry.keys("HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall")) + + def check_compatible(self) -> None: + if not self.target.has_function("registry"): + raise UnsupportedPluginError("No Windows registry found") + + if not self.keys: + raise UnsupportedPluginError("No 'Uninstall' registry keys found") + + @export(record=WindowsApplicationRecord) + def applications(self) -> Iterator[WindowsApplicationRecord]: + """Yields currently installed applications from the Windows registry. + + Use the Windows eventlog plugin (``evtx``, ``evt``) to parse install and uninstall events + of applications and services (e.g. ``4697``, ``110707``, ``1034`` and ``11724``). + + Resources: + - https://learn.microsoft.com/en-us/windows/win32/msi/uninstall-registry-key + + Yields ``WindowsApplicationRecord`` records with the following fields: + + .. code-block:: text + + ts_modified (datetime): timestamp when the installation was modified according to the registry + ts_installed (datetime): timestamp when the application was installed according to the application + name (string): name of the application + version (string): version of the application + author (string): author of the application + type (string): type of the application, either user or system + path (string): path to the installed location or installer of the application + """ + for uninstall in self.keys: + for app in uninstall.subkeys(): + values = {value.name: value.value for value in app.values()} + + if install_date := values.get("InstallDate"): + install_date = datetime.strptime(install_date, "%Y%m%d") + + yield WindowsApplicationRecord( + ts_modified=app.ts, + ts_installed=install_date, + name=values.get("DisplayName") or app.name, + version=values.get("DisplayVersion"), + author=values.get("Publisher"), + type="system" if values.get("SystemComponent") or not values else "user", + path=values.get("DisplayIcon") or values.get("InstallLocation") or values.get("InstallSource"), + _target=self.target, + ) diff --git a/tests/_data/plugins/os/unix/applications/code_code.desktop b/tests/_data/plugins/os/unix/applications/code_code.desktop new file mode 100644 index 000000000..78a27aba7 --- /dev/null +++ b/tests/_data/plugins/os/unix/applications/code_code.desktop @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:adbb1b293e5cad29e494937d82762b8838244ec55df73917441b958859ece358 +size 972 diff --git a/tests/_data/plugins/os/unix/applications/firefox_firefox.desktop b/tests/_data/plugins/os/unix/applications/firefox_firefox.desktop new file mode 100644 index 000000000..ac5639cba --- /dev/null +++ b/tests/_data/plugins/os/unix/applications/firefox_firefox.desktop @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1048d67b7e83577a2fff2239399dcff5c0311685ada454cd7defb800d1f1dc12 +size 9461 diff --git a/tests/_data/plugins/os/unix/applications/gimp.desktop b/tests/_data/plugins/os/unix/applications/gimp.desktop new file mode 100644 index 000000000..6c2813880 --- /dev/null +++ b/tests/_data/plugins/os/unix/applications/gimp.desktop @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f77e0c692ed6f090de885e10b9c3f8b94b77fb9a8eb30b95fa019d0b0210e247 +size 15373 diff --git a/tests/_data/plugins/os/unix/applications/python.desktop b/tests/_data/plugins/os/unix/applications/python.desktop new file mode 100644 index 000000000..958029014 --- /dev/null +++ b/tests/_data/plugins/os/unix/applications/python.desktop @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc30300f81cb89c4f1d5c3575f539cd4c74293c4752e0d9724f7f359389d31ed +size 224 diff --git a/tests/_data/plugins/os/unix/applications/terminal.desktop b/tests/_data/plugins/os/unix/applications/terminal.desktop new file mode 100644 index 000000000..05750997e --- /dev/null +++ b/tests/_data/plugins/os/unix/applications/terminal.desktop @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a9caa775a1d5ae964e7c860c22de470a7d2b62edccb8ad1c7cacd88a1b1f3c20 +size 571 diff --git a/tests/_data/plugins/os/unix/applications/vlc.desktop b/tests/_data/plugins/os/unix/applications/vlc.desktop new file mode 100644 index 000000000..66ea7b8ea --- /dev/null +++ b/tests/_data/plugins/os/unix/applications/vlc.desktop @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce6aea28e742fb482a755a3e2aa9bc033cbc6136716ddad615454e7ccdd561fa +size 14904 diff --git a/tests/_data/plugins/os/unix/applications/vmware-workstation.desktop b/tests/_data/plugins/os/unix/applications/vmware-workstation.desktop new file mode 100644 index 000000000..6e9eceda2 --- /dev/null +++ b/tests/_data/plugins/os/unix/applications/vmware-workstation.desktop @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:05fc84c9998faf664bb08c57e4183604bf1d604d9a2cfaad8332a8a949eba2c5 +size 323 diff --git a/tests/_data/plugins/os/unix/linux/debian/snap/firefox.snap b/tests/_data/plugins/os/unix/linux/debian/snap/firefox.snap new file mode 100644 index 000000000..cfb8c6434 --- /dev/null +++ b/tests/_data/plugins/os/unix/linux/debian/snap/firefox.snap @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c2f63e48b7e4a064a993c6a9e82d6e26e1b024192e20017706227e6e054341ac +size 20480 diff --git a/tests/plugins/os/unix/linux/debian/test_snap.py b/tests/plugins/os/unix/linux/debian/test_snap.py new file mode 100644 index 000000000..2ae67e45e --- /dev/null +++ b/tests/plugins/os/unix/linux/debian/test_snap.py @@ -0,0 +1,38 @@ +from datetime import datetime, timezone +from io import BytesIO + +from dissect.target.filesystem import VirtualFilesystem +from dissect.target.plugins.os.unix._os import UnixPlugin +from dissect.target.plugins.os.unix.linux.debian.snap import SnapPlugin +from dissect.target.target import Target +from tests._utils import absolute_path + + +def test_snap_packages(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None: + """test if snap packages are discovered on unix systems""" + + fs_unix.map_file_fh("/etc/hostname", BytesIO(b"hostname")) + fs_unix.map_file( + "/var/lib/snapd/snaps/firefox_12345.snap", + absolute_path("_data/plugins/os/unix/linux/debian/snap/firefox.snap"), + ) + fs_unix.map_file( + "/var/lib/snapd/snaps/firefox_67890.snap", + absolute_path("_data/plugins/os/unix/linux/debian/snap/firefox.snap"), + ) + + target_unix_users.add_plugin(UnixPlugin) + target_unix_users.add_plugin(SnapPlugin) + + assert target_unix_users.has_function("snap") + + results = list(target_unix_users.snaps()) + assert len(results) == 2 + + assert results[0].hostname == "hostname" + assert results[0].ts_modified == datetime(2024, 9, 17, 13, 18, 58, tzinfo=timezone.utc) + assert results[0].name == "firefox" + assert results[0].version == "129.0.2-1" + assert results[0].author is None + assert results[0].type is None + assert results[0].path == "/var/lib/snapd/snaps/firefox_12345.snap" diff --git a/tests/plugins/os/unix/test_applications.py b/tests/plugins/os/unix/test_applications.py new file mode 100644 index 000000000..b50f545d6 --- /dev/null +++ b/tests/plugins/os/unix/test_applications.py @@ -0,0 +1,92 @@ +from io import BytesIO + +from dissect.target.filesystem import VirtualFilesystem +from dissect.target.plugins.os.unix._os import UnixPlugin +from dissect.target.plugins.os.unix.applications import UnixApplicationsPlugin +from dissect.target.target import Target +from tests._utils import absolute_path + + +def test_unix_applications_desktop_files(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None: + """test if .desktop files registering installed applications are detected correctly""" + + fs_unix.map_file_fh("/etc/hostname", BytesIO(b"hostname")) + + # system paths + fs_unix.map_file( + "/var/lib/snapd/desktop/applications/firefox_firefox.desktop", + absolute_path("_data/plugins/os/unix/applications/firefox_firefox.desktop"), + ) + fs_unix.map_file( + "/var/lib/snapd/desktop/applications/code_code.desktop", + absolute_path("_data/plugins/os/unix/applications/code_code.desktop"), + ) + fs_unix.map_file( + "/usr/share/applications/gimp.desktop", + absolute_path("_data/plugins/os/unix/applications/gimp.desktop"), + ) + fs_unix.map_file( + "/usr/local/share/applications/vmware-workstation.desktop", + absolute_path("_data/plugins/os/unix/applications/vmware-workstation.desktop"), + ) + fs_unix.map_file( + "/var/lib/flatpak/exports/share/applications/python.desktop", + absolute_path("_data/plugins/os/unix/applications/python.desktop"), + ) + + # user paths + fs_unix.map_file( + "/home/user/.local/share/applications/vlc.desktop", + absolute_path("_data/plugins/os/unix/applications/vlc.desktop"), + ) + fs_unix.map_file( + "/root/.local/share/applications/terminal.desktop", + absolute_path("_data/plugins/os/unix/applications/terminal.desktop"), + ) + + target_unix_users.add_plugin(UnixPlugin) + target_unix_users.add_plugin(UnixApplicationsPlugin) + results = sorted(list(target_unix_users.applications()), key=lambda r: r.name) + + assert len(results) == 7 + + assert results[0].ts_installed is None + assert results[0].name == "Firefox Web Browser" + assert results[0].version == "1.0" + assert results[0].author is None + assert results[0].type == "user" + assert ( + results[0].path + == "env BAMF_DESKTOP_FILE_HINT=/var/lib/snapd/desktop/applications/firefox_firefox.desktop /snap/bin/firefox %u" + ) # noqa: E501 + assert results[0].hostname == "hostname" + + assert [r.name for r in results] == [ + "Firefox Web Browser", + "GNU Image Manipulation Program", + "Python (v3.12)", + "Terminal", + "VLC media player", + "VMware Workstation", + "Visual Studio Code", + ] + + assert [r.path for r in results] == [ + "env BAMF_DESKTOP_FILE_HINT=/var/lib/snapd/desktop/applications/firefox_firefox.desktop /snap/bin/firefox %u", + "gimp-2.10 %U", + "/usr/bin/python3.12", + "gnome-terminal", + "/usr/bin/vlc --started-from-file %U", + "/usr/bin/vmware %U", + "env BAMF_DESKTOP_FILE_HINT=/var/lib/snapd/desktop/applications/code_code.desktop /snap/bin/code --force-user-env %F", # noqa: E501 + ] + + assert [r.type for r in results] == [ + "user", + "user", + "user", + "system", + "user", + "user", + "user", + ] diff --git a/tests/plugins/os/windows/regf/test_applications.py b/tests/plugins/os/windows/regf/test_applications.py new file mode 100644 index 000000000..1150a76cd --- /dev/null +++ b/tests/plugins/os/windows/regf/test_applications.py @@ -0,0 +1,99 @@ +from datetime import datetime, timezone + +from dissect.target.helpers.regutil import VirtualHive, VirtualKey +from dissect.target.plugins.os.windows.regf.applications import ( + WindowsApplicationsPlugin, +) +from dissect.target.target import Target + + +def test_windows_applications(target_win_users: Target, hive_hklm: VirtualHive) -> None: + """test if windows applications are detected correctly in the registry""" + + firefox_name = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Mozilla Firefox 123.0.1 (x64 nl)" + firefox_key = VirtualKey(hive_hklm, firefox_name) + firefox_key.add_value("Comments", "Mozilla Firefox 123.0.1 (x64 nl)") + firefox_key.add_value("DisplayIcon", "C:\\Program Files\\Mozilla Firefox\\firefox.exe,0") + firefox_key.add_value("DisplayName", "Mozilla Firefox (x64 nl)") + firefox_key.add_value("DisplayVersion", "123.0.1") + firefox_key.add_value("EstimatedSize", 238271) + firefox_key.add_value("HelpLink", "https://support.mozilla.org") + firefox_key.add_value("InstallLocation", "C:\\Program Files\\Mozilla Firefox") + firefox_key.add_value("NoModify", 1) + firefox_key.add_value("NoRepair", 1) + firefox_key.add_value("Publisher", "Mozilla") + firefox_key.add_value("URLInfoAbout", "https://www.mozilla.org") + firefox_key.add_value("URLUpdateInfo", "https://www.mozilla.org/firefox/123.0.1/releasenotes") + firefox_key.add_value("UninstallString", '"C:\\Program Files\\Mozilla Firefox\\uninstall\\helper.exe"') + hive_hklm.map_key(firefox_name, firefox_key) + + chrome_name = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{47FB91DD-98F3-3C87-A963-357B14EAC7C9}" + chrome_key = VirtualKey(hive_hklm, chrome_name) + chrome_key.add_value("DisplayVersion", "122.0.6261.95") + chrome_key.add_value("InstallDate", "20240301") + chrome_key.add_value("InstallLocation", "") + chrome_key.add_value("InstallSource", "C:\\Users\\user\\Desktop\\GoogleChromeEnterpriseBundle64\\Installers\\") + chrome_key.add_value("ModifyPath", "MsiExec.exe /X{47FB91DD-98F3-3C87-A963-357B14EAC7C9}") + chrome_key.add_value("NoModify", 1) + chrome_key.add_value("Publisher", "Google LLC") + chrome_key.add_value("EstimatedSize", 113725) + chrome_key.add_value("UninstallString", "MsiExec.exe /X{47FB91DD-98F3-3C87-A963-357B14EAC7C9}") + chrome_key.add_value("VersionMajor", 70) + chrome_key.add_value("VersionMinor", 29) + chrome_key.add_value("WindowsInstaller", 1) + chrome_key.add_value("Version", 1176322143) + chrome_key.add_value("Language", 1033) + chrome_key.add_value("DisplayName", "Google Chrome") + hive_hklm.map_key(chrome_name, chrome_key) + + addressbook_name = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\AddressBook" + addressbook_key = VirtualKey(hive_hklm, addressbook_name) + addressbook_key.timestamp = datetime(2024, 12, 31, 13, 37, 0, tzinfo=timezone.utc) + hive_hklm.map_key(addressbook_name, addressbook_key) + + msvc_name = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{D5D19E2F-7189-42FE-8103-92CD1FA457C2}" + msvc_key = VirtualKey(hive_hklm, msvc_name) + msvc_key.add_value("DisplayName", "Microsoft Visual C++ 2022 X64 Minimum Runtime - 14.36.32532") + msvc_key.add_value("InstallDate", "20240301") + msvc_key.add_value("DisplayVersion", "14.36.32532") + msvc_key.add_value("Publisher", "Microsoft Corporation") + msvc_key.add_value( + "InstallSource", + "C:\\ProgramData\\Package Cache\\{D5D19E2F-7189-42FE-8103-92CD1FA457C2}v14.36.32532\\packages\\vcRuntimeMinimum_amd64\\", # noqa: E501 + ) + msvc_key.add_value("SystemComponent", 1) + hive_hklm.map_key(msvc_name, msvc_key) + + target_win_users.add_plugin(WindowsApplicationsPlugin) + results = sorted(list(target_win_users.applications()), key=lambda r: r.name) + + assert len(results) == 4 + + assert results[0].ts_installed is None + assert results[0].ts_modified == datetime(2024, 12, 31, 13, 37, 0, tzinfo=timezone.utc) + assert results[0].name == "AddressBook" + assert results[0].type == "system" + + assert results[1].ts_installed == datetime(2024, 3, 1, 0, 0, 0, tzinfo=timezone.utc) + assert results[1].name == "Google Chrome" + assert results[1].version == "122.0.6261.95" + assert results[1].author == "Google LLC" + assert results[1].type == "user" + assert results[1].path == "C:\\Users\\user\\Desktop\\GoogleChromeEnterpriseBundle64\\Installers\\" + + assert results[2].ts_installed == datetime(2024, 3, 1, 0, 0, 0, tzinfo=timezone.utc) + assert results[2].name == "Microsoft Visual C++ 2022 X64 Minimum Runtime - 14.36.32532" + assert results[2].version == "14.36.32532" + assert results[2].author == "Microsoft Corporation" + assert results[2].type == "system" + assert ( + results[2].path + == "C:\\ProgramData\\Package Cache\\{D5D19E2F-7189-42FE-8103-92CD1FA457C2}v14.36.32532\\packages\\vcRuntimeMinimum_amd64\\" # noqa: E501 + ) + + assert results[3].ts_installed is None + assert results[3].name == "Mozilla Firefox (x64 nl)" + assert results[3].version == "123.0.1" + assert results[3].author == "Mozilla" + assert results[3].type == "user" + assert results[3].path == "C:\\Program Files\\Mozilla Firefox\\firefox.exe,0"