Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Unix and Windows application plugins #851

Merged
merged 19 commits into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions dissect/target/helpers/record.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
11 changes: 10 additions & 1 deletion dissect/target/helpers/regutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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()]
Expand Down
78 changes: 78 additions & 0 deletions dissect/target/plugins/os/unix/applications.py
Original file line number Diff line number Diff line change
@@ -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")

Check warning on line 43 in dissect/target/plugins/os/unix/applications.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/os/unix/applications.py#L43

Added line #L43 was not covered by tests

@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,
)
79 changes: 79 additions & 0 deletions dissect/target/plugins/os/unix/linux/debian/snap.py
Original file line number Diff line number Diff line change
@@ -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")

Check warning on line 25 in dissect/target/plugins/os/unix/linux/debian/snap.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/os/unix/linux/debian/snap.py#L25

Added line #L25 was not covered by tests

if not self.installs:
raise UnsupportedPluginError("No snapd install folder(s) found")

Check warning on line 28 in dissect/target/plugins/os/unix/linux/debian/snap.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/os/unix/linux/debian/snap.py#L28

Added line #L28 was not covered by tests

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

Check warning on line 65 in dissect/target/plugins/os/unix/linux/debian/snap.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/os/unix/linux/debian/snap.py#L62-L65

Added lines #L62 - L65 were not covered by tests

if not (meta := squashfs.path("meta/snap.yaml")).exists():
self.target.log.warning("Snap %s has no meta/snap.yaml file")
continue

Check warning on line 69 in dissect/target/plugins/os/unix/linux/debian/snap.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/os/unix/linux/debian/snap.py#L68-L69

Added lines #L68 - L69 were not covered by tests

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,
)
2 changes: 1 addition & 1 deletion dissect/target/plugins/os/unix/packagemanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from dissect.target.plugin import NamespacePlugin

PackageManagerLogRecord = TargetRecordDescriptor(
"unix/log/packagemanager",
"unix/packagemanager/log",
[
("datetime", "ts"),
("string", "package_manager"),
Expand Down
62 changes: 62 additions & 0 deletions dissect/target/plugins/os/windows/regf/applications.py
Original file line number Diff line number Diff line change
@@ -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")

Check warning on line 19 in dissect/target/plugins/os/windows/regf/applications.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/os/windows/regf/applications.py#L19

Added line #L19 was not covered by tests

if not self.keys:
raise UnsupportedPluginError("No 'Uninstall' registry keys found")

Check warning on line 22 in dissect/target/plugins/os/windows/regf/applications.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/os/windows/regf/applications.py#L22

Added line #L22 was not covered by tests

@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,
)
3 changes: 3 additions & 0 deletions tests/_data/plugins/os/unix/applications/code_code.desktop
Git LFS file not shown
Git LFS file not shown
3 changes: 3 additions & 0 deletions tests/_data/plugins/os/unix/applications/gimp.desktop
Git LFS file not shown
3 changes: 3 additions & 0 deletions tests/_data/plugins/os/unix/applications/python.desktop
Git LFS file not shown
3 changes: 3 additions & 0 deletions tests/_data/plugins/os/unix/applications/terminal.desktop
Git LFS file not shown
3 changes: 3 additions & 0 deletions tests/_data/plugins/os/unix/applications/vlc.desktop
Git LFS file not shown
Git LFS file not shown
3 changes: 3 additions & 0 deletions tests/_data/plugins/os/unix/linux/debian/snap/firefox.snap
Git LFS file not shown
38 changes: 38 additions & 0 deletions tests/plugins/os/unix/linux/debian/test_snap.py
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading