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 plugin to parse recentlyused.xbel files from Linux desktops #715

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
141 changes: 141 additions & 0 deletions dissect/target/plugins/os/unix/linux/recentlyused.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
from datetime import datetime
from typing import Iterator, Union

from defusedxml import ElementTree
from flow.record import GroupedRecord

from dissect.target.exceptions import UnsupportedPluginError
from dissect.target.helpers.record import TargetRecordDescriptor
from dissect.target.plugin import Plugin, export

RecentlyUsedRecord = TargetRecordDescriptor(
"unix/linux/recentlyused",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"unix/linux/recentlyused",
"unix/linux/recently_used",

[
("datetime", "ts"),
("string", "user"),
("string", "source"),
("string", "href"),
("datetime", "added"),
("datetime", "modified"),
("datetime", "visited"),
("string", "mimetype"),
("string", "groups"),
("boolean", "private"),
],
)

RecentlyUsedIconRecord = TargetRecordDescriptor(
"unix/linux/recentlyusedicon",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"unix/linux/recentlyusedicon",
"unix/linux/recently_used_icon",

[
("string", "type"),
("string", "href"),
("string", "name"),
],
)

RecentlyUsedApplicationRecord = TargetRecordDescriptor(
"unix/linux/recentlyusedapplication",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"unix/linux/recentlyusedapplication",
"unix/linux/recently_used_application",

[
("datetime", "ts"),
("string", "name"),
("string", "exec"),
("varint", "count"),
],
)
ns = {
"bookmark": "http://www.freedesktop.org/standards/desktop-bookmarks",
"mime": "http://www.freedesktop.org/standards/shared-mime-info",
}


def parse_ts(ts):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you add typehints?

"""Parse timestamp format from xbel file"""
# datetime.fromisoformat() doesn´t support the trailing Z in python <= 3.10
return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%S.%fZ")


def parse_recentlyused_xbel(username, xbel_file):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def parse_recentlyused_xbel(username, xbel_file):
def parse_recently_used_xbel(username, xbel_file):

could you also add typehints?

with xbel_file.open() as fh:
et = ElementTree.fromstring(fh.read(), forbid_dtd=True)
for b in et.iter("bookmark"):
# The spec says there should always be exactly one.
# Ignore if there are fewer or more.
mimetypes = b.findall("./info/metadata/mime:mime-type", ns)
if mimetypes and len(mimetypes) == 1:
mimetype = mimetypes[0].get("type")
else:
mimetype = None

# This is just a list of names, GroupedRecords seem overkill
groups = b.findall("./info/metadata/bookmark:groups/bookmark:group", ns)
group_list = ", ".join(group.text for group in groups)

# There should be at most one "private" tag, but accept multiple
private_entries = b.findall("./info/metadata/bookmark:private", ns)
private = private_entries is not None and len(private_entries) > 0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe b.findall always returns a list, so the none check here is redundant.


cur = RecentlyUsedRecord(
ts=parse_ts(b.get("visited")),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are the timestamp fields always available? cause if they are None the plugin will crash

user=username,
source=xbel_file,
href=b.get("href"),
added=parse_ts(b.get("added")),
modified=parse_ts(b.get("modified")),
visited=parse_ts(b.get("visited")),
mimetype=mimetype,
groups=group_list,
private=private,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private=private,
private=private,
_target=self.target,

The TargetRecordDescriptor provides a field named _target this will cause the hostname and domain fields to get filled.

That being said, I don't think RecentlyUsedIconRecord and RecentlyUsedApplicationRecord need to be a TargetRecordDescriptor as it would only provide redundant information

)
yield cur

# Icon is optional, spec says at most one.
icons = b.findall("./info/metadata/bookmark:icon", ns)
if icons and len(icons) >= 1:
icon = icons[0]
iconrecord = RecentlyUsedIconRecord(
type=icon.get("type"),
href=icon.get("href"),
name=icon.get("name"),
)
yield GroupedRecord("unix/linux/recentlyused/grouped", [cur, iconrecord])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
yield GroupedRecord("unix/linux/recentlyused/grouped", [cur, iconrecord])
yield GroupedRecord("unix/linux/recently_used/grouped", [cur, iconrecord])


# Spec says there should be at least one application
apps = b.findall("./info/metadata/bookmark:applications/bookmark:application", ns)
for app in apps:
apprecord = RecentlyUsedApplicationRecord(
ts=parse_ts(app.get("modified")),
name=app.get("name"),
exec=app.get("exec"),
count=app.get("count"),
)
yield GroupedRecord("unix/linux/recentlyused/grouped", [cur, apprecord])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
yield GroupedRecord("unix/linux/recentlyused/grouped", [cur, apprecord])
yield GroupedRecord("unix/linux/recently_used/grouped", [cur, apprecord])



class RecentlyUsedPlugin(Plugin):
"""Parse recently-used.xbel files on Gnome-based Linux Desktops.

Based on the spec on https://www.freedesktop.org/wiki/Specifications/desktop-bookmark-spec/
"""

FILEPATH = ".local/share/recently-used.xbel"

def __init__(self, target):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you add typehints?

super().__init__(target)
self.users_files = []
for user_details in self.target.user_details.all_with_home():
xbel_file = user_details.home_path.joinpath(self.FILEPATH)
if not xbel_file.exists():
continue
self.users_files.append((user_details.user, xbel_file))

def check_compatible(self) -> None:
if not len(self.users_files):
raise UnsupportedPluginError("No recently-used.xbel files found")

@export(record=RecentlyUsedRecord)
def recentlyused(self) -> Iterator[Union[RecentlyUsedRecord, GroupedRecord]]:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def recentlyused(self) -> Iterator[Union[RecentlyUsedRecord, GroupedRecord]]:
def recently_used(self) -> Iterator[Union[RecentlyUsedRecord, GroupedRecord]]:

"""Parse recently-used.xbel files on Linux Desktops."""

for user, xbel_file in self.users_files:
for record in parse_recentlyused_xbel(user.name, xbel_file):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
for record in parse_recentlyused_xbel(user.name, xbel_file):
for record in parse_recently_used_xbel(user.name, xbel_file):

yield record
3 changes: 3 additions & 0 deletions tests/_data/plugins/os/unix/linux/recently-used.xbel
Git LFS file not shown
22 changes: 22 additions & 0 deletions tests/plugins/os/unix/linux/test_recentlyused.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from flow.record.fieldtypes import datetime as dt

from dissect.target.filesystem import VirtualFilesystem
from dissect.target.plugins.os.unix.linux.recentlyused import RecentlyUsedPlugin
from dissect.target.target import Target
from tests._utils import absolute_path


def test_recentlyused(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None:
data_file = absolute_path("_data/plugins/os/unix/linux/recently-used.xbel")
fs_unix.map_file("/home/user/.local/share/recently-used.xbel", data_file)
target_unix_users.add_plugin(RecentlyUsedPlugin)

results = list(target_unix_users.recentlyused())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
results = list(target_unix_users.recentlyused())
results = list(target_unix_users.recently_used())

assert len(results) == 15
assert results[0].user == "user"
assert results[0].href == "file:///home/sjaak/.profile"
assert results[0].ts == dt("2023-10-18 13:12:41.905277Z")
assert results[0].added == dt("2023-10-18 13:12:41.905276Z")
assert results[0].modified == dt("2023-10-18 13:14:09.483576Z")
assert results[0].visited == dt("2023-10-18 13:12:41.905277Z")
assert results[0].mimetype == "text/plain"