diff --git a/dissect/target/container.py b/dissect/target/container.py
index 6931b2d5b9..d896ef90d1 100644
--- a/dissect/target/container.py
+++ b/dissect/target/container.py
@@ -258,3 +258,4 @@ def open(item: list | str | BinaryIO | Path, *args, **kwargs) -> Container:
register("hds", "HdsContainer")
register("split", "SplitContainer")
register("fortifw", "FortiFirmwareContainer")
+register("asif", "AsifContainer")
diff --git a/dissect/target/helpers/utils.py b/dissect/target/helpers/utils.py
index b97b6e790d..919a032704 100644
--- a/dissect/target/helpers/utils.py
+++ b/dissect/target/helpers/utils.py
@@ -110,7 +110,7 @@ def readinto(buffer: bytearray, fh: BinaryIO) -> int:
def year_rollover_helper(
path: Path, re_ts: str | re.Pattern, ts_format: str, tzinfo: tzinfo = timezone.utc
-) -> Iterator[tuple[datetime, str]]:
+) -> Iterator[tuple[datetime | None, str]]:
"""Helper function for determining the correct timestamps for log files without year notation.
Supports compressed files by using :func:`open_decompress`.
@@ -142,7 +142,7 @@ def year_rollover_helper(
if not warned:
log.warning("No timestamp found in one of the lines in %s!", path)
warned = True
- log.debug("Skipping line: %s", line)
+ yield None, line
continue
# We have to append the current_year to strptime instead of adding it using replace later.
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/airport_preferences.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/airport_preferences.py
new file mode 100644
index 0000000000..d7ae42ae4b
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/airport_preferences.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+import plistlib
+from typing import TYPE_CHECKING
+
+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 dissect.target import Target
+
+AirportPreferencesRecord = TargetRecordDescriptor(
+ "macos/airport_preferences",
+ [
+ ("varint", "counter"),
+ ("string", "device_uuid"),
+ ("string[]", "preferred_order"),
+ ("varint", "version_number"),
+ ("path", "source"),
+ ],
+)
+
+
+class AirportPreferencesPlugin(Plugin):
+ """macOS AirPort (WiFi) preferences plugin.
+
+ Contains WiFi network information.
+
+ References:
+ - https://apple.stackexchange.com/questions/301346/how-can-i-better-sort-and-set-wifi-network-preferences-on-mac
+ """
+
+ PATH = "/Library/Preferences/SystemConfiguration/com.apple.airport.preferences.plist"
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.file = self.target.fs.path(self.PATH) if self.target.fs.path(self.PATH).exists() else None
+
+ def check_compatible(self) -> None:
+ if not self.file:
+ raise UnsupportedPluginError("No com.apple.airport.preferences.plist file found")
+
+ @export(record=AirportPreferencesRecord)
+ def airport_preferences(self) -> Iterator[AirportPreferencesRecord]:
+ """Return macOS AirPort (Wi-Fi) preferences.
+
+ Yields AirportPreferencesRecord with the following fields:
+
+ .. code-block:: text
+
+ counter (varint): The Counter key of the plist.
+ device_uuid (string): UUID of the device.
+ preferred_order (string[]): Ordered list of known Wi-Fi network SSIDs.
+ version_number (varint): The version number of the plist.
+ source (path): Path to the com.apple.airport.preferences.plist file.
+ """
+ plist = plistlib.load(self.file.open())
+
+ yield AirportPreferencesRecord(
+ counter=plist.get("Counter"),
+ device_uuid=plist.get("DeviceUUID"),
+ preferred_order=plist.get("PreferredOrder"),
+ version_number=plist.get("Version"),
+ source=self.file,
+ _target=self.target,
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/at_jobs.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/at_jobs.py
new file mode 100644
index 0000000000..259bb457a2
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/at_jobs.py
@@ -0,0 +1,126 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+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 dissect.target.target import Target
+
+AtJobsRecord = TargetRecordDescriptor(
+ "macos/at_jobs",
+ [
+ ("string", "queue"),
+ ("varint", "seq"),
+ ("datetime", "execution_time"),
+ ("string", "command"),
+ ("path", "source"),
+ ],
+)
+
+
+class AtJobsPlugin(Plugin):
+ """macOS at jobs plugin.
+
+ The at utility schedules commands to be executed at a later time.
+
+ References:
+ - https://man.freebsd.org/cgi/man.cgi?query=at&sektion=1&format=html
+ - github.com/freebsd/freebsd-src/blob/main/usr.bin/at/at.c
+ """
+
+ PATHS = ("/usr/lib/cron/jobs/*",)
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.at_jobs_files = self._find_files()
+
+ def check_compatible(self) -> None:
+ if not (self.at_jobs_files):
+ raise UnsupportedPluginError("No At Jobs files found")
+
+ def _find_files(self) -> set:
+ files = set()
+
+ for pattern in self.PATHS:
+ for path in self.target.fs.glob(pattern):
+ files.add(self.target.fs.path(path))
+
+ return files
+
+ @export(record=AtJobsRecord)
+ def at_jobs(self) -> Iterator[AtJobsRecord]:
+ """Return macOS `at` job records.
+
+ Yields AtJobsRecord with the following fields:
+
+ .. code-block:: text
+
+ queue (string): Queue identifier derived from the job filename.
+ seq (varint): Sequence number derived from the job filename.
+ execution_time (datetime): Execution time derived from the job filename.
+ command (string): Command contents extracted from the job file.
+ source (path): Path to the `at` job file.
+
+ The job filename typically follows the structure:
+
+ QSSSSSTTTTTTTT
+
+ Where:
+ Q = queue identifier
+ S = sequence number (hexadecimal)
+ T = execution time (hexadecimal, in minutes)
+
+ Lines following the environment setup (typically after 'export OLDPWD') are treated
+ as the command content.
+ """
+ for file in self.at_jobs_files:
+ name = file.name
+
+ if name in (".SEQ", ".lockfile"):
+ continue
+
+ if len(name) < 6:
+ continue
+
+ queue = name[0]
+ seq = int(name[1:6], 16)
+ time_hex = name[6:]
+
+ execution_time = None
+ try:
+ minutes = int(time_hex, 16)
+ execution_time = minutes * 60
+ except ValueError:
+ pass
+
+ command_line = False
+ command = ""
+ with file.open("r") as f:
+ for line in f:
+ if command_line:
+ command += line
+ else:
+ line = line.strip()
+
+ if not line or line.startswith(("#", "export")):
+ continue
+
+ line = line.split("#", 1)[0].strip()
+
+ if "export OLDPWD" in line:
+ command_line = True
+
+ command = command.rstrip("\n")
+
+ yield AtJobsRecord(
+ queue=queue,
+ seq=seq,
+ execution_time=execution_time,
+ command=command,
+ source=file,
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/authorization_rules.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/authorization_rules.py
new file mode 100644
index 0000000000..db0185875e
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/authorization_rules.py
@@ -0,0 +1,170 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_sqlite_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target import Target
+
+AuthorizationRulesRecord = TargetRecordDescriptor(
+ "macos/authorization_rules",
+ [
+ ("string[]", "tables"),
+ ("varint", "rules_id"),
+ ("string", "rules_name"),
+ ("varint", "rules_type"),
+ ("varint", "rules_class"),
+ ("string", "rules_group"),
+ ("varint", "rules_kofn"),
+ ("varint", "rules_timeout"),
+ ("varint", "rules_flags"),
+ ("string", "rules_tries"),
+ ("varint", "rules_version"),
+ ("datetime", "rules_created"),
+ ("datetime", "rules_modified"),
+ ("string", "rules_hash"),
+ ("string", "rules_identifier"),
+ ("bytes", "rules_requirement"),
+ ("string", "rules_comment"),
+ ("string[]", "rules_delegates_map"),
+ ("datetime", "rules_history_timestamp"),
+ ("string", "rules_history_source"),
+ ("varint", "rules_history_operation"),
+ ("varint", "mechanisms_map_m_id"),
+ ("varint", "mechanisms_map_ord"),
+ ("string", "mechanisms_plugin"),
+ ("string", "mechanisms_param"),
+ ("varint", "mechanisms_privileged"),
+ ("path", "source"),
+ ],
+)
+
+ConfigTableRecord = TargetRecordDescriptor(
+ "macos/authorization_rules/config",
+ [
+ ("string", "table"),
+ ("string", "key"),
+ ("string", "value"),
+ ("path", "source"),
+ ],
+)
+
+SQLiteSequenceTableRecord = TargetRecordDescriptor(
+ "macos/authorization_rules/sqlite_sequence",
+ [
+ ("string", "table"),
+ ("string", "name"),
+ ("varint", "seq"),
+ ("path", "source"),
+ ],
+)
+
+AuthorizationRulesRecords = (
+ AuthorizationRulesRecord,
+ ConfigTableRecord,
+ SQLiteSequenceTableRecord,
+)
+
+joins = (
+ {"table1": "rules", "key1": "name", "table2": "rules_history", "key2": "rule", "join": "iterate"},
+ {"table1": "rules", "key1": "version", "table2": "rules_history", "key2": "version", "join": "ignore"},
+ {"table1": "rules", "key1": "id", "table2": "mechanisms_map", "key2": "r_id", "join": "iterate"},
+ {"table1": "mechanisms_map", "key1": "m_id", "table2": "mechanisms", "key2": "id", "join": "iterate"},
+ {"table1": "rules", "key1": "id", "table2": "delegates_map", "key2": "r_id", "join": "nested"},
+)
+
+CONVERT_TIMESTAMPS = {
+ "rules_created": "2001",
+ "rules_modified": "2001",
+}
+
+
+class AuthorizationRulesPlugin(Plugin):
+ """macOS authorization rules plugin.
+
+ The database located in /var/db/auth.db is used to store permissions to perform sensitive operations.
+
+ References:
+ - https://angelica.gitbook.io/hacktricks/macos-hardening/macos-security-and-privilege-escalation/macos-security-protections/macos-authorizations-db-and-authd
+ """
+
+ PATH = "/var/db/auth.db"
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.file = self.target.fs.path(self.PATH) if self.target.fs.path(self.PATH).exists() else None
+
+ def check_compatible(self) -> None:
+ if not self.file:
+ raise UnsupportedPluginError("No auth.db file found")
+
+ @export(record=AuthorizationRulesRecords)
+ def authorization_rules(self) -> Iterator[AuthorizationRulesRecords]:
+ """Return macOS authorization rules from the auth.db database.
+
+ The /var/db/auth.db database stores authorization rules used by macOS
+ to determine whether a client is allowed to perform privileged operations.
+
+ Yields the following record types:
+
+ .. code-block:: text
+
+ AuthorizationRulesRecord:
+ tables (string[]): Names of source tables contributing to the record.
+ rules_id (varint): Unique identifier for the rule.
+ rules_name (string): Unique name used to identify the authorization rule.
+ rules_type (varint): Rule type value.
+ rules_class (varint): Rule class defining how the rule is evaluated.
+ rules_group (string): User group associated with the rule.
+ rules_kofn (varint): "k-of-n" parameter indicating how many subrules must be satisfied.
+ rules_timeout (varint): Duration in seconds before the authorization expires.
+ rules_flags (varint): Flags modifying rule behavior.
+ rules_tries (string): Maximum number of allowed authorization attempts.
+ rules_version (varint): Version of the rule.
+ rules_created (datetime): Timestamp when the rule was created.
+ rules_modified (datetime): Timestamp of the last modification.
+ rules_hash (string): Hash value used to verify rule integrity.
+ rules_identifier (string): Identifier string used for external reference.
+ rules_requirement (bytes): Serialized data defining rule requirements and mechanisms.
+ rules_comment (string): Human-readable description of the rule.
+ rules_delegates_map (string[]): Delegate mappings for the rule.
+ rules_history_timestamp (datetime): Timestamp from the rules_history table.
+ rules_history_source (string): Source of the history entry (e.g. authd).
+ rules_history_operation (varint): Operation type recorded in rules_history.
+ mechanisms_map_m_id (varint): Mechanism identifier from mechanisms_map.
+ mechanisms_map_ord (varint): Order of the mechanism within the rule.
+ mechanisms_plugin (string): Mechanism plugin name.
+ mechanisms_param (string): Mechanism parameter value.
+ mechanisms_privileged (varint): Indicates whether the mechanism runs with privileges.
+ source (path): Path to the auth.db database file.
+
+ ConfigTableRecord:
+ table (string): Name of the source table (config).
+ key (string): Configuration key.
+ value (string): Stored value associated with the key.
+ source (path): Path to the auth.db database file.
+
+ SQLiteSequenceTableRecord:
+ table (string): Name of the source table (sqlite_sequence).
+ name (string): Name of the table for which the sequence applies.
+ seq (varint): Current autoincrement value for the table.
+ source (path): Path to the auth.db database file.
+
+ Records are constructed by joining data from the rules,
+ rules_history, mechanisms_map, mechanisms, and
+ delegates_map tables.
+
+ Multiple records may be produced for a single rule when multiple
+ mechanisms or delegate mappings exist.
+ """
+ yield from build_sqlite_records(
+ self, (self.file,), AuthorizationRulesRecords, joins, convert_timestamps=CONVERT_TIMESTAMPS
+ )
+
+ # TODO: Add prompts & buttons tables
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/call_history.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/call_history.py
new file mode 100644
index 0000000000..dc9243064d
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/call_history.py
@@ -0,0 +1,225 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import _build_userdirs
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import (
+ build_sqlite_records,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target import Target
+
+ZCallBPropertiesRecord = TargetRecordDescriptor(
+ "macos/call_history/call_db_properties_record",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("varint", "z_timer_all"),
+ ("varint", "z_timer_incoming"),
+ ("varint", "z_timer_last"),
+ ("varint", "z_timer_lifetime"),
+ ("varint", "z_timer_outgoing"),
+ ("path", "source"),
+ ],
+)
+
+ZPrimaryKeyRecord = TargetRecordDescriptor(
+ "macos/call_history/z_primary_key",
+ [
+ ("string", "table"),
+ ("varint", "z_ent"),
+ ("string", "z_name"),
+ ("varint", "z_super"),
+ ("varint", "z_max"),
+ ("path", "source"),
+ ],
+)
+
+ZMetadataRecord = TargetRecordDescriptor(
+ "macos/call_history/z_metadata",
+ [
+ ("string", "table"),
+ ("varint", "z_version"),
+ ("string", "z_uuid"),
+ ("path", "source"),
+ ],
+)
+
+ZPlistRecord = TargetRecordDescriptor(
+ "macos/call_history/z_plist",
+ [
+ ("varint", "ac_account_type_version"),
+ ("varint", "ns_auto_vacuum_level"),
+ ("varint", "ns_persistence_framework_version"),
+ ("varint", "ns_persistence_maximum_framework_version"),
+ ("string", "ns_store_model_version_checksum_key"),
+ ("string", "ns_store_model_version_hashes_digest"),
+ ("varint", "ns_store_model_version_hashes_version"),
+ ("string", "ns_store_model_version_identifiers"),
+ ("string", "ns_store_type"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+NSStoreModelVersionHashesRecord = TargetRecordDescriptor(
+ "macos/call_history/ns_store_model_version_hashes",
+ [
+ ("bytes", "call_db_properties"),
+ ("bytes", "call_record"),
+ ("bytes", "emergency_media_item"),
+ ("bytes", "handle"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+# Contains additional Z_CONTENT field which is a binary blob. This field been removed
+# from the record descriptor. The field's presence will still be mentioned in a warning.
+ZModelCacheRecord = TargetRecordDescriptor(
+ "macos/call_history/z_model_cache",
+ [
+ ("string", "table"),
+ ("path", "source"),
+ ],
+)
+
+CallHistoryRecords = (
+ ZCallBPropertiesRecord,
+ ZPrimaryKeyRecord,
+ ZMetadataRecord,
+ ZPlistRecord,
+ NSStoreModelVersionHashesRecord,
+ ZModelCacheRecord,
+)
+
+FIELD_MAPPINGS = {
+ "Z_PK": "z_pk",
+ "Z_ENT": "z_ent",
+ "Z_OPT": "z_opt",
+ "Z_NAME": "z_name",
+ "Z_SUPER": "z_super",
+ "Z_MAX": "z_max",
+ "Z_VERSION": "z_version",
+ "Z_UUID": "z_uuid",
+ "ZTIMER_ALL": "z_timer_all",
+ "ZTIMER_INCOMING": "z_timer_incoming",
+ "ZTIMER_LAST": "z_timer_last",
+ "ZTIMER_LIFETIME": "z_timer_lifetime",
+ "ZTIMER_OUTGOING": "z_timer_outgoing",
+ "NSAutoVacuumLevel": "ns_auto_vacuum_level",
+ "NSPersistenceFrameworkVersion": "ns_persistence_framework_version",
+ "NSPersistenceMaximumFrameworkVersion": "ns_persistence_maximum_framework_version",
+ "NSStoreModelVersionChecksumKey": "ns_store_model_version_checksum_key",
+ "NSStoreModelVersionHashesDigest": "ns_store_model_version_hashes_digest",
+ "NSStoreModelVersionHashesVersion": "ns_store_model_version_hashes_version",
+ "NSStoreModelVersionIdentifiers": "ns_store_model_version_identifiers",
+ "NSStoreType": "ns_store_type",
+ "CallDBProperties": "call_db_properties",
+ "CallRecord": "call_record",
+ "EmergencyMediaItem": "emergency_media_item",
+ "Handle": "handle",
+}
+
+
+class CallHistoryPlugin(Plugin):
+ """macOS call history plugin.
+
+ Parses macOS call history SQLite database file.
+
+ References:
+ - https://fatbobman.com/en/posts/tables_and_fields_of_coredata/
+ - https://developer.apple.com/documentation/coredata/nsstoremodelversionidentifierskey
+ """
+
+ USER_PATH = ("Library/Application Support/CallHistoryDB/CallHistory.storedata",)
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = self._find_files()
+
+ def check_compatible(self) -> None:
+ if not (self.files):
+ raise UnsupportedPluginError("No CallHistory.storedata file found")
+
+ def _find_files(self) -> set:
+ files = set()
+ for _, path in _build_userdirs(self, self.USER_PATH):
+ files.add(path)
+ return files
+
+ @export(record=CallHistoryRecords)
+ def call_history(
+ self,
+ ) -> Iterator[CallHistoryRecords]:
+ """Return call history information.
+
+ Yields the following record types extracted from the
+ CallHistory.storedata database:
+
+ .. code-block:: text
+
+ ZCallBPropertiesRecord:
+ table (string): Name of the source table (ZCALLDBPROPERTIES).
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_timer_all (varint): Timer for all calls.
+ z_timer_incoming (varint): Timer for incoming calls.
+ z_timer_last (varint): Timer for last call.
+ z_timer_lifetime (varint): Timer of lifetime.
+ z_timer_outgoing (varint): Timer for outgoing calls.
+ source (path): Path to the CallHistory.storedata database file.
+
+ ZPrimaryKeyRecord:
+ table (string): Name of the source table (Z_PRIMARYKEY).
+ z_ent (varint): Entity identifier.
+ z_name (string): The name of the entity in the data model.
+ z_super (varint): This value corresponds to the Z_ENT of the parent entity.
+ 0 indicates that the entity has no parent entity.
+ z_max (varint): Marks the last used z_pk value for each registry table.
+ source (path): Path to the CallHistory.storedata database file.
+
+ ZMetadataRecord:
+ table (string): Name of the source table (Z_METADATA).
+ z_version (varint): The specific purpose is unknown, value is always 1.
+ z_uuid (string): The ID identifier (UUID type) of the current database file.
+ source (path): Path to the CallHistory.storedata database file.
+
+ ZPlistRecord (Plist extracted from Z_METADATA's Z_PLIST field):
+ ac_account_type_version (varint): AC account type version.
+ ns_persistence_maximum_framework_version (varint): Maximum supported persistence framework version.
+ ns_store_model_version_identifiers (string[]): Version identifiers for the model,
+ used to create the store.
+ ns_store_type (string): Store type.
+ ns_auto_vacuum_level (varint): Auto-vacuum level.
+ ns_store_model_version_hashes_digest (string): Digest of model version hashes.
+ ns_store_model_version_checksum_key (string): Model version checksum key.
+ ns_persistence_framework_version (varint): Persistence framework version.
+ ns_store_model_version_hashes_version (varint): Version of the ns store version hashes.
+ plist_path (string): Path pointing to the Z_METADATA table and Z_VERSION value of the
+ Z_PLIST row that this record was extracted from.
+ source (path): Path to the CallHistory.storedata database file.
+
+ NSStoreModelVersionHashesRecord:
+ call_db_properties (bytes): Hash for ZCALLDBPROPERTIES entity.
+ call_record (bytes): Hash for ZCALLRECORD entity.
+ emergency_media_item (bytes): Hash for ZEMERGENCYMEDIAITEM entity.
+ handle (bytes): Hash for ZHANDLE entity.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the CallHistory.storedata database file.
+
+ ZModelCacheRecord (contains Z_CONTENT field with binary data):
+ table (string): Name of the source table (Z_MODELCACHE).
+ source (path): Path to the CallHistory.storedata database file.
+ """
+ yield from build_sqlite_records(self, self.files, CallHistoryRecords, field_mappings=FIELD_MAPPINGS)
+
+ # TODO: Add ZCALLRECORD, Z_2REMOTEPARTICIPANTHANDLES, ZEMERGENCYMEDIAITEM, ZHANDLE tables
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/code_signature_coderesources.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/code_signature_coderesources.py
new file mode 100644
index 0000000000..ad8f630165
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/code_signature_coderesources.py
@@ -0,0 +1,160 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import find_bundle_files
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_plist_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target.target import Target
+
+OmitRecord = TargetRecordDescriptor(
+ "macos/code_signature_coderesources/omit",
+ [
+ ("boolean", "omit"),
+ ("varint", "weight"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+NestedRecord = TargetRecordDescriptor(
+ "macos/code_signature_coderesources/nested",
+ [
+ ("boolean", "nested"),
+ ("varint", "weight"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+OptionalRecord = TargetRecordDescriptor(
+ "macos/code_signature_coderesources/optional",
+ [
+ ("boolean", "optional"),
+ ("varint", "weight"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+HashRecord = TargetRecordDescriptor(
+ "macos/code_signature_coderesources/hash",
+ [
+ ("string", "hash"),
+ ("boolean", "optional"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+HashTwoRecord = TargetRecordDescriptor(
+ "macos/code_signature_coderesources/hash_two",
+ [
+ ("string", "hash2"),
+ ("boolean", "optional"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+CDHashRecord = TargetRecordDescriptor(
+ "macos/code_signature_coderesources/cdhash",
+ [
+ ("string", "cdhash"),
+ ("string", "requirement"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+CodeSignatureCodeResourcesRecords = (
+ OmitRecord,
+ NestedRecord,
+ OptionalRecord,
+ HashRecord,
+ HashTwoRecord,
+ CDHashRecord,
+)
+
+FIELD_MAPPINGS = {
+ "Resources_PROMISE_icns": "resources_promise_icns",
+}
+
+
+class CodeSignatureCodeResourcesPlugin(Plugin):
+ """macOS Code signature CodeResources plugin.
+
+
+ _CodeSignature/CodeResources files are part of the macOS code
+ signing system and store metadata about signed resources within an
+ application bundle. They contains hashes and rules used to verify the
+ integrity of code and resources during code signature validation.
+
+ References:
+ - https://developer.apple.com/library/archive/documentation/Security/Conceptual/CodeSigningGuide/AboutCS/AboutCS.html
+ - https://developer.apple.com/documentation/endpointsecurity/es_process_t/cdhash
+ - https://alfiecg.uk/2024/01/06/Ad-hoc-signing.html
+ """
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = find_bundle_files(self.target, "/_CodeSignature/CodeResources")
+
+ def check_compatible(self) -> None:
+ if not (self.files):
+ raise UnsupportedPluginError("No code signature coderesources files found")
+
+ @export(record=CodeSignatureCodeResourcesRecords)
+ def code_signature_coderesources(self) -> Iterator[CodeSignatureCodeResourcesRecords]:
+ """Return macOS CodeResources plist entries.
+
+ Yields the following record types:
+
+ .. code-block:: text
+
+ OmitRecord:
+ omit (boolean): Flag indicating the entry is marked as omitted.
+ weight (varint): Priority over other resources.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the CodeResources file.
+
+ NestedRecord:
+ nested (boolean): Flag indicating the entry may be associated with nested code,
+ such as libraries, helper tools, and other bits of code that are embedded in the app.
+ weight (varint): Priority over other resources.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the CodeResources file.
+
+ OptionalRecord:
+ optional (boolean)
+ weight (varint): Priority over other resources.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the CodeResources file.
+
+ HashRecord:
+ hash (string): Hash value.
+ optional (boolean): Flag indicating the entry is marked as optional.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the CodeResources file.
+
+ HashTwoRecord:
+ hash2 (string): Secondary hash value.
+ optional (boolean): Flag indicating the entry is marked as optional.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the CodeResources file.
+
+ CDHashRecord:
+ cdhash (string): The code directory hash value.
+ requirement (string): Code signing requirement.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the CodeResources file.
+ """
+ yield from build_plist_records(
+ self, self.files, CodeSignatureCodeResourcesRecords, field_mappings=FIELD_MAPPINGS
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/contents_info.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/contents_info.py
new file mode 100644
index 0000000000..5832be599a
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/contents_info.py
@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import DynamicDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import find_bundle_files
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_plist_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target.target import Target
+
+
+class ContentsInfoPlugin(Plugin):
+ """macOS contents info plugin.
+
+ The information property list file is a structured file that contains configuration information
+ for an application.
+ """
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = find_bundle_files(self.target, "Info.plist")
+
+ def check_compatible(self) -> None:
+ if not (self.files):
+ raise UnsupportedPluginError("No contents info files found")
+
+ @export(record=DynamicDescriptor(["string"]))
+ def contents_info(self) -> Iterator[DynamicDescriptor]:
+ """Yield contents info information."""
+ yield from build_plist_records(self, self.files, function_name="macos/contents_info")
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/contents_version.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/contents_version.py
new file mode 100644
index 0000000000..b84b2993d0
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/contents_version.py
@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import find_bundle_files
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_plist_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target.target import Target
+
+ContentsVersionRecord = TargetRecordDescriptor(
+ "macos/contents_version",
+ [
+ ("string", "build_alias_of"),
+ ("varint", "build_version"),
+ ("string", "cf_bundle_short_version_string"),
+ ("string", "cf_bundle_version"),
+ ("string", "project_name"),
+ ("varint", "source_version"),
+ ("path", "source"),
+ ],
+)
+
+
+ContentsVersionRecords = (ContentsVersionRecord,)
+
+FIELD_MAPPINGS = {
+ "BuildAliasOf": "build_alias_of",
+ "BuildVersion": "build_version",
+ "CFBundleShortVersionString": "cf_bundle_short_version_string",
+ "CFBundleVersion": "cf_bundle_version",
+ "ProjectName": "project_name",
+ "SourceVersion": "source_version",
+}
+
+
+class ContentsVersionPlugin(Plugin):
+ """macOS contents version plugin.
+
+ The version.plist file is a property list found in macOS bundles.
+
+ References:
+ - https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundleversion
+ - https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundleshortversionstring
+ """
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = self.files = find_bundle_files(self.target, "version.plist")
+
+ def check_compatible(self) -> None:
+ if not self.files:
+ raise UnsupportedPluginError("No contents version.plist files found")
+
+ @export(record=ContentsVersionRecord)
+ def contents_version(self) -> Iterator[ContentsVersionRecord]:
+ """Return macOS version.plist entries.
+
+ Yields ContentsVersionRecord with the following fields:
+
+ .. code-block:: text
+
+ build_alias_of (string): Name of another component this entry is associated with.
+ build_version (varint): Build version number.
+ cf_bundle_short_version_string (string): The release or version number of the bundle.
+ cf_bundle_version (string): The version of the build that identifies an iteration of the bundle.
+ project_name (string): Project name.
+ source_version (varint): Internal source version.
+ source (path): Path to the version.plist file.
+ """
+ yield from build_plist_records(self, self.files, ContentsVersionRecords, field_mappings=FIELD_MAPPINGS)
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/directory_services_local_nodes.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/directory_services_local_nodes.py
new file mode 100644
index 0000000000..d3a015047a
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/directory_services_local_nodes.py
@@ -0,0 +1,129 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.database.sqlite3 import SQLite3
+
+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 dissect.target import Target
+
+DirectoryServicesLocalNodesRecord = TargetRecordDescriptor(
+ "macos/directory_services_local_nodes",
+ [
+ ("string[]", "tables"),
+ ("datetime", "filetime"),
+ ("string", "filename"),
+ ("string", "recordtype"),
+ ("string", "value"),
+ ("path", "source"),
+ ],
+)
+
+
+class DirectoryServicesLocalNodesPlugin(Plugin):
+ """macOS Directory Services local nodes plugin.
+
+ The /var/db/dslocal/sqlindex database tracks metadata for plist files in the directory structure
+
+ References:
+ - https://web.archive.org/web/20221206190314/https://samsclass.info/121/lec16/ch13.pdf
+ """
+
+ PATH = "/var/db/dslocal/nodes/Default/sqlindex"
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.file = self.target.fs.path(self.PATH) if self.target.fs.path(self.PATH).exists() else None
+
+ def check_compatible(self) -> None:
+ if not self.file:
+ raise UnsupportedPluginError("No sqlindex file found")
+
+ @export(record=DirectoryServicesLocalNodesRecord)
+ def directory_services_local_nodes(
+ self,
+ ) -> Iterator[DirectoryServicesLocalNodesRecord]:
+ """Return macOS Directory Services local node entries.
+
+ Yields DirectoryServicesLocalNodesRecord with the following fields:
+
+ .. code-block:: text
+
+ tables (string[]): Names of tables contributing to the record.
+ filetime (datetime): Timestamp associated with the row.
+ filename (string): Name of the backing plist file.
+ recordtype (string): Type of directory record (e.g. users, groups).
+ value (string): Attribute value associated with the record.
+ source (path): Path to the sqlindex file.
+
+ Data is derived from the sqlindex database, where:
+ - Tables prefixed with "rec:" contain rows with plist filenames and associated filetimes.
+ - Attribute tables (e.g. name, uid, gid) contain rows with filenames, recordtypes, and values.
+
+ Records are created by correlating rows between "rec:" and attribute tables.
+ """
+ with SQLite3(self.file) as database:
+ ATTRIBUTE_TABLES = {
+ "name",
+ "realname",
+ "generateduid",
+ "uid",
+ "gid",
+ "ip_address",
+ "ipv6_address",
+ "smb_sid",
+ "smb_rid",
+ "groupmembers",
+ "users",
+ "nestedgroups",
+ }
+
+ r_rows: list[tuple[str, object]] = []
+ n_tables = set()
+
+ for table in database.tables():
+ if table.name.startswith("rec:"):
+ r_rows.extend((table.name, r_row) for r_row in table.rows())
+ elif table.name in ATTRIBUTE_TABLES:
+ n_tables.add(table)
+
+ for table in n_tables:
+ rec_rows = list(r_rows)
+
+ for n_row in table.rows():
+ matched = False
+
+ for idx, (table_name, r_row) in enumerate(rec_rows):
+ if r_row.filename == n_row.filename:
+ yield DirectoryServicesLocalNodesRecord(
+ tables=[table.name, table_name],
+ filetime=r_row.filetime,
+ filename=n_row.filename,
+ recordtype=n_row.recordtype,
+ value=n_row.value,
+ source=self.file,
+ _target=self.target,
+ )
+
+ del rec_rows[idx]
+ matched = True
+ break
+
+ if not matched:
+ yield DirectoryServicesLocalNodesRecord(
+ tables=[table.name],
+ filetime=None,
+ filename=n_row.filename,
+ recordtype=n_row.recordtype,
+ value=n_row.value,
+ source=self.file,
+ _target=self.target,
+ )
+
+ # TODO: Add altsecurityidentities, hardwareuuid, en_address, mail, member tables
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/duet_activity_scheduler.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/duet_activity_scheduler.py
new file mode 100644
index 0000000000..a8a69ae0d6
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/duet_activity_scheduler.py
@@ -0,0 +1,211 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_sqlite_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target import Target
+
+ZPrimaryKeyRecord = TargetRecordDescriptor(
+ "macos/duet_activity_scheduler/z_primary_key",
+ [
+ ("string", "table"),
+ ("varint", "z_ent"),
+ ("string", "z_name"),
+ ("varint", "z_super"),
+ ("varint", "z_max"),
+ ("path", "source"),
+ ],
+)
+
+ZGroupRecord = TargetRecordDescriptor(
+ "macos/duet_activity_scheduler/z_group",
+ [
+ ("string", "table"),
+ ("varint", "z_max_concurrent"),
+ ("string", "z_name"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("varint", "z_pk"),
+ ("path", "source"),
+ ],
+)
+
+ZMetadataRecord = TargetRecordDescriptor(
+ "macos/duet_activity_scheduler/z_metadata",
+ [
+ ("string", "table"),
+ ("varint", "z_version"),
+ ("string", "z_uuid"),
+ ("path", "source"),
+ ],
+)
+
+ZPlistRecord = TargetRecordDescriptor(
+ "macos/duet_activity_scheduler/z_plist",
+ [
+ ("varint", "ns_persistence_maximum_framework_version"),
+ ("string[]", "ns_store_model_version_identifiers"),
+ ("string", "ns_store_type"),
+ ("varint", "ns_auto_vacuum_level"),
+ ("string", "ns_store_model_version_hashes_digest"),
+ ("string", "ns_store_model_version_checksum_key"),
+ ("varint", "ns_persistence_framework_version"),
+ ("varint", "ns_store_model_version_hashes_version"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+NSStoreModelVersionHashesRecord = TargetRecordDescriptor(
+ "macos/duet_activity_scheduler/ns_store_model_version_hashes",
+ [
+ ("bytes", "activity"),
+ ("bytes", "group_hash"),
+ ("bytes", "trigger"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+# Contains additional Z_CONTENT field which is a binary blob. This field been removed
+# from the record descriptor. The field's presence will still be mentioned in a warning.
+ZModelCacheRecord = TargetRecordDescriptor(
+ "macos/duet_activity_scheduler/z_model_cache",
+ [
+ ("string", "table"),
+ ("path", "source"),
+ ],
+)
+
+DuetActivityRecords = (
+ ZPrimaryKeyRecord,
+ ZGroupRecord,
+ ZMetadataRecord,
+ ZPlistRecord,
+ NSStoreModelVersionHashesRecord,
+ ZModelCacheRecord,
+)
+
+FIELD_MAPPINGS = {
+ "Z_PK": "z_pk",
+ "Z_ENT": "z_ent",
+ "Z_OPT": "z_opt",
+ "Z_NAME": "z_name",
+ "Z_SUPER": "z_super",
+ "Z_MAX": "z_max",
+ "ZMAXCONCURRENT": "z_max_concurrent",
+ "ZNAME": "z_name",
+ "Z_VERSION": "z_version",
+ "Z_UUID": "z_uuid",
+ "NSPersistenceMaximumFrameworkVersion": "ns_persistence_maximum_framework_version",
+ "NSStoreModelVersionIdentifiers": "ns_store_model_version_identifiers",
+ "NSStoreType": "ns_store_type",
+ "NSAutoVacuumLevel": "ns_auto_vacuum_level",
+ "NSStoreModelVersionHashesDigest": "ns_store_model_version_hashes_digest",
+ "NSStoreModelVersionChecksumKey": "ns_store_model_version_checksum_key",
+ "NSPersistenceFrameworkVersion": "ns_persistence_framework_version",
+ "NSStoreModelVersionHashesVersion": "ns_store_model_version_hashes_version",
+ "Activity": "activity",
+ "Group": "group_hash",
+ "Trigger": "trigger",
+}
+
+
+class DuetActivitySchedulerPlugin(Plugin):
+ """macOS Duet Activity Scheduler plugin.
+
+ The Duet Activity Scheduler is a macOS background daemon
+ responsible for scheduling and managing deferred and conditional
+ activities.
+
+ References:
+ - https://fatbobman.com/en/posts/tables_and_fields_of_coredata/
+ - https://developer.apple.com/documentation/coredata/nsstoremodelversionidentifierskey
+ """
+
+ PATH = "/var/db/DuetActivityScheduler/DuetActivitySchedulerClassC.db"
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.file = self.target.fs.path(self.PATH) if self.target.fs.path(self.PATH).exists() else None
+
+ def check_compatible(self) -> None:
+ if not self.file:
+ raise UnsupportedPluginError("No DuetActivitySchedulerClassC.db file found")
+
+ @export(record=DuetActivityRecords)
+ def duet_activity_scheduler(
+ self,
+ ) -> Iterator[DuetActivityRecords]:
+ """Return macOS Duet Activity Scheduler database entries.
+
+ Yields the following record types extracted from the
+ DuetActivitySchedulerClassC.db database:
+
+ .. code-block:: text
+
+ ZPrimaryKeyRecord:
+ table (string): Name of the source table (Z_PRIMARYKEY).
+ z_ent (varint): Entity identifier.
+ z_name (string): The name of the entity in the data model.
+ z_super (varint): This value corresponds to the Z_ENT of the parent entity.
+ 0 indicates that the entity has no parent entity.
+ z_max (varint): Marks the last used z_pk value for each registry table.
+ source (path): Path to the DuetActivitySchedulerClassC.db file.
+
+ ZGroupRecord:
+ table (string): Name of the source table (ZGROUP).
+ z_max_concurrent (varint): Maximum number of concurrent activities allowed.
+ z_name (string): The name of the entity in the data model.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_pk (varint): The autoincrement primary key of the table.
+ source (path): Path to the DuetActivitySchedulerClassC.db file.
+
+ ZMetadataRecord:
+ table (string): Name of the source table (Z_METADATA).
+ z_version (varint): The specific purpose is unknown, value is always 1.
+ z_uuid (string): The ID identifier (UUID type) of the current database file.
+ source (path): Path to the DuetActivitySchedulerClassC.db file.
+
+ ZPlistRecord (Plist extracted from Z_METADATA's Z_PLIST field):
+ ns_persistence_maximum_framework_version (varint): Maximum supported persistence framework version.
+ ns_store_model_version_identifiers (string[]): Version identifiers for the model,
+ used to create the store.
+ ns_store_type (string): Store type.
+ ns_auto_vacuum_level (varint): Auto-vacuum level.
+ ns_store_model_version_hashes_digest (string): Digest of model version hashes.
+ ns_store_model_version_checksum_key (string): Model version checksum key.
+ ns_persistence_framework_version (varint): Persistence framework version.
+ ns_store_model_version_hashes_version (varint): Version of the ns store version hashes.
+ plist_path (string): Path pointing to the Z_METADATA table and Z_VERSION value of the
+ Z_PLIST row that this record was extracted from.
+ source (path): Path to the DuetActivitySchedulerClassC.db file.
+
+ NSStoreModelVersionHashesRecord:
+ activity (bytes): Hash for ZACTIVITY entity.
+ group_hash (bytes): Hash for ZGROUP entity.
+ trigger (bytes): Hash for ZTRIGGER entity.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the DuetActivitySchedulerClassC.db file.
+
+ ZModelCacheRecord (contains Z_CONTENT field with binary data):
+ table (string): Name of the source table (Z_MODELCACHE).
+ source (path): Path to the DuetActivitySchedulerClassC.db file.
+ """
+ yield from build_sqlite_records(
+ self,
+ (self.file,),
+ DuetActivityRecords,
+ field_mappings=FIELD_MAPPINGS,
+ )
+
+ # TODO: Add ZACTIVITY, Z_1TRIGGERS, ZTRIGGER,
+ # Z_PRIMARYKEY, Z_METADATA,Z_MODELCACHE tables
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/duet_interaction_c.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/duet_interaction_c.py
new file mode 100644
index 0000000000..2d087a1812
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/duet_interaction_c.py
@@ -0,0 +1,250 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_sqlite_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target import Target
+
+ZPrimaryKeyRecord = TargetRecordDescriptor(
+ "macos/duet_interaction_c/z_primary_key",
+ [
+ ("string", "table"),
+ ("varint", "z_ent"),
+ ("string", "z_name"),
+ ("varint", "z_super"),
+ ("varint", "z_max"),
+ ("path", "source"),
+ ],
+)
+
+ZMetadataRecord = TargetRecordDescriptor(
+ "macos/duet_interaction_c/z_metadata",
+ [
+ ("string", "table"),
+ ("varint", "z_version"),
+ ("string", "z_uuid"),
+ ("path", "source"),
+ ],
+)
+
+ZPlistRecord = TargetRecordDescriptor(
+ "macos/duet_interaction_c/z_plist",
+ [
+ ("varint", "ns_persistence_maximum_framework_version"),
+ ("string[]", "ns_store_model_version_identifiers"),
+ ("string", "ns_store_type"),
+ ("varint", "ns_auto_vacuum_level"),
+ ("string", "ns_store_model_version_hashes_digest"),
+ ("string", "ns_store_model_version_checksum_key"),
+ ("varint", "ns_persistence_framework_version"),
+ ("varint", "ns_store_model_version_hashes_version"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+NSStoreModelVersionHashesRecord = TargetRecordDescriptor(
+ "macos/duet_interaction_c/ns_store_model_version_hashes",
+ [
+ ("bytes", "attachment_hash"),
+ ("bytes", "contacts"),
+ ("bytes", "interactions"),
+ ("bytes", "keywords_hash"),
+ ("bytes", "metadata"),
+ ("bytes", "version_hash"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+ZKeyValueMetadataRecord = TargetRecordDescriptor(
+ "macos/duet_interaction_c/z_key_value_metadata",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("string", "z_key"),
+ ("string", "z_value"),
+ ("path", "source"),
+ ],
+)
+
+ZVersionRecord = TargetRecordDescriptor(
+ "macos/duet_interaction_c/z_version",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("varint", "z_number"),
+ ("datetime", "z_creation_date"),
+ ("string", "z_key"),
+ ("path", "source"),
+ ],
+)
+
+# Contains additional Z_CONTENT field which is a binary blob. This field been removed
+# from the record descriptor. The field's presence will still be mentioned in a warning.
+ZModelCacheRecord = TargetRecordDescriptor(
+ "macos/duet_interaction_c/z_model_cache",
+ [
+ ("string", "table"),
+ ("path", "source"),
+ ],
+)
+
+DuetInteractionCRecords = (
+ ZPrimaryKeyRecord,
+ ZMetadataRecord,
+ ZPlistRecord,
+ NSStoreModelVersionHashesRecord,
+ ZModelCacheRecord,
+ ZKeyValueMetadataRecord,
+ ZVersionRecord,
+)
+
+FIELD_MAPPINGS = {
+ "Z_PK": "z_pk",
+ "Z_ENT": "z_ent",
+ "Z_OPT": "z_opt",
+ "Z_NAME": "z_name",
+ "Z_SUPER": "z_super",
+ "Z_MAX": "z_max",
+ "ZNAME": "z_name",
+ "Z_VERSION": "z_version",
+ "Z_UUID": "z_uuid",
+ "NSPersistenceMaximumFrameworkVersion": "ns_persistence_maximum_framework_version",
+ "NSStoreModelVersionIdentifiers": "ns_store_model_version_identifiers",
+ "NSStoreType": "ns_store_type",
+ "NSAutoVacuumLevel": "ns_auto_vacuum_level",
+ "NSStoreModelVersionHashesDigest": "ns_store_model_version_hashes_digest",
+ "NSStoreModelVersionChecksumKey": "ns_store_model_version_checksum_key",
+ "NSPersistenceFrameworkVersion": "ns_persistence_framework_version",
+ "NSStoreModelVersionHashesVersion": "ns_store_model_version_hashes_version",
+ "ZKEY": "z_key",
+ "ZVALUE": "z_value",
+ "ZNUMBER": "z_number",
+ "ZCREATIONDATE": "z_creation_date",
+ "Attachment": "attachment_hash",
+ "Contacts": "contacts",
+ "Interactions": "interactions",
+ "Keywords": "keywords_hash",
+ "Metadata": "metadata",
+ "Version": "version_hash",
+}
+
+CONVERT_TIMESTAMPS = {
+ "z_creation_date": "2001",
+}
+
+
+class DuetInteractionCPlugin(Plugin):
+ """macOS Duet InteractionC plugin.
+
+ Parses basic information about recent app activity.
+
+ References:
+ - https://www.msab.com/blog/hidden-gems-in-apple-ios-digital-forensics/
+ - https://fatbobman.com/en/posts/tables_and_fields_of_coredata/
+ - https://developer.apple.com/documentation/coredata/nsstoremodelversionidentifierskey
+ """
+
+ PATH = "/var/db/CoreDuet/People/interactionC.db"
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.file = self.target.fs.path(self.PATH) if self.target.fs.path(self.PATH).exists() else None
+
+ def check_compatible(self) -> None:
+ if not self.file:
+ raise UnsupportedPluginError("No interactionC.db file found")
+
+ @export(record=DuetInteractionCRecords)
+ def duet_interaction_c(
+ self,
+ ) -> Iterator[DuetInteractionCRecords]:
+ """Return macOS Duet InteractionC database entries.
+
+ Yields the following record types extracted from the
+ interactionC.db database:
+
+ .. code-block:: text
+
+ ZPrimaryKeyRecord:
+ table (string): Name of the source table (Z_PRIMARYKEY).
+ z_ent (varint): Entity identifier.
+ z_name (string): The name of the entity in the data model.
+ z_super (varint): This value corresponds to the Z_ENT of the parent entity.
+ 0 indicates that the entity has no parent entity.
+ z_max (varint): Marks the last used z_pk value for each registry table.
+ source (path): Path to the interactionC.db file.
+
+ ZMetadataRecord:
+ table (string): Name of the source table (Z_METADATA).
+ z_version (varint): The specific purpose is unknown, value is always 1.
+ z_uuid (string): The ID identifier (UUID type) of the current database file.
+ source (path): Path to the interactionC.db file.
+
+ ZPlistRecord (Plist extracted from Z_METADATA's Z_PLIST field):
+ ns_persistence_maximum_framework_version (varint): Maximum supported persistence framework version.
+ ns_store_model_version_identifiers (string[]): Version identifiers for the model,
+ used to create the store.
+ ns_store_type (string): Store type.
+ ns_auto_vacuum_level (varint): Auto-vacuum level.
+ ns_store_model_version_hashes_digest (string): Digest of model version hashes.
+ ns_store_model_version_checksum_key (string): Model version checksum key.
+ ns_persistence_framework_version (varint): Persistence framework version.
+ ns_store_model_version_hashes_version (varint): Version of the ns store version hashes.
+ plist_path (string): Path pointing to the Z_METADATA table and Z_VERSION value of the
+ Z_PLIST row that this record was extracted from.
+ source (path): Path to the interactionC.db file.
+
+ NSStoreModelVersionHashesRecord:
+ attachment_hash (bytes): Hash for ZATTACHMENT entity.
+ contacts (bytes): Hash for ZCONTACTS entity.
+ interactions (bytes): Hash for ZINTERACTIONS entity.
+ keywords_hash (bytes): Hash for ZKEYWORDS entity.
+ metadata (bytes): Hash for ZMETADATA entity.
+ version_hash (bytes): Hash for ZVERSION entity.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the knowledgeC.db database file.
+
+ ZModelCacheRecord (contains Z_CONTENT field with binary data):
+ table (string): Name of the source table (Z_MODELCACHE).
+ source (path): Path to the interactionC.db file.
+
+ ZKeyValueMetadataRecord:
+ table (string): Name of the source table (ZMETADATA).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_key (string): Property key.
+ z_value (string): Property value.
+ source (path): Path to the knowledgeC.db database file.
+
+ ZVersionRecord:
+ table (string): Name of the source table (ZVERSION).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_creation_date (datetime): Creation timestamp.
+ z_key (string): Property key.
+ """
+ yield from build_sqlite_records(
+ self,
+ (self.file,),
+ DuetInteractionCRecords,
+ field_mappings=FIELD_MAPPINGS,
+ convert_timestamps=CONVERT_TIMESTAMPS,
+ )
+
+ # TODO: Add ZATTACHMENT, Z_1INTERACTIONS, ZCONTACTS, Z_2INTERACTIONRECIPIENT,
+ # ZINTERACTIONS, `Z_3KEYWORDS, ZKEYWORDS tables
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/duet_knowledge_c.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/duet_knowledge_c.py
new file mode 100644
index 0000000000..b714687ee4
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/duet_knowledge_c.py
@@ -0,0 +1,484 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import _build_userdirs
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_sqlite_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target import Target
+
+ZKeyValueRecord = TargetRecordDescriptor(
+ "macos/duet_knowledge_c/z_key_value",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("string", "z_domain"),
+ ("string", "z_key"),
+ ("string", "z_value"),
+ ("path", "source"),
+ ],
+)
+
+ZContextualChangeRegistrationRecord = TargetRecordDescriptor(
+ "macos/duet_knowledge_c/z_contextual_change_registration",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("boolean", "z_is_active"),
+ ("boolean", "z_is_multi_device_registration"),
+ ("datetime", "z_creation_date"),
+ ("string", "z_identifier"),
+ ("string", "z_properties"),
+ ("path", "source"),
+ ],
+)
+
+ZObjectRecord = TargetRecordDescriptor(
+ "macos/duet_knowledge_c/z_object",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("varint", "z_uuid_hash"),
+ ("string", "z_event"),
+ ("varint", "z_source_fk"),
+ ("string", "z_category_type"),
+ ("varint", "z_integer_value"),
+ ("varint", "z_compatibility_version"),
+ ("varint", "z_end_day_of_week"),
+ ("varint", "z_end_second_of_day"),
+ ("boolean", "z_has_custom_metadata"),
+ ("boolean", "z_has_structured_metadata"),
+ ("varint", "z_seconds_from_gmt"),
+ ("varint", "z_should_sync"),
+ ("varint", "z_start_day_of_week"),
+ ("varint", "z_start_second_of_day"),
+ ("varint", "z_value_class"),
+ ("varint", "z_value_integer"),
+ ("varint", "z_value_type_code"),
+ ("varint", "z_structured_metadata"),
+ ("string", "z_value"),
+ ("string", "z_9_value"),
+ ("string", "z_identifier_type"),
+ ("string", "z_quantity_type"),
+ ("datetime", "z_creation_date"),
+ ("datetime", "z_local_creation_date"),
+ ("varint", "z_confidence"),
+ ("datetime", "z_end_date"),
+ ("datetime", "z_start_date"),
+ ("varint", "z_value_double"),
+ ("varint", "z_double_value"),
+ ("string", "z_uuid"),
+ ("string", "z_stream_name"),
+ ("string", "z_value_string"),
+ ("string", "z_string"),
+ ("string", "z_metadata"),
+ ("path", "source"),
+ ],
+)
+
+ZSourceRecord = TargetRecordDescriptor(
+ "macos/duet_knowledge_c/z_source",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("string", "z_user_id"),
+ ("string", "z_bundle_id"),
+ ("string", "z_device_id"),
+ ("string", "z_group_id"),
+ ("string", "z_intent_id"),
+ ("string", "z_item_id"),
+ ("string", "z_source_id"),
+ ("path", "source"),
+ ],
+)
+
+# ZSTRUCTUREDMETADATA table contains 200+ more columns, most of which are None in the majority of rows.
+# Reduced record descriptor to core fields, other fields will be included in a warning.
+ZStructuredMetadataRecord = TargetRecordDescriptor(
+ "macos/duet_knowledge_c/z_structured_metadata",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("path", "source"),
+ ],
+)
+
+ZPrimaryKeyRecord = TargetRecordDescriptor(
+ "macos/duet_knowledge_c/z_primary_key",
+ [
+ ("string", "table"),
+ ("varint", "z_ent"),
+ ("string", "z_name"),
+ ("varint", "z_super"),
+ ("varint", "z_max"),
+ ("path", "source"),
+ ],
+)
+
+ZMetadataRecord = TargetRecordDescriptor(
+ "macos/duet_knowledge_c/z_metadata",
+ [
+ ("string", "table"),
+ ("varint", "z_version"),
+ ("string", "z_uuid"),
+ ("path", "source"),
+ ],
+)
+
+ZPlistRecord = TargetRecordDescriptor(
+ "macos/duet_knowledge_c/z_plist",
+ [
+ ("varint", "ns_persistence_maximum_framework_version"),
+ ("string[]", "ns_store_model_version_identifiers"),
+ ("string", "ns_store_type"),
+ ("varint", "ns_auto_vacuum_level"),
+ ("string", "ns_store_model_version_hashes_digest"),
+ ("string", "ns_store_model_version_checksum_key"),
+ ("varint", "ns_persistence_framework_version"),
+ ("varint", "ns_store_model_version_hashes_version"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+NSStoreModelVersionHashesRecord = TargetRecordDescriptor(
+ "macos/duet_knowledge_c/ns_store_model_version_hashes",
+ [
+ ("bytes", "addition_change_set"),
+ ("bytes", "category_hash"),
+ ("bytes", "contextual_change_registration"),
+ ("bytes", "contextual_key_path"),
+ ("bytes", "custom_metadata"),
+ ("bytes", "deletion_change_set"),
+ ("bytes", "event"),
+ ("bytes", "histogram"),
+ ("bytes", "histogram_value"),
+ ("bytes", "identifier_hash"),
+ ("bytes", "key_value"),
+ ("bytes", "z_object"),
+ ("bytes", "quantity"),
+ ("bytes", "z_source"),
+ ("bytes", "structured_metadata"),
+ ("bytes", "sync_peer"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+# Contains additional Z_CONTENT field which is a binary blob. This field been removed
+# from the record descriptor. The field's presence will still be mentioned in a warning.
+ZModelCacheRecord = TargetRecordDescriptor(
+ "macos/duet_knowledge_c/z_model_cache",
+ [
+ ("string", "table"),
+ ("path", "source"),
+ ],
+)
+
+DuetKnowledgeCRecords = (
+ ZKeyValueRecord,
+ ZContextualChangeRegistrationRecord,
+ ZObjectRecord,
+ ZSourceRecord,
+ ZStructuredMetadataRecord,
+ ZPlistRecord,
+ NSStoreModelVersionHashesRecord,
+ ZPrimaryKeyRecord,
+ ZMetadataRecord,
+ ZModelCacheRecord,
+)
+FIELD_MAPPINGS = {
+ "Z_PK": "z_pk",
+ "Z_ENT": "z_ent",
+ "Z_OPT": "z_opt",
+ "ZISACTIVE": "z_is_active",
+ "ZISMULTIDEVICEREGISTRATION": "z_is_multi_device_registration",
+ "ZCREATIONDATE": "z_creation_date",
+ "ZIDENTIFIER": "z_identifier",
+ "ZPROPERTIES": "z_properties",
+ "ZUUIDHASH": "z_uuid_hash",
+ "ZEVENT": "z_event",
+ "ZSOURCE": "z_source_fk",
+ "ZCATEGORYTYPE": "z_category_type",
+ "ZINTEGERVALUE": "z_integer_value",
+ "ZCOMPATIBILITYVERSION": "z_compatibility_version",
+ "ZENDDAYOFWEEK": "z_end_day_of_week",
+ "ZENDSECONDOFDAY": "z_end_second_of_day",
+ "ZHASCUSTOMMETADATA": "z_has_custom_metadata",
+ "ZHASSTRUCTUREDMETADATA": "z_has_structured_metadata",
+ "ZSECONDSFROMGMT": "z_seconds_from_gmt",
+ "ZSHOULDSYNC": "z_should_sync",
+ "ZSTARTDAYOFWEEK": "z_start_day_of_week",
+ "ZSTARTSECONDOFDAY": "z_start_second_of_day",
+ "ZVALUECLASS": "z_value_class",
+ "ZVALUEINTEGER": "z_value_integer",
+ "ZVALUETYPECODE": "z_value_type_code",
+ "ZSTRUCTUREDMETADATA": "z_structured_metadata",
+ "ZKEY": "z_key",
+ "ZVALUE": "z_value",
+ "Z9_VALUE": "z_9_value",
+ "ZIDENTIFIERTYPE": "z_identifier_type",
+ "ZQUANTITYTYPE": "z_quantity_type",
+ "ZLOCALCREATIONDATE": "z_local_creation_date",
+ "ZCONFIDENCE": "z_confidence",
+ "ZENDDATE": "z_end_date",
+ "ZSTARTDATE": "z_start_date",
+ "ZVALUEDOUBLE": "z_value_double",
+ "ZDOUBLEVALUE": "z_double_value",
+ "ZUUID": "z_uuid",
+ "Z_UUID": "z_uuid",
+ "ZSTREAMNAME": "z_stream_name",
+ "ZVALUESTRING": "z_value_string",
+ "ZSTRING": "z_string",
+ "ZMETADATA": "z_metadata",
+ "ZDOMAIN": "z_domain",
+ "ZUSERID": "z_user_id",
+ "ZBUNDLEID": "z_bundle_id",
+ "ZDEVICEID": "z_device_id",
+ "ZGROUPID": "z_group_id",
+ "ZINTENTID": "z_intent_id",
+ "ZITEMID": "z_item_id",
+ "ZSOURCEID": "z_source_id",
+ "Z_NAME": "z_name",
+ "ZNAME": "z_name",
+ "Z_SUPER": "z_super",
+ "Z_MAX": "z_max",
+ "Z_VERSION": "z_version",
+ "NSPersistenceMaximumFrameworkVersion": "ns_persistence_maximum_framework_version",
+ "NSStoreModelVersionIdentifiers": "ns_store_model_version_identifiers",
+ "NSStoreType": "ns_store_type",
+ "NSAutoVacuumLevel": "ns_auto_vacuum_level",
+ "NSStoreModelVersionHashesDigest": "ns_store_model_version_hashes_digest",
+ "NSStoreModelVersionChecksumKey": "ns_store_model_version_checksum_key",
+ "NSPersistenceFrameworkVersion": "ns_persistence_framework_version",
+ "NSStoreModelVersionHashesVersion": "ns_store_model_version_hashes_version",
+ "AdditionChangeSet": "addition_change_set",
+ "Category": "category_hash",
+ "ContextualChangeRegistration": "contextual_change_registration",
+ "ContextualKeyPath": "contextual_key_path",
+ "CustomMetadata": "custom_metadata",
+ "DeletionChangeSet": "deletion_change_set",
+ "Event": "event",
+ "Histogram": "histogram",
+ "HistogramValue": "histogram_value",
+ "Identifier": "identifier_hash",
+ "KeyValue": "key_value",
+ "Object": "z_object",
+ "Quantity": "quantity",
+ "Source": "z_source",
+ "StructuredMetadata": "structured_metadata",
+ "SyncPeer": "sync_peer",
+}
+
+CONVERT_TIMESTAMPS = {
+ "z_creation_date": "2001",
+ "z_local_creation_date": "2001",
+ "z_end_date": "2001",
+ "z_start_date": "2001",
+}
+
+
+class DuetKnowledgeCPlugin(Plugin):
+ """macOS Duet KnowledgeC Plugin.
+
+ Parses information about app and system activities.
+
+ References:
+ - https://www.msab.com/blog/hidden-gems-in-apple-ios-digital-forensics/
+ - https://fatbobman.com/en/posts/tables_and_fields_of_coredata/
+ - https://developer.apple.com/documentation/coredata/nsstoremodelversionidentifierskey
+ """
+
+ PATH = "/var/db/CoreDuet/Knowledge/knowledgeC.db"
+
+ USER_PATH = ("Library/Application Support/Knowledge/knowledgeC.db",)
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = self._find_files()
+
+ def _find_files(self) -> set:
+ files = set()
+ files.add(self.target.fs.path(self.PATH))
+ for _, path in _build_userdirs(self, self.USER_PATH):
+ files.add(path)
+ return files
+
+ def check_compatible(self) -> None:
+ if not (self.files):
+ raise UnsupportedPluginError("No knowledgeC.db files found")
+
+ @export(record=DuetKnowledgeCRecords)
+ def duet_knowledge_c(
+ self,
+ ) -> Iterator[DuetKnowledgeCRecords]:
+ """Return macOS KnowledgeC database entries.
+
+ Yields multiple record types extracted from the knowledgeC.db databases.
+
+ .. code-block:: text
+
+ ZKeyValueRecord:
+ table (string): Name of the source table (Z_KEYVALUE).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_domain (string): Domain of the key/value pair.
+ z_key (string): Property key.
+ z_value (string): Property value.
+ source (path): Path to the knowledgeC.db database file.
+
+ ZContextualChangeRegistrationRecord:
+ table (string): Name of the source table (ZCONTEXTUALCHANGEREGISTRATION).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_is_active (boolean): Whether the registration is active.
+ z_is_multi_device_registration (boolean): Whether the registration is a multi-device registration.
+ z_creation_date (datetime): Creation timestamp.
+ z_identifier (string): Identifier of the registration.
+ z_properties (string): Properties of the registration.
+ source (path): Path to the knowledgeC.db database file.
+
+ ZObjectRecord:
+ table (string): Name of the source table (ZOBJECT).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_uuid_hash (varint): Hash of the UUID.
+ z_event (string): Associated event.
+ z_source_fk (varint): Reference to z_pk in ZSOURCE table.
+ z_category_type (string): Category type.
+ z_integer_value (varint): Integer value.
+ z_compatibility_version (varint): Compatibility version.
+ z_end_day_of_week (varint): End day of week.
+ z_end_second_of_day (varint): End second of day.
+ z_has_custom_metadata (boolean): Whether entry has custom metadata.
+ z_has_structured_metadata (boolean): Whether entry has structured metadata.
+ z_seconds_from_gmt (varint): Timezone offset from gmt.
+ z_should_sync (varint): Sync flag.
+ z_start_day_of_week (varint): Start day of week.
+ z_start_second_of_day (varint): Start second of day.
+ z_value_class (varint): Value class identifier.
+ z_value_integer (varint): Integer value.
+ z_value_type_code (varint): Value type code.
+ z_structured_metadata (varint): Foreign key to z_pk in structured metadata.
+ z_value (string): Property value.
+ z_9_value (string): Secondary property value.
+ z_identifier_type (string): Identifier type.
+ z_quantity_type (string): Quantity type.
+ z_creation_date (datetime): Creation timestamp.
+ z_local_creation_date (datetime): Local creation timestamp.
+ z_confidence (varint): Confidence score.
+ z_end_date (datetime): End timestamp.
+ z_start_date (datetime): Start timestamp.
+ z_value_double (varint): Double value.
+ z_double_value (varint): Double value? (Usually None)
+ z_uuid (string): The ID identifier (UUID type) of the current database file.
+ z_stream_name (string): Name of associated stream, identifies the activity.
+ z_value_string (string): String value.
+ z_string (string): Additional string field.
+ z_metadata (string): Metadata.
+ source (path): Path to the knowledgeC.db database file.
+
+ ZSourceRecord:
+ table (string): Name of the source table (ZSOURCE).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_user_id (string): User identifier.
+ z_bundle_id (string): Application bundle ID.
+ z_device_id (string): Device identifier.
+ z_group_id (string): Group identifier.
+ z_intent_id (string): Intent identifier.
+ z_item_id (string): Item identifier.
+ z_source_id (string): Unique source identifier.
+ source (path): Path to the knowledgeC.db database file.
+
+ ZStructuredMetadataRecord (table contains 200+ more columns):
+ table (string): Name of the source table (ZSTRUCTUREDMETADATA).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ source (path): Path to the knowledgeC.db database file.
+
+ ZPrimaryKeyRecord:
+ table (string): Name of the source table (Z_PRIMARYKEY).
+ z_ent (varint): Entity identifier.
+ z_name (string): The name of the entity in the data model.
+ z_super (varint): This value corresponds to the Z_ENT of the parent entity.
+ 0 indicates that the entity has no parent entity.
+ z_max (varint): Marks the last used z_pk value for each registry table.
+ source (path): Path to the knowledgeC.db database file.
+
+ ZMetadataRecord:
+ table (string): Name of the source table (Z_METADATA).
+ z_version (varint): The specific purpose is unknown, value is always 1.
+ z_uuid (string): The ID identifier (UUID type) of the current database file.
+ source (path): Path to the knowledgeC.db database file.
+
+ ZPlistRecord (Plist extracted from Z_METADATA's Z_PLIST field):
+ ns_persistence_maximum_framework_version (varint): Maximum supported persistence framework version.
+ ns_store_model_version_identifiers (string[]): Version identifiers for the model,
+ used to create the store.
+ ns_store_type (string): Store type.
+ ns_auto_vacuum_level (varint): Auto-vacuum level.
+ ns_store_model_version_hashes_digest (string): Digest of model version hashes.
+ ns_store_model_version_checksum_key (string): Model version checksum key.
+ ns_persistence_framework_version (varint): Persistence framework version.
+ ns_store_model_version_hashes_version (varint): Version of the ns store version hashes.
+ plist_path (string): Path pointing to the Z_METADATA table and Z_VERSION value of the
+ Z_PLIST row that this record was extracted from.
+ source (path): Path to the knowledgeC.db database file.
+
+ NSStoreModelVersionHashesRecord:
+ addition_change_set (bytes): Hash for ZADDITIONCHANGESET entity.
+ category_hash (bytes): Category hash.
+ contextual_change_registration (bytes): Hash for ZCONTEXTUALCHANGEREGISTRATION entity.
+ contextual_key_path (bytes): Hash for ZCONTEXTUALKEYPATH entity.
+ custom_metadata (bytes): Hash for ZCUSTOMMETADATA entity.
+ deletion_change_set (bytes): Hash for ZDELETIONCHANGESET entity.
+ event (bytes): Hash for Z_4EVENT entity.
+ histogram (bytes): Hash for ZHISTOGRAM entity.
+ histogram_value (bytes): Hash for ZHISTOGRAMVALUE entity.
+ identifier_hash (bytes): Identifier hash.
+ key_value (bytes): Hash for ZKEYVALUE entity.
+ z_object (bytes): Hash for ZOBJECT entity.
+ quantity (bytes): Hash for ZQUANTITY entity.
+ z_source (bytes): Hash for ZSOURCE entity.
+ structured_metadata (bytes): Hash for ZSTRUCTUREDMETADATA entity.
+ sync_peer (bytes): Hash for ZSYNCPEER entity.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the knowledgeC.db database file.
+
+ ZModelCacheRecord (contains Z_CONTENT field with binary data):
+ table (string): Name of the source table (Z_MODELCACHE).
+ source (path): Path to the knowledgeC.db database file.
+ """
+ yield from build_sqlite_records(
+ self,
+ self.files,
+ DuetKnowledgeCRecords,
+ field_mappings=FIELD_MAPPINGS,
+ convert_timestamps=CONVERT_TIMESTAMPS,
+ )
+
+
+# TODO: Add ZADDITIONCHANGESET, ZCONTEXTUALKEYPATH, ZCUSTOMMETADATA, Z_4EVENT,
+# ZDELETIONCHANGESET, ZHISTOGRAM, ZHISTOGRAMVALUE, ZKEYVALUE, ZSYNCPEER tables
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/fs_events.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/fs_events.py
new file mode 100644
index 0000000000..c39e4e3cb1
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/fs_events.py
@@ -0,0 +1,306 @@
+from __future__ import annotations
+
+import contextlib
+import io
+import zipfile
+from typing import TYPE_CHECKING, BinaryIO
+
+from dissect.cstruct import cstruct
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.fsutil import open_decompress
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target.target import Target
+
+cs = cstruct(endian="<")
+
+cs.load("""
+struct fsevent_v1 {
+ uint64 event_id;
+ uint32 flags;
+};
+
+struct fsevent_v2 {
+ uint64 event_id;
+ uint32 flags;
+ uint64 node_id;
+};
+
+struct fsevent_v3 {
+ uint64 event_id;
+ uint32 flags;
+ uint64 node_id;
+ uint32 padding;
+};
+""")
+
+FSEventRecord = TargetRecordDescriptor(
+ "macos/fsevents/entry",
+ [
+ ("string", "path"),
+ ("varint", "event_id"),
+ ("string[]", "event_flags"),
+ ("varint", "node_id"),
+ ("path", "source"),
+ ],
+)
+
+# FSEvents flag definitions
+# TODO: Verify that these flag definitions are correct
+# And look into missing definitions (eg. 0x00800000)
+FSEVENTS_FLAGS = {
+ 0x00000001: "MustScanSubDirs",
+ 0x00000002: "UserDropped",
+ 0x00000004: "KernelDropped",
+ 0x00000008: "EventIdsWrapped",
+ 0x00000010: "HistoryDone",
+ 0x00000020: "RootChanged",
+ 0x00000040: "Mount",
+ 0x00000080: "Unmount",
+ 0x00000100: "ItemCreated",
+ 0x00000200: "ItemRemoved",
+ 0x00000400: "InodeMetaMod",
+ 0x00000800: "ItemRenamed",
+ 0x00001000: "ItemModified",
+ 0x00002000: "ItemFinderInfoMod",
+ 0x00004000: "ItemChangeOwner",
+ 0x00008000: "ItemXattrMod",
+ 0x00010000: "ItemIsFile",
+ 0x00020000: "ItemIsDir",
+ 0x00040000: "ItemIsSymlink",
+ 0x00080000: "OwnEvent",
+ 0x00100000: "ItemIsHardlink",
+ 0x00200000: "ItemIsLastHardlink",
+ 0x00400000: "ItemCloned",
+}
+
+DLS1_MAGIC = b"1SLD"
+DLS2_MAGIC = b"2SLD"
+DLS3_MAGIC = b"3SLD"
+
+
+def _decode_flags(flags: int) -> list[str] | None:
+ """Decode FSEvents flag bitmask to a list of human-readable descriptions."""
+ if flags == 0:
+ return None
+
+ parts = []
+ known_mask = 0
+
+ for bit, name in FSEVENTS_FLAGS.items():
+ if flags & bit:
+ parts.append(name)
+ known_mask |= bit
+
+ # detect unknown bits
+ unknown = flags & ~known_mask
+
+ if unknown:
+ # If flags contain bits outside the defined mask, add unknown flag
+ parts.append(f"Unknown(0x{unknown:08x})")
+
+ return parts
+
+
+def _parse_fsevents_page(data: bytes) -> Iterator[dict]:
+ """Parse a single uncompressed FSEvents page (DLS1 or DLS2 format).
+
+ Yields dicts with path, event_id, flags, node_id.
+ """
+ if len(data) < 12:
+ return
+
+ magic = data[:4]
+ if magic == DLS3_MAGIC:
+ version = 3
+ elif magic == DLS2_MAGIC:
+ version = 2
+ elif magic == DLS1_MAGIC:
+ version = 1
+ else:
+ return
+
+ pos = 12 # skip header (magic + padding)
+
+ while pos < len(data):
+ # Read null-terminated path
+ null_idx = data.find(b"\x00", pos)
+ if null_idx == -1:
+ break
+
+ try:
+ path = data[pos:null_idx].decode("utf-8", errors="replace")
+ except Exception:
+ break
+ pos = null_idx + 1
+
+ if version == 3:
+ # DLS3: event_id (uint64) + flags (uint32) + node_id (uint64) + padding (uint32) = 24 bytes
+ if pos + 24 > len(data):
+ break
+ rec = cs.fsevent_v3(data[pos : pos + 24])
+ event_id = rec.event_id
+ flags = rec.flags
+ node_id = rec.node_id
+ pos += 24
+ elif version == 2:
+ # DLS2: event_id (uint64) + flags (uint32) + node_id (uint64) = 20 bytes
+ if pos + 20 > len(data):
+ break
+ rec = cs.fsevent_v2(data[pos : pos + 20])
+ event_id = rec.event_id
+ flags = rec.flags
+ node_id = rec.node_id
+ pos += 20
+ else:
+ # DLS1: event_id (uint64) + flags (uint32) = 12 bytes
+ if pos + 12 > len(data):
+ break
+
+ rec = cs.fsevent_v1(data[pos : pos + 12])
+ event_id = rec.event_id
+ flags = rec.flags
+ node_id = 0
+ pos += 12
+
+ yield {
+ "path": path,
+ "event_id": event_id,
+ "flags": flags,
+ "node_id": node_id,
+ }
+
+
+def _parse_fsevents_stream(data: bytes) -> Iterator[dict]:
+ pos = 0
+ length = len(data)
+
+ while pos < length:
+ if data[pos : pos + 4] not in (DLS1_MAGIC, DLS2_MAGIC, DLS3_MAGIC):
+ pos += 1
+ continue
+
+ start = pos
+ pos += 12
+
+ # find next header
+ next_pos = pos
+ while next_pos < length:
+ if data[next_pos : next_pos + 4] in (DLS1_MAGIC, DLS2_MAGIC, DLS3_MAGIC):
+ break
+ next_pos += 1
+
+ page = data[start:next_pos]
+
+ yield from _parse_fsevents_page(page)
+
+ pos = next_pos
+
+
+def _read_fsevents_file(fh: BinaryIO) -> Iterator[dict]:
+ """Read and decompress an FSEvents file, then parse all records."""
+ raw = fh.read()
+
+ # FSEvents files are gzip-compressed
+ try:
+ bio = io.BytesIO(raw)
+ data = open_decompress(fileobj=bio).read()
+ yield from _parse_fsevents_stream(data)
+ except Exception:
+ pass
+ else:
+ return
+
+ # Velociraptor may zip-compress collected files
+ if raw[:2] == b"PK":
+ try:
+ zf = zipfile.ZipFile(io.BytesIO(raw))
+ for name in zf.namelist():
+ inner = zf.read(name)
+ # Inner file may be gzip-compressed
+ with contextlib.suppress(Exception):
+ bio = io.BytesIO(inner)
+ inner = open_decompress(fileobj=bio).read()
+ yield from _parse_fsevents_page(inner)
+ except Exception:
+ pass
+ else:
+ return
+
+ # May be uncompressed (older macOS or partial)
+ yield from _parse_fsevents_page(raw)
+
+
+class FSEventsPlugin(Plugin):
+ """Plugin to parse macOS FSEvents (File System Events).
+
+ FFSEvents is a macOS API that allows applications to register for notifications of
+ changes like file creation, deletion, modification, and renaming to a given directory tree
+ which helps applications to keep track of file system changes in real-time without continuously
+ scanning the disk.
+
+ Forensic value: FSEvents capture detailed information about modifications occurring within
+ the file system such as file creation, deletion, modification, renaming and mounting. offering
+ a timeline of changes that can help identify patterns or anomalies, and can uncover critical
+ artifacts that play a key role in cross-referencing and validating other digital evidence during investigations.
+
+ References:
+ - https://developer.apple.com/documentation/coreservices/1455361-fseventstreameventflags
+ - https://hackmd.io/@M4shl3/FSEvents
+ """
+
+ PATHS = (
+ ".fseventsd/fc*",
+ "%2Efseventsd/fc*",
+ "/var/db/fseventsd/fc*",
+ "System/Volumes/Data/.fseventsd/fc*",
+ "System/Volumes/Data/%2Efseventsd/fc*",
+ )
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = self._find_files()
+
+ def _find_files(self) -> set:
+ files = set()
+
+ for pattern in self.PATHS:
+ for path in self.target.fs.glob(pattern):
+ files.add(self.target.fs.path(path))
+
+ return files
+
+ def check_compatible(self) -> None:
+ if not self.files:
+ raise UnsupportedPluginError("No FSEvents files found")
+
+ @export(record=FSEventRecord)
+ def fs_events(self) -> Iterator[FSEventRecord]:
+ """Parse all FSEvents records showing file system activity.
+
+ Yields FSEventRecords with the following fields:
+
+ .. code-block:: text
+
+ path (string): Path to the affected file or directory.
+ event_id (varint): Identifier for the event.
+ flags (string[]): Event flag names decoded from bitmask.
+ node_id (varint): File system node identifier.
+ source (path): Path to the fs events file.
+ """
+ for file in self.files:
+ with file.open("rb") as fh:
+ for rec in _read_fsevents_file(fh):
+ yield FSEventRecord(
+ path=rec["path"],
+ event_id=rec["event_id"],
+ event_flags=_decode_flags(rec["flags"]),
+ node_id=rec["node_id"],
+ source=file,
+ _target=self.target,
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/gkopaque.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/gkopaque.py
new file mode 100644
index 0000000000..50706ff047
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/gkopaque.py
@@ -0,0 +1,105 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_sqlite_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target import Target
+
+WhitelistRecord = TargetRecordDescriptor(
+ "macos/gkopaque/whitelist",
+ [
+ ("string", "table"),
+ ("bytes", "current"),
+ ("bytes", "opaque"),
+ ("path", "source"),
+ ],
+)
+
+ConditionsRecord = TargetRecordDescriptor(
+ "macos/gkopaque/conditions",
+ [
+ ("string", "table"),
+ ("string", "label"),
+ ("varint", "weight"),
+ ("string", "conditions_source"),
+ ("string", "identifier"),
+ ("string", "version"),
+ ("string", "conditions"),
+ ("path", "source"),
+ ],
+)
+
+
+GatekeeperOpaqueConfigurationRecords = (
+ WhitelistRecord,
+ ConditionsRecord,
+)
+
+FIELD_MAPPINGS = {
+ "source": "conditions_source",
+}
+
+
+class GatekeeperOpaqueConfigurationPlugin(Plugin):
+ """macOS gatekeeper opaque configuration plugin.
+
+ Gatekeeper is a macOS security feature that checks the code signing of downloaded
+ apps and blocks those that don't meet Apple's trust and policy requirements.
+ /var/db/gkopaque.bundle/Contents/Resources/gkopaque.db contains a whitelist table of
+ gatekeeper-trusted application code signature hashes and associated opaque values.
+ It also includes a conditions table, which defines policy rules applied to
+ specific applications.
+
+
+ References:
+ - https://indiestack.com/2014/10/gatekeepers-opaque-whitelist/
+ - https://developer.apple.com/documentation/metal/mtlbinaryarchive/label?changes=_1&language=objc
+ """
+
+ PATH = "/var/db/gkopaque.bundle/Contents/Resources/gkopaque.db"
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.file = self.target.fs.path(self.PATH) if self.target.fs.path(self.PATH).exists() else None
+
+ def check_compatible(self) -> None:
+ if not self.file:
+ raise UnsupportedPluginError("No gkopaque.db file found")
+
+ @export(record=GatekeeperOpaqueConfigurationRecords)
+ def gkopaque(self) -> Iterator[GatekeeperOpaqueConfigurationRecords]:
+ """Return macOS Gatekeeper opaque configuration database entries.
+
+ Yields the following record types extracted from the
+ gkopaque.db database:
+
+ .. code-block:: text
+
+ WhitelistRecord:
+ table (string): Name of the source table (whitelist).
+ current (bytes): Code Directory Hash (CDHash) identifying a trusted code object.
+ opaque (bytes): Associated opaque validation data.
+ source (path): Path to the gkopaque.db file.
+
+ ConditionsRecord:
+ table (string): Name of the source table (conditions).
+ label (string): A string that identifies the library.
+ weight (varint): Priority value assigned to the condition.
+ conditions_source (string): Identifier indicating the origin of the condition rule.
+ identifier (string): Bundle identifier associated with the condition rule.
+ version (string): Version string for the condition.
+ conditions (string): Condition expression used by Gatekeeper.
+ source (path): Path to the gkopaque.db file.
+ """
+ yield from build_sqlite_records(
+ self, (self.file,), GatekeeperOpaqueConfigurationRecords, field_mappings=FIELD_MAPPINGS
+ )
+
+ # TODO: Add merged table
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/global_user_preferences.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/global_user_preferences.py
new file mode 100644
index 0000000000..950988b1c2
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/global_user_preferences.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import DynamicDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import _build_userdirs
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_plist_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target.target import Target
+
+
+class GlobalUserPreferencesPlugin(Plugin):
+ """macOS global user preferences plugin.
+
+ .GlobalPreferences.plist files are located in each user's
+ ~/Library/Preferences directory. This property list contains
+ system-wide preference settings that apply across applications for the user.
+ """
+
+ PATHS = ("Library/Preferences/.GlobalPreferences.plist",)
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = self._find_files()
+
+ def check_compatible(self) -> None:
+ if not (self.files):
+ raise UnsupportedPluginError("No global user preferences files found")
+
+ def _find_files(self) -> set:
+ files = set()
+ for _, path in _build_userdirs(self, self.PATHS):
+ files.add(path)
+ return files
+
+ @export(record=DynamicDescriptor(["string"]))
+ def global_user_preferences(self) -> Iterator[DynamicDescriptor]:
+ """Yield global user preference information."""
+ yield from build_plist_records(self, self.files, function_name="macos/global_user_preferences")
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/groups.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/groups.py
new file mode 100644
index 0000000000..194bb8bc32
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/groups.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+import plistlib
+from typing import TYPE_CHECKING
+
+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 dissect.target import Target
+
+GroupInfoRecord = TargetRecordDescriptor(
+ "macos/groups",
+ [
+ ("string[]", "generateduid"),
+ ("string[]", "members"),
+ ("string[]", "smb_sid"),
+ ("varint[]", "gid"),
+ ("string[]", "name"),
+ ("string[]", "realname"),
+ ("path", "source"),
+ ],
+)
+
+
+class GroupPlugin(Plugin):
+ """macOS group plugin.
+
+ Parses ``/var/db/dslocal/nodes/Default/groups/*.plist`` files, which contain config data for groups.
+
+ References:
+ - https://xmcyber.com/blog/introducing-machound-a-solution-to-macos-active-directory-based-attacks/
+ """
+
+ GROUP_PATH_GLOB = "/var/db/dslocal/nodes/Default/groups/*.plist"
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.group_files = self._resolve_files()
+
+ def check_compatible(self) -> None:
+ if not self.group_files:
+ raise UnsupportedPluginError("No group files found")
+
+ def _resolve_files(self) -> set():
+ files = set()
+ for file in self.target.fs.glob(self.GROUP_PATH_GLOB):
+ files.add(file)
+ return files
+
+ @export(record=GroupInfoRecord)
+ def groups(self) -> Iterator[GroupInfoRecord]:
+ """Return group information.
+
+ Yields GroupInfoRecords with the following fields:
+
+ .. code-block:: text
+
+ generateduid (string[]): Generated unique identifier(s) for the group.
+ members (string[]): List of user accounts that are members of the group.
+ smb_sid (string[]): SMB security identifier(s) associated with the group.
+ gid (varint[]): Group ID(s) assigned to the group.
+ name (string[]): Name(s) of the group.
+ realname (string[]): Realname(s) of the group.
+ source (path): Path to the group plist file.
+ """
+ for file in self.group_files:
+ file = self.target.fs.path(file)
+ group_data = plistlib.load(file.open())
+
+ yield GroupInfoRecord(
+ generateduid=group_data.get("generateduid"),
+ members=group_data.get("users"),
+ smb_sid=group_data.get("smb_sid"),
+ gid=group_data.get("gid"),
+ name=group_data.get("name"),
+ realname=group_data.get("realname"),
+ source=file,
+ _target=self.target,
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/helpers/__init__.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/helpers/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/helpers/build_paths.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/helpers/build_paths.py
new file mode 100644
index 0000000000..cc19fb4a67
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/helpers/build_paths.py
@@ -0,0 +1,118 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from dissect.target.plugin import Plugin
+ from dissect.target.plugins.general.users import UserDetails
+ from dissect.target.target import Target
+
+
+def _build_userdirs(plugin: Plugin, hist_paths: list[str]) -> set[tuple[UserDetails, Path]]:
+ """Join the selected dirs with the user home path.
+
+ Args:
+ hist_paths: A list with paths as strings.
+
+ Returns:
+ List of tuples containing user and unique file path objects.
+ """
+ users_dirs: set[tuple] = set()
+ for user_details in plugin.target.user_details.all_with_home():
+ for d in hist_paths:
+ home_dir: Path = user_details.home_path
+ try:
+ for cur_dir in home_dir.glob(d):
+ cur_dir = cur_dir.resolve()
+ if cur_dir.exists():
+ users_dirs.add((user_details, cur_dir))
+ except Exception:
+ pass
+ return users_dirs
+
+
+START_PATHS = {
+ "/Applications/",
+ "/Applications/Utilities/",
+ "/System/Library/CoreServices/",
+ "/System/Library/Extensions/",
+ "/System/Library/Filesystems/*/",
+ "/System/Library/Filesystems/*/Encodings/",
+ "/System/Library/PrivateFrameworks/",
+ "/System/Library/SystemProfiler/*/",
+ "/System/Library/Frameworks",
+}
+
+EXTENSIONS = {
+ "*.app": ["/Contents/", "/Contents/Resources/"],
+ "*.kext": ["/Contents/", "/Contents/PlugIns/", "/Contents/Resources/"],
+ "*.framework": ["/Versions/A/", "/Versions/A/Resources/"],
+ "*.bundle": ["/Contents/", "/Contents/Resources/"],
+ "*.plugin": ["/Contents/"],
+ "*.prefPane": ["/Contents/", "/Contents/Resources/"],
+ "*.help": ["/Contents/", "/Contents/Resources/"],
+ "*.spreporter": ["/Contents/Resources/"],
+}
+
+
+def find_bundle_files(target: Target, end_path: str) -> set:
+ """Search for files matching a given end path within known macOS bundle locations.
+
+ Iterates over predefined base system paths and collects all matching files
+ found by recursively exploring bundle structures.
+
+ Args:
+ target (Target): Object providing filesystem access.
+ end_path (str): Relative path to search for within bundles.
+
+ Returns:
+ set: A set of matching file paths.
+ """
+ results = set()
+
+ for base in START_PATHS:
+ results.update(find_end_paths(target, end_path, target.fs.path(base)))
+
+ return results
+
+
+def find_end_paths(target: Target, end_path: str, base_path: Path) -> set:
+ """Recursively search for files within macOS bundle directories that match a given end path.
+
+ Looks for known bundle types (e.g., .app, .framework, .kext) based on the
+ EXTENSIONS mapping, which defines the internal subdirectories that should be traversed
+ for each bundle extension. Explores the relevant subpaths for every discovered bundle,
+ and checks whether the specified relative file path (end path) exists.
+
+ Continues recursively into each valid subpath, in order to handle
+ nested bundle structures.
+
+ Args:
+ target (Target): Object providing filesystem access.
+ end_path (str): Relative path to locate inside bundle directories.
+ base_path (Path): Base directory from which the search begins.
+
+ Returns:
+ set: A set of matching file paths found within bundle hierarchies.
+ """
+ found = set()
+
+ for ext, subpaths in EXTENSIONS.items():
+ for bundle_str in target.fs.glob(f"{base_path}/{ext}"):
+ bundle = target.fs.path(bundle_str)
+
+ for sub in subpaths:
+ sub_path = bundle.joinpath(sub.lstrip("/"))
+
+ if not sub_path.exists():
+ continue
+
+ candidate = sub_path.joinpath(end_path.lstrip("/"))
+ if candidate.exists():
+ found.add(candidate)
+
+ found.update(find_end_paths(target, end_path, sub_path))
+
+ return found
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/helpers/build_records.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/helpers/build_records.py
new file mode 100644
index 0000000000..6443ba994f
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/helpers/build_records.py
@@ -0,0 +1,997 @@
+from __future__ import annotations
+
+import plistlib
+import re
+import uuid
+from collections import defaultdict
+from datetime import datetime, timedelta, timezone
+from io import BytesIO
+from itertools import product
+from typing import TYPE_CHECKING, Any, BinaryIO
+
+from dissect.database.sqlite3 import SQLite3
+from dissect.util.plist import NSDictionary, NSKeyedArchiver
+
+from dissect.target.helpers.fsutil import open_decompress
+from dissect.target.helpers.record import TargetRecordDescriptor
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+ from pathlib import Path
+
+ from flow.record.base import Record
+
+ from dissect.target.plugin import Plugin
+
+re_non_identifier = re.compile(r"[^A-Za-z0-9_]")
+
+
+def build_sqlite_records(
+ plugin: Plugin,
+ files: set[str],
+ record_descriptors: tuple | None = None,
+ joins: tuple = (),
+ field_mappings: dict | None = None,
+ value_mappings: dict | None = None,
+ convert_timestamps: dict | None = None,
+) -> Iterator[TargetRecordDescriptor]:
+ """Extract and normalize records from SQLite databases.
+
+ Iterates over provided SQLite files, reads all tables and rows, and converts
+ them into dictionaries. Supports decompression. Supports decoding of binary plist values and
+ NSKeyedArchiver blobs. The optional joins arg can be used to combine and
+ filter rows across related tables.
+
+ Join behavior is controlled via the `joins` configuration:
+ iterate: iterates over rows of table2 and merges them with
+ matching table1 entries. Table2 rows that do not match with any table1
+ will be yielded separately.
+ nested: adds a field to table1 rows containing all of the matching table2 rows.
+ ignore: removes specific fields when values match, used to avoid duplicate fields on joins.
+
+ Args:
+ plugin (Plugin): Plugin instance providing logging and target access.
+ files (set[str]): Paths to SQLite database files.
+ record_descriptors (tuple | None): Optional descriptors for record construction.
+ joins (tuple): Collection of join configuration dictionaries defining relationships between tables.
+ Each item is a dict describing a join rule between two tables:
+ table1 (str): Name of the primary table.
+ key1 (str): Column in table1 used for joining.
+ table2 (str): Name of the secondary table.
+ key2 (str): Column in table2 used for joining.
+ join (str): Join type to apply:
+ iterate = Create separate records for each matching row in table2.
+ nested = Attach matching table2 rows as a nested dict in table1 records.
+ ignore = Used to omit duplicate or redundant fields during joins.
+ field_mappings (dict | None): Optional field name mappings.
+ value_mappings (dict| None): Optional value mappings.
+ convert_timestamps (dict | None): Optional timestamp conversion rules.
+
+ Yields:
+ TargetRecordDescriptor: Normalized records constructed from database rows.
+ """
+ joins_by_table1 = defaultdict(list)
+ joins_by_table2 = defaultdict(list)
+ ignore_joins_map = defaultdict(list)
+
+ for j in joins:
+ joins_by_table1[j["table1"]].append(j)
+ joins_by_table2[j["table2"]].append(j)
+
+ if j["join"] == "ignore":
+ key = (j["table1"], j["table2"])
+ ignore_joins_map[key].append(j)
+ for file in files:
+ try:
+ with SQLite3(file) as database:
+ for table in database.tables():
+ for row in table.rows():
+ row_dict = {k: v for k, v in row} # noqa C416
+
+ for key, value in list(row_dict.items()):
+ # Try decompressing binary fields
+ if isinstance(value, (bytes, bytearray)) and len(value) > 4:
+ try:
+ bio = BytesIO(value)
+ decompressed = open_decompress(fileobj=bio).read()
+
+ if decompressed != value:
+ row_dict[key] = decompressed
+ value = decompressed
+
+ except Exception:
+ pass
+
+ # Decode binary plist values (including NSKeyedArchiver blobs)
+ if isinstance(value, (bytes, bytearray)) and value.startswith(b"bplist00"):
+ if is_nskeyedarchive_blob(value):
+ try:
+ archiver = NSKeyedArchiver(BytesIO(value))
+ decoded_value = archiver.top.get("root")
+ row_dict[key] = decoded_value
+ except Exception:
+ plugin.target.log.exception(
+ "Failed to decode %s value for key %s",
+ table.name,
+ key,
+ )
+ else:
+ try:
+ plist_data = (
+ load_plist_data(value)
+ if b"$archiver" in value[:128]
+ else plistlib.loads(value)
+ )
+
+ if isinstance(plist_data, dict):
+ pk = table.primary_key
+ if pk:
+ # Add the source row to the plist_path
+ # format -> "
/="
+ pk_value = row_dict[pk]
+ path = "/".join([table.name, f"{pk}={pk_value}"])
+ else:
+ # Add the source row to the plist_path
+ # format -> ""
+ path = table.name
+ yield from emit_dict_records(
+ plugin,
+ plist_data,
+ file,
+ path=path,
+ record_descriptors=record_descriptors,
+ field_mappings=field_mappings,
+ value_mappings=value_mappings,
+ convert_timestamps=convert_timestamps,
+ )
+ row_dict.pop(key)
+ else:
+ # If binary plist is an attribute and not a dict
+ # replace binary plist with extracted value
+ row_dict[key] = plist_data
+
+ except Exception:
+ plugin.target.log.exception(
+ "Failed to parse plist in %s (table=%s, key=%s)",
+ file,
+ table.name,
+ key,
+ )
+ row_dict.pop(key)
+
+ if table.name in joins_by_table2:
+ # Yield table2 rows that don't have a matching table1
+ for j2 in joins_by_table2[table.name]:
+ if row_dict.get(j2["key2"]) is None:
+ row_dict["table"] = table.name
+ yield build_record(
+ plugin,
+ row_dict,
+ file,
+ record_descriptors,
+ field_mappings,
+ value_mappings,
+ convert_timestamps,
+ )
+ break
+ else:
+ match = False
+ for row1 in database.table(j2["table1"]).rows():
+ row1_dict = {k: v for k, v in row1} # noqa C416
+ if row1_dict.get(j2["key1"]) == row_dict.get(j2["key2"]):
+ match = True
+ break
+
+ if not match:
+ row_dict["table"] = table.name
+ yield build_record(
+ plugin,
+ row_dict,
+ file,
+ record_descriptors,
+ field_mappings,
+ value_mappings,
+ convert_timestamps,
+ )
+ break
+
+ elif table.name in joins_by_table1:
+ tables = set()
+ iterate_rows = defaultdict(list)
+ tables.add(table.name)
+
+ # Handle joins
+ for j in joins_by_table1[table.name]:
+ ignore_joins = ignore_joins_map[(j["table1"], j["table2"])]
+
+ if j["join"] == "iterate":
+ for expanded in handle_iterate_join(
+ database, row_dict, j, joins_by_table1, ignore_joins_map, tables
+ ):
+ iterate_rows[j["table2"]].append(expanded)
+
+ elif j["join"] == "nested":
+ handle_nested_join(database, row_dict, j, ignore_joins, tables)
+
+ # If there were iterate joins, yield records for every combination
+ if len(iterate_rows) > 0:
+ row_dict = prefix_row(row_dict, table.name)
+ keys = list(iterate_rows.keys())
+ values = list(iterate_rows.values())
+ for key in keys:
+ tables.add(key)
+ row_dict["tables"] = tables
+
+ for combination in product(*values):
+ combined_row = dict(row_dict)
+ for joined_row in combination:
+ combined_row.update(joined_row)
+
+ yield build_record(
+ plugin,
+ combined_row,
+ file,
+ record_descriptors,
+ convert_timestamps=convert_timestamps,
+ )
+ else:
+ # Yield row as record without iterate joins
+ yield build_record(
+ plugin,
+ row_dict,
+ file,
+ record_descriptors,
+ field_mappings,
+ value_mappings,
+ convert_timestamps,
+ )
+ # Yield row as record without joins
+ else:
+ row_dict["table"] = table.name
+ yield build_record(
+ plugin,
+ row_dict,
+ file,
+ record_descriptors,
+ field_mappings,
+ value_mappings,
+ convert_timestamps,
+ )
+ except Exception:
+ plugin.target.log.exception("Failed to process SQLite file: %s", file)
+
+
+def prefix_row(row_dict: dict, table: str) -> dict:
+ """Prefix all keys in a row dictionary with the table name.
+
+ This prevents duplicate field names when joining multiple tables.
+
+ Args:
+ row_dict (dict): Row data as key-value pairs.
+ table (str): Table name to prepend to each key.
+
+ Returns:
+ dict: A new dictionary with prefixed keys.
+ """
+ return {f"{table}_{k}": v for k, v in row_dict.items()}
+
+
+def handle_iterate_join(
+ database: SQLite3,
+ parent_dict: dict,
+ current_join: dict,
+ joins_by_table1: defaultdict(list),
+ ignore_joins_map: defaultdict(list),
+ tables: set[str],
+) -> list[dict]:
+ """Process iterative joins between tables.
+
+ Matches child rows to a parent row based on join keys and expands them
+ into separate result dictionaries. Recursively processes downstream joins,
+ allowing chained relationships across multiple tables.
+
+ Applies "ignore" rules to remove matching fields where configured.
+
+ Args:
+ database (SQLite3): Open SQLite database instance.
+ parent_dict (dict): Current parent row data.
+ current_join (dict): Join configuration describing the relationship.
+ joins_by_table1 (defaultdict): Joins indexed by source table.
+ ignore_joins_map (defaultdict): Mapping of ignore join rules.
+ tables (set[str]): Set of involved table names.
+
+ Returns:
+ list[dict]: Expanded and prefixed row dictionaries.
+ """
+ results: list[dict] = []
+
+ ignore_joins = ignore_joins_map[(current_join["table1"], current_join["table2"])]
+
+ for child_row in database.table(current_join["table2"]).rows():
+ if child_row.get(current_join["key2"]) != parent_dict.get(current_join["key1"]):
+ continue
+
+ tables.add(current_join["table2"])
+
+ child_dict = {k: v for k, v in child_row} # noqa C416
+ child_dict.pop(current_join["key2"], None)
+
+ for ij in ignore_joins:
+ if parent_dict.get(ij["key1"]) == child_dict.get(ij["key2"]):
+ child_dict.pop(ij["key2"], None)
+
+ # Remove fields from child_dict that will be yielded as separate plists
+ for k, v in child_dict.items():
+ if isinstance(v, (bytes, bytearray)) and v.startswith(b"bplist00") and not is_nskeyedarchive_blob(v):
+ try:
+ plist_data = load_plist_data(v) if b"$archiver" in v[:128] else plistlib.loads(v)
+
+ if isinstance(plist_data, dict):
+ child_dict.pop(k)
+ except Exception:
+ child_dict.pop(k)
+
+ downstream_iterate_rows = defaultdict(list)
+ for j in joins_by_table1[current_join["table2"]]:
+ downstream_ignore = ignore_joins_map[(j["table1"], j["table2"])]
+
+ if j["join"] == "iterate":
+ for expanded in handle_iterate_join(database, child_dict, j, joins_by_table1, ignore_joins_map, tables):
+ downstream_iterate_rows[j["table2"]].append(expanded)
+
+ elif j["join"] == "nested":
+ tables.add(j["table2"])
+ handle_nested_join(database, child_dict, j, downstream_ignore)
+
+ if len(downstream_iterate_rows) > 0:
+ base_prefixed = prefix_row(child_dict, current_join["table2"])
+ values = list(downstream_iterate_rows.values())
+
+ for combination in product(*values):
+ combined_row = dict(base_prefixed)
+ for joined_row in combination:
+ combined_row.update(joined_row)
+ results.append(combined_row)
+ else:
+ results.append(prefix_row(child_dict, current_join["table2"]))
+
+ return results
+
+
+def handle_nested_join(
+ database: SQLite3,
+ row_dict: dict,
+ current_join: dict,
+ ignore_joins: list[dict],
+ tables: set[str],
+) -> None:
+ """Process nested joins between tables.
+
+ Finds related rows in a secondary table and embeds them as a list of
+ dictionaries under a key named after the joined table.
+
+ Applies ignore rules to remove matching fields where configured.
+
+ Args:
+ database (SQLite3): Open SQLite database instance.
+ row_dict (dict): Current row being enriched.
+ current_join (dict): Join configuration describing the relationship.
+ ignore_joins (list[dict]): Ignore rules for this join.
+ tables (set[str]): Set of involved table names.
+
+ Returns:
+ None: Modifies `row_dict` in place.
+ """
+ n_rows = []
+ for n_row in database.table(current_join["table2"]).rows():
+ if n_row[current_join["key2"]] == row_dict[current_join["key1"]]:
+ tables.add(current_join["table2"])
+ n_dict = {k: v for k, v in n_row} # noqa C416
+ n_dict.pop(current_join["key2"])
+ for ij in ignore_joins:
+ if row_dict.get(ij["key1"]) == n_dict.get(ij["key2"]):
+ n_dict.pop(ij["key2"])
+ n_rows.append(n_dict)
+ row_dict[current_join["table2"]] = n_rows
+
+
+def is_nskeyedarchive_blob(value: (bytes, bytearray)) -> bool:
+ """Determine whether a binary blob is an NSKeyedArchiver-encoded plist.
+
+ Attempts to parse the blob as a plist and checks for the structural
+ markers used by NSKeyedArchiver.
+
+ Args:
+ value (bytes | bytearray): Binary data to inspect.
+
+ Returns:
+ bool: True if the blob appears to be an NSKeyedArchiver archive.
+ """
+ try:
+ plist_obj = plistlib.loads(value)
+ except Exception:
+ return False
+
+ return (
+ isinstance(plist_obj, dict)
+ and plist_obj.get("$archiver") == "NSKeyedArchiver"
+ and "$objects" in plist_obj
+ and "$top" in plist_obj
+ )
+
+
+def build_plist_records(
+ plugin: Plugin,
+ files: set[str],
+ record_descriptors: tuple | None = None,
+ collapse_paths: set[tuple[str, bool]] | None = None,
+ field_mappings: dict | None = None,
+ value_mappings: dict | None = None,
+ convert_timestamps: dict | None = None,
+ function_name: str | None = None,
+) -> Iterator[Record]:
+ """Extract and normalize records from plist files.
+
+ Iterates over provided file paths, parses each file as a plist, and emits
+ records from the resulting data structures. Supports both standard plist
+ formats and NSKeyedArchiver-encoded plists.
+
+ Parsed data is passed to emit_dict_records for recursive traversal
+ and record construction.
+
+ Args:
+ plugin (Plugin): Plugin instance providing logging and target access.
+ files (set[str]): Paths to plist files.
+ record_descriptors (tuple | None): Optional descriptors for record construction.
+ collapse_paths (set[tuple[str, bool]] | None): Plist paths to collapse during recursive traversal.
+ field_mappings (dict | None): Optional field name mappings.
+ value_mappings (dict| None): Optional value mappings.
+ convert_timestamps (dict | None): Optional timestamp conversion rules.
+ function_name (str | None): Optional name used for dynamic record creation.
+
+ Yields:
+ Record: Normalized records constructed from plist contents.
+ """
+ for file in files:
+ file = plugin.target.fs.path(file)
+ try:
+ fh = file.open(mode="rb")
+ except FileNotFoundError:
+ plugin.target.log.exception("File not found: %s", file)
+ continue
+
+ try:
+ if b"$archiver" in fh.peek(64):
+ fh.seek(0)
+ try:
+ data = load_plist_data(fh)
+ except Exception:
+ fh.seek(0)
+ data = plistlib.load(fh)
+ else:
+ fh.seek(0)
+ data = plistlib.load(fh)
+
+ yield from emit_dict_records(
+ plugin,
+ data,
+ file,
+ record_descriptors=record_descriptors,
+ collapse_paths=collapse_paths,
+ field_mappings=field_mappings,
+ value_mappings=value_mappings,
+ convert_timestamps=convert_timestamps,
+ function_name=function_name,
+ )
+
+ except Exception:
+ plugin.target.log.exception("Failed to parse %s", file)
+
+
+def dynamic_build_record(plugin: Plugin, function_name: str, rdict: dict, source: Path) -> Record:
+ """Dynamically construct a record descriptor and corresponding record from a dictionary.
+
+ Infers field types based on Python value types and builds a descriptor
+ accordingly. Supports lists, and includes the source
+ path as a field.
+
+ Args:
+ plugin (Plugin): Plugin instance providing target context.
+ function_name (str): Name used for the generated record descriptor.
+ rdict (dict): Dictionary containing record data.
+ source (Path): Source path associated with the record.
+
+ Returns:
+ Record: A record instance created from the dynamically generated descriptor.
+ """
+ record_fields = sorted(rdict.items())
+
+ record_values = {
+ "_target": plugin.target,
+ "source": source,
+ }
+ record_fields = []
+
+ for k, v in rdict.items():
+ k = format_key(k)
+
+ if isinstance(v, bool):
+ record_fields.append(("boolean", k))
+ elif isinstance(v, int):
+ record_fields.append(("varint", k))
+ elif isinstance(v, list):
+ record_fields.append(("string[]", k))
+ else:
+ record_fields.append(("string", k))
+
+ record_values[k] = v
+
+ record_fields.append(("path", "source"))
+
+ desc = TargetRecordDescriptor(function_name, record_fields)
+
+ return desc(**record_values)
+
+
+def select_descriptor(
+ record_descriptors: tuple,
+ rdict: dict,
+ plugin: Plugin,
+ source: Path,
+) -> TargetRecordDescriptor | None:
+ """Select the most appropriate record descriptor for a given data dictionary.
+
+ Compares the keys in the dictionary against available record descriptors
+ and selects the one with the highest number of matching fields. In case of a tie,
+ the descriptor with fewer total fields is preferred.
+
+ Logs a warning if the input contains fields not defined in the selected descriptor.
+
+ Args:
+ record_descriptors (tuple): Available record descriptors.
+ rdict (dict): Dictionary containing record data.
+ plugin (Plugin): Plugin instance for logging.
+ source (Path): Source path associated with the record.
+
+ Returns:
+ TargetRecordDescriptor | None: The selected descriptor, or None if no match is found.
+ """
+ formatted_rdict = {format_key(k): type(v).__name__ for k, v in rdict.items()}
+
+ selected_record = None
+ best_match_count = 0
+ best_match_length = 0
+
+ for record in record_descriptors:
+ record_keys = set(record.fields.keys())
+
+ matched_keys = set(formatted_rdict.keys()) & record_keys
+ match_count = len(matched_keys)
+
+ if match_count > best_match_count:
+ best_match_count = match_count
+ best_match_length = len(record_keys)
+ selected_record = record
+
+ elif match_count == best_match_count and len(record_keys) < best_match_length:
+ best_match_length = len(record_keys)
+ selected_record = record
+
+ if best_match_count == 0:
+ return None
+
+ missing_fields = {
+ key: type_name
+ for key, type_name in formatted_rdict.items()
+ if key not in selected_record.fields and type_name != "NoneType"
+ }
+
+ if missing_fields:
+ formatted = ", ".join(f"{k} ({v})" for k, v in sorted(missing_fields.items()))
+ plugin.target.log.warning(
+ "Source %s contains fields not defined in the selected record descriptor: %s",
+ source,
+ formatted,
+ )
+
+ return selected_record
+
+
+def build_record(
+ plugin: Plugin,
+ rdict: dict,
+ source: Path,
+ record_descriptors: tuple,
+ field_mappings: dict | None = None,
+ value_mappings: dict | None = None,
+ convert_timestamps: dict | None = None,
+) -> Record:
+ """Construct a record from a dictionary using a matching record descriptor.
+
+ Applies provided field mappings, selects an appropriate descriptor based on
+ the fields in the dictionary, filters unsupported fields, performs provided
+ timestamp and value conversions before instantiating the record.
+
+ If no matching descriptor is found, logs an error and returns None.
+
+ Args:
+ plugin (Plugin): Plugin instance providing logging and target context.
+ rdict (dict): Dictionary containing record data.
+ source (Path): Source path associated with the record.
+ record_descriptors (tuple): Available record descriptors.
+ field_mappings (dict | None): Optional field name mappings.
+ value_mappings (dict| None): Optional value mappings.
+ convert_timestamps (dict | None): Optional timestamp conversion rules.
+
+ Returns:
+ Record | None: Constructed record, or None if no descriptor matched.
+ """
+ if field_mappings:
+ for key in list(rdict):
+ for src, dst in field_mappings.items():
+ if format_key(key) == src:
+ rdict[dst] = rdict.pop(key)
+
+ desc = select_descriptor(record_descriptors, rdict, plugin, source)
+
+ if desc is None:
+ typed_fields = ", ".join(f"{format_key(k)} ({type(v).__name__})" for k, v in sorted(rdict.items()))
+ plugin.target.log.exception(
+ "No matching record descriptor for %s with fields %s",
+ source,
+ typed_fields,
+ )
+ return None
+
+ allowed_keys = set(desc.fields.keys())
+ filtered_rdict = {k: v for k, v in rdict.items() if format_key(k) in allowed_keys}
+
+ record_values = {
+ "_target": plugin.target,
+ "source": source,
+ }
+
+ for k, v in filtered_rdict.items():
+ key = format_key(k)
+ if convert_timestamps and key in convert_timestamps:
+ v = convert_timestamp(v, convert_timestamps[key])
+
+ if value_mappings and key in value_mappings:
+ v = convert_value(plugin, key, v, value_mappings, source)
+
+ record_values[key] = v
+
+ return desc(**record_values)
+
+
+def convert_value(
+ plugin: Plugin,
+ key: str,
+ value: Any,
+ value_mappings: dict,
+ source: str,
+) -> Any:
+ """Convert a value using configured value mappings.
+
+ Applies direct value mappings where available, and decodes bitmask-style
+ combined values for integer inputs. If no mapping is found, the original
+ value is returned and a warning is logged.
+
+ Args:
+ plugin (Plugin): Plugin instance providing logging and target access.
+ key (str): Field name associated with the value.
+ value (Any): Input value to convert.
+ value_mappings (dict): Mapping of field names to value maps.
+ source (Path): Source path associated with the record.
+
+ Returns:
+ Any: Converted value if mapping is found, otherwise the original value.
+ """
+ if not value_mappings or key not in value_mappings:
+ return value
+
+ value_mapping = value_mappings[key]
+
+ # Try direct mapping
+ new_value = value_mapping.get(value)
+ if new_value is not None:
+ return new_value
+
+ # Try bitmask decoding
+ if isinstance(value, int) and is_bitmask_mapping(value_mapping):
+ if value == 0:
+ return None
+
+ matched = [mapped_value for raw_value, mapped_value in value_mapping.items() if value & raw_value]
+
+ if matched:
+ return matched
+
+ # No mapping found → log + fallback
+ plugin.target.log.warning(
+ "No value mapping defined for field '%s' with value '%s' in %s",
+ key,
+ value,
+ source,
+ )
+
+ return value
+
+
+def is_bitmask_mapping(mapping: dict) -> bool:
+ """Check if all non-zero keys are powers of two (bitmask flags)."""
+ return all(isinstance(k, int) and (k & (k - 1) == 0) for k in mapping if k != 0)
+
+
+def convert_timestamp(value: Any, mode: str) -> datetime | Any:
+ """Convert a value to a datetime based on the specified timestamp format.
+
+ Supports conversion from Apple's epoch (seconds since 2001-01-01 UTC).
+ If the value is None, it is returned unchanged.
+ Unsupported modes return the original value.
+
+ Args:
+ value (Any): Input value to convert.
+ mode (str): Conversion mode (e.g., "2001" for Apple epoch).
+
+ Returns:
+ datetime | Any: Converted datetime, or original value if no conversion applied.
+ """
+ if value is None:
+ return value
+
+ if mode == "2001":
+ return datetime(2001, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=float(value))
+
+ return value
+
+
+def format_key(key: str) -> str:
+ """Normalize a key name (string) to a valid format.
+
+ Replaces unsupported characters with underscores, collapses
+ repeated underscores, removes leading underscores, and ensures
+ the key does not start with a digit.
+
+ Args:
+ key (str): The key name to be formatted.
+
+ Returns:
+ str: Normalized key name suitable for use as an identifier.
+ """
+ key = re_non_identifier.sub("_", key)
+
+ key = re.sub(r"_+", "_", key)
+
+ key = key.lstrip("_")
+
+ if not key or key[0].isdigit():
+ key = f"k_{key}"
+
+ return key
+
+
+UUID_RE = re.compile(
+ r"^[0-9A-Fa-f]{8}-"
+ r"[0-9A-Fa-f]{4}-"
+ r"[0-9A-Fa-f]{4}-"
+ r"[0-9A-Fa-f]{4}-"
+ r"[0-9A-Fa-f]{12}$"
+)
+
+
+def is_collapsed_path(child_path: str, collapse_paths: set[tuple[str, bool]]) -> bool:
+ """Determine whether a path should be collapsed during traversal.
+
+ A path is considered collapsed if it matches a configured path exactly,
+ or if it is a descendant of a configured path when non-exact matching is allowed.
+
+ Args:
+ child_path (str): Current traversal path.
+ collapse_paths (set[tuple[str, bool]]): Set of (path, exact) rules.
+
+ Returns:
+ bool: True if the path should be collapsed, False otherwise.
+ """
+ for collapse_path, exact in collapse_paths:
+ if child_path == collapse_path:
+ return True
+ if not exact and collapse_path and child_path.startswith(f"{collapse_path}/"):
+ return True
+ return False
+
+
+def emit_dict_records(
+ plugin: Plugin,
+ node: dict,
+ source: Path,
+ *,
+ section: str | None = None,
+ path: str | None = None,
+ record_descriptors: tuple | None = None,
+ collapse_paths: set[tuple[str, bool]] | None = None,
+ field_mappings: dict | None = None,
+ value_mappings: dict | None = None,
+ convert_timestamps: dict | None = None,
+ function_name: str | None = None,
+) -> Iterator[Record]:
+ """Recursively traverse a dictionary and emit records from its contents.
+
+ Splits dictionary entries into attribute values and nested child dictionaries.
+
+ Handles lists by separating scalar elements from nested dictionaries:
+ scalar values are retained as attributes
+ dictionary elements are treated as child nodes and processed recursively.
+
+ Supports decompression. Supports decoding of binary plist values and NSKeyedArchiver blobs.
+
+ Collapses specified paths into attribute values instead of recursing.
+
+ Generates records for nodes containing attribute data, using either dynamic
+ descriptor construction or predefined record descriptors. Recursively processes
+ nested child dictionaries to emit additional records.
+
+ Args:
+ plugin (Plugin): Plugin instance providing logging and target context.
+ node (dict): Dictionary node to process.
+ source (Path): Source path associated with the data.
+ section (str | None): Optional section label for records.
+ path (str | None): Current traversal path within the structure.
+ record_descriptors (tuple | None): Optional descriptors for record construction.
+ collapse_paths (set[tuple[str, bool]] | None): Paths to collapse instead of recurse.
+ field_mappings (dict | None): Optional field name mappings.
+ value_mappings (dict| None): Optional value mappings.
+ convert_timestamps (dict | None): Optional timestamp conversion rules.
+ function_name (str | None): Optional name for dynamic record creation.
+
+ Yields:
+ Record: Records generated from dictionary contents.
+ """
+ if path and path.endswith("$class"):
+ return
+
+ attributes = {}
+ child_dicts = {}
+
+ for k, v in node.items():
+ child_path = f"{path}/{k}" if path else k
+
+ if collapse_paths and isinstance(v, dict) and is_collapsed_path(child_path, collapse_paths):
+ attributes[k] = list(v.items())
+ continue
+
+ if isinstance(v, dict):
+ child_dicts[k] = v
+
+ elif isinstance(v, list):
+ cleaned_list = []
+ contains_dict = False
+ for i, item in enumerate(v):
+ if isinstance(item, dict):
+ contains_dict = True
+ child_dicts[f"{k}[{i}]"] = item
+ else:
+ cleaned_list.append(item)
+
+ if cleaned_list or not contains_dict:
+ attributes[k] = cleaned_list
+
+ elif isinstance(v, (bytes, bytearray)):
+ if len(v) > 4:
+ try:
+ bio = BytesIO(v)
+ decompressed = open_decompress(fileobj=bio).read()
+
+ if decompressed != v:
+ v = decompressed
+
+ except Exception:
+ pass
+ if isinstance(v, (bytes, bytearray)) and v.startswith(b"bplist00"):
+ if is_nskeyedarchive_blob(v):
+ try:
+ archiver = NSKeyedArchiver(BytesIO(v))
+ decoded_value = archiver.top.get("root")
+ attributes[k] = decoded_value
+ except Exception:
+ plugin.target.log.exception(
+ "Failed to decode %s value for key %s",
+ child_path,
+ k,
+ )
+ else:
+ try:
+ plist_data = load_plist_data(v) if b"$archiver" in v[:128] else plistlib.loads(v)
+
+ if isinstance(plist_data, dict):
+ yield from emit_dict_records(
+ plugin,
+ plist_data,
+ source,
+ record_descriptors=record_descriptors,
+ field_mappings=field_mappings,
+ value_mappings=value_mappings,
+ convert_timestamps=convert_timestamps,
+ )
+ else:
+ # If binary plist is an attribute and not a dict
+ attributes[k] = plist_data
+
+ except Exception:
+ plugin.target.log.exception(
+ "Failed to parse plist in %s (plist_path=%s, key=%s)",
+ source,
+ child_path,
+ k,
+ )
+ else:
+ attributes[k] = v
+ else:
+ attributes[k] = v
+
+ if node and all(isinstance(k, str) and UUID_RE.fullmatch(k) for k in node):
+ attributes = {}
+
+ if attributes:
+ record_data = dict(attributes)
+
+ if section is not None:
+ record_data["section"] = section
+ if path is not None:
+ record_data["plist_path"] = path
+
+ if record_descriptors is None:
+ yield dynamic_build_record(plugin, function_name, record_data, source)
+ else:
+ yield build_record(
+ plugin, record_data, source, record_descriptors, field_mappings, value_mappings, convert_timestamps
+ )
+
+ for k, child in child_dicts.items():
+ child_path = f"{path}/{k}" if path else k
+ yield from emit_dict_records(
+ plugin,
+ child,
+ source,
+ section=section,
+ path=child_path,
+ record_descriptors=record_descriptors,
+ collapse_paths=collapse_paths,
+ field_mappings=field_mappings,
+ value_mappings=value_mappings,
+ convert_timestamps=convert_timestamps,
+ function_name=function_name,
+ )
+
+
+def normalize_nsobj(obj: Any) -> Any:
+ """Convert NSKeyedArchiver output to plain Python types."""
+ if isinstance(obj, NSDictionary):
+ return {k: normalize_nsobj(v) for k, v in obj.items()}
+
+ if isinstance(obj, dict):
+ return {k: normalize_nsobj(v) for k, v in obj.items()}
+
+ if isinstance(obj, list):
+ return [normalize_nsobj(v) for v in obj]
+
+ if isinstance(obj, (str, int, float, bool)) or obj is None:
+ return obj
+
+ if isinstance(obj, datetime):
+ return obj
+
+ if isinstance(obj, uuid.UUID):
+ return str(obj)
+
+ if hasattr(obj, "keys"):
+ return {k: normalize_nsobj(obj.get(k)) for k in obj.keys()} # noqa: SIM118
+
+ return obj
+
+
+def load_plist_data(fh: BinaryIO) -> Any:
+ """Load and normalize data from an NSKeyedArchiver-encoded plist."""
+ ns = NSKeyedArchiver(fh)
+ root = ns.get("store")
+ return normalize_nsobj(root)
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/helpers/parse_timestamp.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/helpers/parse_timestamp.py
new file mode 100644
index 0000000000..88f06f5e01
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/helpers/parse_timestamp.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ import re
+
+
+def parse_timestamp(timestamp: re.Match) -> datetime:
+ """Parse a timestamp match into a datetime object.
+
+ Attempts to parse the matched string as an ISO 8601 datetime. If that fails,
+ falls back to parsing a syslog-style timestamp ("%b %d %H:%M:%S") and assigns UTC timezone.
+
+ Args:
+ timestamp (re.Match): A regex match object containing the timestamp string.
+
+ Returns:
+ datetime: The parsed datetime object.
+ """
+ ts = None
+ value = timestamp.group()
+ try:
+ value = value.removesuffix("-0")
+
+ ts = datetime.fromisoformat(value)
+ except ValueError:
+ ts = datetime.strptime(value, "%b %d %H:%M:%S").replace(tzinfo=timezone.utc)
+
+ return ts
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/identity_services.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/identity_services.py
new file mode 100644
index 0000000000..8137fe0253
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/identity_services.py
@@ -0,0 +1,86 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.database.sqlite3 import SQLite3
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import _build_userdirs
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target import Target
+
+
+SqliteDatabasePropertiesRecord = TargetRecordDescriptor(
+ "macos/identity_services",
+ [
+ ("string", "table"),
+ ("string", "key"),
+ ("string", "value"),
+ ("path", "source"),
+ ],
+)
+
+
+class IdentityServicesPlugin(Plugin):
+ """macOS identity services plugin.
+
+ ids.db is a SQLite database used by macOS's Identity Services framework (IDS),
+ the system that powers services like iMessage and FaceTime via the identityservicesd
+ daemon to store local identity-related data and metadata.
+ """
+
+ USER_PATH = ("Library/IdentityServices/ids.db",)
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = self._find_files()
+
+ def check_compatible(self) -> None:
+ if not (self.files):
+ raise UnsupportedPluginError("No ids.db files found")
+
+ def _find_files(self) -> set:
+ files = set()
+ for _, path in _build_userdirs(self, self.USER_PATH):
+ files.add(path)
+ return files
+
+ @export(
+ record=[
+ SqliteDatabasePropertiesRecord,
+ ]
+ )
+ def identity_services(
+ self,
+ ) -> Iterator[
+ [
+ SqliteDatabasePropertiesRecord,
+ ]
+ ]:
+ """Return identity services information.
+
+ Yields SqliteDatabasePropertiesRecords with the following fields:
+
+ .. code-block:: text
+
+ table (string): Name of the source table (_SqliteDatabaseProperties).
+ key (string): Key name.
+ value (string): Value associated with the key.
+ source (path): Path to the com.apple.airport.preferences.plist file.
+ """
+ for file in self.files:
+ with SQLite3(file) as database:
+ for row in database.table("_SqliteDatabaseProperties").rows():
+ yield SqliteDatabasePropertiesRecord(
+ table="_SqliteDatabaseProperties",
+ key=row.key,
+ value=row.value,
+ source=file,
+ )
+
+ # TODO: Add outgoing_message, sqlite_sequence, incoming_message, outgoing_messages_to_delete tables
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/installation_history.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/installation_history.py
new file mode 100644
index 0000000000..07b93c19dc
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/installation_history.py
@@ -0,0 +1,70 @@
+from __future__ import annotations
+
+import plistlib
+from typing import TYPE_CHECKING
+
+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 dissect.target import Target
+
+InstallationHistoryRecord = TargetRecordDescriptor(
+ "macos/installation_history",
+ [
+ ("datetime", "ts"),
+ ("string", "display_name"),
+ ("string", "display_version"),
+ ("string", "process_name"),
+ ("path", "source"),
+ ],
+)
+
+
+class InstallationHistoryPlugin(Plugin):
+ """macOS Software installation history property list plugin.
+
+ Extracts the history of installed applications and updates.
+
+ References:
+ - https://forensics.wiki/mac_os_x_10.9_artifacts_location/#software-installation
+ """
+
+ PATH = "/Library/Receipts/InstallHistory.plist"
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.file = self.target.fs.path(self.PATH) if self.target.fs.path(self.PATH).exists() else None
+
+ def check_compatible(self) -> None:
+ if not self.file:
+ raise UnsupportedPluginError("No InstallHistory.plist file found")
+
+ @export(record=InstallationHistoryRecord)
+ def installation_history(self) -> Iterator[InstallationHistoryRecord]:
+ """Return installation history information.
+
+ Yields InstallationHistoryRecord with the following fields:
+
+ .. code-block:: text
+
+ ts (datetime): Timestamp of the installation.
+ display_name (string): Display name of the installed software.
+ display_version (string): Display version of the installed software.
+ process_name (string): Name of the installation process.
+ source (path): Path to the InstallHistory.plist file.
+ """
+ plist = plistlib.load(self.file.open())
+ data = plist[0]
+
+ yield InstallationHistoryRecord(
+ ts=data.get("date"),
+ display_name=data.get("displayName"),
+ display_version=data.get("displayVersion"),
+ process_name=data.get("processName"),
+ source=self.file,
+ _target=self.target,
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/keyboard_layout.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/keyboard_layout.py
new file mode 100644
index 0000000000..9b173cd9e5
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/keyboard_layout.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+import plistlib
+from typing import TYPE_CHECKING
+
+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 dissect.target import Target
+
+KeyboardLayoutRecord = TargetRecordDescriptor(
+ "macos/keyboard_layout",
+ [
+ ("string", "input_source_kind"),
+ ("string", "keyboard_layout_name"),
+ ("varint", "keyboard_layout_id"),
+ ("boolean", "enabled_layout"),
+ ("boolean", "selected_layout"),
+ ("boolean", "current_layout"),
+ ("path", "source"),
+ ],
+)
+
+
+class KeyboardLayoutPlugin(Plugin):
+ """macOS keyboard layout plugin.
+
+ This plugin extracts information about the keyboard layouts of the system.
+ """
+
+ PATH = "/Library/Preferences/com.apple.HIToolbox.plist"
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.file = self.target.fs.path(self.PATH) if self.target.fs.path(self.PATH).exists() else None
+
+ def check_compatible(self) -> None:
+ if not self.file:
+ raise UnsupportedPluginError("No com.apple.HIToolbox.plist file found")
+
+ @export(record=KeyboardLayoutRecord)
+ def keyboard_layout(self) -> Iterator[KeyboardLayoutRecord]:
+ """Return macOS keyboard layout information.
+
+ Yields KeyboardLayoutRecord with the following fields:
+
+ .. code-block:: text
+
+ input_source_kind (string): Kind of the input source.
+ keyboard_layout_name (string): Name of the keyboard layout.
+ keyboard_layout_id (varint): ID of the keyboard layout.
+ enabled_layout (boolean): Whether the layout is enabled.
+ selected_layout (boolean): Whether the layout is selected.
+ current_layout (boolean): Whether it is the current layout.
+ source (path): Path to the com.apple.HIToolbox.plist file.
+ """
+ plist = plistlib.loads(self.file.read_bytes())
+
+ for source in plist.get("AppleEnabledInputSources", []):
+ yield KeyboardLayoutRecord(
+ input_source_kind=source.get("InputSourceKind"),
+ keyboard_layout_name=source.get("KeyboardLayout Name"),
+ keyboard_layout_id=source.get("KeyboardLayout ID"),
+ enabled_layout=True,
+ selected_layout=source in plist.get("AppleSelectedInputSources", []),
+ current_layout=(
+ source.get("KeyboardLayout ID") == plist.get("AppleCurrentKeyboardLayoutInputSourceID")
+ ),
+ source=self.file,
+ _target=self.target,
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/keychain.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/keychain.py
new file mode 100644
index 0000000000..d4819d42b6
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/keychain.py
@@ -0,0 +1,253 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import _build_userdirs
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_sqlite_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target import Target
+
+GenpRecord = TargetRecordDescriptor(
+ "macos/keychain/genp",
+ [
+ ("string", "table"),
+ ("varint", "row_id"),
+ ("datetime", "cdat"),
+ ("datetime", "mdat"),
+ ("bytes", "desc"),
+ ("bytes", "icmt"),
+ ("varint", "crtr"),
+ ("varint", "keychain_type"),
+ ("varint", "scrp"),
+ ("bytes", "labl"),
+ ("bytes", "alis"),
+ ("varint", "invi"),
+ ("varint", "nega"),
+ ("varint", "cusi"),
+ ("bytes", "prot"),
+ ("bytes", "acct"),
+ ("bytes", "svce"),
+ ("bytes", "gena"),
+ ("string", "data"),
+ ("string", "agrp"),
+ ("string", "pdmn"),
+ ("varint", "sync"),
+ ("varint", "tomb"),
+ ("string", "sha1"),
+ ("string", "vwht"),
+ ("string", "tkid"),
+ ("string", "musr"),
+ ("string", "UUID"),
+ ("varint", "sysb"),
+ ("varint", "pcss"),
+ ("bytes", "pcsk"),
+ ("bytes", "pcsi"),
+ ("bytes", "persistref"),
+ ("varint", "clip"),
+ ("string", "ggrp"),
+ ("path", "source"),
+ ],
+)
+
+
+InetRecord = TargetRecordDescriptor(
+ "macos/keychain/inet",
+ [
+ ("string", "table"),
+ ("varint", "row_id"),
+ ("datetime", "cdat"),
+ ("datetime", "mdat"),
+ ("bytes", "desc"),
+ ("bytes", "icmt"),
+ ("varint", "crtr"),
+ ("varint", "keychain_type"),
+ ("varint", "scrp"),
+ ("bytes", "labl"),
+ ("bytes", "alis"),
+ ("varint", "invi"),
+ ("varint", "nega"),
+ ("varint", "cusi"),
+ ("bytes", "prot"),
+ ("bytes", "acct"),
+ ("bytes", "sdmn"),
+ ("bytes", "srvr"),
+ ("string", "ptcl"),
+ ("bytes", "atyp"),
+ ("varint", "port"),
+ ("bytes", "path_binary"),
+ ("string", "data"),
+ ("string", "agrp"),
+ ("string", "pdmn"),
+ ("varint", "sync"),
+ ("varint", "tomb"),
+ ("string", "sha1"),
+ ("string", "vwht"),
+ ("string", "tkid"),
+ ("string", "musr"),
+ ("string", "UUID"),
+ ("varint", "sysb"),
+ ("varint", "pcss"),
+ ("bytes", "pcsk"),
+ ("bytes", "pcsi"),
+ ("bytes", "persistref"),
+ ("varint", "clip"),
+ ("string", "ggrp"),
+ ("path", "source"),
+ ],
+)
+
+KeysRecord = TargetRecordDescriptor(
+ "macos/keychain/keys",
+ [
+ ("string", "table"),
+ ("varint", "row_id"),
+ ("datetime", "cdat"),
+ ("datetime", "mdat"),
+ ("bytes", "kcls"),
+ ("bytes", "labl"),
+ ("bytes", "alis"),
+ ("varint", "perm"),
+ ("varint", "priv"),
+ ("varint", "modi"),
+ ("bytes", "klbl"),
+ ("bytes", "atag"),
+ ("varint", "crtr"),
+ ("varint", "keychain_type"),
+ ("varint", "bsiz"),
+ ("varint", "esiz"),
+ ("varint", "sdat"),
+ ("varint", "edat"),
+ ("varint", "sens"),
+ ("varint", "asen"),
+ ("varint", "extr"),
+ ("varint", "next"),
+ ("varint", "encr"),
+ ("varint", "decr"),
+ ("varint", "drve"),
+ ("varint", "sign"),
+ ("varint", "vrfy"),
+ ("varint", "snrc"),
+ ("varint", "vyrc"),
+ ("varint", "wrap"),
+ ("varint", "unwp"),
+ ("string", "data"),
+ ("string", "agrp"),
+ ("string", "pdmn"),
+ ("varint", "sync"),
+ ("varint", "tomb"),
+ ("string", "sha1"),
+ ("string", "vwht"),
+ ("string", "tkid"),
+ ("string", "musr"),
+ ("string", "UUID"),
+ ("varint", "sysb"),
+ ("varint", "pcss"),
+ ("bytes", "pcsk"),
+ ("bytes", "pcsi"),
+ ("bytes", "persistref"),
+ ("varint", "clip"),
+ ("string", "ggrp"),
+ ("path", "source"),
+ ],
+)
+
+SqliteSequenceRecord = TargetRecordDescriptor(
+ "macos/keychain/sqlite_sequence",
+ [
+ ("string", "table"),
+ ("string", "name"),
+ ("varint", "seq"),
+ ("path", "source"),
+ ],
+)
+
+TVersionRecord = TargetRecordDescriptor(
+ "macos/keychain/t_version",
+ [
+ ("string", "table"),
+ ("varint", "row_id"),
+ ("string", "version"),
+ ("varint", "minor"),
+ ("path", "source"),
+ ],
+)
+
+MetaDataKeysRecord = TargetRecordDescriptor(
+ "macos/keychain/meta_data_keys",
+ [
+ ("string", "table"),
+ ("varint", "keyclass"),
+ ("varint", "actual_keyclass"),
+ ("string", "data"),
+ ("path", "source"),
+ ],
+)
+
+KeychainRecords = (
+ GenpRecord,
+ InetRecord,
+ KeysRecord,
+ SqliteSequenceRecord,
+ TVersionRecord,
+ MetaDataKeysRecord,
+)
+
+FIELD_MAPPINGS = {
+ "actualKeyclass": "actual_keyclass",
+ "rowid": "row_id",
+ "path": "path_binary",
+ "type": "keychain_type",
+}
+
+CONVERT_TIMESTAMPS = {
+ "cdat": "2001",
+ "mdat": "2001",
+}
+
+
+class KeychainPlugin(Plugin):
+ """macOS keychain plugin.
+
+ Parses Data Protection keychain databases (keychain-2.db).
+ These are stored in ``Library/Keychains/*/``, where ``*`` is the UUID that assigned to that Mac.
+
+ References:
+ - https://eclecticlight.co/2023/08/07/an-introduction-to-keychains-and-how-theyve-changed/
+ """
+
+ USER_PATH = ("Library/Keychains/*/keychain-2.db",)
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = self._find_files()
+
+ def check_compatible(self) -> None:
+ if not (self.files):
+ raise UnsupportedPluginError("No keychain-2.db files found")
+
+ def _find_files(self) -> set:
+ files = set()
+ for _, path in _build_userdirs(self, self.USER_PATH):
+ files.add(path)
+ return files
+
+ @export(record=KeychainRecords)
+ def keychain(
+ self,
+ ) -> Iterator[KeychainRecords]:
+ """Return macOS Keychain database entries."""
+ yield from build_sqlite_records(
+ self, self.files, KeychainRecords, field_mappings=FIELD_MAPPINGS, convert_timestamps=CONVERT_TIMESTAMPS
+ )
+
+ # TODO: Add cert, outgoingqueue, incomingqueue, synckeys, ckmirror, currentkeys,
+ # ckstate, item_backup, backup_keybag, ckmanifest, pending_manifest, ckmanifest_leaf,
+ # backup_keyarchive, currentkeyarchives, archived_key_backup, pending_manifest_leaf,
+ # currentitems, ckdevicestate, tlkshare, sharingIncomingQueue,
+ # sharingMirror, sharingOutgoingQueue tables
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/launchers.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/launchers.py
new file mode 100644
index 0000000000..713781a944
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/launchers.py
@@ -0,0 +1,498 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import _build_userdirs
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_plist_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target.target import Target
+
+LauncherRecord = [
+ ("string", "label"),
+ ("string", "program"),
+ ("string[]", "program_arguments"),
+ ("string", "keep_alive"),
+ ("boolean", "on_demand"),
+ ("string", "disabled"),
+ ("boolean", "run_at_load"),
+ ("boolean", "launch_only_once"),
+ ("string", "process_type"),
+ ("boolean", "wait"),
+ ("string", "limit_load_to_session_type"),
+ ("boolean", "limit_load_to_developer_mode"),
+ ("string", "limit_load_from_variant"),
+ ("string", "limit_load_to_variant"),
+ ("string", "limit_load_from_boot_mode"),
+ ("string[]", "limit_load_to_boot_mode"),
+ ("string[]", "limit_load_from_hardware"),
+ ("string[]", "limit_load_to_hardware"),
+ ("string[]", "launch_events"),
+ ("string[]", "mach_services"),
+ ("string", "enable_pressured_exit"),
+ ("boolean", "enable_transactions"),
+ ("string[]", "environment_variables"),
+ ("string", "user_name"),
+ ("boolean", "init_groups"),
+ ("string", "group_name"),
+ ("varint", "start_interval"),
+ ("string[]", "start_calendar_interval"),
+ ("varint", "throttle_interval"),
+ ("boolean", "enable_globbing"),
+ ("string", "standard_in_path"),
+ ("string", "standard_out_path"),
+ ("string", "standard_error_path"),
+ ("varint", "nice"),
+ ("boolean", "abandon_process_group"),
+ ("boolean", "low_priority_io"),
+ ("string", "root_directory"),
+ ("string", "working_directory"),
+ ("varint", "umask"),
+ ("varint", "time_out"),
+ ("varint", "exit_time_out"),
+ ("string[]", "watch_paths"),
+ ("string[]", "queue_directories"),
+ ("boolean", "start_on_mount"),
+ ("string[]", "soft_resource_limits"),
+ ("string[]", "hard_resource_limits"),
+ ("boolean", "debug"),
+ ("boolean", "wait_for_debugger"),
+ ("string[]", "path_state"),
+ ("string[]", "other_job_enabled"),
+ ("boolean", "low_priority_background_io"),
+ ("string", "plist_path"),
+ ("path", "source"),
+]
+
+SocketRecord = [
+ ("string", "socket_key"),
+ ("string", "sock_type"),
+ ("boolean", "sock_passive"),
+ ("string", "sock_node_name"),
+ ("string", "sock_service_name"),
+ ("string", "sock_family"),
+ ("string", "sock_protocol"),
+ ("varint", "sock_path_mode"),
+ ("string", "sock_path_name"),
+ ("string", "secure_socket_with_key"),
+ ("varint", "sock_path_owner"),
+ ("varint", "sock_path_group"),
+ ("string", "bonjour"),
+ ("string", "multicast_group"),
+ ("string", "plist_path"),
+ ("path", "source"),
+]
+
+UNRecord = [
+ ("boolean", "un_setting_alerts"),
+ ("boolean", "un_setting_always_show_previews"),
+ ("boolean", "un_setting_lock_screen"),
+ ("boolean", "un_setting_modal_alert_style"),
+ ("boolean", "un_automatically_show_settings"),
+ ("boolean", "un_setting_notification_center"),
+ ("boolean", "un_daemon_should_receive_background_responses"),
+ ("boolean", "un_suppress_user_authorization_prompt"),
+ ("string", "plist_path"),
+ ("path", "source"),
+]
+
+UNNotificationRecord = [
+ ("string", "un_notification_icon_default"),
+ ("string", "un_notification_icon_settings"),
+ ("string", "plist_path"),
+ ("path", "source"),
+]
+
+ListenersRecord = [
+ ("string[]", "listeners"),
+ ("string", "plist_path"),
+ ("path", "source"),
+]
+
+FIELD_MAPPINGS = {
+ # LauncherRecord
+ "Label": "label",
+ "Program": "program",
+ "ProgramArguments": "program_arguments",
+ "KeepAlive": "keep_alive",
+ "OnDemand": "on_demand",
+ "Disabled": "disabled",
+ "RunAtLoad": "run_at_load",
+ "LaunchOnlyOnce": "launch_only_once",
+ "ProcessType": "process_type",
+ "Wait": "wait",
+ "LimitLoadToSessionType": "limit_load_to_session_type",
+ "LimitLoadFromHardware": "limit_load_from_hardware",
+ "LimitLoadToDeveloperMode": "limit_load_to_developer_mode",
+ "LimitLoadFromVariant": "limit_load_from_variant",
+ "LimitLoadToVariant": "limit_load_to_variant",
+ "LimitLoadFromBootMode": "limit_load_from_boot_mode",
+ "LimitLoadToBootMode": "limit_load_to_boot_mode",
+ "LimitLoadToHardware": "limit_load_to_hardware",
+ "LaunchEvents": "launch_events",
+ "MachServices": "mach_services",
+ "EnablePressuredExit": "enable_pressured_exit",
+ "EnableTransactions": "enable_transactions",
+ "EnvironmentVariables": "environment_variables",
+ "UserName": "user_name",
+ "GroupName": "group_name",
+ "StartInterval": "start_interval",
+ "StartCalendarInterval": "start_calendar_interval",
+ "ThrottleInterval": "throttle_interval",
+ "EnableGlobbing": "enable_globbing",
+ "StandardInPath": "standard_in_path",
+ "StandardOutPath": "standard_out_path",
+ "StandardErrorPath": "standard_error_path",
+ "Nice": "nice",
+ "LowPriorityIO": "low_priority_io",
+ "AbandonProcessGroup": "abandon_process_group",
+ "RootDirectory": "root_directory",
+ "WorkingDirectory": "working_directory",
+ "Umask": "umask",
+ "TimeOut": "time_out",
+ "ExitTimeOut": "exit_time_out",
+ "InitGroups": "init_groups",
+ "WatchPaths": "watch_paths",
+ "QueueDirectories": "queue_directories",
+ "StartOnMount": "start_on_mount",
+ "SoftResourceLimits": "soft_resource_limits",
+ "HardResourceLimits": "hard_resource_limits",
+ "Debug": "debug",
+ "WaitForDebugger": "wait_for_debugger",
+ "path_state": "PathState",
+ "other_job_enabled": "OtherJobEnabled",
+ "low_priority_background_io": "LowPriorityBackgroundIO",
+ # SocketRecord
+ "SocketKey": "socket_key",
+ "SockType": "sock_type",
+ "SockPassive": "sock_passive",
+ "SockNodeName": "sock_node_name",
+ "SockServiceName": "sock_service_name",
+ "SockFamily": "sock_family",
+ "SockProtocol": "sock_protocol",
+ "SockPathMode": "sock_path_mode",
+ "SockPathName": "sock_path_name",
+ "SecureSocketWithKey": "secure_socket_with_key",
+ "SockPathOwner": "sock_path_owner",
+ "SockPathGroup": "sock_path_group",
+ "Bonjour": "bonjour",
+ "MulticastGroup": "multicast_group",
+ # UNRecord
+ "UNSettingAlerts": "un_setting_alerts",
+ "UNSettingAlwaysShowPreviews": "un_setting_always_show_previews",
+ "UNSettingLockScreen": "un_setting_lock_screen",
+ "UNSettingModalAlertStyle": "un_setting_modal_alert_style",
+ "UNAutomaticallyShowSettings": "un_automatically_show_settings",
+ "UNSettingNotificationCenter": "un_setting_notification_center",
+ "UNDaemonShouldReceiveBackgroundResponses": "un_daemon_should_receive_background_responses",
+ "UNSuppressUserAuthorizationPrompt": "un_suppress_user_authorization_prompt",
+ # UNNotificationRecord
+ "UNNotificationIconDefault": "un_notification_icon_default",
+ "UNNotificationIconSettings": "un_notification_icon_settings",
+ # ListenersRecord
+ "Listeners": "listeners",
+}
+
+LaunchAgentRecords = (
+ TargetRecordDescriptor(
+ "macos/launch_agents",
+ LauncherRecord,
+ ),
+ TargetRecordDescriptor(
+ "macos/launch_agents/socket",
+ SocketRecord,
+ ),
+ TargetRecordDescriptor(
+ "macos/launch_agents/un",
+ UNRecord,
+ ),
+ TargetRecordDescriptor(
+ "macos/launch_agents/un_notification",
+ UNNotificationRecord,
+ ),
+)
+
+LaunchDaemonRecords = (
+ TargetRecordDescriptor(
+ "macos/launch_daemons",
+ LauncherRecord,
+ ),
+ TargetRecordDescriptor(
+ "macos/launch_daemons/socket",
+ SocketRecord,
+ ),
+ TargetRecordDescriptor(
+ "macos/launch_daemons/listeners",
+ ListenersRecord,
+ ),
+)
+
+COLLAPSE_PATHS = {
+ ("LaunchEvents", False),
+ ("MachServices", True),
+ ("EnvironmentVariables", True),
+ ("KeepAlive", False),
+ ("SoftResourceLimits", True),
+ ("HardResourceLimits", True),
+ ("MultipleInstances", True),
+ ("UserName", True),
+ ("GroupName", True),
+ ("RemoteServices", False),
+ ("JetsamProperties", True),
+ ("LimitLoadToSessionType", True),
+ ("Version4", False),
+ ("EnablePressuredExit", True),
+ ("LimitLoadToHardware", True),
+ ("_PanicOnCrash", True),
+ ("LimitLoadFromHardware", True),
+ ("StartCalendarInterval", True),
+ ("PublishesEvents", False),
+ ("_AdditionalProperties", False),
+ ("Disabled", True),
+ ("com.apple.usbcd", True),
+ ("NoEnvironmentVariables", True),
+ ("NO_EnvironmentVariables", True),
+ ("NSAppTransportSecurity", True),
+ ("_UrgentLogSubmission", True),
+ ("AppIntents", True),
+}
+
+
+class LaunchersPlugin(Plugin):
+ """macOS launchers plugin.
+
+ Parses LaunchAgent and LaunchDaemon files, which are configuration-based background services
+ managed by macOS that automatically run tasks or processes based on system or user-level triggers.
+
+ References:
+ - https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html
+ - https://www.manpagez.com/man/5/launchd.plist/osx-10.13.1.php
+ - https://developer.apple.com/documentation/usernotifications/unusernotificationcenter
+ - https://medium.com/@durgaviswanadh/understanding-macos-launchagents-and-login-items-a-clear-practical-guide-5c0e39e3a6b3
+ """
+
+ SYSTEM_LAUNCH_AGENT_PATHS = (
+ "/System/Library/LaunchAgents/*.plist",
+ "/Library/LaunchAgents/*.plist",
+ )
+
+ SYSTEM_LAUNCH_DAEMON_PATHS = (
+ "/System/Library/LaunchDaemons/*.plist",
+ "/Library/LaunchDaemons/*.plist",
+ )
+
+ USER_LAUNCH_AGENT_PATHS = ("Library/LaunchAgents/*.plist",)
+
+ USER_LAUNCH_DAEMON_PATHS = ("Library/LaunchDaemons/*.plist",)
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+
+ self.launch_agent_files = self._find_agent_files()
+ self.launch_daemon_files = self._find_daemon_files()
+
+ def check_compatible(self) -> None:
+ if not (self.launch_agent_files or self.launch_daemon_files):
+ raise UnsupportedPluginError("No Agent or Deamon files found")
+
+ def _find_agent_files(self) -> set:
+ launch_agent_files = set()
+ for pattern in self.SYSTEM_LAUNCH_AGENT_PATHS:
+ for path in self.target.fs.glob(pattern):
+ launch_agent_files.add(path)
+ for _, path in _build_userdirs(self, self.USER_LAUNCH_AGENT_PATHS):
+ launch_agent_files.add(path)
+ return launch_agent_files
+
+ def _find_daemon_files(self) -> set:
+ launch_daemon_files = set()
+ for pattern in self.SYSTEM_LAUNCH_DAEMON_PATHS:
+ for path in self.target.fs.glob(pattern):
+ launch_daemon_files.add(path)
+ for _, path in _build_userdirs(self, self.USER_LAUNCH_DAEMON_PATHS):
+ launch_daemon_files.add(path)
+ return launch_daemon_files
+
+ @export(record=LaunchAgentRecords)
+ def launch_agents(self) -> Iterator[LaunchAgentRecords]:
+ """Return macOS LaunchAgent plist entries.
+
+ Yields the following record types extracted from
+ LaunchAgent plist files:
+
+ .. code-block:: text
+
+ LauncherRecord:
+ label (string): Required key that uniquely identifies the job in launchd.
+ program (string): Absolute path to the executable mapped to execv(3).
+ program_arguments (string[]): Argument vector passed to execvp(3).
+ keep_alive (string): Controls whether the job remains running continuously
+ or is restarted based on configured conditions.
+ on_demand (boolean): Deprecated key; false is equivalent to KeepAlive=true.
+ disabled (string): Specifies if the job should be loaded by default; may be overridden externally.
+ run_at_load (boolean): Starts the job when it is loaded into launchd.
+ launch_only_once (boolean): Indicates the job must not be respawned after execution.
+ process_type (string): Declares the job classification (e.g. Background,
+ Adaptive, Interactive) used for resource management.
+ wait (boolean): inetd compatibility flag determining whether sockets are passed
+ directly or accepted on behalf of the job.
+ limit_load_to_developer_mode (boolean): Restricts execution depending on developer mode state.
+ limit_load_to_variant (string): Restricts loading to specific system variants.
+ limit_load_from_variant (string): Prevents loading on specific system variants.
+ limit_load_to_boot_mode (string[]): Restricts loading to specified boot modes.
+ limit_load_from_boot_mode (string): Prevents loading when in specified boot mode.
+ limit_load_to_hardware (string[]): Restricts loading to systems matching hardware values.
+ limit_load_from_hardware (string[]): Prevents loading on matching hardware values.
+ launch_events (string[]): Defines event-based triggers used to start the job.
+ mach_services (string[]): Mach services registered in the bootstrap namespace.
+ enable_pressured_exit (string): Enables lifecycle management under memory pressure.
+ enable_transactions (boolean): Indicates the job uses XPC transaction tracking for safe termination.
+ environment_variables (string[]): Environment variables to set before execution.
+ user_name (string): User identity the job runs as (for system domain jobs).
+ init_groups (boolean): Whether initgroups(3) is called to initialize group membership.
+ group_name (string): Group identity the job runs as.
+ start_interval (varint): Starts the job every specified number of seconds.
+ start_calendar_interval (string[]): Scheduling rules similar to cron-style timing.
+ throttle_interval (varint): Minimum interval between job invocations.
+ enable_globbing (boolean): Expands program arguments using glob(3) before execution.
+ standard_in_path (string): File mapped to stdin(4).
+ standard_out_path (string): File mapped to stdout(4).
+ standard_error_path (string): File mapped to stderr(4).
+ nice (varint): Scheduling priority applied with nice(3).
+ abandon_process_group (boolean): Prevents launchd from terminating the job's process group.
+ low_priority_io (boolean): Marks the job as low priority for filesystem I/O.
+ root_directory (string): Directory used as a chroot(2) environment.
+ working_directory (string): Directory set via chdir(2) before execution.
+ umask (varint): Value passed to umask(2) for file creation permissions.
+ time_out (varint): Idle timeout hint (deprecated and not implemented).
+ exit_time_out (varint): Time between SIGTERM and SIGKILL when stopping the job.
+ watch_paths (string[]): Triggers job when specified filesystem paths change.
+ queue_directories (string[]): Keeps job alive while directories are not empty.
+ start_on_mount (boolean): Starts the job when filesystems are mounted.
+ soft_resource_limits (string[]): Soft setrlimit(2)-based resource limits.
+ hard_resource_limits (string[]): Hard setrlimit(2)-based resource limits.
+ debug (boolean): Temporarily elevates logging to debug level for this job.
+ wait_for_debugger (boolean): Launches the process suspended until a debugger attaches.
+
+ SocketRecord:
+ socket_key (string): Identifier used to associate socket configuration with the job.
+ sock_type (string): Socket type passed to socket(2) (e.g. stream, dgram).
+ sock_passive (boolean): Determines if listen(2) or connect(2) is used.
+ sock_node_name (string): Node name used for bind(2) or connect(2).
+ sock_service_name (string): Service name or port used for the socket.
+ sock_family (string): Address family (e.g. IPv4, IPv6, Unix).
+ sock_protocol (string): Protocol used by the socket (TCP or UDP).
+ sock_path_mode (varint): File mode for Unix domain socket.
+ sock_path_name (string): Filesystem path for Unix domain socket.
+ secure_socket_with_key (string): Environment variable key assigned to a generated socket path.
+ sock_path_owner (varint): User ID ownership of the socket file.
+ sock_path_group (varint): Group ID of the socket file.
+ bonjour (string): Registers the service with Bonjour if specified.
+ multicast_group (string): Multicast group joined by the socket.
+
+ UNRecord:
+ un_setting_alerts (boolean): Indicates if notifications are allowed to show alerts.
+ un_setting_always_show_previews (boolean): Controls whether notification previews are always displayed.
+ un_setting_lock_screen (boolean): Determines if notifications are shown on the lock screen.
+ un_setting_modal_alert_style (boolean): Indicates if notifications use modal alert presentation.
+ un_automatically_show_settings (boolean): Indicates if notification settings are shown automatically.
+ un_setting_notification_center (boolean): Indicates if notifications appear in Notification Center.
+ un_daemon_should_receive_background_responses (boolean): Indicates background handling of
+ notification responses.
+ un_suppress_user_authorization_prompt (boolean): Indicates suppression of notification
+ permission prompts.
+
+ UNNotificationRecord:
+ un_notification_icon_default (string): Default icon used for notifications.
+ un_notification_icon_settings (string): Icon used in notification settings.
+ """
+ yield from build_plist_records(
+ self, self.launch_agent_files, LaunchAgentRecords, COLLAPSE_PATHS, FIELD_MAPPINGS
+ )
+
+ @export(record=LaunchDaemonRecords)
+ def launch_daemons(self) -> Iterator[LaunchDaemonRecords]:
+ """Return macOS LaunchDaemon plist entries.
+
+ Yields the following record types extracted from
+ LaunchDaemon plist files:
+
+ .. code-block:: text
+
+ LauncherRecord:
+ label (string): Unique identifier for the daemon in launchd.
+ program (string): Absolute executable path used with execv(3).
+ program_arguments (string[]): Argument vector passed with execvp(3).
+ keep_alive (string): Defines whether the daemon should remain running or
+ restart under specified conditions.
+ on_demand (boolean): Deprecated; false implies KeepAlive behavior.
+ disabled (string): Indicates whether the daemon is disabled by default.
+ run_at_load (boolean): Starts the daemon immediately when loaded.
+ launch_only_once (boolean): Ensures the daemon is only executed one time.
+ process_type (string): Declares job classification influencing system resource limits.
+ wait (boolean): inetd-style behavior for socket handling.
+ limit_load_to_developer_mode (boolean): Restricts loading based on developer mode.
+ limit_load_to_variant (string): Restricts loading to a system variant.
+ limit_load_from_variant (string): Prevents loading on a system variant.
+ limit_load_to_boot_mode (string[]): Restricts loading to specific boot modes.
+ limit_load_from_boot_mode (string): Prevents loading on specific boot modes.
+ limit_load_to_hardware (string[]): Restricts loading to specific hardware values.
+ limit_load_from_hardware (string[]): Prevents loading on specified hardware.
+ launch_events (string[]): Defines event sources that trigger daemon launch.
+ mach_services (string[]): Mach services advertised to the bootstrap namespace.
+ enable_pressured_exit (string): Enables system-managed termination under memory pressure.
+ enable_transactions (boolean): Enables XPC transaction tracking for controlled shutdown.
+ environment_variables (string[]): Environment variables set before execution.
+ user_name (string): User account used to run the daemon.
+ init_groups (boolean): Indicates if initgroups(3) is used for group setup.
+ group_name (string): Group under which the daemon runs.
+ start_interval (varint): Interval-based execution timing.
+ start_calendar_interval (string[]): Calendar-based scheduling rules.
+ throttle_interval (varint): Minimum delay between successive launches.
+ enable_globbing (boolean): Enables glob(3) expansion of arguments.
+ standard_in_path (string): File path mapped to stdin.
+ standard_out_path (string): File path mapped to stdout.
+ standard_error_path (string): File path mapped to stderr.
+ nice (varint): CPU scheduling priority.
+ abandon_process_group (boolean): Prevents launchd from terminating child processes.
+ low_priority_io (boolean): Applies low-priority I/O classification.
+ root_directory (string): Directory used as chroot environment.
+ working_directory (string): Working directory before execution.
+ umask (varint): File creation mask applied to the process.
+ time_out (varint): Deprecated idle timeout setting.
+ exit_time_out (varint): Delay before SIGKILL after SIGTERM.
+ watch_paths (string[]): Launch triggers based on filesystem changes.
+ queue_directories (string[]): Keeps daemon running while directories contain files.
+ start_on_mount (boolean): Starts daemon when filesystems are mounted.
+ soft_resource_limits (string[]): Soft resource limits applied via setrlimit(2).
+ hard_resource_limits (string[]): Hard resource limits applied via setrlimit(2).
+ debug (boolean): Enables debug logging in launchd for this job.
+ wait_for_debugger (boolean): Starts process suspended for debugger attachment.
+
+ SocketRecord:
+ socket_key (string): Identifier for grouping socket definitions.
+ sock_type (string): Socket type used when creating the descriptor.
+ sock_passive (boolean): Indicates if the socket is listening or connecting.
+ sock_node_name (string): Address used when binding or connecting.
+ sock_service_name (string): Port or service name for the socket.
+ sock_family (string): Address family used for socket creation.
+ sock_protocol (string): Protocol associated with the socket.
+ sock_path_mode (varint): Permissions applied to socket file.
+ sock_path_name (string): Filesystem location for the socket.
+ secure_socket_with_key (string): Environment variable referencing generated socket path.
+ sock_path_owner (varint): Owner user ID of the socket file.
+ sock_path_group (varint): Group ID of the socket file.
+ bonjour (string): Optional Bonjour registration.
+ multicast_group (string): Multicast group subscription.
+
+ ListenersRecord:
+ listeners (string[]): Identifiers referring to defined socket groups.
+ """
+ yield from build_plist_records(
+ self, self.launch_daemon_files, LaunchDaemonRecords, COLLAPSE_PATHS, FIELD_MAPPINGS
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/locale.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/locale.py
new file mode 100644
index 0000000000..d8ae7c3103
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/locale.py
@@ -0,0 +1,96 @@
+from __future__ import annotations
+
+import plistlib
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.localeutil import normalize_language
+from dissect.target.plugin import export
+from dissect.target.plugins.os.default.locale import LocalePlugin
+
+if TYPE_CHECKING:
+ from dissect.target.target import Target
+
+
+class LocalePlugin(LocalePlugin):
+ """macOS locale plugin.
+
+ This plugin retrieves locale information from the system.
+ """
+
+ GLOBAL = "/Library/Preferences/.GlobalPreferences.plist"
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+
+ def check_compatible(self) -> None:
+ if not self.target.os == "macos":
+ raise UnsupportedPluginError("Unsupported Plugin")
+
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+ @export(property=True)
+ def timezone(self) -> str | None:
+ """Return the configured timezone of the system.
+
+ .. code-block::
+
+ ["Europe", "Amsterdam"] -> Europe/Amsterdam
+ ["UTC"] -> UTC
+ """
+ preferences = plistlib.load(self.target.fs.path(self.GLOBAL).open())
+ tz_data = preferences.get("com.apple.TimeZonePref.Last_Selected_City")
+
+ tz_candidate = "/".join(tz_data)
+ result = self.check_timezone(tz_candidate)
+ if result:
+ return result
+
+ for entry in tz_data:
+ result = self.check_timezone(entry)
+ if result:
+ return result
+
+ return None
+
+ def check_timezone(self, entry: str) -> str | None:
+ try:
+ return ZoneInfo(entry).key
+ except (ZoneInfoNotFoundError, PermissionError):
+ return None
+
+ @export(property=True)
+ def language(self) -> str | None:
+ """Return a list of installed languages on the system."""
+ preferences = plistlib.load(self.target.fs.path(self.GLOBAL).open())
+ languages = preferences["AppleLanguages"]
+ clean_languages = []
+ for lang in languages:
+ language = normalize_language(lang.replace("-", "_"))
+ if language not in clean_languages:
+ clean_languages.append(language)
+
+ return clean_languages
+
+ @export(property=True)
+ def install_date(self) -> str | None:
+ """Return the installation date of the system."""
+ mtime = self.target.fs.path("/private/var/db/.AppleSetupDone").lstat().st_mtime
+ return datetime.fromtimestamp(mtime, timezone.utc)
+
+ @export(property=True)
+ def location_services_active(self) -> bool | None:
+ """Return whether location services are active."""
+ path = self.target.fs.path("/Library/Preferences/com.apple.timezone.auto.plist")
+
+ if not path.exists():
+ return None
+
+ try:
+ with path.open("rb") as fh:
+ plist = plistlib.load(fh)
+ return plist.get("Active")
+ except Exception:
+ return None
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/login_items.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/login_items.py
new file mode 100644
index 0000000000..448ac87260
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/login_items.py
@@ -0,0 +1,192 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import _build_userdirs
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_plist_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target.target import Target
+
+LoginItemsRecord = TargetRecordDescriptor(
+ "macos/login_items",
+ [
+ ("string", "associated_bundle_identifiers"),
+ ("bytes", "bookmark"),
+ ("string", "bundle_identifier"),
+ ("string", "container"),
+ ("string", "designated_requirement"),
+ ("string", "developer_name"),
+ ("string", "login_item_disposition"),
+ ("datetime", "executable_modification_date"),
+ ("path", "executable_path"),
+ ("varint", "flags"),
+ ("varint", "generation"),
+ ("string", "identifier"),
+ ("bytes", "lightweight_requirement"),
+ ("datetime", "modification_date"),
+ ("string", "name"),
+ ("string", "program_arguments"),
+ ("string", "sha256"),
+ ("string", "team_identifier"),
+ ("string", "login_item_type"),
+ ("string", "url"),
+ ("string", "uuid"),
+ ("string[]", "items"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+LoginItemsMetadataRecord = TargetRecordDescriptor(
+ "macos/login_items/metadata",
+ [
+ ("varint", "generation"),
+ ("varint", "background_app_refresh_load_count"),
+ ("boolean", "launch_services_items_imported"),
+ ("boolean", "service_management_login_items_migrated"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+LoginItemsRecords = (LoginItemsRecord, LoginItemsMetadataRecord)
+
+FIELD_MAPPINGS = {
+ "associatedBundleIdentifiers": "associated_bundle_identifiers",
+ "bundleIdentifier": "bundle_identifier",
+ "designatedRequirement": "designated_requirement",
+ "developerName": "developer_name",
+ "disposition": "login_item_disposition",
+ "executableModificationDate": "executable_modification_date",
+ "executablePath": "executable_path",
+ "lightweightRequirement": "lightweight_requirement",
+ "modificationDate": "modification_date",
+ "programArguments": "program_arguments",
+ "teamIdentifier": "team_identifier",
+ "type": "login_item_type",
+ "backgroundAppRefreshLoadCount": "background_app_refresh_load_count",
+ "launchServicesItemsImported": "launch_services_items_imported",
+ "serviceManagementLoginItemsMigrated": "service_management_login_items_migrated",
+}
+
+VALUE_MAPPINGS = {
+ "login_item_disposition": {
+ 1: "Enabled",
+ 2: "Allowed",
+ 4: "Hidden",
+ 8: "Notified",
+ },
+ "login_item_type": {
+ 1: "user item",
+ 2: "app",
+ 4: "login item",
+ 8: "agent",
+ 16: "daemon",
+ 32: "developer",
+ 64: "spotlight",
+ 2048: "quicklook",
+ 65536: "legacy",
+ 524288: "curated",
+ },
+}
+
+CONVERT_TIMESTAMPS = {
+ "modification_date": "2001",
+}
+
+
+class LoginItemsPlugin(Plugin):
+ """macOS login items plugin.
+
+ Parses macOS login items and background task entries from plist and BTM
+ files, which are used by the system to launch applications and services
+ at user login.
+
+ References:
+ - https://www.swiftforensics.com/2025/01/macapt-update-to-btm-processing.html
+ - https://developer.apple.com/documentation/corefoundation/cfurl
+ """
+
+ SYSTEM_LOGIN_ITEMS_PATHS = ("/var/db/com.apple.backgroundtaskmanagement/BackgroundItems-v*.btm",)
+
+ USER_LOGIN_ITEMS_PATHS = (
+ "Library/Preferences/com.apple.loginitems.plist",
+ "Library/Application Support/com.apple.backgroundtaskmanagementagent/backgrounditems.btm",
+ )
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.login_items_files = self._find_files()
+
+ def check_compatible(self) -> None:
+ if not (self.login_items_files):
+ raise UnsupportedPluginError("No Login Items files found")
+
+ def _find_files(self) -> set:
+ login_items_files = set()
+ for pattern in self.SYSTEM_LOGIN_ITEMS_PATHS:
+ for path in self.target.fs.glob(pattern):
+ login_items_files.add(path)
+
+ for _, path in _build_userdirs(self, self.USER_LOGIN_ITEMS_PATHS):
+ login_items_files.add(path)
+
+ return login_items_files
+
+ @export(record=LoginItemsRecord)
+ def login_items(self) -> Iterator[LoginItemsRecord]:
+ """Return macOS login items and background task entries.
+
+ Yields the following record types extracted from the
+ backgrounditems.btm and com.apple.loginitems.plist files:
+
+ .. code-block:: text
+
+ LoginItemsRecord:
+ associated_bundle_identifiers (string): Associated bundle identifiers.
+ bookmark (bytes): CFURL bookmark data referencing a file-system resource.
+ bundle_identifier (string): Bundle identifier of the item.
+ container (string): Containing app or bundle.
+ designated_requirement (string): Code signing designated requirement.
+ developer_name (string): Developer name.
+ login_item_disposition (string): Numeric value describing state.
+ executable_modification_date (datetime): Last modification time of the executable.
+ executable_path (path): Path to the executable.
+ flags (varint): Additional flags associated with the item.
+ generation (varint): Generation identifier of the record.
+ identifier (string): Unique identifier for the item.
+ lightweight_requirement (bytes): Lightweight code signing requirement data.
+ modification_date (datetime): Last modification timestamp of the record.
+ name (string): Name of the item.
+ program_arguments (string): Program arguments for execution.
+ sha256 (string): SHA256 hash of the executable.
+ team_identifier (string): Apple developer team identifier.
+ login_item_type (string): Numeric value describing the item type.
+ url (string): URL associated with the item.
+ uuid (string): Universally unique identifier.
+ items (string[]): List of items.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the backgrounditems.btm or com.apple.loginitems.plist file.
+
+ LoginItemsMetadataRecord:
+ generation (varint): Metadata generation value.
+ background_app_refresh_load_count (varint): Background app refresh load count.
+ launch_services_items_imported (boolean): Indicates LaunchServices import.
+ service_management_login_items_migrated (boolean): Indicates migration status.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the backgrounditems.btm or com.apple.loginitems.plist file.
+ """
+ yield from build_plist_records(
+ self,
+ self.login_items_files,
+ LoginItemsRecords,
+ field_mappings=FIELD_MAPPINGS,
+ value_mappings=VALUE_MAPPINGS,
+ convert_timestamps=CONVERT_TIMESTAMPS,
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/login_window.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/login_window.py
new file mode 100644
index 0000000000..0d68165fad
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/login_window.py
@@ -0,0 +1,135 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import _build_userdirs
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_plist_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target.target import Target
+
+LoginWindowRecord = TargetRecordDescriptor(
+ "macos/login_window",
+ [
+ ("string", "build_version_as_string"),
+ ("varint", "build_version_stamp_as_number"),
+ ("string", "system_version_stamp_as_string"),
+ ("varint", "system_version_stamp_as_number"),
+ ("path", "source"),
+ ],
+)
+
+# Found these fields on macOS Tahoe, but not on Ventura
+MiniBuddyRecord = TargetRecordDescriptor(
+ "macos/login_window/mini_buddy",
+ [
+ ("boolean", "mini_buddy_launch"),
+ ("path", "source"),
+ ],
+)
+
+# Found these fields on macOS Ventura, but not on Tahoe
+BundleRecord = TargetRecordDescriptor(
+ "macos/login_window/bundle",
+ [
+ ("boolean", "hide"),
+ ("string", "bundle_id"),
+ ("path", "path"),
+ ("varint", "background_state"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+LoginWindowRecordRecords = (LoginWindowRecord, MiniBuddyRecord, BundleRecord)
+
+FIELD_MAPPINGS = {
+ "BuildVersionStampAsString": "build_version_as_string",
+ "SystemVersionStampAsNumber": "system_version_stamp_as_number",
+ "SystemVersionStampAsString": "system_version_stamp_as_string",
+ "BuildVersionStampAsNumber": "build_version_stamp_as_number",
+ "MiniBuddyLaunch": "mini_buddy_launch",
+ "Hide": "hide",
+ "BundleID": "bundle_id",
+ "Path": "path",
+ "BackgroundState": "background_state",
+}
+
+
+class LoginWindowPlugin(Plugin):
+ """macOS login window plugin.
+
+ Parses configuration settings related to macOS login window behavior.
+ Login window is the system component that handles actions during login and logout,
+ such as relaunching applications on login.
+
+ References:
+ - https://developer.apple.com/documentation/devicemanagement/loginwindowscripts
+ - https://cocomelonc.github.io/macos/2026/03/29/mac-malware-persistence-7.html
+ - https://forums.macrumors.com/threads/keychain-minibuddyitem-what-this-is.2077558/
+ """
+
+ SYSTEM_LOGIN_WINDOW_PATHS = (
+ "/Library/Preferences/com.apple.loginwindow.plist",
+ "/var/root/Library/Preferences/com.apple.loginwindow.plist",
+ )
+
+ USER_LOGIN_WINDOW_PATHS = (
+ "Library/Preferences/loginwindow.plist",
+ "Library/Preferences/ByHost/com.apple.loginwindow.plist",
+ "Library/Preferences/ByHost/com.apple.loginwindow.*.plist",
+ )
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.login_window_files = self._find_files()
+
+ def check_compatible(self) -> None:
+ if not (self.login_window_files):
+ raise UnsupportedPluginError("No Login Window files found")
+
+ def _find_files(self) -> set:
+ login_window_files = set()
+ for pattern in self.SYSTEM_LOGIN_WINDOW_PATHS:
+ for path in self.target.fs.glob(pattern):
+ login_window_files.add(path)
+ for _, path in _build_userdirs(self, self.USER_LOGIN_WINDOW_PATHS):
+ login_window_files.add(path)
+ return login_window_files
+
+ @export(record=LoginWindowRecordRecords)
+ def login_window(self) -> Iterator[LoginWindowRecordRecords]:
+ """Return macOS login window configuration settings.
+
+ Yields the following record types extracted from the
+ com.apple.loginwindow.plist files:
+
+ .. code-block:: text
+
+ LoginWindowRecord:
+ build_version_as_string (string): OS build version.
+ build_version_stamp_as_number (varint): Numeric build stamp.
+ system_version_stamp_as_string (string): OS version string.
+ system_version_stamp_as_number (varint): Numeric system version.
+ source (path): Path to the plist file.
+
+ MiniBuddyRecord:
+ mini_buddy_launch (boolean): Whether MiniBuddy, the macOS Setup Assistant, should launch on login.
+ source (path): Path to the plist file.
+
+ BundleRecord:
+ hide (boolean): Whether the app is hidden on relaunch.
+ bundle_id (string): Application bundle identifier.
+ path (path): Path to the application bundle.
+ background_state (varint): Relaunch background state, 2 = background process.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the plist file.
+ """
+ yield from build_plist_records(
+ self, self.login_window_files, LoginWindowRecordRecords, field_mappings=FIELD_MAPPINGS
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/logs/__init__.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/logs/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/logs/asl.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/logs/asl.py
new file mode 100644
index 0000000000..2a2387f5a3
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/logs/asl.py
@@ -0,0 +1,278 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+from dissect.cstruct import cstruct
+
+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 typing import Any
+
+ from dissect.target.target import Target
+
+cs = cstruct(endian=">")
+
+cs.load("""
+struct asl_record {
+ uint64 next;
+ uint64 msg_id;
+ uint64 time_s;
+ uint32 unknown;
+
+ uint16 level;
+ uint16 flags;
+
+ uint32 pid;
+
+ uint32 uid;
+ uint32 gid;
+ uint32 ruid;
+ uint32 rgid;
+ uint32 rpid;
+ uint32 kvcount;
+
+ uint64 host_ref;
+ uint64 sender_ref;
+ uint64 facility_ref;
+ uint64 message_ref;
+ uint64 ref5;
+ uint64 ref6;
+};
+""")
+
+cs.load("""
+struct asl_string_header {
+ uint16 marker;
+ uint32 length;
+};
+""")
+
+
+ASLRecord = TargetRecordDescriptor(
+ "macos/logs/asl",
+ [
+ ("datetime", "ts"),
+ ("string", "priority_level"),
+ ("varint", "pid"),
+ ("string", "asl_host"),
+ ("string", "sender"),
+ ("string", "facility"),
+ ("string", "message"),
+ ("path", "source"),
+ ],
+)
+
+
+def _parse_asl_string_ref(data: bytes, ref: int) -> str | None:
+ # Resolve a string reference from the ASL file (inline or external)
+ if ref == 0:
+ return None
+
+ # Inline string packed into the reference itself
+ if ref & 0x8000000000000000:
+ ref_bytes = (ref & 0x7FFFFFFFFFFFFFFF).to_bytes(8, "big")
+ slen = ref_bytes[0]
+ return ref_bytes[1 : 1 + slen].decode("utf-8", errors="replace").rstrip("\x00")
+
+ # External string stored elsewhere in the file
+ if ref + 6 < len(data) and data[ref : ref + 2] == b"\x00\x01":
+ try:
+ hdr = cs.asl_string_header(data[ref : ref + 6])
+ slen = hdr.length
+ except Exception:
+ return None
+
+ if 0 < slen < 65536 and ref + 6 + slen <= len(data):
+ return data[ref + 6 : ref + 6 + slen].decode("utf-8", errors="replace").rstrip("\x00")
+
+ return None
+
+
+def _valid_value(s: str | None) -> bool:
+ if not s:
+ return True
+ printable = sum(32 <= ord(c) < 127 for c in s)
+ return printable / len(s) > 0.9
+
+
+def _valid_ref(data: bytes, ref: int) -> bool:
+ if ref == 0:
+ return False
+ if ref & 0x8000000000000000:
+ return True
+ return ref + 6 < len(data) and data[ref : ref + 2] == b"\x00\x01"
+
+
+def _parse_asl_file(data: bytes) -> Iterator[dict[str, Any]]:
+ # Basic file validation (header + minimum size)
+ if len(data) < 80 or data[:6] != b"ASL DB":
+ return
+
+ now = int(datetime.now(tz=timezone.utc).timestamp())
+ pos = 0x80 # Records typically start after header
+
+ # Iterate through file and try to locate valid ASL records
+ while pos < len(data) - 60:
+ rec_len = int.from_bytes(data[pos : pos + 2], "big")
+
+ # Skip invalid or unrealistic record sizes
+ if rec_len < 120 or rec_len > 65535 or pos + rec_len + 2 > len(data):
+ pos += 2
+ continue
+
+ try:
+ # Parse record structure using cstruct
+ rec = cs.asl_record(data[pos + 2 : pos + 2 + rec_len])
+ except Exception:
+ pos += 2
+ continue
+
+ # Filter out invalid timestamps
+ if not (946684800 < rec.time_s < now + 31536000):
+ pos += 2
+ continue
+
+ # Validate that at least one string reference looks valid
+ refs = [
+ rec.host_ref,
+ rec.sender_ref,
+ rec.facility_ref,
+ rec.message_ref,
+ rec.ref5,
+ rec.ref6,
+ ]
+
+ if not any(_valid_ref(data, r) for r in refs):
+ pos += 2
+ continue
+
+ # Decode referenced strings (host, sender, etc.)
+ host = _parse_asl_string_ref(data, rec.host_ref)
+ sender = _parse_asl_string_ref(data, rec.sender_ref)
+ facility = _parse_asl_string_ref(data, rec.facility_ref)
+ message = _parse_asl_string_ref(data, rec.message_ref)
+
+ # Drop non-printable strings
+ if not _valid_value(host):
+ host = None
+ if not _valid_value(sender):
+ sender = None
+ if not _valid_value(facility):
+ facility = None
+ if not _valid_value(message):
+ message = None
+
+ # Skip records without meaningful string content
+ if not any([host, sender, facility, message]):
+ pos += 2
+ continue
+
+ # Yield parsed log entry
+ yield {
+ "ts": datetime.fromtimestamp(rec.time_s, tz=timezone.utc),
+ "level": rec.level,
+ "pid": rec.pid,
+ "host": host,
+ "sender": sender,
+ "facility": facility,
+ "message": message,
+ }
+
+ # Move to next record
+ pos += rec_len + 2
+
+
+PRIORITY_LEVEL_MAP = {
+ 0: "Emergency",
+ 1: "Alert",
+ 2: "Critical",
+ 3: "Error",
+ 4: "Warning",
+ 5: "Notice",
+ 6: "Informational",
+ 7: "Debug",
+}
+
+
+class ASLPlugin(Plugin):
+ """Plugin to parse macOS Apple System Log (ASL) databases.
+
+ The Apple System Log (ASL) system is a macOS logging mechanism designed with a similar goal
+ to the traditional Unix syslog API. ASL logs are stored in a proprietary binary format and
+ are typically located in /private/var/log/asl/.
+
+ References:
+ - https://asl.readthedocs.io/en/latest/api.html#asl-messages
+ - https://www.cyberengage.org/post/making-sense-of-macos-logs-part1-a-user-friendly-guide
+ """
+
+ ASL_PATHS = (
+ "var/log/asl/*.asl",
+ "var/log/powermanagement/*.asl",
+ "var/log/DiagnosticMessages/*.asl",
+ )
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self._asl_files = self._find_files()
+
+ def _find_files(self) -> set:
+ files = set()
+
+ for pattern in self.ASL_PATHS:
+ for path in self.target.fs.path("/").glob(pattern):
+ if path.is_file():
+ files.add(path)
+
+ return files
+
+ def check_compatible(self) -> None:
+ if not self._asl_files:
+ raise UnsupportedPluginError("No .asl files found")
+
+ @export(record=ASLRecord)
+ def asl(self) -> Iterator[ASLRecord]:
+ """Return all macOS Apple System Log (ASL) messages.
+
+ Yields ASLRecord with the following fields:
+
+ .. code-block:: text
+
+ ts (datetime): Timestamp (UTC).
+ priority_level (string): ASL priority level.
+ pid (varint): Process ID.
+ asl_host (string): Hostname as stored in the ASL record.
+ sender (string): Sender process name.
+ facility (string): Logging facility.
+ message (string): Log message content.
+ source (path): Path to the ASL file.
+ """
+ for asl_path in self._asl_files:
+ try:
+ with asl_path.open("rb") as fh:
+ data = fh.read()
+ records = _parse_asl_file(data)
+ except Exception as e:
+ self.target.log.warning("Error parsing ASL file %s: %s", asl_path, e)
+ continue
+
+ for rec in records:
+ level = rec["level"]
+ priority_level = PRIORITY_LEVEL_MAP.get(level, level)
+
+ yield ASLRecord(
+ ts=rec["ts"],
+ priority_level=priority_level,
+ pid=rec["pid"],
+ asl_host=rec["host"],
+ sender=rec["sender"],
+ facility=rec["facility"],
+ message=rec["message"],
+ source=asl_path,
+ _target=self.target,
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/logs/fsck_apfs_log.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/logs/fsck_apfs_log.py
new file mode 100644
index 0000000000..46d1885e1a
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/logs/fsck_apfs_log.py
@@ -0,0 +1,85 @@
+from __future__ import annotations
+
+import re
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+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
+
+
+FsckAPFSLogRecord = TargetRecordDescriptor(
+ "macos/fsck_apfs_log",
+ [
+ ("datetime", "ts"),
+ ("string", "disk_path"),
+ ("string", "message"),
+ ("path", "source"),
+ ],
+)
+
+
+RE_TIMESTAMP_PATTERN = re.compile(
+ r"(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+"
+ r"(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+"
+ r"\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4}$"
+)
+
+
+class FsckAPFSLogPlugin(Plugin):
+ """Plugin to parse File System Consistency Check (FSCK) logs on macOS.
+
+ The fsck_apfs.log file is a macOS log file associated with
+ filesystem checking activity. The fsck utility is used to check and
+ optionally repair filesystems.
+
+ References:
+ - https://linux.die.net/man/8/fsck
+ - https://www.cyberengage.org/post/macos-incident-response-tactics-log-analysis-and-forensic-tools
+ - https://www.hackthelogs.com/MacLogs.html
+ """
+
+ FSCK_APFS_LOG_PATH = "/var/log/fsck_apfs.log"
+
+ def check_compatible(self) -> None:
+ if not self.target.fs.exists(self.FSCK_APFS_LOG_PATH):
+ raise UnsupportedPluginError("No fsck_apfs.log file found.")
+
+ @export(record=FsckAPFSLogRecord)
+ def fsck_apfs_log(self) -> Iterator[FsckAPFSLogRecord]:
+ """Return all macOS fsck_apfs log messages.
+
+ Yields FsckAPFSLogRecord with the following fields:
+
+ .. code-block:: text
+
+ ts (datetime): Timestamp (UTC), if present in the log line.
+ disk_path (string): Disk or APFS volume identifier from the log line.
+ message (string): Log message content.
+ source (path): Path to the fsck_apfs.log file.
+
+ Lines without a recognizable timestamp will have ts set to None.
+ """
+ with self.target.fs.path(self.FSCK_APFS_LOG_PATH).open(mode="rt") as fh:
+ for line in fh:
+ if line != "\n":
+ parts = line.split(" ", 1)
+
+ disk_path, message = parts
+ disk_path = disk_path.strip(":")
+ ts = None
+
+ if ts_match := RE_TIMESTAMP_PATTERN.search(message):
+ ts = datetime.strptime(ts_match.group(), "%a %b %d %H:%M:%S %Y").replace(tzinfo=timezone.utc)
+
+ yield FsckAPFSLogRecord(
+ ts=ts,
+ disk_path=disk_path.strip(),
+ message=message.strip(),
+ source=self.FSCK_APFS_LOG_PATH,
+ _target=self.target,
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/logs/install_log.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/logs/install_log.py
new file mode 100644
index 0000000000..2254ce80d1
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/logs/install_log.py
@@ -0,0 +1,112 @@
+from __future__ import annotations
+
+import re
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.parse_timestamp import parse_timestamp
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+
+InstallLogRecord = TargetRecordDescriptor(
+ "macos/install_log",
+ [
+ ("datetime", "ts"),
+ ("string", "host"),
+ ("string", "component"),
+ ("string", "message"),
+ ("path", "source"),
+ ],
+)
+
+RE_TIMESTAMP_PATTERN = re.compile(
+ r"^(?:"
+ r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}"
+ r"|"
+ r"\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}[+-]\d{1,2}(?::?\d{2})?"
+ r")"
+)
+
+
+class InstallLogPlugin(Plugin):
+ """Plugin to parse install logs on macOS.
+
+ Contains information on the software installation history.
+
+ References:
+ - https://sansorg.egnyte.com/dl/m9ftGF7heI
+ - https://www.cyberengage.org/post/macos-incident-response-tactics-log-analysis-and-forensic-tools
+ - https://www.hackthelogs.com/MacLogs.html
+ """
+
+ INSTALL_LOG_PATH = "/var/log/install.log"
+
+ def check_compatible(self) -> None:
+ if not self.target.fs.exists(self.INSTALL_LOG_PATH):
+ raise UnsupportedPluginError("No install.log file found.")
+
+ def parse_log(self, current_ts: re.Match[str], current_buf: str) -> Iterator[InstallLogRecord]:
+ # Log format: " : "
+ # Strip the timestamp (and following space) to extract the rest of the line
+ asdf = current_buf[len(current_ts.group()) + 1 :]
+
+ # Split into hostname, component and message
+ parts = asdf.split(" ", 2)
+
+ if len(parts) == 3:
+ hostname, component, message = parts
+ yield InstallLogRecord(
+ ts=parse_timestamp(current_ts),
+ host=hostname.strip(),
+ component=component.strip() if component else None,
+ message=message.strip(),
+ source=self.INSTALL_LOG_PATH,
+ _target=self.target,
+ )
+ elif len(parts) != 3:
+ self.target.log.warning(
+ "Skipping malformed install log entry in %s: "
+ "expected 3 fields (hostname, component, message), got %d -> '%s'",
+ self.INSTALL_LOG_PATH,
+ len(parts),
+ asdf.strip(),
+ )
+
+ @export(record=InstallLogRecord)
+ def install_log(self) -> Iterator[InstallLogRecord]:
+ """Return all macOS install log messages.
+
+ Yields InstallLogRecord with the following fields:
+
+ .. code-block:: text
+
+ ts (datetime): Timestamp (UTC).
+ host (string): Hostname parsed from the log line.
+ component (string): Component responsible for the log entry.
+ message (string): Log message content.
+ source (path): Path to the install.log file.
+ """
+ current_ts: re.Match[str] | None = None
+ current_buf = ""
+ with self.target.fs.path(self.INSTALL_LOG_PATH).open(mode="rt") as fh:
+ for line in fh:
+ # New timestamp indicates the start of new log entry.
+ if ts_match := RE_TIMESTAMP_PATTERN.match(line):
+ # If previous log entry exists, parse it.
+ if current_ts:
+ yield from self.parse_log(current_ts, current_buf)
+
+ current_ts = ts_match
+ current_buf = line
+
+ # Lines without a timestamp are part of the previous log entry.
+ elif current_buf:
+ current_buf += line # append continuation
+
+ # If previous log entry exists, parse it.
+ if current_ts and current_buf:
+ yield from self.parse_log(current_ts, current_buf)
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/logs/system_log.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/logs/system_log.py
new file mode 100644
index 0000000000..144aea1d7b
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/logs/system_log.py
@@ -0,0 +1,90 @@
+from __future__ import annotations
+
+from datetime import timezone
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.helpers.utils import year_rollover_helper
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.log.helpers import RE_TS
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target.target import Target
+
+SystemLogRecord = TargetRecordDescriptor(
+ "macos/system_log",
+ [
+ ("datetime", "ts"),
+ ("string", "host"),
+ ("string", "component"),
+ ("string", "message"),
+ ("path", "source"),
+ ],
+)
+
+
+class SystemLogPlugin(Plugin):
+ """Plugin to parse system logs on macOS.
+
+ Contains information on system diagnostics and general messages.
+
+ References:
+ - https://www.cyberengage.org/post/macos-incident-response-tactics-log-analysis-and-forensic-tools
+ - https://www.hackthelogs.com/MacLogs.html
+ """
+
+ SYSTEM_LOG_GLOB = "/var/log/system.log*"
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.log_files = self._resolve_files()
+
+ def check_compatible(self) -> None:
+ if not self.log_files:
+ raise UnsupportedPluginError("No system log files found.")
+
+ def _resolve_files(self) -> set:
+ return set(self.target.fs.glob(self.SYSTEM_LOG_GLOB))
+
+ @export(record=SystemLogRecord)
+ def system_log(self) -> Iterator[SystemLogRecord]:
+ """Return all macOS system log messages.
+
+ Yields SystemLogRecord with the following fields:
+
+ .. code-block:: text
+
+ ts (datetime): Timestamp (UTC) derived from the log line.
+ host (string): Hostname parsed from the log entry.
+ component (string): Component or process associated with the log entry.
+ message (string): Log message content.
+ source (path): Path to the system log file.
+ """
+ for file in self.log_files:
+ filepath = self.target.fs.path(file)
+
+ lines = []
+
+ for ts, line in year_rollover_helper(filepath, RE_TS, "%b %d %H:%M:%S", timezone.utc):
+ lines.insert(0, line)
+
+ if ts:
+ current_buf = "\n\t".join(lines)
+
+ match = RE_TS.match(current_buf)
+ asdf = current_buf[match.end() :].lstrip(" ")
+ hostname, component, message = asdf.split(" ", 2)
+
+ yield SystemLogRecord(
+ ts=ts,
+ host=hostname.strip(),
+ component=component.strip(),
+ message=message.strip(),
+ source=filepath,
+ _target=self.target,
+ )
+
+ lines = []
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/logs/wifi_log.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/logs/wifi_log.py
new file mode 100644
index 0000000000..aefb6137b9
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/logs/wifi_log.py
@@ -0,0 +1,101 @@
+from __future__ import annotations
+
+import re
+from datetime import timezone
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.helpers.utils import year_rollover_helper
+from dissect.target.plugin import Plugin, export
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target.target import Target
+
+RE_TS = re.compile(
+ r"""
+ ^
+ (?P
+ [A-Za-z]{3}
+ \s+
+ [A-Za-z]{3}
+ \s+
+ \d{1,2}
+ \s+
+ \d{2}:\d{2}:\d{2}
+ \.\d{3}
+ )
+ """,
+ re.VERBOSE,
+)
+
+
+WifiLogRecord = TargetRecordDescriptor(
+ "wifi_log",
+ [
+ ("datetime", "ts"),
+ ("string", "host"),
+ ("string", "message"),
+ ("path", "source"),
+ ],
+)
+
+
+class WifiLogPlugin(Plugin):
+ """Plugin to parse WiFi logs on macOS.
+
+ Contains information on WiFi connections and known hotspots
+
+ References:
+ - https://www.cyberengage.org/post/macos-incident-response-tactics-log-analysis-and-forensic-tools
+ - https://www.hackthelogs.com/MacLogs.html
+ """
+
+ PATH = "/var/log/wifi.log"
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.file = self.target.fs.path(self.PATH) if self.target.fs.path(self.PATH).exists() else None
+
+ def check_compatible(self) -> None:
+ if not self.file:
+ raise UnsupportedPluginError("No wifi.log file found")
+
+ @export(record=WifiLogRecord)
+ def wifi_log(self) -> Iterator[WifiLogRecord]:
+ """Return all macOS Wi-Fi log messages.
+
+ Yields WifiLogRecord with the following fields:
+
+ .. code-block:: text
+
+ ts (datetime): Timestamp (UTC) derived from the log line.
+ host (string): Hostname parsed from the log entry.
+ message (string): Log message content.
+ source (path): Path to the wifi.log file.
+ """
+ current_buf = ""
+
+ for ts, line in year_rollover_helper(
+ self.file,
+ RE_TS,
+ "%a %b %d %H:%M:%S.%f",
+ timezone.utc,
+ ):
+ current_buf = line + "\n\t" + current_buf
+ if ts:
+ match = RE_TS.match(current_buf)
+ asdf = current_buf[match.end() :].lstrip(" ")
+ hostname, message = asdf.split(" ", 1)
+
+ yield WifiLogRecord(
+ ts=ts,
+ host=hostname.strip(),
+ message=message.strip(),
+ source=self.file,
+ _target=self.target,
+ )
+
+ current_buf = ""
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/notes.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/notes.py
new file mode 100644
index 0000000000..83ac7b4f83
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/notes.py
@@ -0,0 +1,740 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import _build_userdirs
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import (
+ build_sqlite_records,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target import Target
+
+# TODO: Look into changing the implementation to yield 1 record per existing
+# or deleted note instead of yielding records for every row in the databases.
+
+ZAccountRecord = TargetRecordDescriptor(
+ "macos/notes/z_account",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("boolean", "z_allow_insecure_authentication"),
+ ("boolean", "z_did_choose_to_migrate"),
+ ("boolean", "z_enabled"),
+ ("varint", "z_root_folder"),
+ ("varint", "z6_root_folder"),
+ ("varint", "z_trash_folder"),
+ ("string", "z_gmail_capabilities_support"),
+ ("string", "z_port"),
+ ("string", "z_security_layer_type"),
+ ("boolean", "z_migration_offered"),
+ ("string", "z_account_description"),
+ ("string", "z_email_address"),
+ ("string", "z_full_name"),
+ ("string", "z_parent_account_identifier"),
+ ("string", "z_user_name"),
+ ("string", "z_folder_hierarchy_sync_state"),
+ ("string", "z_authentication"),
+ ("string", "z_host_name"),
+ ("string", "z_server_path_prefix"),
+ ("string", "z_external_url"),
+ ("string", "z_internal_url"),
+ ("string", "z_last_used_autodiscover_url"),
+ ("string", "z_tls_certificate"),
+ ("path", "source"),
+ ],
+)
+
+ZFolderRecord = TargetRecordDescriptor(
+ "macos/notes/z_folder",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("varint", "z_account"),
+ ("varint", "z1_account"),
+ ("varint", "z_parent"),
+ ("varint", "z6_parent"),
+ ("boolean", "z_is_distinguished"),
+ ("string", "z_alleged_highest_modification_sequence"),
+ ("string", "z_computed_highest_modification_sequence"),
+ ("string", "z_uid_next"),
+ ("string", "z_uid_validity"),
+ ("varint", "z_trash_account"),
+ ("varint", "z1_trash_account"),
+ ("string", "z_name"),
+ ("string", "z_change_key"),
+ ("string", "z_user_name"),
+ ("varint", "z_folder_id"),
+ ("string", "z_sync_state"),
+ ("string", "z_server_name"),
+ ("path", "source"),
+ ],
+)
+
+ZPrimaryKeyRecord = TargetRecordDescriptor(
+ "macos/notes/z_primary_key",
+ [
+ ("string", "table"),
+ ("varint", "z_ent"),
+ ("string", "z_name"),
+ ("varint", "z_super"),
+ ("varint", "z_max"),
+ ("path", "source"),
+ ],
+)
+
+ZMetadataRecord = TargetRecordDescriptor(
+ "macos/notes/z_metadata",
+ [
+ ("string", "table"),
+ ("varint", "z_version"),
+ ("string", "z_uuid"),
+ ("path", "source"),
+ ],
+)
+
+ZPlistRecord = TargetRecordDescriptor(
+ "macos/notes/z_plist",
+ [
+ ("varint", "ac_account_type_version"),
+ ("varint", "ns_auto_vacuum_level"),
+ ("varint", "ns_persistence_framework_version"),
+ ("varint", "ns_persistence_maximum_framework_version"),
+ ("string", "ns_store_model_version_checksum_key"),
+ ("string", "ns_store_model_version_hashes_digest"),
+ ("varint", "ns_store_model_version_hashes_version"),
+ ("string", "ns_store_model_version_identifiers"),
+ ("string", "ns_store_type"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+NSStoreModelVersionHashesRecord = TargetRecordDescriptor(
+ "macos/notes/ns_store_model_version_hashes",
+ [
+ ("bytes", "account_hash"),
+ ("bytes", "attachment_hash"),
+ ("bytes", "ews_account"),
+ ("bytes", "ews_folder"),
+ ("bytes", "ews_note"),
+ ("bytes", "folder"),
+ ("bytes", "folder_action"),
+ ("bytes", "imap_account"),
+ ("bytes", "imap_folder"),
+ ("bytes", "imap_note"),
+ ("bytes", "insert_folder_action"),
+ ("bytes", "insert_note_action"),
+ ("bytes", "local_account"),
+ ("bytes", "move_folder_action"),
+ ("bytes", "move_note_action"),
+ ("bytes", "note"),
+ ("bytes", "note_action"),
+ ("bytes", "note_body"),
+ ("bytes", "offline_action"),
+ ("bytes", "trash_folder"),
+ ("bytes", "update_folder_action"),
+ ("bytes", "update_note_action"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+iCloudNSStoreModelVersionHashesRecord = TargetRecordDescriptor(
+ "macos/notes/icloud_ns_store_model_version_hashes",
+ [
+ ("bytes", "ic_account"),
+ ("bytes", "ic_account_data"),
+ ("bytes", "ic_asset_signature"),
+ ("bytes", "ic_attachment"),
+ ("bytes", "ic_attachment_location"),
+ ("bytes", "ic_attachment_preview_image"),
+ ("bytes", "ic_cloud_state"),
+ ("bytes", "ic_cloud_syncing_object"),
+ ("bytes", "ic_device_migration_state"),
+ ("bytes", "ic_folder"),
+ ("bytes", "ic_hashtag"),
+ ("bytes", "ic_inline_attachment"),
+ ("bytes", "ic_invitation"),
+ ("bytes", "ic_legacy_tombstone"),
+ ("bytes", "ic_location"),
+ ("bytes", "ic_media"),
+ ("bytes", "ic_note"),
+ ("bytes", "ic_note_container"),
+ ("bytes", "ic_note_data"),
+ ("bytes", "ic_note_participant"),
+ ("bytes", "ic_search_index_state"),
+ ("bytes", "ic_server_change_token"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+# Contains additional Z_CONTENT field which is a binary blob. This field been removed
+# from the record descriptor. The field's presence will still be mentioned in a warning.
+ZModelCacheRecord = TargetRecordDescriptor(
+ "macos/notes/z_model_cache",
+ [
+ ("string", "table"),
+ ("path", "source"),
+ ],
+)
+
+AChangeRecord = TargetRecordDescriptor(
+ "macos/notes/a_change",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("varint", "z_change_type"),
+ ("varint", "z_entity"),
+ ("varint", "z_entity_pk"),
+ ("varint", "z_transaction_id"),
+ ("bytes", "z_columns"),
+ ("path", "source"),
+ ],
+)
+
+ATransactionRecord = TargetRecordDescriptor(
+ "macos/notes/a_transaction",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("varint", "z_author_ts"),
+ ("varint", "z_bundle_id_ts"),
+ ("varint", "z_context_name_ts"),
+ ("varint", "z_process_id_ts"),
+ ("datetime", "z_timestamp"),
+ ("string", "z_author"),
+ ("string", "z_bundle_id"),
+ ("string", "z_context_name"),
+ ("varint", "z_process_id"),
+ ("string", "z_query_gen"),
+ ("path", "source"),
+ ],
+)
+
+ATransactionStringRecord = TargetRecordDescriptor(
+ "macos/notes/a_transaction_string",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("string", "z_name"),
+ ("path", "source"),
+ ],
+)
+
+ZICCloudStateRecord = TargetRecordDescriptor(
+ "macos/notes/z_ic_cloud_state",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("varint", "z_current_local_version"),
+ ("boolean", "z_in_cloud"),
+ ("varint", "z_latest_version_synced_to_cloud"),
+ ("string", "z_cloud_syncing_object"),
+ ("string", "z3_cloud_syncing_object"),
+ ("datetime", "z_local_version_date"),
+ ("path", "source"),
+ ],
+)
+
+# ZICCLOUDSYNCINGOBJECT table contains 200+ more columns, most of which are None in the majority of rows.
+# Reduced record descriptor to core fields, other fields will be included in a warning.
+ZICCloudSyncingRecord = TargetRecordDescriptor(
+ "macos/notes/z_ic_cloud_syncing_object",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("varint", "z_cloud_state"),
+ ("varint", "z_crypto_iteration_count"),
+ ("boolean", "z_is_password_protected"),
+ ("boolean", "z_is_share_dirty"),
+ ("boolean", "marked_for_deletion"),
+ ("varint", "minimum_supported_notes_version"),
+ ("boolean", "z_needs_initial_fetch_from_cloud"),
+ ("string", "z_identifier"),
+ ("path", "source"),
+ ],
+)
+
+ZICNoteDataRecord = TargetRecordDescriptor(
+ "macos/notes/z_ic_note_data",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("varint", "z_note"),
+ ("string", "z_crypto_initialization_vector"),
+ ("string", "z_crypto_tag"),
+ ("bytes", "z_data"),
+ ("path", "source"),
+ ],
+)
+
+ZICSearchIndexStateRecord = TargetRecordDescriptor(
+ "macos/notes/z_ic_search_index_state",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("varint", "z_state_value"),
+ ("string", "z_identifier"),
+ ("path", "source"),
+ ],
+)
+
+NotesRecords = (
+ ZAccountRecord,
+ ZFolderRecord,
+ ZPrimaryKeyRecord,
+ ZMetadataRecord,
+ ZPlistRecord,
+ NSStoreModelVersionHashesRecord,
+ iCloudNSStoreModelVersionHashesRecord,
+ ZModelCacheRecord,
+ AChangeRecord,
+ ATransactionRecord,
+ ATransactionStringRecord,
+ ZICCloudStateRecord,
+ ZICCloudSyncingRecord,
+ ZICNoteDataRecord,
+ ZICSearchIndexStateRecord,
+)
+
+FIELD_MAPPINGS = {
+ "Z_PK": "z_pk",
+ "Z_ENT": "z_ent",
+ "Z_OPT": "z_opt",
+ "Z_NAME": "z_name",
+ "Z_SUPER": "z_super",
+ "Z_MAX": "z_max",
+ "Z_VERSION": "z_version",
+ "Z_UUID": "z_uuid",
+ "NSAutoVacuumLevel": "ns_auto_vacuum_level",
+ "NSPersistenceFrameworkVersion": "ns_persistence_framework_version",
+ "NSPersistenceMaximumFrameworkVersion": "ns_persistence_maximum_framework_version",
+ "NSStoreModelVersionChecksumKey": "ns_store_model_version_checksum_key",
+ "NSStoreModelVersionHashesDigest": "ns_store_model_version_hashes_digest",
+ "NSStoreModelVersionHashesVersion": "ns_store_model_version_hashes_version",
+ "NSStoreModelVersionIdentifiers": "ns_store_model_version_identifiers",
+ "NSStoreType": "ns_store_type",
+ "ZALLOWINSECUREAUTHENTICATION": "z_allow_insecure_authentication",
+ "ZDIDCHOOSETOMIGRATE": "z_did_choose_to_migrate",
+ "ZENABLED": "z_enabled",
+ "ZROOTFOLDER": "z_root_folder",
+ "Z6_ROOTFOLDER": "z6_root_folder",
+ "ZTRASHFOLDER": "z_trash_folder",
+ "ZGMAILCAPABILITIESSUPPORT": "z_gmail_capabilities_support",
+ "ZPORT": "z_port",
+ "ZMIGRATIONOFFERED": "z_migration_offered",
+ "ZACCOUNTDESCRIPTION": "z_account_description",
+ "ZEMAILADDRESS": "z_email_address",
+ "ZFULLNAME": "z_full_name",
+ "ZUSERNAME": "z_user_name",
+ "ZFOLDERHIERARCHYSYNCSTATE": "z_folder_hierarchy_sync_state",
+ "ZAUTHENTICATION": "z_authentication",
+ "ZHOSTNAME": "z_host_name",
+ "ZSERVERPATHPREFIX": "z_server_path_prefix",
+ "ZEXTERNALURL": "z_external_url",
+ "ZINTERNALURL": "z_internal_url",
+ "ZLASTUSEDAUTODISCOVERURL": "z_last_used_autodiscover_url",
+ "ZTLSCERTIFICATE": "z_tls_certificate",
+ "ZACCOUNT": "z_account",
+ "Z1_ACCOUNT": "z1_account",
+ "ZPARENT": "z_parent",
+ "Z6_PARENT": "z6_parent",
+ "ZISDISTINGUISHED": "z_is_distinguished",
+ "ZALLEGEDHIGHESTMODIFICATIONSEQUENCE": "z_alleged_highest_modification_sequence",
+ "ZCOMPUTEDHIGHESTMODIFICATIONSEQUENCE": "z_computed_highest_modification_sequence",
+ "ZUIDNEXT": "z_uid_next",
+ "ZTRASHACCOUNT": "z_trash_account",
+ "Z1_TRASHACCOUNT": "z1_trash_account",
+ "ZNAME": "z_name",
+ "ZCHANGEKEY": "z_change_key",
+ "ZFOLDERID": "z_folder_id",
+ "ZSYNCSTATE": "z_sync_state",
+ "ZSERVERNAME": "z_server_name",
+ "ZCHANGETYPE": "z_change_type",
+ "ZENTITY": "z_entity",
+ "ZENTITYPK": "z_entity_pk",
+ "ZTRANSACTIONID": "z_transaction_id",
+ "ZCOLUMNS": "z_columns",
+ "ZAUTHORTS": "z_author_ts",
+ "ZBUNDLEIDTS": "z_bundle_id_ts",
+ "ZCONTEXTNAMETS": "z_context_name_ts",
+ "ZPROCESSIDTS": "z_process_id_ts",
+ "ZTIMESTAMP": "z_timestamp",
+ "ZAUTHOR": "z_author",
+ "ZBUNDLEID": "z_bundle_id",
+ "ZCONTEXTNAME": "z_context_name",
+ "ZPROCESSID": "z_process_id",
+ "ZQUERYGEN": "z_query_gen",
+ "ZSECURITYLAYERTYPE": "z_security_layer_type",
+ "ZPARENTACACCOUNTIDENTIFIER": "z_parent_account_identifier",
+ "ZUIDVALIDITY": "z_uid_validity",
+ "Account": "account_hash",
+ "Attachment": "attachment_hash",
+ "EWSAccount": "ews_account",
+ "EWSFolder": "ews_folder",
+ "EWSNote": "ews_note",
+ "Folder": "folder",
+ "FolderAction": "folder_action",
+ "IMAPAccount": "imap_account",
+ "IMAPFolder": "imap_folder",
+ "IMAPNote": "imap_note",
+ "InsertFolderAction": "insert_folder_action",
+ "InsertNoteAction": "insert_note_action",
+ "LocalAccount": "local_account",
+ "MoveFolderAction": "move_folder_action",
+ "MoveNoteAction": "move_note_action",
+ "Note": "note",
+ "NoteAction": "note_action",
+ "NoteBody": "note_body",
+ "OfflineAction": "offline_action",
+ "TrashFolder": "trash_folder",
+ "UpdateFolderAction": "update_folder_action",
+ "UpdateNoteAction": "update_note_action",
+ "ICAccount": "ic_account",
+ "ICAccountData": "ic_account_data",
+ "ICAssetSignature": "ic_asset_signature",
+ "ICAttachment": "ic_attachment",
+ "ICAttachmentLocation": "ic_attachment_location",
+ "ICAttachmentPreviewImage": "ic_attachment_preview_image",
+ "ICCloudState": "ic_cloud_state",
+ "ICCloudSyncingObject": "ic_cloud_syncing_object",
+ "ICDeviceMigrationState": "ic_device_migration_state",
+ "ICFolder": "ic_folder",
+ "ICHashtag": "ic_hashtag",
+ "ICInlineAttachment": "ic_inline_attachment",
+ "ICInvitation": "ic_invitation",
+ "ICLegacyTombstone": "ic_legacy_tombstone",
+ "ICLocation": "ic_location",
+ "ICMedia": "ic_media",
+ "ICNote": "ic_note",
+ "ICNoteContainer": "ic_note_container",
+ "ICNoteData": "ic_note_data",
+ "ICNoteParticipant": "ic_note_participant",
+ "ICSearchIndexState": "ic_search_index_state",
+ "ICServerChangeToken": "ic_server_change_token",
+ "ZCURRENTLOCALVERSION": "z_current_local_version",
+ "ZINCLOUD": "z_in_cloud",
+ "ZLATESTVERSIONSYNCEDTOCLOUD": "z_latest_version_synced_to_cloud",
+ "ZCLOUDSYNCINGOBJECT": "z_cloud_syncing_object",
+ "Z3_CLOUDSYNCINGOBJECT": "z3_cloud_syncing_object",
+ "ZLOCALVERSIONDATE": "z_local_version_date",
+ "ZNOTE": "z_note",
+ "ZCRYPTOINITIALIZATIONVECTOR": "z_crypto_initialization_vector",
+ "ZCRYPTOTAG": "z_crypto_tag",
+ "ZDATA": "z_data",
+ "ZSTATEVALUE": "z_state_value",
+ "ZCLOUDSTATE": "z_cloud_state",
+ "ZCRYPTOITERATIONCOUNT": "z_crypto_iteration_count",
+ "ZISPASSWORDPROTECTED": "z_is_password_protected",
+ "ZISSHAREDIRTY": "z_is_share_dirty",
+ "ZMARKEDFORDELETION": "marked_for_deletion",
+ "ZMINIMUMSUPPORTEDNOTESVERSION": "minimum_supported_notes_version",
+ "ZNEEDSINITIALFETCHFROMCLOUD": "z_needs_initial_fetch_from_cloud",
+ "ZIDENTIFIER": "z_identifier",
+}
+
+CONVERT_TIMESTAMPS = {
+ "z_timestamp": "2001",
+ "z_local_version_date": "2001",
+}
+
+
+class NotesPlugin(Plugin):
+ """macOS notes plugin.
+
+ Parses macOS notes SQLite database file.
+
+ References:
+ - https://fatbobman.com/en/posts/tables_and_fields_of_coredata/
+ - https://developer.apple.com/documentation/coredata/nsstoremodelversionidentifierskey
+ - https://stackoverflow.com/questions/13503243/what-does-dirty-flag-dirty-values-mean
+ """
+
+ USER_PATH = (
+ "Library/Containers/com.apple.Notes/Data/Library/Notes/NotesV*.storedata",
+ "Library/Group Containers/group.com.apple.notes/NoteStore.sqlite",
+ )
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = self._find_files()
+
+ def check_compatible(self) -> None:
+ if not (self.files):
+ raise UnsupportedPluginError("No NotesV*.storedata files found")
+
+ def _find_files(self) -> set:
+ files = set()
+ for _, path in _build_userdirs(self, self.USER_PATH):
+ files.add(path)
+ return files
+
+ @export(record=NotesRecords)
+ def notes(
+ self,
+ ) -> Iterator[NotesRecords]:
+ """Return notes information.
+
+ Yields the following record types extracted from the
+ NotesV*.storedata and NoteStore.sqlite databases:
+
+ .. code-block:: text
+
+ ZAccountRecord:
+ table (string): Name of the source table (ZACCOUNT).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_allow_insecure_authentication (boolean): Indicates whether insecure authentication is allowed.
+ z_did_choose_to_migrate (boolean): Indicates if migration was selected.
+ z_enabled (boolean): Indicates whether the account is enabled.
+ z_root_folder (varint): Reference to the root folder.
+ z6_root_folder (varint): Alternate root folder reference.
+ z_trash_folder (varint): Reference to the trash folder.
+ z_gmail_capabilities_support (string): Gmail capability support flag.
+ z_port (string): Port value.
+ z_security_layer_type (string): Security layer type.
+ z_migration_offered (boolean): Indicates if migration was offered.
+ z_account_description (string): Account description:
+ z_email_address (string): Associated email address.
+ z_full_name (string): Full name of the account.
+ z_parent_account_identifier (string): Parent account identifier.
+ z_user_name (string): Username.
+ z_folder_hierarchy_sync_state (string): Folder sync state.
+ z_authentication (string): Authentication method.
+ z_host_name (string): Hostname.
+ z_server_path_prefix (string): Server path prefix.
+ z_external_url (string): External URL.
+ z_internal_url (string): Internal URL.
+ z_last_used_autodiscover_url (string): Last used autodiscover URL.
+ z_tls_certificate (string): TLS certificate data.
+ source (path): Path to the database file.
+
+ ZFolderRecord:
+ table (string): Name of the source table (ZFOLDER).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_account (varint): Reference to Z_PK in ZACCOUNT.
+ z1_account (varint): Alternate reference to Z_PK in ZACCOUNT.
+ z_parent (varint): Parent folder reference.
+ z6_parent (varint): Alternate parent reference.
+ z_is_distinguished (boolean): Whether entry is distinguished.
+ z_alleged_highest_modification_sequence (string): Alleged highest modification sequence.
+ z_computed_highest_modification_sequence (string): Computed highest modification sequence.
+ z_uid_next (string): Next UID value.
+ z_uid_validity: UID validity.
+ z_trash_account (varint): Trash account reference.
+ z1_trash_account (varint): Alternate trash account reference.
+ z_name (string): Entry name.
+ z_change_key (string): Change key.
+ z_user_name (string): Username.
+ z_folder_id (varint): Folder identifier.
+ z_sync_state (string): Synchronization state.
+ z_server_name (string): Server name.
+ source (path): Path to the database file.
+
+ ZPrimaryKeyRecord:
+ table (string): Name of the source table (Z_PRIMARYKEY).
+ z_ent (varint): Entity identifier.
+ z_name (string): The name of the entity in the data model.
+ z_super (varint): This value corresponds to the Z_ENT of the parent entity.
+ 0 indicates that the entity has no parent entity.
+ z_max (varint): Marks the last used z_pk value for each registry table.
+ source (path): Path to the database file.
+
+ ZMetadataRecord:
+ table (string): Name of the source table (Z_METADATA).
+ z_version (varint): The specific purpose is unknown, value is always 1.
+ z_uuid (string): The ID identifier (UUID type) of the current database file.
+ source (path): Path to the database file.
+
+ ZPlistRecord (Plist extracted from Z_METADATA's Z_PLIST field):
+ ac_account_type_version (varint): AC account type version.
+ ns_persistence_maximum_framework_version (varint): Maximum supported persistence framework version.
+ ns_store_model_version_identifiers (string[]): Version identifiers for the model,
+ used to create the store.
+ ns_store_type (string): Store type.
+ ns_auto_vacuum_level (varint): Auto-vacuum level.
+ ns_store_model_version_hashes_digest (string): Digest of model version hashes.
+ ns_store_model_version_checksum_key (string): Model version checksum key.
+ ns_persistence_framework_version (varint): Persistence framework version.
+ ns_store_model_version_hashes_version (varint): Version of the ns store version hashes.
+ plist_path (string): Path pointing to the Z_METADATA table and Z_VERSION value of the
+ Z_PLIST row that this record was extracted from.
+ source (path): Path to the database file.
+
+ NSStoreModelVersionHashesRecord:
+ account_hash (bytes): Hash for ZACCOUNT entity.
+ attachment_hash (bytes): Hash for ZATTACHMENT entity.
+ ews_account (bytes): Hash for EWS account.
+ ews_folder (bytes): Hash for EWS folder.
+ ews_note (bytes): Hash for EWS note.
+ folder (bytes): Hash for ZFOLDER entity.
+ folder_action (bytes): Hash for folder action.
+ imap_account (bytes): Hash for IMAP account.
+ imap_folder (bytes): Hash for IMAP folder.
+ imap_note (bytes): Hash for IMAP note.
+ insert_folder_action (bytes): Hash for insert folder action.
+ insert_note_action (bytes): Hash for insert note action.
+ local_account (bytes): Hash for local account.
+ move_folder_action (bytes): Hash for move folder action.
+ move_note_action (bytes): Hash for move note action.
+ note (bytes): Hash for ZNOTE entity.
+ note_action (bytes): Hash for note action.
+ note_body (bytes): Hash for ZNOTEBODY entity.
+ offline_action (bytes): Hash for ZOFFLINEACTION entity.
+ trash_folder (bytes): Hash for trash folder.
+ update_folder_action (bytes): Hash for update folder action.
+ update_note_action (bytes): Hash for update note action.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the database file.
+
+ iCloudNSStoreModelVersionHashesRecord:
+ ic_account (bytes): Hash for account.
+ ic_account_data (bytes): Hash for account data.
+ ic_asset_signature (bytes): Hash for ZICASSETSIGNATURE table.
+ ic_attachment (bytes): Hash for attachment.
+ ic_attachment_location (bytes): Hash for attachment location.
+ ic_attachment_preview_image (bytes): Hash for attachment preview image.
+ ic_cloud_state (bytes): Hash for ZICCLOUDSTATE table.
+ ic_cloud_syncing_object (bytes): Hash for ZICCLOUDSYNCINGOBJECT table.
+ ic_device_migration_state (bytes): Hash for device migration state.
+ ic_folder (bytes): Hash for folder.
+ ic_hashtag (bytes): Hash for hashtag.
+ ic_inline_attachment (bytes): Hash for inline attachment.
+ ic_invitation (bytes): Hash for ZICINVITATION table.
+ ic_legacy_tombstone (bytes): Hash for legacy tombstone data.
+ ic_location (bytes): Hash for ZICLOCATION table.
+ ic_media (bytes): Hash for media.
+ ic_note (bytes): Hash for note.
+ ic_note_container (bytes): Hash for note container.
+ ic_note_data (bytes): Hash for ZICNOTEDATA table.
+ ic_note_participant (bytes): Hash for ZICNOTEPARTICIPANT table.
+ ic_search_index_state (bytes): Hash for ZICSEARCHINDEXSTATE table.
+ ic_server_change_token (bytes): Hash for ZICSERVERCHANGETOKEN table.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the database file.
+
+ ZModelCacheRecord (contains Z_CONTENT field with binary data):
+ table (string): Name of the source table (Z_MODELCACHE).
+ source (path): Path to the database file.
+
+ ZICCloudStateRecord:
+ table (string): Name of the source table (ZICCLOUDSTATE).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_current_local_version (varint): Current local version of the object.
+ z_in_cloud (boolean): Indicates whether the object exists in the cloud.
+ z_latest_version_synced_to_cloud (varint): Latest version synced to the cloud.
+ z_cloud_syncing_object (string): Reference to the associated ZICCLOUDSYNCINGOBJECT.
+ z3_cloud_syncing_object (string): Alternate reference to the ZICCLOUDSYNCINGOBJECT.
+ z_local_version_date (datetime): Timestamp of the local version.
+ source (path): Path to the database file.
+
+ ZICCloudSyncingRecord:
+ table (string): Name of the source table (ZICCLOUDSYNCINGOBJECT).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier (determines object type).
+ z_opt (varint): The version number of the data record.
+ z_cloud_state (varint): Cloud state of the object.
+ z_crypto_iteration_count (varint): Cryptographic iteration count.
+ z_is_password_protected (boolean): Indicates whether the object is password protected.
+ z_is_share_dirty (boolean): Indicates whether the shared object has unsynced changes.
+ marked_for_deletion (boolean): Indicates whether the object is marked for deletion.
+ minimum_supported_notes_version (varint): Minimum supported notes version.
+ z_needs_initial_fetch_from_cloud (boolean): Indicates whether initial cloud fetch is required.
+ z_identifier (string): Identifier for the object.
+ source (path): Path to the database file.
+
+ ZICNoteDataRecord:
+ table (string): Name of the source table (ZICNOTEDATA).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_note (varint): Reference to the associated note.
+ z_crypto_initialization_vector (string): Initialization vector used for cryptographic encryption.
+ z_crypto_tag (string): Cryptography tag.
+ z_data (bytes): Note data blob, contains note body.
+ source (path): Path to the database file.
+
+ ZICSearchIndexStateRecord:
+ table (string): Name of the source table (ZICSEARCHINDEXSTATE).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_state_value (varint): State value of the search index.
+ z_identifier (string): Identifier for the indexed object.
+ source (path): Path to the database file.
+
+ AChangeRecord:
+ table (string): Name of the source table (ACHANGE).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_change_type (varint): Type of change.
+ z_entity (varint): Entity type affected.
+ z_entity_pk (varint): Primary key of affected entity.
+ z_transaction_id (varint): Transaction identifier.
+ z_columns (bytes): Columns affected by the change.
+ source (path): Path to the database file.
+
+ ATransactionRecord:
+ table (string): Name of the source table (ATRANSACTION).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_author_ts (varint): Author timestamp reference.
+ z_bundle_id_ts (varint): Bundle ID timestamp reference.
+ z_context_name_ts (varint): Context name timestamp reference.
+ z_process_id_ts (varint): Process ID timestamp reference.
+ z_timestamp (datetime): Transaction timestamp.
+ z_author (string): Author of the transaction.
+ z_bundle_id (string): Bundle identifier.
+ z_context_name (string): Context name.
+ z_process_id (varint): Process ID.
+ z_query_gen (string): Query generation.
+ source (path): Path to the database file.
+
+ ATransactionStringRecord:
+ table (string): Name of the source table (ATRANSACTIONSTRING).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_name (string): The name of the entity in the data model.
+ source (path): Path to the database file.
+ """
+ yield from build_sqlite_records(
+ self, self.files, NotesRecords, field_mappings=FIELD_MAPPINGS, convert_timestamps=CONVERT_TIMESTAMPS
+ )
+
+ # TODO: Add ZNOTE, ZNOTEBODY, ZOFFLINEACTION, ZATTACHMENT, tables for NotesV*.storedata
+
+ # TODO: Add ZICASSETSIGNATURE, ZICINVITATION, ZICLOCATION, ZICNOTEPARTICIPANT,
+ # ZICSERVERCHANGETOKEN, tables for NoteStore.sqlite
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/periodic.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/periodic.py
new file mode 100644
index 0000000000..52a46c7880
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/periodic.py
@@ -0,0 +1,427 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+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 dissect.target.target import Target
+
+PeriodicScriptsRecord = TargetRecordDescriptor(
+ "macos/periodic_scripts",
+ [
+ ("path", "source"),
+ ],
+)
+
+PeriodicConfRecord = TargetRecordDescriptor(
+ "macos/periodic_conf",
+ [
+ ("string", "local_periodic"),
+ ("string", "dir_output"),
+ ("boolean", "dir_show_success"),
+ ("boolean", "dir_show_info"),
+ ("boolean", "dir_show_badconfig"),
+ ("varint", "anticongestion_sleeptime"),
+ ("path", "source"),
+ ],
+)
+
+PeriodicConfDailyRecord = TargetRecordDescriptor(
+ "macos/periodic_conf/daily",
+ [
+ ("boolean", "daily_clean_disks_enable"),
+ ("string", "daily_clean_disks_files"),
+ ("varint", "daily_clean_disks_days"),
+ ("boolean", "daily_clean_disks_verbose"),
+ ("boolean", "daily_clean_tmps_enable"),
+ ("string", "daily_clean_tmps_dirs"),
+ ("varint", "daily_clean_tmps_days"),
+ ("string", "daily_clean_tmps_ignore"),
+ ("boolean", "daily_clean_tmps_verbose"),
+ ("boolean", "daily_clean_preserve_enable"),
+ ("varint", "daily_clean_preserve_days"),
+ ("boolean", "daily_clean_preserve_verbose"),
+ ("boolean", "daily_clean_msgs_enable"),
+ ("varint", "daily_clean_msgs_days"),
+ ("boolean", "daily_clean_rwho_enable"),
+ ("varint", "daily_clean_rwho_days"),
+ ("boolean", "daily_clean_rwho_verbose"),
+ ("boolean", "daily_clean_hoststat_enable"),
+ ("boolean", "daily_backup_efi_enable"),
+ ("boolean", "daily_backup_gmirror_enable"),
+ ("boolean", "daily_backup_gmirror_verbose"),
+ ("boolean", "daily_backup_gpart_enable"),
+ ("boolean", "daily_backup_gpart_verbose"),
+ ("boolean", "daily_backup_passwd_enable"),
+ ("boolean", "daily_backup_aliases_enable"),
+ ("boolean", "daily_backup_zfs_enable"),
+ ("string", "daily_backup_zfs_list_flags"),
+ ("string", "daily_backup_zpool_list_flags"),
+ ("boolean", "daily_backup_zfs_props_enable"),
+ ("string", "daily_backup_zfs_get_flags"),
+ ("string", "daily_backup_zpool_get_flags"),
+ ("boolean", "daily_backup_zfs_verbose"),
+ ("boolean", "daily_calendar_enable"),
+ ("boolean", "daily_accounting_enable"),
+ ("boolean", "daily_accounting_compress"),
+ ("varint", "daily_accounting_save"),
+ ("string", "daily_accounting_flags"),
+ ("boolean", "daily_status_disks_enable"),
+ ("string", "daily_status_disks_df_flags"),
+ ("boolean", "daily_status_zfs_enable"),
+ ("boolean", "daily_status_zfs_zpool_list_enable"),
+ ("boolean", "daily_status_gmirror_enable"),
+ ("boolean", "daily_status_graid3_enable"),
+ ("boolean", "daily_status_gstripe_enable"),
+ ("boolean", "daily_status_gconcat_enable"),
+ ("boolean", "daily_status_mfi_enable"),
+ ("boolean", "daily_status_network_enable"),
+ ("string", "daily_status_network_netstat_flags"),
+ ("boolean", "daily_status_network_usedns"),
+ ("boolean", "daily_status_uptime_enable"),
+ ("boolean", "daily_status_mailq_enable"),
+ ("boolean", "daily_status_mailq_shorten"),
+ ("boolean", "daily_status_include_submit_mailq"),
+ ("boolean", "daily_status_security_enable"),
+ ("boolean", "daily_status_security_inline"),
+ ("string", "daily_status_security_output"),
+ ("boolean", "daily_status_mail_rejects_enable"),
+ ("varint", "daily_status_mail_rejects_logs"),
+ ("boolean", "daily_status_ntpd_enable"),
+ ("boolean", "daily_status_world_kernel"),
+ ("boolean", "daily_queuerun_enable"),
+ ("boolean", "daily_submit_queuerun"),
+ ("boolean", "daily_scrub_zfs_enable"),
+ ("string", "daily_scrub_zfs_pools"),
+ ("varint", "daily_scrub_zfs_default_threshold"),
+ ("boolean", "daily_trim_zfs_enable"),
+ ("string", "daily_trim_zfs_pools"),
+ ("string", "daily_local"),
+ ("string", "daily_diff_flags"),
+ ("path", "source"),
+ ],
+)
+
+PeriodicConfWeeklyRecord = TargetRecordDescriptor(
+ "macos/periodic_conf/weekly",
+ [
+ ("boolean", "weekly_locate_enable"),
+ ("boolean", "weekly_whatis_enable"),
+ ("boolean", "weekly_noid_enable"),
+ ("string", "weekly_noid_dirs"),
+ ("boolean", "weekly_status_security_enable"),
+ ("boolean", "weekly_status_security_inline"),
+ ("string", "weekly_status_security_output"),
+ ("boolean", "weekly_status_pkg_enable"),
+ ("string", "pkg_version"),
+ ("string", "pkg_version_index"),
+ ("string", "weekly_local"),
+ ("path", "source"),
+ ],
+)
+
+PeriodicConfMonthlyRecord = TargetRecordDescriptor(
+ "macos/periodic_conf/monthly",
+ [
+ ("boolean", "monthly_accounting_enable"),
+ ("boolean", "monthly_status_security_enable"),
+ ("boolean", "monthly_status_security_inline"),
+ ("string", "monthly_status_security_output"),
+ ("string", "monthly_local"),
+ ("path", "source"),
+ ],
+)
+
+# Part of the official FreeBSD periodic.conf documentation.
+# Used by scripts in /etc/periodic/security.
+# I was not able to find this folder nor these fields on a macOS Ventura system however.
+# TODO: Look into this and remove this record descriptor if not applicable to macOS.
+PeriodicConfSecurityRecord = TargetRecordDescriptor(
+ "macos/periodic_conf/security",
+ [
+ ("string", "security_status_diff_flags"),
+ ("boolean", "security_status_chksetuid_enable"),
+ ("string", "security_status_chksetuid_period"),
+ ("boolean", "security_status_chkportsum_enable"),
+ ("string", "security_status_chkportsum_period"),
+ ("boolean", "security_status_neggrpperm_enable"),
+ ("string", "security_status_neggrpperm_period"),
+ ("boolean", "security_status_chkmounts_enable"),
+ ("string", "security_status_chkmounts_period"),
+ ("boolean", "security_status_noamd"),
+ ("boolean", "security_status_chkuid0_enable"),
+ ("string", "security_status_chkuid0_period"),
+ ("boolean", "security_status_passwdless_enable"),
+ ("string", "security_status_passwdless_period"),
+ ("boolean", "security_status_logincheck_enable"),
+ ("string", "security_status_logincheck_period"),
+ ("boolean", "security_status_ipfwdenied_enable"),
+ ("string", "security_status_ipfwdenied_period"),
+ ("boolean", "security_status_ipfdenied_enable"),
+ ("string", "security_status_ipfdenied_period"),
+ ("boolean", "security_status_pfdenied_enable"),
+ ("string", "security_status_pfdenied_additionalanchors"),
+ ("string", "security_status_pfdenied_period"),
+ ("boolean", "security_status_ipfwlimit_enable"),
+ ("string", "security_status_ipfwlimit_period"),
+ ("boolean", "security_status_kernelmsg_enable"),
+ ("string", "security_status_kernelmsg_period"),
+ ("boolean", "security_status_loginfail_enable"),
+ ("string", "security_status_loginfail_period"),
+ ("boolean", "security_status_tcpwrap_enable"),
+ ("string", "security_status_tcpwrap_period"),
+ ("path", "source"),
+ ],
+)
+
+PeriodicConfRecords = (
+ PeriodicConfRecord,
+ PeriodicConfDailyRecord,
+ PeriodicConfWeeklyRecord,
+ PeriodicConfMonthlyRecord,
+ PeriodicConfSecurityRecord,
+)
+
+
+class PeriodicPlugin(Plugin):
+ """macOS periodic plugin.
+
+ Parses information on daily, weekly and monthly system maintenance jobs.
+ No longer in use since macOS Sequoia.
+
+ References:
+ - https://man.freebsd.org/cgi/man.cgi?periodic.conf
+ """
+
+ PERIODIC_SCRIPTS_PATHS = (
+ "/etc/daily.local/*",
+ "/etc/monthly.local/*",
+ "/etc/periodic/**2",
+ "/etc/periodic/daily/*",
+ "/etc/periodic/monthly/*",
+ "/etc/periodic/weekly/*",
+ "/etc/weekly.local/*",
+ "/usr/local/etc/periodic/**2",
+ )
+
+ PERIODIC_CONF_PATHS = (
+ "/etc/defaults/periodic.conf",
+ "/etc/periodic.conf",
+ "/etc/periodic.conf.local",
+ )
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+
+ self.periodic_scripts_files = set()
+ self.periodic_conf_files = set()
+ self._find_files()
+
+ def check_compatible(self) -> None:
+ if not (self.periodic_scripts_files or self.periodic_conf_files):
+ raise UnsupportedPluginError("No periodic files found")
+
+ def _find_files(self) -> None:
+ for pattern in self.PERIODIC_SCRIPTS_PATHS:
+ for path in self.target.fs.glob(pattern):
+ self.periodic_scripts_files.add(path)
+
+ for path in self.PERIODIC_CONF_PATHS:
+ p = self.target.fs.path(path)
+ if p.exists():
+ self.periodic_conf_files.add(p)
+
+ @export(record=PeriodicScriptsRecord)
+ def periodic_scripts(self) -> Iterator[PeriodicScriptsRecord]:
+ """Return macOS periodic script paths."""
+ for file in self.periodic_scripts_files:
+ yield PeriodicScriptsRecord(
+ source=file,
+ )
+
+ @export(record=PeriodicConfRecords)
+ def periodic_conf(self) -> Iterator[PeriodicConfRecords]:
+ """Return macOS periodic configuration information.
+
+ Yields the following record types extracted from the
+ periodic.conf files:
+
+ .. code-block:: text
+
+ PeriodicConfRecord:
+ local_periodic (string): List of directories to search for periodic scripts
+ when a non-absolute argument is passed to periodic(8).
+ dir_output (string): Specifies how script output is handled; an absolute path
+ writes to a file, otherwise treated as email recipients.
+ dir_show_success (boolean): Controls masking of output for scripts exiting with code 0.
+ dir_show_info (boolean): Controls masking of output for scripts exiting with code 1.
+ dir_show_badconfig (boolean): Controls masking of output for scripts exiting with code 2.
+ anticongestion_sleeptime (varint): Maximum number of seconds to randomly sleep to reduce load bursts.
+ source (path): Path to the periodic.conf file.
+
+ PeriodicConfDailyRecord:
+ daily_clean_disks_enable (boolean): Enables removal of files matching configured patterns.
+ daily_clean_disks_files (string): List of filename patterns to match (wildcards allowed).
+ daily_clean_disks_days (varint): File age in days required before deletion.
+ daily_clean_disks_verbose (boolean): Reports removed files in output.
+ daily_clean_tmps_enable (boolean): Enables cleanup of temporary directories.
+ daily_clean_tmps_dirs (string): Directories to clean when enabled.
+ daily_clean_tmps_days (varint): File age threshold before deletion.
+ daily_clean_tmps_ignore (string): File patterns excluded from deletion.
+ daily_clean_tmps_verbose (boolean): Reports removed temporary files.
+ daily_clean_preserve_enable (boolean): Enables cleanup of /var/preserve.
+ daily_clean_preserve_days (varint): File age threshold before deletion.
+ daily_clean_preserve_verbose (boolean): Reports removed files.
+ daily_clean_msgs_enable (boolean): Enables purging of old system messages.
+ daily_clean_msgs_days (varint): Age threshold for message deletion.
+ daily_clean_rwho_enable (boolean): Enables purging of files in /var/who.
+ daily_clean_rwho_days (varint): File age threshold before deletion.
+ daily_clean_rwho_verbose (boolean): Reports removed files.
+ daily_clean_hoststat_enable (boolean): Runs sendmail host status cleanup.
+ daily_backup_efi_enable (boolean): Enables backup of EFI System Partition.
+ daily_backup_gmirror_enable (boolean): Enables backup of gmirror information.
+ daily_backup_gmirror_verbose (boolean): Reports differences between backups.
+ daily_backup_gpart_enable (boolean): Enables backup of partition tables and boot data.
+ daily_backup_gpart_verbose (boolean): Reports differences in partition backups.
+ daily_backup_passwd_enable (boolean): Enables backup and verification of passwd/group files.
+ daily_backup_aliases_enable (boolean): Enables backup and reporting of mail aliases.
+ daily_backup_zfs_enable (boolean): Enables backup of zfs-list and zpool-list output.
+ daily_backup_zfs_list_flags (string): Arguments passed to zfs-list(8).
+ daily_backup_zpool_list_flags (string): Arguments passed to zpool-list(8).
+ daily_backup_zfs_props_enable (boolean): Enables backup of zfs-get and zpool-get output.
+ daily_backup_zfs_get_flags (string): Arguments passed to zfs-get(8).
+ daily_backup_zpool_get_flags (string): Arguments passed to zpool-get(8).
+ daily_backup_zfs_verbose (boolean): Reports differences between ZFS backups.
+ daily_calendar_enable (boolean): Runs calendar(1) daily.
+ daily_accounting_enable (boolean): Enables rotation of process accounting files.
+ daily_accounting_compress (boolean): Compresses accounting files with gzip.
+ daily_accounting_save (varint): Number of accounting files retained.
+ daily_accounting_flags (string): Arguments passed to sa(8) utility.
+ daily_status_disks_enable (boolean): Enables disk status reporting (df and dump).
+ daily_status_disks_df_flags (string): Arguments passed to df(1).
+ daily_status_zfs_enable (boolean): Enables zpool status reporting.
+ daily_status_zfs_zpool_list_enable (boolean): Enables zpool list output.
+ daily_status_gmirror_enable (boolean): Enables gmirror status reporting.
+ daily_status_graid3_enable (boolean): Enables graid3 status reporting.
+ daily_status_gstripe_enable (boolean): Enables gstripe status reporting.
+ daily_status_gconcat_enable (boolean): Enables gconcat status reporting.
+ daily_status_mfi_enable (boolean): Enables mfi device status reporting.
+ daily_status_network_enable (boolean): Enables network interface reporting.
+ daily_status_network_netstat_flags (string): Arguments passed to netstat(1).
+ daily_status_network_usedns (boolean): Enables DNS lookups in netstat output.
+ daily_status_uptime_enable (boolean): Runs uptime(1) or ruptime(1).
+ daily_status_mailq_enable (boolean): Enables mail queue reporting.
+ daily_status_mailq_shorten (boolean): Produces abbreviated mail queue output.
+ daily_status_include_submit_mailq (boolean): Includes submit queue in output.
+ daily_status_security_enable (boolean): Enables execution of periodic security scripts.
+ daily_status_security_inline (boolean): Outputs security results inline.
+ daily_status_security_output (string): Destination for security output when not inline.
+ daily_status_mail_rejects_enable (boolean): Summarises rejected mail entries.
+ daily_status_mail_rejects_logs (varint): Number of maillog files inspected.
+ daily_status_ntpd_enable (boolean): Enables NTP status check.
+ daily_status_world_kernel (boolean): Checks kernel and userland consistency.
+ daily_queuerun_enable (boolean): Runs mail queue at least once daily.
+ daily_submit_queuerun (boolean): Runs submit mail queue when enabled.
+ daily_scrub_zfs_enable (boolean): Enables periodic ZFS scrub.
+ daily_scrub_zfs_pools (string): ZFS pools to scrub (defaults to all).
+ daily_scrub_zfs_default_threshold (varint): Default number of days between scrubs.
+ daily_trim_zfs_enable (boolean): Enables ZFS trim operation.
+ daily_trim_zfs_pools (string): ZFS pools to trim (defaults to all).
+ daily_local (string): Additional scripts executed after standard daily scripts.
+ daily_diff_flags (string): Arguments passed to diff(1) for comparisons.
+ source (path): Path to the periodic.conf file.
+
+ PeriodicConfWeeklyRecord:
+ weekly_locate_enable (boolean): Runs locate.updatedb to rebuild locate database.
+ weekly_whatis_enable (boolean): Regenerates whatis database for apropos(1).
+ weekly_noid_enable (boolean): Searches for files with invalid ownership.
+ weekly_noid_dirs (string): Directories to scan for orphaned files.
+ weekly_status_security_enable (boolean): Enables weekly security checks.
+ weekly_status_security_inline (boolean): Outputs security results inline.
+ weekly_status_security_output (string): Destination for security output.
+ weekly_status_pkg_enable (boolean): Lists outdated installed packages.
+ pkg_version (string): Program used to determine outdated packages.
+ pkg_version_index (string): INDEX file used for package version comparison.
+ weekly_local (string): Additional scripts executed after standard weekly scripts.
+ source (path): Path to the periodic.conf file.
+
+ PeriodicConfMonthlyRecord:
+ monthly_accounting_enable (boolean): Enables login accounting using ac(8).
+ monthly_status_security_enable (boolean): Enables monthly security checks.
+ monthly_status_security_inline (boolean): Outputs security results inline.
+ monthly_status_security_output (string): Destination for security output.
+ monthly_local (string): Additional scripts executed after standard monthly scripts.
+ source (path): Path to the periodic.conf file.
+
+ PeriodicConfSecurityRecord:
+ security_status_diff_flags (string): Arguments passed to diff(1) for comparisons.
+ security_status_chksetuid_enable (boolean): Compares setuid file modes and timestamps.
+ security_status_chksetuid_period (string): Frequency of execution.
+ security_status_chkportsum_enable (boolean): Verifies installed package checksums.
+ security_status_chkportsum_period (string): Frequency of execution.
+ security_status_neggrpperm_enable (boolean): Checks for group permission inconsistencies.
+ security_status_neggrpperm_period (string): Frequency of execution.
+ security_status_chkmounts_enable (boolean): Compares mounted filesystem changes.
+ security_status_chkmounts_period (string): Frequency of execution.
+ security_status_noamd (boolean): Ignores amd mounts when comparing filesystems.
+ security_status_chkuid0_enable (boolean): Checks for accounts with UID 0.
+ security_status_chkuid0_period (string): Frequency of execution.
+ security_status_passwdless_enable (boolean): Checks for accounts without passwords.
+ security_status_passwdless_period (string): Frequency of execution.
+ security_status_logincheck_enable (boolean): Checks ownership of /etc/login.conf.
+ security_status_logincheck_period (string): Frequency of execution.
+ security_status_ipfwdenied_enable (boolean): Reports packets denied by ipfw.
+ security_status_ipfwdenied_period (string): Frequency of execution.
+ security_status_ipfdenied_enable (boolean): Reports packets denied by ipf.
+ security_status_ipfdenied_period (string): Frequency of execution.
+ security_status_pfdenied_enable (boolean): Reports packets denied by pf.
+ security_status_pfdenied_additionalanchors (string): Additional anchors to include.
+ security_status_pfdenied_period (string): Frequency of execution.
+ security_status_ipfwlimit_enable (boolean): Displays ipfw rules at limit.
+ security_status_ipfwlimit_period (string): Frequency of execution.
+ security_status_kernelmsg_enable (boolean): Shows new kernel messages (dmesg).
+ security_status_kernelmsg_period (string): Frequency of execution.
+ security_status_loginfail_enable (boolean): Reports failed login attempts.
+ security_status_loginfail_period (string): Frequency of execution.
+ security_status_tcpwrap_enable (boolean): Reports tcpwrapper denied connections.
+ security_status_tcpwrap_period (string): Frequency of execution.
+ source (path): Path to the periodic.conf file.
+ """
+ for file in self.periodic_conf_files:
+ for record in PeriodicConfRecords:
+ record_keys = set(record.fields.keys())
+ record_dict = {}
+
+ with file.open("r") as f:
+ for line in f:
+ line = line.strip()
+
+ if not line or line.startswith("#"):
+ continue
+
+ line = line.split("#", 1)[0].strip()
+
+ if "=" in line:
+ key, value = line.split("=", 1)
+ key = key.strip()
+
+ if key in record_keys:
+ value = value.strip().strip('"')
+ if value == "":
+ continue
+ elif value == "YES":
+ value = True
+ elif value == "NO":
+ value = False
+ record_dict[key] = value
+
+ if record_dict:
+ record_dict["source"] = file
+ yield record(
+ **record_dict,
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/resources_info_strings.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/resources_info_strings.py
new file mode 100644
index 0000000000..1cf104c6c5
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/resources_info_strings.py
@@ -0,0 +1,102 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import find_bundle_files
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_plist_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target.target import Target
+
+ResourcesInfoStringsRecord = TargetRecordDescriptor(
+ "macos/resources_info_strings",
+ [
+ ("string", "cf_bundle_name"),
+ ("string", "cf_bundle_display_name"),
+ ("string", "cf_bundle_identifier"),
+ ("string", "cf_bundle_version"),
+ ("string", "cf_bundle_package_type"),
+ ("string", "cf_bundle_signature"),
+ ("string", "cf_bundle_executable"),
+ ("string[]", "cf_bundle_document_types"),
+ ("string", "cf_bundle_short_version_string"),
+ ("string", "ls_minimum_system_version"),
+ ("string", "ns_human_readable_copyright"),
+ ("string", "ns_main_nib_file"),
+ ("string", "ns_principal_class"),
+ ("path", "source"),
+ ],
+)
+
+ResourcesInfoStringsRecords = (ResourcesInfoStringsRecord,)
+
+FIELD_MAPPINGS = {
+ "CFBundleName": "cf_bundle_name",
+ "CFBundleDisplayName": "cf_bundle_display_name",
+ "CFBundleIdentifier": "cf_bundle_identifier",
+ "CFBundleVersion": "cf_bundle_version",
+ "CFBundlePackageType": "cf_bundle_package_type",
+ "CFBundleSignature": "cf_bundle_signature",
+ "CFBundleExecutable": "cf_bundle_executable",
+ "CFBundleDocumentTypes": "cf_bundle_document_types",
+ "CFBundleShortVersionString ": "cf_bundle_short_version_string",
+ "LSMinimumSystemVersion": "ls_minimum_system_version",
+ "NSHumanReadableCopyright": "ns_human_readable_copyright",
+ "NSMainNibFile": "ns_main_nib_file",
+ "NSPrincipalClass": "ns_principal_class",
+}
+
+
+class ResourcesInfoStringsPlugin(Plugin):
+ """macOS Resources InfoPlist.strings plugin.
+
+ Parses localized bundle metadata for the Info.plist file.
+
+ References:
+ - https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html
+ """
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = find_bundle_files(self.target, "InfoPlist.strings")
+
+ def check_compatible(self) -> None:
+ if not self.files:
+ raise UnsupportedPluginError("No Resources InfoPlist.strings files found")
+
+ @export(record=ResourcesInfoStringsRecords)
+ def resources_info_strings(self) -> Iterator[ResourcesInfoStringsRecords]:
+ """Return Resources InfoPlist.strings information.
+
+ Yields ResourcesInfoStringsRecords with the following fields:
+
+ .. code-block:: text
+
+ cf_bundle_name (string): Short name for the bundle.
+ cf_bundle_display_name (string): Localized version of the application name.
+ cf_bundle_identifier (string): Identifies the application to the system.
+ cf_bundle_version (string): Specifies the build version number of the bundle.
+ cf_bundle_package_type (string): Type of bundle.
+ cf_bundle_signature (string): Creator code for the bundle.
+ cf_bundle_executable (string): Name of the main executable file.
+ cf_bundle_document_types (string[]): Document types supported by the application.
+ cf_bundle_short_version_string (string): Release version of the application.
+ ls_minimum_system_version (string): Minimum version of macOS required
+ for this application to run.
+ ns_human_readable_copyright (string): Copyright notice for the application.
+ ns_main_nib_file (string): The nib file to load when the application is launched
+ (without the .nib filename extension).
+ ns_principal_class (string): Entry point for dynamically loaded Objective-C code.
+ source (path): Path to the com.apple.airport.preferences.plist file.
+ """
+ yield from build_plist_records(
+ self,
+ self.files,
+ ResourcesInfoStringsRecords,
+ field_mappings=FIELD_MAPPINGS,
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/resources_localizable_strings.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/resources_localizable_strings.py
new file mode 100644
index 0000000000..1c469fa95e
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/resources_localizable_strings.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import DynamicDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import find_bundle_files
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_plist_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target.target import Target
+
+
+class ResourcesLocalizableStringsPlugin(Plugin):
+ """macOS Resources Localizable.strings plist file.
+
+ Parses resource files used to store text that can be translated into different languages.
+
+ References:
+ - https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/LoadingResources/Strings/Strings.html
+ """
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = find_bundle_files(self.target, "Localizable.strings")
+
+ def check_compatible(self) -> None:
+ if not self.files:
+ raise UnsupportedPluginError("No Resources Localizable.strings files found")
+
+ @export(record=DynamicDescriptor(["string"]))
+ def resources_localizable_strings(self) -> Iterator[DynamicDescriptor]:
+ """Yield Resources Localizable.strings information."""
+ yield from build_plist_records(self, self.files, function_name="macos/resources_localizable_strings")
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/safari/__init__.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/safari/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/safari/safari_downloads.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/safari/safari_downloads.py
new file mode 100644
index 0000000000..46aa230ec5
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/safari/safari_downloads.py
@@ -0,0 +1,105 @@
+from __future__ import annotations
+
+import plistlib
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import _build_userdirs
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target import Target
+
+SafariDownloadRecord = TargetRecordDescriptor(
+ "macos/safari_downloads",
+ [
+ ("varint", "download_entry_progress_total_to_load"),
+ ("varint", "download_entry_progress_bytes_so_far"),
+ ("string", "download_entry_path"),
+ ("datetime", "download_entry_date_added"),
+ ("boolean", "download_entry_remove_when_done"),
+ ("boolean", "download_entry_should_use_request_url_as_origin_url_if_necessary"),
+ ("string", "download_entry_profile_uuid_string"),
+ ("datetime", "download_entry_date_finished"),
+ ("string", "download_entry_url"),
+ ("string", "download_entry_sandbox_identifier"),
+ ("bytes", "download_entry_bookmark_blob"),
+ ("string", "download_entry_identifier"),
+ ("path", "source"),
+ ],
+)
+
+
+class SafariDownloadsPlugin(Plugin):
+ """macOS Safari property list (plist) plugin.
+
+ This plist file contains a record of downloaded files.
+ This data is automatically deleted after one day by default.
+
+ References:
+ - https://medium.com/@cyberengage.org/p13-analyzing-safari-browser-apple-mail-data-and-recents-database-artifacts-on-macos-9b58848d70ec
+ """
+
+ USER_PATH = ("Library/Safari/Downloads.plist",)
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = self._find_files()
+
+ def check_compatible(self) -> None:
+ if not (self.files):
+ raise UnsupportedPluginError("No Downloads.plist files found")
+
+ def _find_files(self) -> set:
+ files = set()
+ for _, path in _build_userdirs(self, self.USER_PATH):
+ files.add(path)
+ return files
+
+ @export(record=SafariDownloadRecord)
+ def safari_downloads(self) -> Iterator[SafariDownloadRecord]:
+ """Return macOS Safari downloads.
+
+ Yields SafariDownloadRecords for each download with the following fields:
+
+ .. code-block:: text
+
+ download_entry_progress_total_to_load (varint): Total bytes size to download.
+ download_entry_progress_bytes_so_far (varint): Amount of bytes downloaded so far.
+ download_entry_path (string): Local file path of the download.
+ download_entry_date_added (datetime): Timestamp when the download was added.
+ download_entry_remove_when_done (boolean): Whether the download is removed after completion.
+ download_entry_should_use_request_url_as_origin_url_if_necessary (boolean):
+ Whether the request URL should be used as the origin URL if needed.
+ download_entry_profile_uuid_string (string): Profile UUID associated with the download.
+ download_entry_date_finished (datetime): Timestamp when the download completed.
+ download_entry_url (string): Source URL of the download.
+ download_entry_sandbox_identifier (string): Sandbox identifier for the download.
+ download_entry_bookmark_blob (bytes): Bookmark data blob for the downloaded file.
+ download_entry_identifier (string): Unique identifier of the download entry.
+ source (path): Path to the Downloads.plist file.
+ """
+ for file in self.files:
+ plist = plistlib.load(file.open())
+ for download in plist.get("DownloadHistory"):
+ yield SafariDownloadRecord(
+ download_entry_progress_total_to_load=download.get("DownloadEntryProgressTotalToLoad"),
+ download_entry_progress_bytes_so_far=download.get("DownloadEntryProgressBytesSoFar"),
+ download_entry_path=download.get("DownloadEntryPath"),
+ download_entry_date_added=download.get("DownloadEntryDateAddedKey"),
+ download_entry_remove_when_done=download.get("DownloadEntryRemoveWhenDoneKey"),
+ download_entry_should_use_request_url_as_origin_url_if_necessary=download.get(
+ "DownloadEntryShouldUseRequestURLAsOriginURLIfNecessaryKey"
+ ),
+ download_entry_profile_uuid_string=download.get("DownloadEntryProfileUUIDStringKey"),
+ download_entry_date_finished=download.get("DownloadEntryDateFinishedKey"),
+ download_entry_url=download.get("DownloadEntryURL"),
+ download_entry_sandbox_identifier=download.get("DownloadEntrySandboxIdentifier"),
+ download_entry_bookmark_blob=download.get("DownloadEntryBookmarkBlob"),
+ download_entry_identifier=download.get("DownloadEntryIdentifier"),
+ source=file,
+ _target=self.target,
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/safari/safari_favicons.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/safari/safari_favicons.py
new file mode 100644
index 0000000000..2bb23c815f
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/safari/safari_favicons.py
@@ -0,0 +1,113 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import _build_userdirs
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_sqlite_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target import Target
+
+PageURLRecord = TargetRecordDescriptor(
+ "macos/safari_favicons/page_url",
+ [
+ ("string", "table"),
+ ("string", "url"),
+ ("string", "uuid"),
+ ("path", "source"),
+ ],
+)
+
+IconInfoRecord = TargetRecordDescriptor(
+ "macos/safari_favicons/icon_info",
+ [
+ ("string", "table"),
+ ("string", "uuid"),
+ ("string", "url"),
+ ("datetime", "timestamp"),
+ ("varint", "width"),
+ ("varint", "height"),
+ ("boolean", "has_generated_representations"),
+ ("path", "source"),
+ ],
+)
+
+RejectedResourcesRecord = TargetRecordDescriptor(
+ "macos/safari_favicons/rejected_resources",
+ [
+ ("string", "table"),
+ ("string", "page_url"),
+ ("string", "icon_url"),
+ ("datetime", "timestamp"),
+ ("path", "source"),
+ ],
+)
+
+
+SafariFaviconRecords = (PageURLRecord, IconInfoRecord, RejectedResourcesRecord)
+
+CONVERT_TIMESTAMPS = {
+ "timestamp": "2001",
+}
+
+
+class SafariFaviconsPlugin(Plugin):
+ """macOS Safari favicons SQLite database plugin."""
+
+ USER_PATH = ("Library/Safari/Favicon Cache/favicons.db",)
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = self._find_files()
+
+ def check_compatible(self) -> None:
+ if not (self.files):
+ raise UnsupportedPluginError("No favicons.db files found")
+
+ def _find_files(self) -> set:
+ files = set()
+ for _, path in _build_userdirs(self, self.USER_PATH):
+ files.add(path)
+ return files
+
+ @export(record=SafariFaviconRecords)
+ def safari_favicons(
+ self,
+ ) -> Iterator[SafariFaviconRecords]:
+ """Return Safari favicon information.
+
+ Yields the following record types:
+
+ .. code-block:: text
+
+ PageURLRecord:
+ table (string): Name of the source table (page_url).
+ url (string): URL of the webpage.
+ uuid (string): Unique identifier.
+ source (path): Path to the favicons.db database file.
+
+ IconInfoRecord:
+ table (string): Name of the source table (icon_info).
+ uuid (string): Unique identifier.
+ url (string): URL of the favicon image.
+ timestamp (datetime): Timestamp.
+ width (varint): Width of the favicon.
+ height (varint): Height of the favicon.
+ has_generated_representations (boolean): Indicates whether the favicon has generated representations.
+ source (path): Path to the favicons.db database file.
+
+ RejectedResourcesRecord:
+ table (string): Name of the source table (rejected_resources).
+ page_url (string): URL of the webpage associated with the rejected favicon.
+ icon_url (string): URL of the rejected favicon resource.
+ timestamp (datetime): Timestamp.
+ source (path): Path to the favicons.db database file.
+ """
+ yield from build_sqlite_records(self, self.files, SafariFaviconRecords, convert_timestamps=CONVERT_TIMESTAMPS)
+
+ # TODO: Add database_info table
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/safari/safari_per_site_preferences.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/safari/safari_per_site_preferences.py
new file mode 100644
index 0000000000..3ce6847a07
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/safari/safari_per_site_preferences.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import _build_userdirs
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_sqlite_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target import Target
+
+
+SQLiteSequenceRecord = TargetRecordDescriptor(
+ "macos/safari_per_site_preferences/sqlite_sequence",
+ [
+ ("string", "table"),
+ ("string", "name"),
+ ("varint", "seq"),
+ ("path", "source"),
+ ],
+)
+
+PreferencesValuesRecord = TargetRecordDescriptor(
+ "macos/safari_per_site_preferences/preferences_values",
+ [
+ ("string", "table"),
+ ("varint", "id"),
+ ("string", "preference_domain"),
+ ("string", "preference"),
+ ("varint", "preference_value"),
+ ("datetime", "timestamp"),
+ ("string", "sync_data"),
+ ("string", "record_name"),
+ ("path", "source"),
+ ],
+)
+
+
+SafariPerSitePreferencesRecords = (
+ SQLiteSequenceRecord,
+ PreferencesValuesRecord,
+)
+
+FIELD_MAPPINGS = {
+ "domain": "preference_domain",
+}
+
+CONVERT_TIMESTAMPS = {
+ "timestamp": "2001",
+}
+
+
+class SafariPerSitePreferencesPlugin(Plugin):
+ """macOS Safari per site preferences SQLite database plugin."""
+
+ USER_PATH = ("Library/Safari/PerSitePreferences.db",)
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = self._find_files()
+
+ def check_compatible(self) -> None:
+ if not (self.files):
+ raise UnsupportedPluginError("No PerSitePreferences.db files found")
+
+ def _find_files(self) -> set:
+ files = set()
+ for _, path in _build_userdirs(self, self.USER_PATH):
+ files.add(path)
+ return files
+
+ @export(record=SafariPerSitePreferencesRecords)
+ def safari_per_site_preferences(
+ self,
+ ) -> Iterator[SafariPerSitePreferencesRecords]:
+ """Return Safari per site preferences information.
+
+ Yields the following record types:
+
+ .. code-block:: text
+
+ PreferencesValuesRecord:
+ table (string): Name of the source table (preference_values).
+ id (varint): Primary key of the row.
+ preference_domain (string): Domain name the preference applies to.
+ preference (string): Name of the preference.
+ preference_value (varint): Value assigned to the preference.
+ timestamp (datetime): Timestamp indicating when the preference was set or updated.
+ sync_data (string): Synchronization metadata associated with the preference.
+ record_name (string): Record name.
+ source (path): Path to the PerSitePreferences.db database file.
+
+ SQLiteSequenceTableRecord:
+ table (string): Name of the source table (sqlite_sequence).
+ name (string): Name of the table for which the sequence applies.
+ seq (varint): Current autoincrement value for the table.
+ source (path): Path to the PerSitePreferences.db database file.
+ """
+ yield from build_sqlite_records(
+ self,
+ self.files,
+ SafariPerSitePreferencesRecords,
+ field_mappings=FIELD_MAPPINGS,
+ convert_timestamps=CONVERT_TIMESTAMPS,
+ )
+
+ # TODO: Add default_preferences table
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/safari/safari_per_site_zoom_preferences.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/safari/safari_per_site_zoom_preferences.py
new file mode 100644
index 0000000000..0561b0a6af
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/safari/safari_per_site_zoom_preferences.py
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+import plistlib
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import _build_userdirs
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target import Target
+
+SafariPerSiteZoomPreferencesRecord = TargetRecordDescriptor(
+ "macos/safari_per_site_zoom_preferences",
+ [
+ ("string", "map_of_ck_record_names_to_ck_records"),
+ ("varint", "zoom_preference_version"),
+ ("path", "source"),
+ ],
+)
+
+HostnameToZoomPreferencesMapRecord = TargetRecordDescriptor(
+ "macos/safari_per_site_zoom_preferences/hostnames_to_zoom_preferences_map",
+ [
+ ("string", "site"),
+ ("varint", "page_zoom_factor"),
+ ("varint", "text_zoom_factor"),
+ ("path", "source"),
+ ],
+)
+
+
+class SafariPerSiteZoomPreferencesPlugin(Plugin):
+ """macOS Safari per site zoom preferences (plist) plugin.
+
+ References:
+ - https://www.magnetforensics.com/blog/macos-safari-preferences-and-privacy/
+ """
+
+ USER_PATH = ("Library/Safari/PerSiteZoomPreferences.plist",)
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = self._find_files()
+
+ def check_compatible(self) -> None:
+ if not (self.files):
+ raise UnsupportedPluginError("No PerSiteZoomPreferences.plist files found")
+
+ def _find_files(self) -> set:
+ files = set()
+ for _, path in _build_userdirs(self, self.USER_PATH):
+ files.add(path)
+ return files
+
+ @export(record=(SafariPerSiteZoomPreferencesRecord, HostnameToZoomPreferencesMapRecord))
+ def safari_per_site_zoom_preferences(
+ self,
+ ) -> Iterator[(SafariPerSiteZoomPreferencesRecord, HostnameToZoomPreferencesMapRecord)]:
+ """Return macOS Safari per site zoom preferences.
+
+ Yields the following record types extracted from the
+ PerSiteZoomPreferences.plist files:
+
+ .. code-block:: text
+
+ SafariPerSiteZoomPreferencesRecord:
+ map_of_ck_record_names_to_ck_records (string): map of ck record names to ck records.
+ zoom_preference_version (varint): Zoom preference version.
+ source (path): Path to the PerSiteZoomPreferences.plist file.
+
+ HostnameToZoomPreferencesMapRecord:
+ site (string): Site the preference applies to.
+ page_zoom_factor (varint): Page zoom factor.
+ text_zoom_factor (varint): Text zoom factor.
+ source (path): Path to the PerSiteZoomPreferences.plist file.
+ """
+ for file in self.files:
+ plist = plistlib.load(file.open())
+
+ yield SafariPerSiteZoomPreferencesRecord(
+ map_of_ck_record_names_to_ck_records=plist.get("MapOfCKRecordNamesToCKRecords"),
+ zoom_preference_version=plist.get("ZoomPreferenceVersion"),
+ source=file,
+ _target=self.target,
+ )
+
+ map_of_hostnames_to_zoom_preferences = plist.get("MapOfHostnamesToZoomPreferences")
+ for site, preferences in map_of_hostnames_to_zoom_preferences.items():
+ yield HostnameToZoomPreferencesMapRecord(
+ site=site,
+ page_zoom_factor=preferences.get("PageZoomFactor"),
+ text_zoom_factor=preferences.get("TextZoomFactor"),
+ source=file,
+ _target=self.target,
+ )
+
+
+# MapOfCKRecordNamesToCKRecords fields are empty in current test data
+# TODO: Look into MapOfCKRecordNamesToCKRecords field and whether
+# it should form a separate record like MapOfHostnamesToZoomPreferences
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/safari/safari_recently_closed_tabs.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/safari/safari_recently_closed_tabs.py
new file mode 100644
index 0000000000..2a6dcb8ba4
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/safari/safari_recently_closed_tabs.py
@@ -0,0 +1,216 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import _build_userdirs
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_plist_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target import Target
+
+SafariRecentlyClosedTabsRecord = TargetRecordDescriptor(
+ "macos/safari_recently_closed_tabs",
+ [
+ ("boolean", "is_disposable"),
+ ("string[]", "ancestor_tab_uuids_key"),
+ ("boolean", "tab_group_type_for_tab_key"),
+ ("string", "tab_group_for_tab"),
+ ("datetime", "date_closed"),
+ ("string", "profile_uuid"),
+ ("boolean", "safe_to_load"),
+ ("varint", "tab_index"),
+ ("string", "window_uuid"),
+ ("datetime", "last_visit_time"),
+ ("string", "tab_uuid"),
+ ("string", "tab_url"),
+ ("varint", "tab_state_version"),
+ ("string", "tab_title"),
+ ("boolean", "is_muted"),
+ ("varint", "process_identifier"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+WindowRecord = TargetRecordDescriptor(
+ "macos/safari_recently_closed_tabs/window",
+ [
+ ("varint", "selected_tab_index"),
+ ("varint", "window_unified_sidebar_mode"),
+ ("boolean", "tab_bar_hidden"),
+ ("datetime", "date_closed"),
+ ("boolean", "favorites_bar_hidden"),
+ ("boolean", "is_popup_window"),
+ ("string", "profile_uuid"),
+ ("string", "window_restoration_archive_data"),
+ ("boolean", "is_private_window"),
+ ("boolean", "miniaturized"),
+ ("boolean", "prefers_reading_list_sidebar_visible"),
+ ("varint", "selected_pinned_tab_index"),
+ ("string[]", "unnamed_tab_group_uuids"),
+ ("string", "window_content_rect"),
+ ("string", "window_state_version"),
+ ("string", "window_uuid"),
+ ("string", "active_tab_group_uuid"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+ClosedPersistentStatesVersionRecord = TargetRecordDescriptor(
+ "macos/safari_recently_closed_tabs/closed_persistent_states_version",
+ [
+ ("string", "closed_tab_or_window_persistent_states_version"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+PersistentStateTypeRecord = TargetRecordDescriptor(
+ "macos/safari_recently_closed_tabs/persistent_state_type",
+ [
+ ("varint", "persistent_state_type"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+SafariRecentlyClosedTabsRecords = (
+ SafariRecentlyClosedTabsRecord,
+ WindowRecord,
+ ClosedPersistentStatesVersionRecord,
+ PersistentStateTypeRecord,
+)
+
+FIELD_MAPPINGS = {
+ "IsDisposable": "is_disposable",
+ "AncestorTabUUIDsKey": "ancestor_tab_uuids_key",
+ "TabGroupTypeForTabKey": "tab_group_type_for_tab_key",
+ "TabGroupForTab": "tab_group_for_tab",
+ "DateClosed": "date_closed",
+ "ProfileUUID": "profile_uuid",
+ "SafeToLoad": "safe_to_load",
+ "TabIndex": "tab_index",
+ "WindowUUID": "window_uuid",
+ "LastVisitTime": "last_visit_time",
+ "TabUUID": "tab_uuid",
+ "TabURL": "tab_url",
+ "TabStateVersion": "tab_state_version",
+ "TabTitle": "tab_title",
+ "IsMuted": "is_muted",
+ "ProcessIdentifier": "process_identifier",
+ "SelectedTabIndex": "selected_tab_index",
+ "WindowUnifiedSidebarMode": "window_unified_sidebar_mode",
+ "TabBarHidden": "tab_bar_hidden",
+ "FavoritesBarHidden": "favorites_bar_hidden",
+ "IsPopupWindow": "is_popup_window",
+ "WindowRestorationArchiveData": "window_restoration_archive_data",
+ "IsPrivateWindow": "is_private_window",
+ "Miniaturized": "miniaturized",
+ "PrefersReadingListSidebarVisible": "prefers_reading_list_sidebar_visible",
+ "SelectedPinnedTabIndex": "selected_pinned_tab_index",
+ "UnnamedTabGroupUUIDs": "unnamed_tab_group_uuids",
+ "WindowContentRect": "window_content_rect",
+ "WindowStateVersion": "window_state_version",
+ "activeTabGroupUUID": "active_tab_group_uuid",
+ "PersistentStateType": "persistent_state_type",
+ "ClosedTabOrWindowPersistentStatesVersion": "closed_tab_or_window_persistent_states_version",
+}
+
+
+class SafariRecentlyClosedTabsPlugin(Plugin):
+ """macOS Safari recently closed tabs (plist) plugin.
+
+ References:
+ - https://medium.com/@cyberengage.org/p13-analyzing-safari-browser-apple-mail-data-and-recents-database-artifacts-on-macos-9b58848d70ec
+ """
+
+ USER_PATH = ("Library/Safari/RecentlyClosedTabs.plist",)
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = self._find_files()
+
+ def check_compatible(self) -> None:
+ if not (self.files):
+ raise UnsupportedPluginError("No RecentlyClosedTabs.plist files found")
+
+ def _find_files(self) -> set:
+ files = set()
+ for _, path in _build_userdirs(self, self.USER_PATH):
+ files.add(path)
+ return files
+
+ @export(record=SafariRecentlyClosedTabsRecords)
+ def safari_recently_closed_tabs(self) -> Iterator[SafariRecentlyClosedTabsRecords]:
+ """Return macOS Safari recently closed tabs information.
+
+ Yields the following record types extracted from the
+ RecentlyClosedTabs.plist files:
+
+ .. code-block:: text
+
+ SafariRecentlyClosedTabsRecord:
+ is_disposable (boolean): Indicates whether the tab entry is disposable.
+ ancestor_tab_uuids_key (string[]): List of ancestor tab UUIDs.
+ tab_group_type_for_tab_key (boolean): Indicates if the tab belongs to a tab group type.
+ tab_group_for_tab (string): UUID of the associated tab group.
+ date_closed (datetime): Timestamp when the tab was closed.
+ profile_uuid (string): Profile identifier.
+ safe_to_load (boolean): Indicates if the tab is safe to restore.
+ tab_index (varint): Index of the tab within the window.
+ window_uuid (string): UUID of the parent window.
+ last_visit_time (datetime): Timestamp of the last visit to the tab.
+ tab_uuid (string): Unique identifier for the tab.
+ tab_url (string): URL of the tab.
+ tab_state_version (varint): Version of the tab state.
+ tab_title (string): Title of the tab.
+ is_muted (boolean): Indicates whether the tab was muted.
+ process_identifier (varint): Process identifier associated with the tab.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the RecentlyClosedTabs.plist file.
+
+ WindowRecord:
+ selected_tab_index (varint): Index of the selected tab in the window.
+ window_unified_sidebar_mode (varint): Sidebar mode.
+ tab_bar_hidden (boolean): Indicates if the tab bar is hidden.
+ date_closed (datetime): Timestamp when the window was closed.
+ favorites_bar_hidden (boolean): Indicates if the favorites bar is hidden.
+ is_popup_window (boolean): Indicates if the window is a popup.
+ profile_uuid (string): Profile identifier.
+ window_restoration_archive_data (string): Window restoration archive data.
+ is_private_window (boolean): Indicates if the window was private.
+ miniaturized (boolean): Indicates if the window was minimized.
+ prefers_reading_list_sidebar_visible (boolean): Reading list sidebar visibility.
+ selected_pinned_tab_index (varint): Index of the selected pinned tab.
+ unnamed_tab_group_uuids (string[]): List of unnamed tab group UUIDs.
+ window_content_rect (string): Window geometry (position and size).
+ window_state_version (string): Version of the window state data.
+ window_uuid (string): Unique identifier for the window.
+ active_tab_group_uuid (string): UUID of the active tab group.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the RecentlyClosedTabs.plist file.
+
+ ClosedPersistentStatesVersionRecord:
+ closed_tab_or_window_persistent_states_version (string): Version of the persistent states.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the RecentlyClosedTabs.plist file.
+
+ PersistentStateTypeRecord:
+ persistent_state_type (varint): Type of the persistent state.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the RecentlyClosedTabs.plist file.
+
+ """
+ yield from build_plist_records(
+ self,
+ self.files,
+ SafariRecentlyClosedTabsRecords,
+ field_mappings=FIELD_MAPPINGS,
+ function_name="macos/safari_recently_closed_tabs",
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/safari/safari_user_notification_permissions.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/safari/safari_user_notification_permissions.py
new file mode 100644
index 0000000000..8e4a47ccae
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/safari/safari_user_notification_permissions.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+import plistlib
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import _build_userdirs
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target import Target
+
+SafariUserNotificationPermissionsRecord = TargetRecordDescriptor(
+ "macos/safari_user_notification_permissions",
+ [
+ ("varint", "permission"),
+ ("datetime", "date_added"),
+ ("string", "site"),
+ ("path", "source"),
+ ],
+)
+
+
+class SafariUserNotificationPermissionsPlugin(Plugin):
+ """macOS Safari user notification permissions property list (plist) plugin.
+
+ References:
+ - https://www.magnetforensics.com/blog/macos-safari-preferences-and-privacy/
+ """
+
+ USER_PATH = ("Library/Safari/UserNotificationPermissions.plist",)
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = self._find_files()
+
+ def check_compatible(self) -> None:
+ if not (self.files):
+ raise UnsupportedPluginError("No UserNotificationPermissions.plist files found")
+
+ def _find_files(self) -> set:
+ files = set()
+ for _, path in _build_userdirs(self, self.USER_PATH):
+ files.add(path)
+ return files
+
+ @export(record=SafariUserNotificationPermissionsRecord)
+ def safari_user_notification_permissions(self) -> Iterator[SafariUserNotificationPermissionsRecord]:
+ """Return macOS Safari user notification permissions.
+
+ Yields SafariUserNotificationPermissionsRecords with the following fields:
+
+ .. code-block:: text
+
+ permission (varint): The notification permission value for the site.
+ date_added (datetime): The timestamp when the permission entry was created.
+ site (string): The website URL associated with the permission.
+ source (path): Path to the UserNotificationPermissions.plist file.
+ """
+ for file in self.files:
+ plist = plistlib.load(file.open())
+ # The top-level keys in the plist represent website URLs (e.g., https://www.macworld.com)
+ # for which Safari has stored notification permission entries.
+ for key in plist:
+ data = plist.get(key)
+ yield SafariUserNotificationPermissionsRecord(
+ permission=data["Permission"],
+ date_added=data["Date Added"],
+ site=key,
+ source=file,
+ _target=self.target,
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/shadow.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/shadow.py
new file mode 100644
index 0000000000..b86836d5c6
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/shadow.py
@@ -0,0 +1,90 @@
+from __future__ import annotations
+
+import plistlib
+from typing import TYPE_CHECKING
+
+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 dissect.target import Target
+
+ShadowRecord = TargetRecordDescriptor(
+ "macos/shadow",
+ [
+ ("string", "name"),
+ ("string", "hash"),
+ ("string", "salt"),
+ ("varint", "iterations"),
+ ("string", "algorithm"),
+ ("path", "source"),
+ ],
+)
+
+
+class ShadowPlugin(Plugin):
+ """macOS shadow plugin.
+
+ Parses user password hashes plist files.
+ """
+
+ USER_FILE_GLOB = "/var/db/dslocal/nodes/Default/users/*.plist"
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.user_files = self._resolve_files()
+
+ def check_compatible(self) -> None:
+ if not self.user_files:
+ raise UnsupportedPluginError("No shadow files found")
+
+ def _resolve_files(self) -> set:
+ user_files = set()
+ for file in self.target.fs.glob(self.USER_FILE_GLOB):
+ user_files.add(file)
+ return user_files
+
+ @export(record=ShadowRecord)
+ def passwords(self) -> Iterator[ShadowRecord]:
+ """Return user password hashes from macOS user plist files.
+
+ Yields ShadowRecords with the following fields:
+
+ .. code-block:: text
+
+ name (string): Username associated with the hash.
+ hash (string): Hex-encoded password hash.
+ salt (string): Hex-encoded salt used for key derivation.
+ iterations (varint): Number of iterations used by the hashing algorithm.
+ algorithm (string): Hashing algorithm identifier.
+ source (path): Path to the plist file.
+ """
+ for path in self.user_files:
+ path = self.target.fs.path(path)
+ user = plistlib.load(path.open())
+ if user.get("ShadowHashData") is None:
+ continue
+
+ shadow = plistlib.loads(user["ShadowHashData"][0])
+ username = user["name"][0]
+
+ for key in shadow:
+ if shadow[key].get("entropy") is None:
+ continue
+
+ hash = shadow[key]["entropy"].hex()
+ salt = shadow[key]["salt"].hex()
+ iterations = shadow[key]["iterations"]
+
+ yield ShadowRecord(
+ name=username,
+ hash=hash,
+ salt=salt,
+ iterations=iterations,
+ algorithm=key,
+ source=path,
+ _target=self.target,
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/software_update_preferences.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/software_update_preferences.py
new file mode 100644
index 0000000000..c45ba98645
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/software_update_preferences.py
@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+import plistlib
+from typing import TYPE_CHECKING
+
+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 dissect.target import Target
+
+SoftwareUpdatePreferencesRecord = TargetRecordDescriptor(
+ "macos/software_update_preferences",
+ [
+ ("varint", "last_result_code"),
+ ("string", "last_attempt_system_version"),
+ ("string", "last_attempt_build_version"),
+ ("boolean", "automatic_download"),
+ ("boolean", "automatically_install_macos_updates"),
+ ("boolean", "critical_update_install"),
+ ("boolean", "config_data_install"),
+ ("string[]", "recommended_updates"),
+ ("boolean", "splat_enabled"),
+ ("boolean", "post_logout_notification"),
+ ("string", "last_recommended_major_os_bundle_id"),
+ ("string[]", "primary_languages"),
+ ("datetime", "last_successful_date"),
+ ("datetime", "last_full_successful_date"),
+ ("path", "source"),
+ ],
+)
+
+
+class SoftwareUpdatePreferencesPlugin(Plugin):
+ """macOS software update preferences plugin.
+
+ References:
+ - https://eclecticlight.co/2022/11/14/how-does-ventura-update-faster-inside-the-macos-update-process/
+ """
+
+ PATH = "/Library/Preferences/com.apple.SoftwareUpdate.plist"
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.file = self.target.fs.path(self.PATH) if self.target.fs.path(self.PATH).exists() else None
+
+ def check_compatible(self) -> None:
+ if not self.file:
+ raise UnsupportedPluginError("No com.apple.SoftwareUpdate.plist file found")
+
+ @export(record=SoftwareUpdatePreferencesRecord)
+ def software_update_preferences(self) -> Iterator[SoftwareUpdatePreferencesRecord]:
+ """Return software update preference information.
+
+ Yields SoftwareUpdatePreferencesRecords with the following fields:
+
+ .. code-block:: text
+
+ last_result_code (varint): Result code of the last update attempt.
+ last_attempt_system_version (string): macOS version targeted in the last update attempt.
+ last_attempt_build_version (string): Build version targeted in the last update attempt.
+ automatic_download (boolean): Whether automatic download of updates is enabled.
+ automatically_install_macos_updates (boolean): Whether macOS updates are installed automatically.
+ critical_update_install (boolean): Whether critical updates are installed automatically.
+ config_data_install (boolean): Whether configuration data updates are installed automatically.
+ recommended_updates (string[]): List of recommended updates.
+ splat_enabled (boolean): Whether the cryptex-based update subsystem (SPLAT) is enabled.
+ post_logout_notification (boolean): Whether to notify user after logout following update.
+ last_recommended_major_os_bundle_id (string): Bundle identifier of last recommended major OS upgrade.
+ primary_languages (string[]): Preferred system languages.
+ last_successful_date (datetime): Timestamp of last successful update.
+ last_full_successful_date (datetime): Timestamp of last full successful update.
+ source (path): Path to the com.apple.SoftwareUpdate.plist file.
+ """
+ plist = plistlib.load(self.file.open())
+
+ yield SoftwareUpdatePreferencesRecord(
+ last_result_code=plist.get("LastResultCode"),
+ last_attempt_system_version=plist.get("LastAttemptSystemVersion"),
+ last_attempt_build_version=plist.get("LastAttemptBuildVersion"),
+ automatic_download=plist.get("AutomaticDownload"),
+ automatically_install_macos_updates=plist.get("AutomaticallyInstallMacOSUpdates"),
+ critical_update_install=plist.get("CriticalUpdateInstall"),
+ config_data_install=plist.get("ConfigDataInstall"),
+ recommended_updates=plist.get("RecommendedUpdates"),
+ splat_enabled=plist.get("SplatEnabled"),
+ post_logout_notification=plist.get("PostSuccessfulMinorUpdatePostLogOutNotification"),
+ last_recommended_major_os_bundle_id=plist.get("LastRecommendedMajorOSBundleIdentifier"),
+ primary_languages=plist.get("PrimaryLanguages"),
+ last_successful_date=plist.get("LastSuccessfulDate"),
+ last_full_successful_date=plist.get("LastFullSuccessfulDate"),
+ source=self.file,
+ _target=self.target,
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/system_preferences.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/system_preferences.py
new file mode 100644
index 0000000000..a053c089f4
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/system_preferences.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import DynamicDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_plist_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target.target import Target
+
+
+class SystemPreferencesPlugin(Plugin):
+ """macOS system preferences plugin."""
+
+ PATHS = ("/Library/Preferences/**/*.plist",)
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = self._find_files()
+
+ def _find_files(self) -> set:
+ files = set()
+ for pattern in self.PATHS:
+ for path in self.target.fs.glob(pattern):
+ files.add(path)
+ return files
+
+ def check_compatible(self) -> None:
+ if not (self.files):
+ raise UnsupportedPluginError("No system preferences files found")
+
+ @export(record=DynamicDescriptor(["string"]))
+ def system_preferences(self) -> Iterator[DynamicDescriptor]:
+ """Yield system preference information."""
+ yield from build_plist_records(self, self.files, function_name="macos/system_preferences")
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/tcc.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/tcc.py
new file mode 100644
index 0000000000..f3234f2d9d
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/tcc.py
@@ -0,0 +1,152 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import _build_userdirs
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_sqlite_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target import Target
+
+AccessRecord = TargetRecordDescriptor(
+ "macos/tcc/access",
+ [
+ ("string", "table"),
+ ("string", "service"),
+ ("string", "client"),
+ ("varint", "client_type"),
+ ("string", "auth_value"),
+ ("string", "auth_reason"),
+ ("varint", "auth_version"),
+ ("bytes", "csreq"),
+ ("string", "policy_id"),
+ ("string", "indirect_object_identifier"),
+ ("string", "indirect_object_identifier_type"),
+ ("bytes", "indirect_object_code_identity"),
+ ("varint", "flags"),
+ ("datetime", "last_modified"),
+ ("varint", "pid"),
+ ("string", "pid_version"),
+ ("string", "boot_uuid"),
+ ("datetime", "last_reminded"),
+ ("path", "source"),
+ ],
+)
+
+KeyValueRecord = TargetRecordDescriptor(
+ "macos/tcc/key_value",
+ [
+ ("string", "table"),
+ ("string", "key"),
+ ("string", "value"),
+ ("path", "source"),
+ ],
+)
+
+TCCRecords = (
+ AccessRecord,
+ KeyValueRecord,
+)
+
+VALUE_MAPPINGS = {
+ "auth_value": {
+ 0: "Denied",
+ 1: "Unknown",
+ 2: "Allowed",
+ 3: "Limited",
+ },
+ "auth_reason": {
+ 1: "Error",
+ 2: "User Consent",
+ 3: "User Set",
+ 4: "System Set",
+ 5: "Service Policy",
+ 6: "MDM Policy",
+ 7: "Override Policy",
+ 8: "Missing usage string",
+ 9: "Prompt Timeout",
+ 10: "Preflight Unknown",
+ 11: "Entitled",
+ 12: "App Type Policy.",
+ },
+}
+
+
+class TCCPlugin(Plugin):
+ """macOS transparency, consent, control (tcc) framework plugin.
+
+ TCC is a mechanism in macOS to limit and control application access to certain features. This can include
+ things such as location services, contacts, photos, microphone, camera, accessibility, full disk access, etc.
+
+ References:
+ - https://www.rainforestqa.com/blog/macos-tcc-db-deep-dive
+ """
+
+ SYSTEM_PATH = "/Library/Application Support/com.apple.TCC/TCC.db"
+ USER_PATH = ("Library/Application Support/com.apple.TCC/TCC.db",)
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = self._find_files()
+
+ def check_compatible(self) -> None:
+ if not (self.files):
+ raise UnsupportedPluginError("No TCC.db files found")
+
+ def _find_files(self) -> set:
+ files = set()
+ files.add(self.target.fs.path(self.SYSTEM_PATH))
+ for _, path in _build_userdirs(self, self.USER_PATH):
+ files.add(path)
+ return files
+
+ @export(record=TCCRecords)
+ def tcc(
+ self,
+ ) -> Iterator[TCCRecords]:
+ """Return transparency, consent, control (tcc) framework information.
+
+ Yields the following record types:
+
+ .. code-block:: text
+
+ AccessRecord:
+ table (string): Source table name (access).
+ service (string): What service access is being restricted to.
+ client (string): Bundle Identifier or absolute path to the program that wants to use the service.
+ client_type (varint): Whether client is a Bundle Identifier(0) or an absolute path(1)
+ auth_value (string): Authorization value.
+ auth_reason (string): Indicates how this auth_value was set.
+ auth_version (varint): Always 1 as of macOS Tahoe.
+ csreq (bytes): Binary code signing requirement blob that the client must
+ satisfy in order for access to be granted.
+ policy_id (string): Might be related to MDM(Mobile Device Management) policies, which can
+ be used by organizations to allow TCC access for some applications at a global level.
+ indirect_object_identifier (string): For some services this is what the client
+ is asking to interact with. This will be set to UNUSED if not applicable.
+ indirect_object_identifier_type (string): Whether indirect_object_identifier is a
+ Bundle Identifier(0) or an absolute path(1)
+ indirect_object_code_identity (bytes): Same as csreq, but for the
+ indirect_object_identifier instead of client.
+ flags (varint): Always 0 as of macOS Tahoe.
+ last_modified (datetime): The last time this entry was modified.
+ pid (varint): Process ID.
+ pid_version (string): Version of the process.
+ boot_uuid (string): System boot session UUID.
+ last_reminded (datetime): Last time user was reminded.
+ source (path): Path to the TCC.db database file.
+
+ KeyValue:
+ table (string): Name of the source table.
+ key (string): Key name.
+ value (string): Value associated with the key.
+ source (path): Path to the TCC.db database file.
+ """
+ yield from build_sqlite_records(self, self.files, TCCRecords, value_mappings=VALUE_MAPPINGS)
+
+ # TODO: Add policies, active_policy, access_overrides, expired tables
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/text_replacements.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/text_replacements.py
new file mode 100644
index 0000000000..1b4e7e0794
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/text_replacements.py
@@ -0,0 +1,254 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import _build_userdirs
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import build_sqlite_records
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target import Target
+
+
+ZTextReplacementEntryRecord = TargetRecordDescriptor(
+ "macos/text_replacements/z_text_replacement_entry",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("varint", "z_was_deleted"),
+ ("varint", "z_needs_save_to_cloud"),
+ ("datetime", "z_timestamp"),
+ ("string", "z_phrase"),
+ ("string", "z_shortcut"),
+ ("string", "z_unique_name"),
+ ("string", "z_remote_record_info"),
+ ("path", "source"),
+ ],
+)
+
+ZTrCloudKitSyncStateRecord = TargetRecordDescriptor(
+ "macos/text_replacements/z_tr_cloud_kit_sync_state",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("varint", "z_did_pull_once"),
+ ("string", "z_fetch_change_token"),
+ ("path", "source"),
+ ],
+)
+
+ZPrimaryKeyRecord = TargetRecordDescriptor(
+ "macos/text_replacements/z_primary_key",
+ [
+ ("string", "table"),
+ ("varint", "z_ent"),
+ ("string", "z_name"),
+ ("varint", "z_super"),
+ ("varint", "z_max"),
+ ("path", "source"),
+ ],
+)
+
+ZMetadataRecord = TargetRecordDescriptor(
+ "macos/text_replacements/z_metadata",
+ [
+ ("string", "table"),
+ ("varint", "z_version"),
+ ("string", "z_uuid"),
+ ("path", "source"),
+ ],
+)
+
+ZPlistRecord = TargetRecordDescriptor(
+ "macos/text_replacements/z_plist",
+ [
+ ("varint", "ns_persistence_maximum_framework_version"),
+ ("string[]", "ns_store_model_version_identifiers"),
+ ("string", "ns_store_type"),
+ ("varint", "ns_auto_vacuum_level"),
+ ("string", "ns_store_model_version_hashes_digest"),
+ ("string", "ns_store_model_version_checksum_key"),
+ ("varint", "ns_persistence_framework_version"),
+ ("varint", "ns_store_model_version_hashes_version"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+NSStoreModelVersionHashesRecord = TargetRecordDescriptor(
+ "macos/text_replacements/ns_store_model_version_hashes",
+ [
+ ("bytes", "tr_cloud_kit_sync_state"),
+ ("bytes", "text_replacement_entry"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+# Contains additional Z_CONTENT field which is a binary blob. This field been removed
+# from the record descriptor. The field's presence will still be mentioned in a warning.
+ZModelCacheRecord = TargetRecordDescriptor(
+ "macos/text_replacements/z_model_cache",
+ [
+ ("string", "table"),
+ ("path", "source"),
+ ],
+)
+
+TextReplacementsRecords = (
+ ZTextReplacementEntryRecord,
+ ZTrCloudKitSyncStateRecord,
+ ZPrimaryKeyRecord,
+ ZMetadataRecord,
+ ZPlistRecord,
+ NSStoreModelVersionHashesRecord,
+ ZModelCacheRecord,
+)
+
+FIELD_MAPPINGS = {
+ "Z_PK": "z_pk",
+ "Z_ENT": "z_ent",
+ "Z_OPT": "z_opt",
+ "ZWASDELETED": "z_was_deleted",
+ "ZNEEDSSAVETOCLOUD": "z_needs_save_to_cloud",
+ "ZTIMESTAMP": "z_timestamp",
+ "ZPHRASE": "z_phrase",
+ "ZSHORTCUT": "z_shortcut",
+ "ZUNIQUENAME": "z_unique_name",
+ "ZREMOTERECORDINFO": "z_remote_record_info",
+ "ZDIDPULLONCE": "z_did_pull_once",
+ "ZFETCHCHANGETOKEN": "z_fetch_change_token",
+ "Z_NAME": "z_name",
+ "Z_SUPER": "z_super",
+ "Z_MAX": "z_max",
+ "Z_VERSION": "z_version",
+ "Z_UUID": "z_uuid",
+ "NSPersistenceMaximumFrameworkVersion": "ns_persistence_maximum_framework_version",
+ "NSStoreModelVersionIdentifiers": "ns_store_model_version_identifiers",
+ "NSStoreType": "ns_store_type",
+ "NSAutoVacuumLevel": "ns_auto_vacuum_level",
+ "NSStoreModelVersionHashesDigest": "ns_store_model_version_hashes_digest",
+ "NSStoreModelVersionChecksumKey": "ns_store_model_version_checksum_key",
+ "NSPersistenceFrameworkVersion": "ns_persistence_framework_version",
+ "NSStoreModelVersionHashesVersion": "ns_store_model_version_hashes_version",
+ "TRCloudKitSyncState": "tr_cloud_kit_sync_state",
+ "TextReplacementEntry": "text_replacement_entry",
+}
+
+CONVERT_TIMESTAMPS = {
+ "z_timestamp": "2001",
+}
+
+
+class TextReplacementsPlugin(Plugin):
+ """macOS text replacements plugin.
+
+ References:
+ - https://fatbobman.com/en/posts/tables_and_fields_of_coredata/
+ - https://developer.apple.com/documentation/coredata/nsstoremodelversionidentifierskey
+ """
+
+ PATH = ("Library/KeyboardServices/TextReplacements.db",)
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = self._find_files()
+
+ def check_compatible(self) -> None:
+ if not (self.files):
+ raise UnsupportedPluginError("No TextReplacements.db files found")
+
+ def _find_files(self) -> set:
+ files = set()
+ for _, path in _build_userdirs(self, self.PATH):
+ files.add(path)
+ return files
+
+ @export(record=TextReplacementsRecords)
+ def text_replacements(
+ self,
+ ) -> Iterator[TextReplacementsRecords]:
+ """Return text replacements information.
+
+ Yields the following record types extracted from the
+ TextReplacements.db database:
+
+ .. code-block:: text
+
+ ZTextReplacementEntryRecord:
+ table (string): Name of the source table (ZTEXTREPLACEMENTENTRY).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_was_deleted (varint): Indicates if the record was deleted.
+ z_needs_save_to_cloud (varint): Indicates if the record needs to be saved to cloud.
+ z_timestamp (datetime): Timestamp associated with the record.
+ z_phrase (string): Full replacement text (what gets inserted).
+ z_shortcut (string): Shortcut text that triggers the replacement.
+ z_unique_name (string): Unique identifier for the replacement entry.
+ z_remote_record_info (string): Remote record info.
+ source (path): Path to the TextReplacements.db file.
+
+ ZTrCloudKitSyncStateRecord:
+ table (string): Name of the source table (ZTRCLOUDKITSYNCSTATE).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_did_pull_once (varint): Indicates if initial CloudKit sync has occurred.
+ z_fetch_change_token (string): CloudKit change token for sync state tracking.
+ source (path): Path to the TextReplacements.db file.
+
+ ZPrimaryKeyRecord:
+ table (string): Name of the source table (Z_PRIMARYKEY).
+ z_ent (varint): Entity identifier.
+ z_name (string): The name of the entity in the data model.
+ z_super (varint): This value corresponds to the Z_ENT of the parent entity.
+ 0 indicates that the entity has no parent entity.
+ z_max (varint): Marks the last used z_pk value for each registry table.
+ source (path): Path to the TextReplacements.db file.
+
+ ZMetadataRecord:
+ table (string): Name of the source table (Z_METADATA).
+ z_version (varint): The specific purpose is unknown, value is always 1.
+ z_uuid (string): The ID identifier (UUID type) of the current database file.
+ source (path): Path to the TextReplacements.db file.
+
+ ZPlistRecord (Plist extracted from Z_METADATA's Z_PLIST field):
+ ns_persistence_maximum_framework_version (varint): Maximum supported persistence framework version.
+ ns_store_model_version_identifiers (string[]): Version identifiers for the model,
+ used to create the store.
+ ns_store_type (string): Store type.
+ ns_auto_vacuum_level (varint): Auto-vacuum level.
+ ns_store_model_version_hashes_digest (string): Digest of model version hashes.
+ ns_store_model_version_checksum_key (string): Model version checksum key.
+ ns_persistence_framework_version (varint): Persistence framework version.
+ ns_store_model_version_hashes_version (varint): Version of the ns store version hashes.
+ plist_path (string): Path pointing to the Z_METADATA table and Z_VERSION value of the
+ Z_PLIST row that this record was extracted from.
+ source (path): Path to the TextReplacements.db file.
+
+ NSStoreModelVersionHashesRecord:
+ tr_cloud_kit_sync_state (bytes): Hash for ZTRCLOUDKITSYNCSTATE entity.
+ text_replacement_entry (bytes): Hash for ZTEXTREPLACEMENTENTRY entity.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the TextReplacements.db file.
+
+ ZModelCacheRecord (contains Z_CONTENT field with binary data):
+ table (string): Name of the source table (Z_MODELCACHE).
+ source (path): Path to the TextReplacements.db file.
+ """
+ yield from build_sqlite_records(
+ self,
+ self.files,
+ TextReplacementsRecords,
+ field_mappings=FIELD_MAPPINGS,
+ convert_timestamps=CONVERT_TIMESTAMPS,
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/time_machine.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/time_machine.py
new file mode 100644
index 0000000000..b1ef3624ac
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/time_machine.py
@@ -0,0 +1,143 @@
+from __future__ import annotations
+
+import plistlib
+from typing import TYPE_CHECKING
+
+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 dissect.target import Target
+
+TimeMachineRecord = TargetRecordDescriptor(
+ "macos/time_machine",
+ [
+ ("string", "last_destination_id"),
+ ("varint", "auto_backup_interval"),
+ ("string[]", "host_uuids"),
+ ("boolean", "requires_ac_power"),
+ ("datetime", "suspend_helper_activity_timestamp"),
+ ("bytes", "backup_alias"),
+ ("varint", "preferences_version"),
+ ("varint", "auto_backup"),
+ ("datetime", "last_activity_backup"),
+ ("path", "source"),
+ ],
+)
+
+DestinationsRecord = TargetRecordDescriptor(
+ "macos/time_machine/destination",
+ [
+ ("string[]", "destination_uuids"),
+ ("string", "last_known_volume_name"),
+ ("varint", "result"),
+ ("string", "filesystem_type_name"),
+ ("string", "last_known_encryption_state"),
+ ("datetime", "stable_local_snapshot_date"),
+ ("varint", "inheritance_decision"),
+ ("string", "destination_id"),
+ ("varint", "bytes_used"),
+ ("varint", "destination_version"),
+ ("varint", "health_check_decision"),
+ ("varint", "smb_conversion_state"),
+ ("datetime[]", "attempt_dates"),
+ ("varint", "bytes_available"),
+ ("path", "source"),
+ ],
+)
+
+
+class TimeMachinePlugin(Plugin):
+ """macOS Time Machine plugin.
+
+ Parses Time Machine, macOS's backup system, preferences.
+ """
+
+ PATH = "/Library/Preferences/com.apple.TimeMachine.plist"
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.file = self.target.fs.path(self.PATH) if self.target.fs.path(self.PATH).exists() else None
+
+ def check_compatible(self) -> None:
+ if not self.file:
+ raise UnsupportedPluginError("No com.apple.TimeMachine.plist file found")
+
+ @export(record=(TimeMachineRecord, DestinationsRecord))
+ def time_machine(self) -> Iterator[(TimeMachineRecord, DestinationsRecord)]:
+ """Return macOS Time Machine preferences.
+
+ Yields the following record types extracted from the
+ com.apple.TimeMachine.plist file:
+
+ .. code-block:: text
+
+ TimeMachineRecord:
+ last_destination_id (string): Identifier of the last used backup destination.
+ auto_backup_interval (varint): Interval between automatic backups.
+ host_uuids (string[]): List of host UUIDs associated with the backup configuration.
+ requires_ac_power (boolean): Indicates whether backups require AC power.
+ suspend_helper_activity_timestamp (datetime): Timestamp when helper activity was suspended.
+ backup_alias (bytes): Binary backup alias.
+ preferences_version (varint): Version of the Time Machine preferences.
+ auto_backup (varint): Flag indicating whether automatic backups are enabled.
+ last_activity_backup (datetime): Timestamp of the last backup activity.
+ source (path): Path to the com.apple.TimeMachine.plist file.
+
+ DestinationsRecord:
+ destination_uuids (string[]): List of destination UUIDs.
+ last_known_volume_name (string): Name of the backup volume.
+ result (varint): Result code of the last backup operation.
+ filesystem_type_name (string): Filesystem type of the destination.
+ last_known_encryption_state (string): Encryption state of the backup destination.
+ stable_local_snapshot_date (datetime): Timestamp of the last stable local snapshot.
+ inheritance_decision (varint): Indicates inheritance decision state.
+ destination_id (string): Identifier for the destination.
+ bytes_used (varint): Amount of bytes used on the destination.
+ destination_version (varint): Destination version.
+ health_check_decision (varint): Health check decision.
+ smb_conversion_state (varint): SMB conversion state indicator.
+ attempt_dates (datetime[]): List of backup attempt timestamps.
+ bytes_available (varint): Available bytes on the destination.
+ source (path): Path to the com.apple.TimeMachine.plist file.
+ """
+ plist = plistlib.load(self.file.open())
+
+ yield TimeMachineRecord(
+ last_destination_id=plist.get("PreferencesLastDestinationIDVersion"),
+ auto_backup_interval=plist.get("AutoBackupInterval"),
+ host_uuids=plist.get("HostUUIDs"),
+ requires_ac_power=plist.get("RequiresACPower"),
+ suspend_helper_activity_timestamp=plist.get("SuspendHelperActivityTimeStamp"),
+ backup_alias=plist.get("BackupAlias"),
+ preferences_version=plist.get("PreferencesVersion"),
+ auto_backup=plist.get("AutoBackup"),
+ last_activity_backup=plist.get("LastBackupActivity"),
+ source=self.file,
+ _target=self.target,
+ )
+
+ destinations = plist.get("Destinations")
+ if destinations:
+ for destination in destinations:
+ yield DestinationsRecord(
+ destination_uuids=destination.get("DestinationUUIDs"),
+ last_known_volume_name=destination.get("LastKnownVolumeName"),
+ result=destination.get("RESULT"),
+ filesystem_type_name=destination.get("FilesystemTypeName"),
+ last_known_encryption_state=destination.get("LastKnownEncryptionState"),
+ stable_local_snapshot_date=destination.get("StableLocalSnapshotDate"),
+ inheritance_decision=destination.get("InheritanceDecision"),
+ destination_id=destination.get("DestinationID"),
+ bytes_used=destination.get("BytesUsed"),
+ destination_version=destination.get("DestinationVersion"),
+ health_check_decision=destination.get("HealthCheckDecision"),
+ smb_conversion_state=destination.get("SMBConversionState"),
+ attempt_dates=destination.get("AttemptDates"),
+ bytes_available=destination.get("BytesAvailable"),
+ source=self.file,
+ _target=self.target,
+ )
diff --git a/dissect/target/plugins/os/unix/bsd/darwin/macos/user_accounts.py b/dissect/target/plugins/os/unix/bsd/darwin/macos/user_accounts.py
new file mode 100644
index 0000000000..32edef36cc
--- /dev/null
+++ b/dissect/target/plugins/os/unix/bsd/darwin/macos/user_accounts.py
@@ -0,0 +1,444 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from dissect.target.exceptions import UnsupportedPluginError
+from dissect.target.helpers.record import TargetRecordDescriptor
+from dissect.target.plugin import Plugin, export
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_paths import _build_userdirs
+from dissect.target.plugins.os.unix.bsd.darwin.macos.helpers.build_records import (
+ build_sqlite_records,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from dissect.target import Target
+
+
+ZAccessOptionsKeyRecord = TargetRecordDescriptor(
+ "macos/user_accounts/z_access_options_key",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("varint", "z_enum_value"),
+ ("string", "z_name"),
+ ("path", "source"),
+ ],
+)
+
+ZOwningAccountTypesRecord = TargetRecordDescriptor(
+ "macos/user_accounts/z_owning_account_types",
+ [
+ ("string", "table"),
+ ("varint", "z_1_access_keys"),
+ ("varint", "z_4_owning_account_types"),
+ ("path", "source"),
+ ],
+)
+
+ZAccountRecord = TargetRecordDescriptor(
+ "macos/user_accounts/z_account",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("boolean", "z_active"),
+ ("boolean", "z_authenticated"),
+ ("boolean", "z_supports_authentication"),
+ ("boolean", "z_visible"),
+ ("boolean", "z_warming_up"),
+ ("varint", "z_account_type"),
+ ("varint", "z_parent_account"),
+ ("datetime", "z_date"),
+ ("datetime", "z_last_credential_renewal_rejection_date"),
+ ("string", "z_account_description"),
+ ("string", "z_authentication_type"),
+ ("string", "z_credential_type"),
+ ("string", "z_identifier"),
+ ("string", "z_modification_id"),
+ ("string", "z_owning_bundle_id"),
+ ("string", "z_username"),
+ ("string", "z_dataclass_properties"),
+ ("path", "source"),
+ ],
+)
+
+ZEnabledDataClassesRecord = TargetRecordDescriptor(
+ "macos/user_accounts/z_enabled_dataclasses",
+ [
+ ("string", "table"),
+ ("varint", "z_2_enabled_accounts"),
+ ("varint", "z_7_enabled_dataclasses"),
+ ("path", "source"),
+ ],
+)
+
+ZAccountPropertyRecord = TargetRecordDescriptor(
+ "macos/user_accounts/z_account_property",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("varint", "z_owner"),
+ ("string", "z_key"),
+ ("string", "z_value"),
+ ("path", "source"),
+ ],
+)
+
+ZAccountTypeRecord = TargetRecordDescriptor(
+ "macos/user_accounts/z_account_type",
+ [
+ ("string", "table"),
+ ("varint", "z_pk"),
+ ("varint", "z_ent"),
+ ("varint", "z_opt"),
+ ("boolean", "z_obsolete"),
+ ("boolean", "z_supports_authentication"),
+ ("boolean", "z_supports_multiple_accounts"),
+ ("boolean", "z_visibility"),
+ ("string", "z_account_type_description"),
+ ("string", "z_credential_protection_policy"),
+ ("string", "z_credential_type"),
+ ("string", "z_identifier"),
+ ("string", "z_owning_bundle_id"),
+ ("path", "source"),
+ ],
+)
+
+ZSupportedDataClassesRecord = TargetRecordDescriptor(
+ "macos/user_accounts/z_supported_dataclasses",
+ [
+ ("string", "table"),
+ ("varint", "z_4_supported_types"),
+ ("varint", "z_7_supported_dataclasses"),
+ ("path", "source"),
+ ],
+)
+
+ZSyncableDataClassesRecord = TargetRecordDescriptor(
+ "macos/user_accounts/z_syncable_dataclasses",
+ [
+ ("string", "table"),
+ ("varint", "z_4_syncable_types"),
+ ("varint", "z_7_syncable_dataclasses"),
+ ("path", "source"),
+ ],
+)
+
+ZPrimaryKeyRecord = TargetRecordDescriptor(
+ "macos/user_accounts/z_primary_key",
+ [
+ ("string", "table"),
+ ("varint", "z_ent"),
+ ("string", "z_name"),
+ ("varint", "z_super"),
+ ("varint", "z_max"),
+ ("path", "source"),
+ ],
+)
+
+ZMetadataRecord = TargetRecordDescriptor(
+ "macos/user_accounts/z_metadata",
+ [
+ ("string", "table"),
+ ("varint", "z_version"),
+ ("string", "z_uuid"),
+ ("path", "source"),
+ ],
+)
+
+ZPlistRecord = TargetRecordDescriptor(
+ "macos/user_accounts/z_plist",
+ [
+ ("varint", "ac_account_type_version"),
+ ("varint", "ns_auto_vacuum_level"),
+ ("varint", "ns_persistence_framework_version"),
+ ("varint", "ns_persistence_maximum_framework_version"),
+ ("string", "ns_store_model_version_checksum_key"),
+ ("string", "ns_store_model_version_hashes_digest"),
+ ("varint", "ns_store_model_version_hashes_version"),
+ ("string", "ns_store_model_version_identifiers"),
+ ("string", "ns_store_type"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+NSStoreModelVersionHashesRecord = TargetRecordDescriptor(
+ "macos/user_accounts/ns_store_model_version_hashes",
+ [
+ ("bytes", "access_options_key"),
+ ("bytes", "account_hash"),
+ ("bytes", "account_property"),
+ ("bytes", "account_type"),
+ ("bytes", "authorization"),
+ ("bytes", "credential_item"),
+ ("bytes", "dataclass"),
+ ("string", "plist_path"),
+ ("path", "source"),
+ ],
+)
+
+# Contains additional Z_CONTENT field which is a binary blob. This field been removed
+# from the record descriptor. The field's presence will still be mentioned in a warning.
+ZModelCacheRecord = TargetRecordDescriptor(
+ "macos/user_accounts/z_model_cache",
+ [
+ ("string", "table"),
+ ("path", "source"),
+ ],
+)
+
+UserAccountRecords = (
+ ZAccessOptionsKeyRecord,
+ ZOwningAccountTypesRecord,
+ ZAccountRecord,
+ ZEnabledDataClassesRecord,
+ ZAccountPropertyRecord,
+ ZAccountTypeRecord,
+ ZSupportedDataClassesRecord,
+ ZSyncableDataClassesRecord,
+ ZPrimaryKeyRecord,
+ ZMetadataRecord,
+ ZPlistRecord,
+ NSStoreModelVersionHashesRecord,
+ ZModelCacheRecord,
+)
+
+FIELD_MAPPINGS = {
+ "Z_PK": "z_pk",
+ "Z_ENT": "z_ent",
+ "Z_OPT": "z_opt",
+ "ZENUMVALUE": "z_enum_value",
+ "ZNAME": "z_name",
+ "Z_1ACCESSKEYS": "z_1_access_keys",
+ "Z_4OWNINGACCOUNTTYPES": "z_4_owning_account_types",
+ "ZACTIVE": "z_active",
+ "ZAUTHENTICATED": "z_authenticated",
+ "ZSUPPORTSAUTHENTICATION": "z_supports_authentication",
+ "ZVISIBLE": "z_visible",
+ "ZWARMINGUP": "z_warming_up",
+ "ZACCOUNTTYPE": "z_account_type",
+ "ZPARENTACCOUNT": "z_parent_account",
+ "ZDATE": "z_date",
+ "ZLASTCREDENTIALRENEWALREJECTIONDATE": "z_last_credential_renewal_rejection_date",
+ "ZACCOUNTDESCRIPTION": "z_account_description",
+ "ZAUTHENTICATIONTYPE": "z_authentication_type",
+ "ZCREDENTIALTYPE": "z_credential_type",
+ "ZIDENTIFIER": "z_identifier",
+ "ZMODIFICATIONID": "z_modification_id",
+ "ZOWNINGBUNDLEID": "z_owning_bundle_id",
+ "ZUSERNAME": "z_username",
+ "ZDATACLASSPROPERTIES": "z_dataclass_properties",
+ "Z_2ENABLEDACCOUNTS": "z_2_enabled_accounts",
+ "Z_7ENABLEDDATACLASSES": "z_7_enabled_dataclasses",
+ "ZOWNER": "z_owner",
+ "ZKEY": "z_key",
+ "ZVALUE": "z_value",
+ "ZOBSOLETE": "z_obsolete",
+ "ZSUPPORTSMULTIPLEACCOUNTS": "z_supports_multiple_accounts",
+ "ZVISIBILITY": "z_visibility",
+ "ZACCOUNTTYPEDESCRIPTION": "z_account_type_description",
+ "ZCREDENTIALPROTECTIONPOLICY": "z_credential_protection_policy",
+ "Z_4SUPPORTEDTYPES": "z_4_supported_types",
+ "Z_7SUPPORTEDDATACLASSES": "z_7_supported_dataclasses",
+ "Z_4SYNCABLETYPES": "z_4_syncable_types",
+ "Z_7SYNCABLEDATACLASSES": "z_7_syncable_dataclasses",
+ "Z_NAME": "z_name",
+ "Z_SUPER": "z_super",
+ "Z_MAX": "z_max",
+ "Z_VERSION": "z_version",
+ "Z_UUID": "z_uuid",
+ "ACAccountTypeVersion": "ac_account_type_version",
+ "NSAutoVacuumLevel": "ns_auto_vacuum_level",
+ "NSPersistenceFrameworkVersion": "ns_persistence_framework_version",
+ "NSPersistenceMaximumFrameworkVersion": "ns_persistence_maximum_framework_version",
+ "NSStoreModelVersionChecksumKey": "ns_store_model_version_checksum_key",
+ "NSStoreModelVersionHashesDigest": "ns_store_model_version_hashes_digest",
+ "NSStoreModelVersionHashesVersion": "ns_store_model_version_hashes_version",
+ "NSStoreModelVersionIdentifiers": "ns_store_model_version_identifiers",
+ "NSStoreType": "ns_store_type",
+ "AccessOptionsKey": "access_options_key",
+ "Account": "account_hash",
+ "AccountProperty": "account_property",
+ "AccountType": "account_type",
+ "Authorization": "authorization",
+ "CredentialItem": "credential_item",
+ "Dataclass": "dataclass",
+}
+
+
+class UserAccountsPlugin(Plugin):
+ """macOS user accounts plugin.
+
+ Parses macOS user account SQLite database files.
+
+ References:
+ - https://fatbobman.com/en/posts/tables_and_fields_of_coredata/
+ - https://developer.apple.com/documentation/coredata/nsstoremodelversionidentifierskey
+ """
+
+ USER_PATH = ("Library/Accounts/Accounts*.sqlite",)
+
+ def __init__(self, target: Target):
+ super().__init__(target)
+ self.files = self._find_files()
+
+ def check_compatible(self) -> None:
+ if not (self.files):
+ raise UnsupportedPluginError("No user account database files found")
+
+ def _find_files(self) -> set:
+ files = set()
+ for _, path in _build_userdirs(self, self.USER_PATH):
+ files.add(path)
+ return files
+
+ @export(record=UserAccountRecords)
+ def user_accounts(
+ self,
+ ) -> Iterator[UserAccountRecords]:
+ """Return user accounts information.
+
+ Yields the following record types extracted from the
+ Accounts*.sqlite databases:
+
+ .. code-block:: text
+
+ ZAccessOptionsKeyRecord:
+ table (string): Name of the source table (ZACCESSOPTIONSKEY).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_enum_value (varint): Enumeration value.
+ z_name (string): The name of the entity in the data model.
+ source (path): Path to the Accounts*.sqlite database file.
+
+ ZOwningAccountTypesRecord:
+ table (string): Name of the source table (Z_1OWNINGACCOUNTTYPES).
+ z_1_access_keys (varint): Reference to z_pk in ZACCESSOPTIONSKEY.
+ z_4_owning_account_types (varint): Reference to z_pk in ZACCOUNTTYPE.
+ source (path): Path to the Accounts*.sqlite database file.
+
+ ZAccountRecord:
+ table (string): Name of the source table (ZACCOUNT).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_active (boolean): Indicates whether the account is active.
+ z_authenticated (boolean): Indicates whether the account is authenticated.
+ z_supports_authentication (boolean): Indicates if authentication is supported.
+ z_visible (boolean): Indicates whether account is visible.
+ z_warming_up (boolean): Indicates account initialization state.
+ z_account_type (varint): Reference to z_pk in ZACCOUNTTYPE.
+ z_parent_account (varint): Reference to z_pk of parent account.
+ z_date (datetime): Timestamp.
+ z_last_credential_renewal_rejection_date (datetime): Timestamp of last credential renewal rejection.
+ z_account_description (string): Account description.
+ z_authentication_type (string): Authentication type.
+ z_credential_type (string): Credential type.
+ z_identifier (string): Account identifier.
+ z_modification_id (string): Modification identifier.
+ z_owning_bundle_id (string): Bundle ID of owning service/app.
+ z_username (string): Username for the account.
+ z_dataclass_properties (string): Dataclass properties.
+ source (path): Path to the Accounts*.sqlite database file.
+
+ ZEnabledDataClassesRecord:
+ table (string): Name of the source table (Z_2ENABLEDDATACLASSES).
+ z_2_enabled_accounts (varint): Reference to z_pk in ZACCOUNT.
+ z_7_enabled_dataclasses (varint): Reference to z_pk in ZDATACLASS.
+ source (path): Path to the Accounts*.sqlite database file.
+
+ ZAccountPropertyRecord:
+ table (string): Name of the source table (ZACCOUNTPROPERTY).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_owner (varint): Reference to z_pk of owning ZACCOUNT.
+ z_key (string): Property key.
+ z_value (string): Property value.
+ source (path): Path to the Accounts*.sqlite database file.
+
+ ZAccountTypeRecord:
+ table (string): Name of the source table (ZACCOUNTTYPE).
+ z_pk (varint): The autoincrement primary key of the table.
+ z_ent (varint): Entity identifier.
+ z_opt (varint): The version number of the data record.
+ z_obsolete (boolean): Indicates whether account type is obsolute.
+ z_supports_authentication (boolean): Indicates whether authentication is supported.
+ z_supports_multiple_accounts (boolean): Indicates whether multiple accounts are supported.
+ z_visibility (boolean): Indicates visibility of the account type.
+ z_account_type_description (string): Description of account type.
+ z_credential_protection_policy (string): Credential protection policy.
+ z_credential_type (string): Credential type.
+ z_identifier (string): Account identifier.
+ z_owning_bundle_id (string): Owning bundle ID.
+ source (path): Path to the Accounts*.sqlite database file.
+
+ ZSupportedDataClassesRecord:
+ table (string): Name of the source table (Z_4SUPPORTEDDATACLASSES).
+ z_4_supported_types (varint): Reference to z_pk in ZACCOUNTTYPE.
+ z_7_supported_dataclasses (varint): Reference to z_pk in ZDATACLASS.
+ source (path): Path to the Accounts*.sqlite database file.
+
+ ZSyncableDataClassesRecord:
+ table (string): Name of the source table (Z_4SYNCABLEDATACLASSES).
+ z_4_syncable_types (varint): Reference to z_pk in ZACCOUNTTYPE.
+ z_7_syncable_dataclasses (varint): Reference to z_pk in ZDATACLASS.
+ source (path): Path to the Accounts*.sqlite database file.
+
+ ZPrimaryKeyRecord:
+ table (string): Name of the source table (Z_PRIMARYKEY).
+ z_ent (varint): Entity identifier.
+ z_name (string): The name of the entity in the data model.
+ z_super (varint): This value corresponds to the Z_ENT of the parent entity.
+ 0 indicates that the entity has no parent entity.
+ z_max (varint): Marks the last used z_pk value for each registry table.
+ source (path): Path to the Accounts*.sqlite database file.
+
+ ZMetadataRecord:
+ table (string): Name of the source table (Z_METADATA).
+ z_version (varint): The specific purpose is unknown, value is always 1.
+ z_uuid (string): The ID identifier (UUID type) of the current database file.
+ source (path): Path to the Accounts*.sqlite database file.
+
+ ZPlistRecord (Plist extracted from Z_METADATA's Z_PLIST field):
+ ac_account_type_version (varint): AC account type version.
+ ns_persistence_maximum_framework_version (varint): Maximum supported persistence framework version.
+ ns_store_model_version_identifiers (string[]): Version identifiers for the model,
+ used to create the store.
+ ns_store_type (string): Store type.
+ ns_auto_vacuum_level (varint): Auto-vacuum level.
+ ns_store_model_version_hashes_digest (string): Digest of model version hashes.
+ ns_store_model_version_checksum_key (string): Model version checksum key.
+ ns_persistence_framework_version (varint): Persistence framework version.
+ ns_store_model_version_hashes_version (varint): Version of the ns store version hashes.
+ plist_path (string): Path pointing to the Z_METADATA table and Z_VERSION value of the
+ Z_PLIST row that this record was extracted from.
+ source (path): Path to the Accounts*.sqlite database file.
+
+ NSStoreModelVersionHashesRecord:
+ access_options_key (bytes): Hash for ZACCESSOPTIONSKEY entity.
+ account (bytes): Hash for ZACCOUNT entity.
+ account_property (bytes): Hash for ZACCOUNTPROPERTY entity.
+ account_type (bytes): Hash for ZACCOUNTTYPE entity.
+ authorization (bytes): Hash for ZAUTHORIZATION entity.
+ credential_item (bytes): Hash for ZCREDENTIALITEM entity.
+ dataclass (bytes): Hash for ZDATACLASS entity.
+ plist_path (string): Path pointing to the location of the entry within the plist structure.
+ source (path): Path to the Accounts*.sqlite database file.
+
+ ZModelCacheRecord (contains Z_CONTENT field with binary data):
+ table (string): Name of the source table (Z_MODELCACHE).
+ source (path): Path to the Accounts*.sqlite database file.
+ """
+ yield from build_sqlite_records(self, self.files, UserAccountRecords, field_mappings=FIELD_MAPPINGS)
+
+ # TODO: Add Z_2PROVISIONEDDATACLASSES, ZAUTHORIZATION, ZCREDENTIALITEM tables
diff --git a/dissect/target/plugins/os/unix/cronjobs.py b/dissect/target/plugins/os/unix/cronjobs.py
index 6f946d149b..e28bff860f 100644
--- a/dissect/target/plugins/os/unix/cronjobs.py
+++ b/dissect/target/plugins/os/unix/cronjobs.py
@@ -70,6 +70,8 @@ class CronjobPlugin(Plugin):
"/var/spool/cron/crontabs",
"/etc/cron.d",
"/usr/local/etc/cron.d", # FreeBSD
+ "/usr/lib/cron/tabs", # macOS
+ "/var/at/tabs",
)
CRONTAB_FILES = (
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/.GlobalPreferences.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/.GlobalPreferences.plist
new file mode 100644
index 0000000000..b3fa705101
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/.GlobalPreferences.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:62a8bdac60526a4ed80707b31a5fb4f301f42c04bfbb52fbb49afb75c4a8b651
+size 1076
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/BackgroundItems-v16.btm b/tests/_data/plugins/os/unix/bsd/darwin/macos/BackgroundItems-v16.btm
new file mode 100644
index 0000000000..d40ee54705
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/BackgroundItems-v16.btm
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c69561684eb1c6fca809cc223dc21f5a53c5b1d99a6a8e72ebfc3257680cb86e
+size 2961
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/InstallHistory.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/InstallHistory.plist
new file mode 100644
index 0000000000..77de086d3a
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/InstallHistory.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c60512c2e057379a387fded64e4e900d679c763b12fc20a9435fb748c40d4431
+size 428
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/a0000901c45260 b/tests/_data/plugins/os/unix/bsd/darwin/macos/a0000901c45260
new file mode 100644
index 0000000000..00a19a35d2
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/a0000901c45260
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6dca01c23993861cfdaeb3de764125e6e69693e2ac8bee8b928a30680bc5b81b
+size 1452
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/auth.db b/tests/_data/plugins/os/unix/bsd/darwin/macos/auth.db
new file mode 100644
index 0000000000..5b80e6f81b
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/auth.db
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ed8fbd68825e7a18c4140d51ea6163d52c6a2c849114b77fa6c159a39dd05480
+size 155648
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/call_history/CallHistory.storedata b/tests/_data/plugins/os/unix/bsd/darwin/macos/call_history/CallHistory.storedata
new file mode 100644
index 0000000000..bd599f21c5
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/call_history/CallHistory.storedata
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:23bfebba8f86e5ae5079e51fdb5d6987c0eefadfa1a23587bedd1c5edabee275
+size 90112
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/call_history/CallHistory.storedata-wal b/tests/_data/plugins/os/unix/bsd/darwin/macos/call_history/CallHistory.storedata-wal
new file mode 100644
index 0000000000..cacf47f3e6
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/call_history/CallHistory.storedata-wal
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:74ed776922490b94b7220b85d72df3f633f5606e3114c6822d71ba6c6ff43d1d
+size 8272
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/code_signature/AudioDMAController_T8140 b/tests/_data/plugins/os/unix/bsd/darwin/macos/code_signature/AudioDMAController_T8140
new file mode 100644
index 0000000000..079543fb6a
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/code_signature/AudioDMAController_T8140
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:46089febb31ff8c9ace764838bd508b44a1e789395c807f9e8763d41d5e174fd
+size 1029
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/code_signature/EndpointSecurity b/tests/_data/plugins/os/unix/bsd/darwin/macos/code_signature/EndpointSecurity
new file mode 100644
index 0000000000..0e93caedfd
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/code_signature/EndpointSecurity
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:af891ecfaa1a58cd1d17dc5c7b2b61bd862f1dc0c0b4833ff5ed9d7d1e560ca8
+size 2672
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/code_signature/Ethernet b/tests/_data/plugins/os/unix/bsd/darwin/macos/code_signature/Ethernet
new file mode 100644
index 0000000000..a7c10a15fe
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/code_signature/Ethernet
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2609fe822410dafbb9d644bc20171fb015735258208ac8f60b991eb1cdb60818
+size 1013
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/code_signature/FileProvider b/tests/_data/plugins/os/unix/bsd/darwin/macos/code_signature/FileProvider
new file mode 100644
index 0000000000..6e2dc2379d
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/code_signature/FileProvider
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:095cf24a76ccc72d91552b76a5f6686b6712a16e995eb63b80ca1c26e585c026
+size 747
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/code_signature/Host b/tests/_data/plugins/os/unix/bsd/darwin/macos/code_signature/Host
new file mode 100644
index 0000000000..432cbc476f
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/code_signature/Host
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4db0d9039339dcadd2a333f76468110c66b0f70656238b515990e732d2adce6a
+size 1021
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/code_signature/MobileDeviceUpdater b/tests/_data/plugins/os/unix/bsd/darwin/macos/code_signature/MobileDeviceUpdater
new file mode 100644
index 0000000000..733732518c
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/code_signature/MobileDeviceUpdater
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:69137193ec18339b83e71a9816621fd25b4001144dc229eaa8fa0a0a81dfa647
+size 48542
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/com.apple.HIToolbox.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/com.apple.HIToolbox.plist
new file mode 100644
index 0000000000..823bb60450
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/com.apple.HIToolbox.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:faec976255adb7aafde03a0b735abd65f45924df434b53eb3e76bdf32814db6c
+size 377
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/com.apple.SoftwareUpdate.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/com.apple.SoftwareUpdate.plist
new file mode 100644
index 0000000000..010f40c18c
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/com.apple.SoftwareUpdate.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d14d03f8788851274539ecc42fdfb25ac93d1492ef41ef16bc388da6b323bd8c
+size 545
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/com.apple.TimeMachine.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/com.apple.TimeMachine.plist
new file mode 100644
index 0000000000..11d09bb889
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/com.apple.TimeMachine.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:248719a7a2e1c4b265818714c6f7a9e4dc5330854be8da60faf8879fa0594973
+size 1198
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/com.apple.airport.preferences.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/com.apple.airport.preferences.plist
new file mode 100644
index 0000000000..1b2c77fd1c
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/com.apple.airport.preferences.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:dc8d75b8539e525d00a16b745560e1a3457d61b7c08e5e6fa3dd38115f437d81
+size 390
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/contents_info/AppleEventLogHandler.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/contents_info/AppleEventLogHandler.plist
new file mode 100644
index 0000000000..98cec89b3e
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/contents_info/AppleEventLogHandler.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e5e0fe1aa04935fc715305c7dd046b4b59aba33ef1123d33fb93400a4f193380
+size 2449
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/contents_info/AppleHPET.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/contents_info/AppleHPET.plist
new file mode 100644
index 0000000000..b63606cc40
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/contents_info/AppleHPET.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:56d36fa3ef2fcac073e3f338edb1b64b5f87ca0d33263e0631d4c0efb46746db
+size 2242
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/contents_info/UnmountAssistantAgent.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/contents_info/UnmountAssistantAgent.plist
new file mode 100644
index 0000000000..f450a1d749
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/contents_info/UnmountAssistantAgent.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:020880a0444267fb7cccce89f241c46ddc7b4655c2d4009bf42bb8f8fa0b1c39
+size 1478
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/contents_version/IOBluetoothUI.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/contents_version/IOBluetoothUI.plist
new file mode 100644
index 0000000000..2011e83def
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/contents_version/IOBluetoothUI.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ec7af55fe1b95fdc55c1c537bc7f98120719fdf7e2bbc6ddad98f203eb88c63f
+size 462
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/contents_version/SwiftUICore.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/contents_version/SwiftUICore.plist
new file mode 100644
index 0000000000..4bf00dbec4
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/contents_version/SwiftUICore.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:da651d1aee926c9cb585ab3b41c40df0c431e1816ce30edb9343754c967c6b89
+size 514
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/contents_version/UniversalControl.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/contents_version/UniversalControl.plist
new file mode 100644
index 0000000000..b1a5db41c9
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/contents_version/UniversalControl.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b7c8e3b4bfe04433539f1eebc7bc58346717b5ae7bd65c3066abda46f73436c8
+size 526
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/cronjobs/one b/tests/_data/plugins/os/unix/bsd/darwin/macos/cronjobs/one
new file mode 100644
index 0000000000..10568b6a42
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/cronjobs/one
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3067b0b0cd5d93d07fd389c50c91741a0c97562695135579d291dd2da62c12a1
+size 260
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/cronjobs/two b/tests/_data/plugins/os/unix/bsd/darwin/macos/cronjobs/two
new file mode 100644
index 0000000000..10568b6a42
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/cronjobs/two
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3067b0b0cd5d93d07fd389c50c91741a0c97562695135579d291dd2da62c12a1
+size 260
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/directory_services_local_nodes/sqlindex b/tests/_data/plugins/os/unix/bsd/darwin/macos/directory_services_local_nodes/sqlindex
new file mode 100644
index 0000000000..d8c6fade7a
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/directory_services_local_nodes/sqlindex
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:71d3022121b116072a9b1d25c64fb6d1abf702406ca9aa1363a2e8193051ae38
+size 409600
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/directory_services_local_nodes/sqlindex-wal b/tests/_data/plugins/os/unix/bsd/darwin/macos/directory_services_local_nodes/sqlindex-wal
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_activity_scheduler/DuetActivitySchedulerClassC.db b/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_activity_scheduler/DuetActivitySchedulerClassC.db
new file mode 100644
index 0000000000..2c34b7bba1
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_activity_scheduler/DuetActivitySchedulerClassC.db
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8996d53c6b25ba17e98b0c6ca72b4ce8d27798dba14e2ef2f857f25deedc585a
+size 4096
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_activity_scheduler/DuetActivitySchedulerClassC.db-wal b/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_activity_scheduler/DuetActivitySchedulerClassC.db-wal
new file mode 100644
index 0000000000..abbd804bb4
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_activity_scheduler/DuetActivitySchedulerClassC.db-wal
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ea44f5136ba2311e6aa084f3391ef54dd4f9cb813426f1f70d4c9da883f106e2
+size 683952
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_interaction_c/interactionC.db b/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_interaction_c/interactionC.db
new file mode 100644
index 0000000000..2c34b7bba1
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_interaction_c/interactionC.db
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8996d53c6b25ba17e98b0c6ca72b4ce8d27798dba14e2ef2f857f25deedc585a
+size 4096
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_interaction_c/interactionC.db-wal b/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_interaction_c/interactionC.db-wal
new file mode 100644
index 0000000000..9888c43401
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_interaction_c/interactionC.db-wal
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:da93fe7cd70cca60b38d57212a15914e6841c6e2313503df1ed1db3e8374a539
+size 296672
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_knowledge_c/Main.db b/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_knowledge_c/Main.db
new file mode 100644
index 0000000000..6eac43a25e
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_knowledge_c/Main.db
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:507abf8563ce3029e054e1a59ebab2ffb79b8c65d83ef928bb3ae7bc9bab2642
+size 753664
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_knowledge_c/Main.db-wal b/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_knowledge_c/Main.db-wal
new file mode 100644
index 0000000000..3091de45dd
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_knowledge_c/Main.db-wal
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9fb1de9dde337f7f1e7dc0b55e191bb47251e010a8d00f75ef6fa698736c0fc2
+size 2101400
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_knowledge_c/User.db b/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_knowledge_c/User.db
new file mode 100644
index 0000000000..ae46074350
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_knowledge_c/User.db
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:40ed13a5f6b61d125dc22edf316234f48322b02d2f9255706c51d61ec1a73ede
+size 679936
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_knowledge_c/User.db-wal b/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_knowledge_c/User.db-wal
new file mode 100644
index 0000000000..f858cc7f64
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/duet_knowledge_c/User.db-wal
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d056066d4a456fff6fab4d083e597dda74e477eec632459f8173a94f3e60e219
+size 1240152
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/fc0077112a6fa939 b/tests/_data/plugins/os/unix/bsd/darwin/macos/fc0077112a6fa939
new file mode 100644
index 0000000000..0f0da0cbc5
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/fc0077112a6fa939
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:043a86dd5ec8514d63ff3b28d3a8215b1a8cefd8ed68b4911502ae6de5734368
+size 44930
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/gkopaque.db b/tests/_data/plugins/os/unix/bsd/darwin/macos/gkopaque.db
new file mode 100644
index 0000000000..605e9ed728
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/gkopaque.db
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:297a6f9e320fd7fa4cb64eb34e1468d1be6a4f95ad047c201ea7d04ed53c989a
+size 6062080
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/global_user_preferences/root.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/global_user_preferences/root.plist
new file mode 100644
index 0000000000..073c8fb814
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/global_user_preferences/root.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:97c2f1cf684f7d4ea1c4eda1ce519461575516f5c8b30d8db83bf1b97b29e915
+size 127
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/global_user_preferences/securityagent.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/global_user_preferences/securityagent.plist
new file mode 100644
index 0000000000..de08975d4f
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/global_user_preferences/securityagent.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a5214ad4e41e0a5f348d79554fe0e2ac47d3d0dc76b2734bea59de8a585079d4
+size 70
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/global_user_preferences/user.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/global_user_preferences/user.plist
new file mode 100644
index 0000000000..b3fa705101
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/global_user_preferences/user.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:62a8bdac60526a4ed80707b31a5fb4f301f42c04bfbb52fbb49afb75c4a8b651
+size 1076
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/groups/_applepay.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/groups/_applepay.plist
new file mode 100644
index 0000000000..5e307bea33
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/groups/_applepay.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:36ea4b5b5397ffd62838451eebd65a6821e60cbd30082227a42e141babdfd414
+size 188
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/groups/_eligibilityd.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/groups/_eligibilityd.plist
new file mode 100644
index 0000000000..10d6effb7e
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/groups/_eligibilityd.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:39b1f19df1fd22d9e16061cad3207d0eebff2a75a6e82fcfd2c65d0a9147185c
+size 269
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/groups/nobody.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/groups/nobody.plist
new file mode 100644
index 0000000000..2d4a2510d4
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/groups/nobody.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1ac4dc2192f02c744164d6fbd44bd8ea688f770030f78718ec6039e4a5a18ccb
+size 213
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/ids.db b/tests/_data/plugins/os/unix/bsd/darwin/macos/ids.db
new file mode 100644
index 0000000000..4d87410225
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/ids.db
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d52672ea4e330d7449dfbc4ed221696e773ef9b0234842e2b9b432af7777c2d3
+size 49152
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/keychain-2.db b/tests/_data/plugins/os/unix/bsd/darwin/macos/keychain-2.db
new file mode 100644
index 0000000000..8046ceb08b
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/keychain-2.db
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fc48a441120098bb5f1b0629b3c08e347ed01a0af1e88b493c56bd47a48314a0
+size 1253376
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/launchers/com.apple.ecosystemagent.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/launchers/com.apple.ecosystemagent.plist
new file mode 100644
index 0000000000..cd072ecbb1
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/launchers/com.apple.ecosystemagent.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c492f0cf292e0fc7cbb29dec47992cfbe87ae98756ee082821ddfc7ae1cb88fb
+size 1636
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/launchers/com.openssh.ssh-agent.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/launchers/com.openssh.ssh-agent.plist
new file mode 100644
index 0000000000..0ec0d20dc2
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/launchers/com.openssh.ssh-agent.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7c88ce7ee57624843ab9e982939a7b13326748145ffda8121a870ff88016929f
+size 541
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/launchers/org.cups.cupsd.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/launchers/org.cups.cupsd.plist
new file mode 100644
index 0000000000..290f696ffb
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/launchers/org.cups.cupsd.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b7c9a867f295208ce48b5bbf8417d737338f02987915469b9b5a829ddf0d2ff2
+size 1201
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/locale/.AppleSetupDone b/tests/_data/plugins/os/unix/bsd/darwin/macos/locale/.AppleSetupDone
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/locale/.GlobalPreferences.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/locale/.GlobalPreferences.plist
new file mode 100644
index 0000000000..f3f17c4922
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/locale/.GlobalPreferences.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2918e637565580a6d784428ae51d674fa6b69797ca92e92026df8e76d3f0deb0
+size 558
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/locale/com.apple.timezone.auto.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/locale/com.apple.timezone.auto.plist
new file mode 100644
index 0000000000..a07c6c93ed
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/locale/com.apple.timezone.auto.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:55775607ea809eb93b51eaa8c68cc33b6b02e66fa4d95a63fb815dca3db8aab0
+size 54
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/login_window/com.apple.loginwindow.16130786-970B-53D1-A07B-005E50471D95.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/login_window/com.apple.loginwindow.16130786-970B-53D1-A07B-005E50471D95.plist
new file mode 100644
index 0000000000..957816632a
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/login_window/com.apple.loginwindow.16130786-970B-53D1-A07B-005E50471D95.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9603e183f310702e78a6c4025abe5dab3a5abf214bcd1227dc73cd800d2f169d
+size 388
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/login_window/com.apple.loginwindow.E253F552-3A40-5010-9ACE-98662C9CFE20.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/login_window/com.apple.loginwindow.E253F552-3A40-5010-9ACE-98662C9CFE20.plist
new file mode 100644
index 0000000000..ff75ec1046
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/login_window/com.apple.loginwindow.E253F552-3A40-5010-9ACE-98662C9CFE20.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:080deca33a1560caffe316ca3f7f47fc6b42538674c52225f5cb1b92f4be5f85
+size 65
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/login_window/loginwindow.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/login_window/loginwindow.plist
new file mode 100644
index 0000000000..a6fa18a9bb
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/login_window/loginwindow.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:af63e46d50ae6d7e97abff7cb83d709070c131b8dddcf6ace59b0154e457ca22
+size 194
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/logs/2026.05.06.G80.asl b/tests/_data/plugins/os/unix/bsd/darwin/macos/logs/2026.05.06.G80.asl
new file mode 100644
index 0000000000..15ff31246d
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/logs/2026.05.06.G80.asl
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c1f251241851f3101de3a206287d0eaed195dae28c3a43d35aede3b5e700e628
+size 13915
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/logs/fsck_apfs.log b/tests/_data/plugins/os/unix/bsd/darwin/macos/logs/fsck_apfs.log
new file mode 100644
index 0000000000..5092326888
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/logs/fsck_apfs.log
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4af304504831599e76e3bcc190d39a8ee55504705ac3d48c82280f234484dbd8
+size 220
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/logs/install.log b/tests/_data/plugins/os/unix/bsd/darwin/macos/logs/install.log
new file mode 100644
index 0000000000..e538e28743
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/logs/install.log
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:aa736920d6262f9cfc35bd451d12f02ba2a060031817ba87ede3bb89b6338161
+size 328
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/logs/system_log/system.log b/tests/_data/plugins/os/unix/bsd/darwin/macos/logs/system_log/system.log
new file mode 100644
index 0000000000..ea5388deb1
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/logs/system_log/system.log
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5cdb1129922d0d4aede061f739857a3ace99fdcaea2c0bd7f4af9664670f3184
+size 355
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/logs/system_log/system.log.0.gz b/tests/_data/plugins/os/unix/bsd/darwin/macos/logs/system_log/system.log.0.gz
new file mode 100644
index 0000000000..c839759200
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/logs/system_log/system.log.0.gz
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f95e9d8c094b11552f472b39a422a78f93ef59e514badf725cb2e4cf61d1d683
+size 2503
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/logs/wifi.log b/tests/_data/plugins/os/unix/bsd/darwin/macos/logs/wifi.log
new file mode 100644
index 0000000000..71d7d879f9
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/logs/wifi.log
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ad95ffb6e3a7112e5ef1ef70e7c798f2a36d64f99342d38a3c221fdbf0720305
+size 289
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/notes/NoteStore.sqlite b/tests/_data/plugins/os/unix/bsd/darwin/macos/notes/NoteStore.sqlite
new file mode 100644
index 0000000000..f625e7eb4e
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/notes/NoteStore.sqlite
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2432e9ef782387885e279df9b401af7ac97ce87a6a358c5f95816a33fe57e177
+size 356352
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/notes/NoteStore.sqlite-wal b/tests/_data/plugins/os/unix/bsd/darwin/macos/notes/NoteStore.sqlite-wal
new file mode 100644
index 0000000000..446c41fc01
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/notes/NoteStore.sqlite-wal
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4eb2b4c133c0db92012d851b025bd524580e51895de9f03efd9721401ce74d73
+size 1668632
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/notes/NotesV7.storedata b/tests/_data/plugins/os/unix/bsd/darwin/macos/notes/NotesV7.storedata
new file mode 100644
index 0000000000..a8df074d5d
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/notes/NotesV7.storedata
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2da33e73a278aa0ac12f1e6fe20c35f24bdc7b752661eb5e6a7de4823a630d03
+size 200704
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/notes/NotesV7.storedata-wal b/tests/_data/plugins/os/unix/bsd/darwin/macos/notes/NotesV7.storedata-wal
new file mode 100644
index 0000000000..85917cedea
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/notes/NotesV7.storedata-wal
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:961e9430a6fd08aeaae68fff4c65548a9f7f67ddb0e05075c650297d3250ab47
+size 148352
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/periodic/110.clean-tmps b/tests/_data/plugins/os/unix/bsd/darwin/macos/periodic/110.clean-tmps
new file mode 100644
index 0000000000..1153df6257
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/periodic/110.clean-tmps
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f99a4803d9f2f8c48c33866487067fbd3a0d02080e10749e70445813b4d60954
+size 1642
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/periodic/199.rotate-fax b/tests/_data/plugins/os/unix/bsd/darwin/macos/periodic/199.rotate-fax
new file mode 100644
index 0000000000..8fa82b514e
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/periodic/199.rotate-fax
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:dd89ec314b4e26aa2481a315c7e71404ccff929ead511269e934406be0e61143
+size 888
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/periodic/periodic.conf b/tests/_data/plugins/os/unix/bsd/darwin/macos/periodic/periodic.conf
new file mode 100644
index 0000000000..a79145da40
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/periodic/periodic.conf
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ee479a7a0dd839c4a08e73d5a6fcffbe750bd47b82cf7c633ed20c5e63b8ed03
+size 4954
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/resources_info_strings/OSvKernDSPLib.strings b/tests/_data/plugins/os/unix/bsd/darwin/macos/resources_info_strings/OSvKernDSPLib.strings
new file mode 100644
index 0000000000..9242e828d2
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/resources_info_strings/OSvKernDSPLib.strings
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0038c966736bfa285115eab47574a334562598110262f5448905acfb971778c8
+size 172
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/resources_info_strings/WiFiAgent.strings b/tests/_data/plugins/os/unix/bsd/darwin/macos/resources_info_strings/WiFiAgent.strings
new file mode 100644
index 0000000000..0b51c765c5
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/resources_info_strings/WiFiAgent.strings
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:265052bc23e88dab2558dfb7c101265fa215fb9df854c879bdd6b5c6c61c989f
+size 101
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/Downloads.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/Downloads.plist
new file mode 100644
index 0000000000..8d92362e48
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/Downloads.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f8e4510dca9e037c9aedfbc03c38751ed5ae1873327fe50c938823102074714e
+size 2739
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/PerSiteZoomPreferences.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/PerSiteZoomPreferences.plist
new file mode 100644
index 0000000000..91b2483fa2
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/PerSiteZoomPreferences.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bedad05becab5403cffe152e05f878c899b2e1dbb9bea88a9919fda6b221b7b2
+size 249
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/RecentlyClosedTabs.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/RecentlyClosedTabs.plist
new file mode 100644
index 0000000000..d65b7709a5
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/RecentlyClosedTabs.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1506d8796099896966ad75e0bf14f661dc5fce1b0a579fbaf37541229a603d77
+size 177131
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/UserNotificationPermissions.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/UserNotificationPermissions.plist
new file mode 100644
index 0000000000..f81cb0e6b5
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/UserNotificationPermissions.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2bd8ba26d027dbf69f1fe1b0fe624fca18b8218fc43f310d74b5f750c89a3f2e
+size 115
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/safari_favicons/favicons.db b/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/safari_favicons/favicons.db
new file mode 100644
index 0000000000..4c64a1e39a
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/safari_favicons/favicons.db
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6e5a602f1f78bab0b4f4191cd539d8e036cc462f2be8baefa98e2d7ea974e714
+size 176128
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/safari_favicons/favicons.db-wal b/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/safari_favicons/favicons.db-wal
new file mode 100644
index 0000000000..e6be8524d0
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/safari_favicons/favicons.db-wal
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:12dcdd53f6259e493d53c6d086671cd488e1da5dd4ca210f817afffe55ba1cdf
+size 148352
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/safari_per_site_preferences/PerSitePreferences.db b/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/safari_per_site_preferences/PerSitePreferences.db
new file mode 100644
index 0000000000..217c0f93b5
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/safari_per_site_preferences/PerSitePreferences.db
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:225e1ef981fff32ba42b1255530ad65b26eb5489e6650c2aa8cdb2bc61ac5fe9
+size 32768
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/safari_per_site_preferences/PerSitePreferences.db-wal b/tests/_data/plugins/os/unix/bsd/darwin/macos/safari/safari_per_site_preferences/PerSitePreferences.db-wal
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/shadow/_mbsetupuser.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/shadow/_mbsetupuser.plist
new file mode 100644
index 0000000000..72ae34e7e1
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/shadow/_mbsetupuser.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c8592d09647af9c8681e8a1e83edb9edf05c88fff7a6ab805860db0fd63b3592
+size 2930
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/shadow/user.plist b/tests/_data/plugins/os/unix/bsd/darwin/macos/shadow/user.plist
new file mode 100644
index 0000000000..f5a2954868
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/shadow/user.plist
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6cae224de709dfab01fe951c2cac4a036daec49d534bcafc1d3552ef84f722ce
+size 710014
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/tcc/system.db b/tests/_data/plugins/os/unix/bsd/darwin/macos/tcc/system.db
new file mode 100644
index 0000000000..53a61cc41b
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/tcc/system.db
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fbfb621157968dea208759597fcde8aa2fafb9e02a00431b09d6352004161cf6
+size 65536
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/tcc/user.db b/tests/_data/plugins/os/unix/bsd/darwin/macos/tcc/user.db
new file mode 100644
index 0000000000..095cd41d3b
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/tcc/user.db
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a7e8a85daf819bdd320c625b5bd05e1f257c020435a8bc66db1b81239d29b072
+size 65536
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/text_replacements/TextReplacements.db b/tests/_data/plugins/os/unix/bsd/darwin/macos/text_replacements/TextReplacements.db
new file mode 100644
index 0000000000..bd347f85ca
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/text_replacements/TextReplacements.db
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:83e365bb335881b96aacb0a2c920a6601f0a81401f66a7c4cf6ad7baac314c16
+size 53248
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/text_replacements/TextReplacements.db-wal b/tests/_data/plugins/os/unix/bsd/darwin/macos/text_replacements/TextReplacements.db-wal
new file mode 100644
index 0000000000..4109768f2e
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/text_replacements/TextReplacements.db-wal
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:552222421037e4b2f5ae04da6edcfa89f923c93737407023eaadd46596db328f
+size 41232
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/user_accounts/Accounts4.sqlite b/tests/_data/plugins/os/unix/bsd/darwin/macos/user_accounts/Accounts4.sqlite
new file mode 100644
index 0000000000..91dc97f750
--- /dev/null
+++ b/tests/_data/plugins/os/unix/bsd/darwin/macos/user_accounts/Accounts4.sqlite
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:171fec3682458506c61b127bdf0126b74552658e072d84eefef939c98cdd5d07
+size 159744
diff --git a/tests/_data/plugins/os/unix/bsd/darwin/macos/user_accounts/Accounts4.sqlite-wal b/tests/_data/plugins/os/unix/bsd/darwin/macos/user_accounts/Accounts4.sqlite-wal
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/logs/__init__.py b/tests/plugins/os/unix/bsd/darwin/macos/logs/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/logs/test_asl.py b/tests/plugins/os/unix/bsd/darwin/macos/logs/test_asl.py
new file mode 100644
index 0000000000..075e702764
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/logs/test_asl.py
@@ -0,0 +1,70 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.logs.asl import ASLPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_file",
+ [
+ ("2026.05.06.G80.asl"),
+ ],
+)
+def test_system_log(test_file: str, target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ tz = timezone.utc
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/logs/{test_file}")
+ fs_unix.map_file(f"/var/log/asl/{test_file}", data_file)
+
+ target_unix.add_plugin(ASLPlugin)
+ results = list(target_unix.asl())
+ results.sort(key=lambda r: r.source)
+
+ assert len(results) == 11
+
+ assert results[0].ts == datetime(2026, 5, 6, 7, 45, 10, tzinfo=tz)
+ assert results[0].priority_level == "Notice"
+ assert results[0].pid == 121
+ assert results[0].asl_host == "localhost"
+ assert results[0].sender == "syslogd"
+ assert results[0].facility == "syslog"
+ assert results[0].message == (
+ "Configuration Notice:\n"
+ 'ASL Module "com.apple.cdscheduler" claims selected messages.\n'
+ "Those messages may not appear in standard system log files or in the ASL database."
+ )
+ assert results[0].source == "/var/log/asl/2026.05.06.G80.asl"
+
+ assert results[1].ts == datetime(2026, 5, 6, 7, 45, 10, tzinfo=tz)
+ assert results[1].priority_level == "Notice"
+ assert results[1].pid == 121
+ assert results[1].asl_host == "localhost"
+ assert results[1].sender == "syslogd"
+ assert results[1].facility == "syslog"
+ assert results[1].message == (
+ "Configuration Notice:\n"
+ 'ASL Module "com.apple.install" claims selected messages.\n'
+ "Those messages may not appear in standard system log files or in the ASL database."
+ )
+ assert results[1].source == "/var/log/asl/2026.05.06.G80.asl"
+
+ assert results[-1].ts == datetime(2026, 5, 6, 11, 50, 20, tzinfo=tz)
+ assert results[-1].priority_level == "Notice"
+ assert results[-1].pid == 122
+ assert results[-1].asl_host == "localhost"
+ assert results[-1].sender == "syslogd"
+ assert results[-1].facility == "syslog"
+ assert results[-1].message == (
+ "Configuration Notice:\n"
+ 'ASL Module "com.apple.eventmonitor" claims selected messages.\n'
+ "Those messages may not appear in standard system log files or in the ASL database."
+ )
+ assert results[-1].source == "/var/log/asl/2026.05.06.G80.asl"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/logs/test_fsck_apfs_log.py b/tests/plugins/os/unix/bsd/darwin/macos/logs/test_fsck_apfs_log.py
new file mode 100644
index 0000000000..dae81db10a
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/logs/test_fsck_apfs_log.py
@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.logs.fsck_apfs_log import FsckAPFSLogPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_file",
+ [
+ "fsck_apfs.log",
+ ],
+)
+def test_fsck_apfs_log(test_file: str, target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ tz = timezone.utc
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/logs/{test_file}")
+ fs_unix.map_file(f"/var/log/{test_file}", data_file)
+
+ target_unix.add_plugin(FsckAPFSLogPlugin)
+
+ results = list(target_unix.fsck_apfs_log())
+ assert len(results) == 3
+
+ assert results[0].ts == datetime(2026, 5, 4, 4, 23, 21, tzinfo=tz)
+ assert results[0].disk_path == "/dev/rdisk1s1"
+ assert results[0].message == "fsck_apfs started at Mon May 4 04:23:21 2026"
+ assert results[0].source == "/var/log/fsck_apfs.log"
+
+ assert results[1].ts is None
+ assert results[1].disk_path == "/dev/rdisk1s1"
+ assert results[1].message == "error: container /dev/rdisk1 is mounted with write access; please re-run with -l."
+ assert results[1].source == "/var/log/fsck_apfs.log"
+
+ assert results[-1].ts == datetime(2026, 5, 4, 4, 23, 21, tzinfo=tz)
+ assert results[-1].disk_path == "/dev/rdisk1s1"
+ assert results[-1].message == "fsck_apfs completed at Mon May 4 04:23:21 2026"
+ assert results[-1].source == "/var/log/fsck_apfs.log"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/logs/test_install_log.py b/tests/plugins/os/unix/bsd/darwin/macos/logs/test_install_log.py
new file mode 100644
index 0000000000..e30406e3b3
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/logs/test_install_log.py
@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.logs.install_log import InstallLogPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_file",
+ [
+ "install.log",
+ ],
+)
+def test_install_log(test_file: str, target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ tz = timezone.utc
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/logs/{test_file}")
+ fs_unix.map_file(f"/var/log/{test_file}", data_file)
+
+ target_unix.add_plugin(InstallLogPlugin)
+
+ results = list(target_unix.install_log())
+ assert len(results) == 3
+
+ assert results[0].ts == datetime(2026, 3, 25, 14, 6, 59, tzinfo=tz)
+ assert results[0].host == "localhost"
+ assert results[0].component == "Installer"
+ assert results[0].message == "Progress[57]: Progress UI App Starting"
+ assert results[0].source == "/var/log/install.log"
+
+ assert results[1].ts == datetime(2026, 3, 25, 14, 7, tzinfo=tz)
+ assert results[1].host == "localhost"
+ assert results[1].component == "Installer"
+ assert results[1].message == "Progress[57]: Logging also using os_log, installerProgressLog = 0xc6501c080"
+ assert results[1].source == "/var/log/install.log"
+
+ assert results[-1].ts == datetime(2026, 3, 25, 15, 18, 58, tzinfo=tz)
+ assert results[-1].host == "users-Virtual-Machine"
+ assert results[-1].component == "loginwindow[1042]:"
+ assert results[-1].message == "+[SUOSULoginCredentialPolicy currentLoginCredentialPolicy] = 0"
+ assert results[-1].source == "/var/log/install.log"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/logs/test_system_log.py b/tests/plugins/os/unix/bsd/darwin/macos/logs/test_system_log.py
new file mode 100644
index 0000000000..fca33742e8
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/logs/test_system_log.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+from unittest.mock import patch
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.logs.system_log import SystemLogPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_files",
+ [
+ ["system.log", "system.log.0.gz"],
+ ],
+)
+def test_system_log(test_files: list[str], target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ tz = timezone.utc
+ stat_results = []
+ entries = []
+ for test_file in test_files:
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/logs/system_log/{test_file}")
+ fs_unix.map_file(f"/var/log/{test_file}", data_file)
+
+ entry = fs_unix.get(f"/var/log/{test_file}")
+ stat_result = entry.stat()
+ stat_result.st_mtime = 1704067199
+
+ entries.append(entry)
+ stat_results.append(stat_result)
+
+ with (
+ patch.object(entries[0], "stat", return_value=stat_results[0]),
+ patch.object(entries[1], "stat", return_value=stat_results[1]),
+ ):
+ target_unix.add_plugin(SystemLogPlugin)
+ results = list(target_unix.system_log())
+ results.reverse()
+ results.sort(key=lambda r: r.source)
+
+ assert len(results) == 286
+
+ assert results[0].ts == datetime(2023, 3, 25, 7, 6, 57, tzinfo=tz)
+ assert results[0].host == "localhost"
+ assert results[0].component == "bootlog[0]:"
+ assert results[0].message == "BOOT_TIME 1774447617 0"
+ assert results[0].source == "/var/log/system.log"
+
+ assert results[1].ts == datetime(2023, 3, 25, 7, 7, tzinfo=tz)
+ assert results[1].host == "localhost"
+ assert results[1].component == "syslogd[60]:"
+ assert results[1].message == (
+ "Configuration Notice:\n\t"
+ 'ASL Module "com.apple.cdscheduler" claims selected messages.\n\t'
+ "Those messages may not appear in standard system log files or in the ASL database."
+ )
+ assert results[1].source == "/var/log/system.log"
+
+ assert results[2].ts == datetime(2023, 3, 25, 16, 19, 5, tzinfo=tz)
+ assert results[2].host == "users-Virtual-Machine"
+ assert results[2].component == "shutdown[1785]:"
+ assert results[2].message == "SHUTDOWN_TIME: 1774451945 577079"
+ assert results[2].source == "/var/log/system.log"
+
+ assert results[-1].ts == datetime(2023, 5, 6, 4, 50, 20, tzinfo=tz)
+ assert results[-1].host == "localhost"
+ assert results[-1].component == "syslogd[122]:"
+ assert results[-1].message == "ASL Sender Statistics"
+ assert results[-1].source == "/var/log/system.log.0.gz"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/logs/test_wifi_log.py b/tests/plugins/os/unix/bsd/darwin/macos/logs/test_wifi_log.py
new file mode 100644
index 0000000000..4a205e21d2
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/logs/test_wifi_log.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+from unittest.mock import patch
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.logs.wifi_log import WifiLogPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_file",
+ [
+ "wifi.log",
+ ],
+)
+def test_wifi_log(test_file: str, target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ tz = timezone.utc
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/logs/{test_file}")
+ fs_unix.map_file(f"/var/log/{test_file}", data_file)
+
+ entry = fs_unix.get(f"/var/log/{test_file}")
+ stat_result = entry.stat()
+ stat_result.st_mtime = 1704067199
+
+ with patch.object(entry, "stat") as mock_stat:
+ mock_stat.return_value = stat_result
+
+ target_unix.add_plugin(WifiLogPlugin)
+
+ results = list(target_unix.wifi_log())
+ assert len(results) == 2
+
+ assert results[0].ts == datetime(2023, 5, 4, 4, 23, 16, 776000, tzinfo=tz)
+ assert results[0].host == "[airport]/114"
+ assert (
+ results[0].message
+ == "@[5.319676] (configdIODriverInterface.m:401) dq:'com.apple.main-thread'/tid[0x39e] FreedDeviceNodeListManager initialized, list[0xb2cd122e0] lock[0xb2d0f8380]" # noqa: E501
+ )
+ assert results[0].source == "/var/log/wifi.log"
+
+ assert results[1].ts == datetime(2023, 5, 4, 4, 23, 16, 774000, tzinfo=tz)
+ assert results[1].host == ""
+ assert results[1].message == "configdStart: ****** [AirPort logger started] ******"
+ assert results[1].source == "/var/log/wifi.log"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/safari/__init__.py b/tests/plugins/os/unix/bsd/darwin/macos/safari/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/safari/test_safari_downloads.py b/tests/plugins/os/unix/bsd/darwin/macos/safari/test_safari_downloads.py
new file mode 100644
index 0000000000..ba2ad4b50a
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/safari/test_safari_downloads.py
@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.helpers.record import UnixUserRecord
+from dissect.target.plugins.os.unix.bsd.darwin.macos.safari.safari_downloads import SafariDownloadsPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_file",
+ [
+ "Downloads.plist",
+ ],
+)
+def test_safari_downloads(test_file: str, target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ user = UnixUserRecord(
+ name="user",
+ uid=501,
+ gid=20,
+ home="/Users/user",
+ shell="/bin/zsh",
+ )
+ target_unix.users = lambda: [
+ user,
+ ]
+
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/safari/{test_file}")
+ fs_unix.map_file(f"Users/user/Library/Safari/{test_file}", data_file)
+
+ target_unix.add_plugin(SafariDownloadsPlugin)
+
+ results = list(target_unix.safari_downloads())
+
+ assert len(results) == 2
+
+ assert results[0].download_entry_progress_total_to_load == 370221
+ assert results[0].download_entry_progress_bytes_so_far == 370221
+ assert results[0].download_entry_path == "/Users/user/Downloads/another_apple.jpg"
+ assert results[0].download_entry_date_added == datetime(2026, 6, 11, 7, 47, 5, 68034, tzinfo=timezone.utc)
+ assert not results[0].download_entry_remove_when_done
+ assert not results[0].download_entry_should_use_request_url_as_origin_url_if_necessary
+ assert results[0].download_entry_profile_uuid_string == "DefaultProfile"
+ assert results[0].download_entry_date_finished == datetime(2026, 6, 11, 7, 47, 15, 349867, tzinfo=timezone.utc)
+ assert results[0].download_entry_url == (
+ "https://images.unsplash.com/photo-1568702846914-96b305d2aaeb"
+ "?ixlib=rb-4.1.0&q=85&fm=jpg&crop=entropy&cs=srgb"
+ "&dl=an_vision-gDPaDDy6_WE-unsplash.jpg"
+ )
+ assert results[0].download_entry_sandbox_identifier == "1109426B-5B25-49B3-9FE7-C27B77F36576"
+ assert results[0].download_entry_bookmark_blob is not None
+ assert isinstance(results[0].download_entry_bookmark_blob, (bytes, bytearray))
+ assert results[0].download_entry_identifier == "AD6033B8-86AA-4CAC-9398-70C180C0FA30"
+ assert results[0].source == "/Users/user/Library/Safari/Downloads.plist"
+
+ assert results[1].download_entry_progress_total_to_load == 1173811
+ assert results[1].download_entry_progress_bytes_so_far == 1173811
+ assert results[1].download_entry_path == "/Users/user/Documents/apple.jpg"
+ assert results[1].download_entry_date_added == datetime(2026, 6, 11, 7, 46, 42, 238320, tzinfo=timezone.utc)
+ assert not results[1].download_entry_remove_when_done
+ assert not results[1].download_entry_should_use_request_url_as_origin_url_if_necessary
+ assert results[1].download_entry_profile_uuid_string == "DefaultProfile"
+ assert results[1].download_entry_date_finished == datetime(2026, 6, 11, 7, 46, 50, 126514, tzinfo=timezone.utc)
+ assert results[1].download_entry_url == (
+ "https://images.unsplash.com/photo-1630563451961-ac2ff27616ab?ixlib=rb-4.1.0&q=85&fm=jpg&crop=entropy&cs=srgb&dl=tobi-zLCR7RsxYGs-unsplash.jpg"
+ )
+ assert results[1].download_entry_sandbox_identifier == "95FB1F71-C489-4A07-AFB9-509F4B2BC105"
+ assert results[1].download_entry_bookmark_blob is not None
+ assert isinstance(results[1].download_entry_bookmark_blob, (bytes, bytearray))
+ assert results[1].download_entry_identifier == "BD390ED6-0E4C-4B25-ACB1-E50F9E2B2347"
+ assert results[1].source == "/Users/user/Library/Safari/Downloads.plist"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/safari/test_safari_favicons.py b/tests/plugins/os/unix/bsd/darwin/macos/safari/test_safari_favicons.py
new file mode 100644
index 0000000000..949b775477
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/safari/test_safari_favicons.py
@@ -0,0 +1,70 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.helpers.record import UnixUserRecord
+from dissect.target.plugins.os.unix.bsd.darwin.macos.safari.safari_favicons import SafariFaviconsPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_files",
+ [
+ [
+ "favicons.db",
+ "favicons.db-wal",
+ ]
+ ],
+)
+def test_safari_favicons(test_files: list[str], target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ user = UnixUserRecord(
+ name="user",
+ uid=501,
+ gid=20,
+ home="/Users/user",
+ shell="/bin/zsh",
+ )
+ target_unix.users = lambda: [
+ user,
+ ]
+ for test_file in test_files:
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/safari/safari_favicons/{test_file}")
+ fs_unix.map_file(f"Users/user/Library/Safari/Favicon Cache/{test_file}", data_file)
+
+ target_unix.add_plugin(SafariFaviconsPlugin)
+
+ results = list(target_unix.safari_favicons())
+
+ assert len(results) == 142
+
+ assert results[0].table == "page_url"
+ assert (
+ results[0].url
+ == "https://www.google.com/search?client=safari&rls=en&q=digital+forensic+artifacts+repositroy&ie=UTF-8&oe=UTF-8&sei=jYX4abCaDamK9u8PoYyHyAU"
+ )
+ assert results[0].uuid == "7273D3EF-09B6-498C-8385-47AEFBA2E3F0"
+ assert results[0].source == "/Users/user/Library/Safari/Favicon Cache/favicons.db"
+
+ assert results[113].table == "icon_info"
+ assert results[113].uuid == "7273D3EF-09B6-498C-8385-47AEFBA2E3F0"
+ assert results[113].url == "https://www.gstatic.com/images/branding/searchlogo/ico/favicon.ico"
+ assert results[113].timestamp == datetime(2026, 5, 4, 11, 39, 58, 200725, tzinfo=timezone.utc)
+ assert results[113].width == 32
+ assert results[113].height == 32
+ assert results[113].has_generated_representations
+ assert results[113].source == "/Users/user/Library/Safari/Favicon Cache/favicons.db"
+
+ assert results[137].table == "rejected_resources"
+ assert (
+ results[137].page_url
+ == "https://www.google.com/search?client=safari&rls=en&q=digital+forensic+artifacts+repositroy&ie=UTF-8&oe=UTF-8"
+ )
+ assert results[137].icon_url == "https://www.google.com/favicon.ico"
+ assert results[137].source == "/Users/user/Library/Safari/Favicon Cache/favicons.db"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/safari/test_safari_per_site_preferences.py b/tests/plugins/os/unix/bsd/darwin/macos/safari/test_safari_per_site_preferences.py
new file mode 100644
index 0000000000..ced5a11298
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/safari/test_safari_per_site_preferences.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.helpers.record import UnixUserRecord
+from dissect.target.plugins.os.unix.bsd.darwin.macos.safari.safari_per_site_preferences import (
+ SafariPerSitePreferencesPlugin,
+)
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_files",
+ [
+ [
+ "PerSitePreferences.db",
+ "PerSitePreferences.db-wal",
+ ]
+ ],
+)
+def test_safari_per_site_preferences(test_files: list[str], target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ user = UnixUserRecord(
+ name="user",
+ uid=501,
+ gid=20,
+ home="/Users/user",
+ shell="/bin/zsh",
+ )
+ target_unix.users = lambda: [
+ user,
+ ]
+ for test_file in test_files:
+ data_file = absolute_path(
+ f"_data/plugins/os/unix/bsd/darwin/macos/safari/safari_per_site_preferences/{test_file}"
+ )
+ fs_unix.map_file(f"Users/user/Library/Safari/{test_file}", data_file)
+
+ target_unix.add_plugin(SafariPerSitePreferencesPlugin)
+
+ results = list(target_unix.safari_per_site_preferences())
+
+ assert len(results) == 3
+
+ assert results[0].table == "sqlite_sequence"
+ assert results[0].name == "preference_values"
+ assert results[0].seq == 2
+ assert results[0].source == "/Users/user/Library/Safari/PerSitePreferences.db"
+
+ assert results[1].table == "preference_values"
+ assert results[1].id == 1
+ assert results[1].preference_domain == "code.visualstudio.com"
+ assert results[1].preference == "PerSitePreferencesDownloads"
+ assert results[1].preference_value == 0
+ assert results[1].timestamp is None
+ assert results[1].sync_data is None
+ assert results[1].record_name is None
+ assert results[1].source == "/Users/user/Library/Safari/PerSitePreferences.db"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/safari/test_safari_per_site_zoom_preferences.py b/tests/plugins/os/unix/bsd/darwin/macos/safari/test_safari_per_site_zoom_preferences.py
new file mode 100644
index 0000000000..56545cfa22
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/safari/test_safari_per_site_zoom_preferences.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.helpers.record import UnixUserRecord
+from dissect.target.plugins.os.unix.bsd.darwin.macos.safari.safari_per_site_zoom_preferences import (
+ SafariPerSiteZoomPreferencesPlugin,
+)
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_file",
+ [
+ "PerSiteZoomPreferences.plist",
+ ],
+)
+def test_safari_per_site_zoom_preferences(test_file: str, target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ user = UnixUserRecord(
+ name="user",
+ uid=501,
+ gid=20,
+ home="/Users/user",
+ shell="/bin/zsh",
+ )
+ target_unix.users = lambda: [
+ user,
+ ]
+
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/safari/{test_file}")
+ fs_unix.map_file(f"Users/user/Library/Safari/{test_file}", data_file)
+
+ target_unix.add_plugin(SafariPerSiteZoomPreferencesPlugin)
+
+ results = list(target_unix.safari_per_site_zoom_preferences())
+
+ assert len(results) == 3
+
+ assert results[0].map_of_ck_record_names_to_ck_records == "{}"
+ assert results[0].zoom_preference_version == 1
+ assert results[0].source == "/Users/user/Library/Safari/PerSiteZoomPreferences.plist"
+
+ assert results[1].site == "google.com"
+ assert results[1].page_zoom_factor == 0
+ assert results[1].text_zoom_factor == 1
+ assert results[1].source == "/Users/user/Library/Safari/PerSiteZoomPreferences.plist"
+
+ assert results[2].site == "apple.com"
+ assert results[2].page_zoom_factor == 1
+ assert results[2].text_zoom_factor == 1
+ assert results[2].source == "/Users/user/Library/Safari/PerSiteZoomPreferences.plist"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/safari/test_safari_recently_closed_tabs.py b/tests/plugins/os/unix/bsd/darwin/macos/safari/test_safari_recently_closed_tabs.py
new file mode 100644
index 0000000000..9c212c8bd9
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/safari/test_safari_recently_closed_tabs.py
@@ -0,0 +1,93 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.helpers.record import UnixUserRecord
+from dissect.target.plugins.os.unix.bsd.darwin.macos.safari.safari_recently_closed_tabs import (
+ SafariRecentlyClosedTabsPlugin,
+)
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_file",
+ [
+ "RecentlyClosedTabs.plist",
+ ],
+)
+def test_safari_recently_closed_tabs(test_file: str, target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ user = UnixUserRecord(
+ name="user",
+ uid=501,
+ gid=20,
+ home="/Users/user",
+ shell="/bin/zsh",
+ )
+ target_unix.users = lambda: [
+ user,
+ ]
+
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/safari/{test_file}")
+ fs_unix.map_file(f"Users/user/Library/Safari/{test_file}", data_file)
+
+ target_unix.add_plugin(SafariRecentlyClosedTabsPlugin)
+
+ results = list(target_unix.safari_recently_closed_tabs())
+
+ assert len(results) == 53
+
+ assert results[0].closed_tab_or_window_persistent_states_version == "1"
+ assert results[0].plist_path is None
+ assert results[0].source == "/Users/user/Library/Safari/RecentlyClosedTabs.plist"
+
+ assert results[1].persistent_state_type == 0
+ assert results[1].plist_path == "ClosedTabOrWindowPersistentStates[0]"
+ assert results[1].source == "/Users/user/Library/Safari/RecentlyClosedTabs.plist"
+
+ assert not results[2].is_disposable
+ assert results[2].ancestor_tab_uuids_key == []
+ assert results[2].tab_group_type_for_tab_key
+ assert results[2].tab_group_for_tab == "E3361DD6-9BEB-4FA9-A76E-ACBEFDE36107"
+ assert results[2].date_closed == datetime(2026, 5, 4, 12, 15, 59, 771103, tzinfo=timezone.utc)
+ assert results[2].profile_uuid == "DefaultProfile"
+ assert results[2].safe_to_load
+ assert results[2].tab_index == 5
+ assert results[2].window_uuid == "36576C8E-921A-4E3B-999F-1E1FD0278E5E"
+ assert results[2].last_visit_time == datetime(2026, 5, 4, 12, 15, 36, 419671, tzinfo=timezone.utc)
+ assert results[2].tab_uuid == "147E0803-BAE8-429D-A032-A50DE3D559E2"
+ assert (
+ results[2].tab_url == "https://stackoverflow.com/questions/39872512/what-command-makes-a-new-file-in-terminal"
+ )
+ assert results[2].tab_state_version == 1
+ assert results[2].tab_title == "What command makes a new file in terminal? - Stack Overflow"
+ assert not results[2].is_muted
+ assert results[2].process_identifier is None
+ assert results[2].plist_path == "ClosedTabOrWindowPersistentStates[0]/PersistentState"
+ assert results[2].source == "/Users/user/Library/Safari/RecentlyClosedTabs.plist"
+
+ assert results[4].selected_tab_index == 0
+ assert results[4].window_unified_sidebar_mode == 0
+ assert not results[4].tab_bar_hidden
+ assert results[4].date_closed == datetime(2026, 5, 4, 14, 11, 10, 954509, tzinfo=timezone.utc)
+ assert results[4].favorites_bar_hidden
+ assert not results[4].is_popup_window
+ assert results[4].profile_uuid == "DefaultProfile"
+ assert results[4].window_restoration_archive_data == ""
+ assert not results[4].is_private_window
+ assert not results[4].miniaturized
+ assert not results[4].prefers_reading_list_sidebar_visible
+ assert results[4].selected_pinned_tab_index == 9223372036854775807
+ assert results[4].unnamed_tab_group_uuids == []
+ assert results[4].window_content_rect == "{{87, 79}, {1264, 791}}"
+ assert results[4].window_state_version == "2.0"
+ assert results[4].window_uuid == "C177698D-59FA-4A93-BF08-1EB399F01B85"
+ assert results[4].active_tab_group_uuid == "8B0B9E28-53F6-4C22-A0E7-4EC2DB16ECF4"
+ assert results[4].plist_path == "ClosedTabOrWindowPersistentStates[1]/PersistentState"
+ assert results[4].source == "/Users/user/Library/Safari/RecentlyClosedTabs.plist"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/safari/test_safari_user_notification_permissions.py b/tests/plugins/os/unix/bsd/darwin/macos/safari/test_safari_user_notification_permissions.py
new file mode 100644
index 0000000000..34aa1033df
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/safari/test_safari_user_notification_permissions.py
@@ -0,0 +1,49 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.helpers.record import UnixUserRecord
+from dissect.target.plugins.os.unix.bsd.darwin.macos.safari.safari_user_notification_permissions import (
+ SafariUserNotificationPermissionsPlugin,
+)
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_file",
+ [
+ "UserNotificationPermissions.plist",
+ ],
+)
+def test_safari_user_notification_permissions(test_file: str, target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ user = UnixUserRecord(
+ name="user",
+ uid=501,
+ gid=20,
+ home="/Users/user",
+ shell="/bin/zsh",
+ )
+ target_unix.users = lambda: [
+ user,
+ ]
+
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/safari/{test_file}")
+ fs_unix.map_file(f"/Users/user/Library/Safari/{test_file}", data_file)
+
+ target_unix.add_plugin(SafariUserNotificationPermissionsPlugin)
+
+ results = list(target_unix.safari_user_notification_permissions())
+
+ assert len(results) == 1
+
+ assert results[0].permission == 0
+ assert results[0].date_added == datetime(2026, 5, 4, 13, 18, 13, 813088, tzinfo=timezone.utc)
+ assert results[0].site == "https://www.macworld.com"
+ assert results[0].source == "/Users/user/Library/Safari/UserNotificationPermissions.plist"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_airport_preferences.py b/tests/plugins/os/unix/bsd/darwin/macos/test_airport_preferences.py
new file mode 100644
index 0000000000..824f8fa03d
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_airport_preferences.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.airport_preferences import AirportPreferencesPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_file",
+ [
+ "com.apple.airport.preferences.plist",
+ ],
+)
+def test_aiport_preferences(test_file: str, target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/{test_file}")
+ fs_unix.map_file(f"/Library/Preferences/SystemConfiguration/{test_file}", data_file)
+
+ target_unix.add_plugin(AirportPreferencesPlugin)
+
+ results = list(target_unix.airport_preferences())
+ assert len(results) == 1
+
+ assert results[0].counter == 2
+ assert results[0].device_uuid == "0527924E-C5F8-4703-BDDC-9283B6E9FDAE"
+ assert results[0].version_number == 7200
+ assert results[0].preferred_order == []
+ assert results[0].source == "/Library/Preferences/SystemConfiguration/com.apple.airport.preferences.plist"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_at_jobs.py b/tests/plugins/os/unix/bsd/darwin/macos/test_at_jobs.py
new file mode 100644
index 0000000000..e8f19560f9
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_at_jobs.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.at_jobs import AtJobsPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_file",
+ [
+ "a0000901c45260",
+ ],
+)
+def test_at_jobs(test_file: str, target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ # Test data was created by running: echo 'say "hello"' | at 09:00 tomorrow
+ # Command was executed on: 2026-05-11 (PDT time).
+ tz = timezone.utc
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/{test_file}")
+ fs_unix.map_file(f"/usr/lib/cron/jobs/{test_file}", data_file)
+
+ target_unix.add_plugin(AtJobsPlugin)
+
+ results = list(target_unix.at_jobs())
+ assert len(results) == 1
+
+ assert results[0].queue == "a"
+ assert results[0].seq == 9
+ assert results[0].execution_time == datetime(2026, 5, 12, 16, 0, 0, tzinfo=tz)
+ assert results[0].command == 'say "hello"'
+ assert results[0].source == "/usr/lib/cron/jobs/a0000901c45260"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_authorization_rules.py b/tests/plugins/os/unix/bsd/darwin/macos/test_authorization_rules.py
new file mode 100644
index 0000000000..67c04eab79
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_authorization_rules.py
@@ -0,0 +1,190 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.authorization_rules import AuthorizationRulesPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_file",
+ [
+ "auth.db",
+ ],
+)
+def test_authorization_rules(test_file: str, target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ tz = timezone.utc
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/{test_file}")
+ fs_unix.map_file(f"/var/db/{test_file}", data_file)
+
+ target_unix.add_plugin(AuthorizationRulesPlugin)
+
+ results = list(target_unix.authorization_rules())
+
+ assert len(results) == 249
+
+ assert results[0].table == "sqlite_sequence"
+ assert results[0].name == "rules"
+ assert results[0].seq == 205
+ assert results[0].source == "/var/db/auth.db"
+
+ assert sorted(results[175].tables) == ["delegates_map", "rules", "rules_history"]
+ assert results[175].rules_id == 159
+ assert results[175].rules_name == "com.apple.tcc.util.admin"
+ assert results[175].rules_type == 1
+ assert results[175].rules_class == 2
+ assert results[175].rules_group is None
+ assert results[175].rules_kofn is None
+ assert results[175].rules_timeout is None
+ assert results[175].rules_flags == 0
+ assert results[175].rules_tries is None
+ assert results[175].rules_version == 0
+ assert results[175].rules_created == datetime(2026, 3, 25, 14, 7, 1, 144442, tzinfo=tz)
+ assert results[175].rules_modified == datetime(2026, 3, 25, 14, 7, 1, 144442, tzinfo=tz)
+ assert results[175].rules_hash is None
+ assert results[175].rules_identifier is None
+ assert results[175].rules_requirement is None
+ assert results[175].rules_comment == "For modification of TCC settings."
+ assert results[175].rules_history_timestamp == datetime(2026, 3, 25, 14, 7, 1, tzinfo=tz)
+ assert results[175].rules_history_source == "authd"
+ assert results[175].rules_history_operation == 0
+ assert results[175].mechanisms_map_m_id is None
+ assert results[175].mechanisms_map_ord is None
+ assert results[175].mechanisms_plugin is None
+ assert results[175].mechanisms_param is None
+ assert results[175].mechanisms_privileged is None
+ assert results[175].rules_delegates_map == ["{'d_id': 9, 'ord': 0}"]
+ assert results[175].source == "/var/db/auth.db"
+
+ assert sorted(results[177].tables) == ["mechanisms", "mechanisms_map", "rules", "rules_history"]
+ assert results[177].rules_id == 161
+ assert results[177].rules_name == "system.login.filevault"
+ assert results[177].rules_type == 1
+ assert results[177].rules_class == 3
+ assert results[177].rules_group is None
+ assert results[177].rules_kofn is None
+ assert results[177].rules_timeout is None
+ assert results[177].rules_flags == 1
+ assert results[177].rules_tries == "10000"
+ assert results[177].rules_version == 0
+ assert results[177].rules_created == datetime(2026, 3, 25, 14, 7, 1, 144442, tzinfo=tz)
+ assert results[177].rules_modified == datetime(2026, 3, 25, 14, 7, 1, 144442, tzinfo=tz)
+ assert results[177].rules_hash is None
+ assert results[177].rules_identifier is None
+ assert results[177].rules_requirement is None
+ assert results[177].rules_comment == "Login mechanism based rule for Filevault."
+ assert results[177].rules_history_timestamp == datetime(2026, 3, 25, 14, 7, 1, tzinfo=tz)
+ assert results[177].rules_history_source == "authd"
+ assert results[177].rules_history_operation == 0
+ assert results[177].mechanisms_map_m_id == 24
+ assert results[177].mechanisms_map_ord == 0
+ assert results[177].mechanisms_plugin == "builtin"
+ assert results[177].mechanisms_param == "policy-banner"
+ assert results[177].mechanisms_privileged == 0
+ assert results[177].rules_delegates_map == []
+ assert results[177].source == "/var/db/auth.db"
+
+ assert sorted(results[-9].tables) == ["rules", "rules_history"]
+ assert results[-9].rules_id == 199
+ assert results[-9].rules_name == "system.preferences.security.remotepair"
+ assert results[-9].rules_type == 1
+ assert results[-9].rules_class == 1
+ assert results[-9].rules_group == "admin"
+ assert results[-9].rules_kofn is None
+ assert results[-9].rules_timeout == 30
+ assert results[-9].rules_flags == 73
+ assert results[-9].rules_tries == "10000"
+ assert results[-9].rules_version == 1
+ assert results[-9].rules_created == datetime(2026, 3, 25, 14, 7, 1, 144442, tzinfo=tz)
+ assert results[-9].rules_modified == datetime(2026, 3, 25, 14, 7, 1, 144442, tzinfo=tz)
+ assert results[-9].rules_hash is None
+ assert results[-9].rules_identifier is None
+ assert results[-9].rules_requirement is None
+ assert results[-9].rules_comment == "Used by Bezel Services to gate IR remote pairing."
+ assert results[-9].rules_delegates_map == []
+ assert results[-9].rules_history_timestamp == datetime(2026, 3, 25, 14, 7, 1, tzinfo=tz)
+ assert results[-9].rules_history_source == "authd"
+ assert results[-9].rules_history_operation == 0
+ assert results[-9].mechanisms_map_m_id is None
+ assert results[-9].mechanisms_map_ord is None
+ assert results[-9].mechanisms_plugin is None
+ assert results[-9].mechanisms_param is None
+ assert results[-9].mechanisms_privileged is None
+ assert results[-9].source == "/var/db/auth.db"
+
+ assert sorted(results[-4].tables) == ["rules", "rules_history"]
+ assert results[-4].rules_id == 204
+ assert results[-4].rules_name == "com.apple.Safari.allow-apple-events-to-run-javascript"
+ assert results[-4].rules_type == 1
+ assert results[-4].rules_class == 1
+ assert results[-4].rules_group is None
+ assert results[-4].rules_kofn is None
+ assert results[-4].rules_timeout == 2147483647
+ assert results[-4].rules_flags == 12
+ assert results[-4].rules_tries == "10000"
+ assert results[-4].rules_version == 0
+ assert results[-4].rules_created == datetime(2026, 3, 25, 14, 7, 1, 144442, tzinfo=tz)
+ assert results[-4].rules_modified == datetime(2026, 3, 25, 14, 7, 1, 144442, tzinfo=tz)
+ assert results[-4].rules_hash is None
+ assert results[-4].rules_identifier is None
+ assert results[-4].rules_requirement is None
+ assert (
+ results[-4].rules_comment
+ == "This right is used by Safari to allow Apple Events to run JavaScript on web pages."
+ )
+ assert results[-4].rules_delegates_map == []
+ assert results[-4].rules_history_timestamp == datetime(2026, 3, 25, 14, 7, 1, tzinfo=tz)
+ assert results[-4].rules_history_source == "authd"
+ assert results[-4].rules_history_operation == 0
+ assert results[-4].mechanisms_map_m_id is None
+ assert results[-4].mechanisms_map_ord is None
+ assert results[-4].mechanisms_plugin is None
+ assert results[-4].mechanisms_param is None
+ assert results[-4].mechanisms_privileged is None
+ assert results[-4].source == "/var/db/auth.db"
+
+ assert sorted(results[-3].tables) == ["delegates_map", "rules", "rules_history"]
+ assert results[-3].rules_id == 205
+ assert results[-3].rules_name == "com.apple.wifi"
+ assert results[-3].rules_type == 1
+ assert results[-3].rules_class == 2
+ assert results[-3].rules_group is None
+ assert results[-3].rules_kofn == 1
+ assert results[-3].rules_timeout is None
+ assert results[-3].rules_flags == 0
+ assert results[-3].rules_tries is None
+ assert results[-3].rules_version == 0
+ assert results[-3].rules_created == datetime(2026, 3, 25, 14, 7, 2, 383632, tzinfo=tz)
+ assert results[-3].rules_modified == datetime(2026, 3, 25, 14, 7, 2, 383632, tzinfo=tz)
+ assert results[-3].rules_hash is None
+ assert results[-3].rules_identifier == "com.apple.airport.airportd"
+ assert results[-3].rules_requirement is not None
+ assert isinstance(results[-3].rules_requirement, (bytes, bytearray))
+ assert results[-3].rules_comment == "For restricting WiFi control"
+ assert results[-3].rules_delegates_map == [
+ "{'d_id': 22, 'ord': 0}",
+ "{'d_id': 26, 'ord': 1}",
+ "{'d_id': 25, 'ord': 2}",
+ "{'d_id': 35, 'ord': 3}",
+ ]
+ assert results[-3].rules_history_timestamp == datetime(2026, 3, 25, 14, 7, 2, tzinfo=tz)
+ assert results[-3].rules_history_source == "/usr/libexec/airportd"
+ assert results[-3].rules_history_operation == 0
+ assert results[-3].mechanisms_map_m_id is None
+ assert results[-3].mechanisms_map_ord is None
+ assert results[-3].mechanisms_plugin is None
+ assert results[-3].mechanisms_param is None
+ assert results[-3].mechanisms_privileged is None
+ assert results[-3].source == "/var/db/auth.db"
+
+ assert results[-1].table == "config"
+ assert results[-1].key == "data_ts"
+ assert results[-1].value == "795677157.0"
+ assert results[-1].source == "/var/db/auth.db"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_call_history.py b/tests/plugins/os/unix/bsd/darwin/macos/test_call_history.py
new file mode 100644
index 0000000000..c384aa73e2
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_call_history.py
@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.helpers.record import UnixUserRecord
+from dissect.target.plugins.os.unix.bsd.darwin.macos.call_history import CallHistoryPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_files",
+ [
+ [
+ "CallHistory.storedata",
+ "CallHistory.storedata-wal",
+ ]
+ ],
+)
+def test_call_history(test_files: list[str], target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ user = UnixUserRecord(
+ name="user",
+ uid=501,
+ gid=20,
+ home="/Users/user",
+ shell="/bin/zsh",
+ )
+ target_unix.users = lambda: [
+ user,
+ ]
+ for test_file in test_files:
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/call_history/{test_file}")
+ fs_unix.map_file(f"Users/user/Library/Application Support/CallHistoryDB/{test_file}", data_file)
+
+ target_unix.add_plugin(CallHistoryPlugin)
+
+ results = list(target_unix.call_history())
+
+ assert len(results) == 9
+
+ assert results[0].table == "ZCALLDBPROPERTIES"
+ assert results[0].z_pk == 1
+ assert results[0].z_ent == 1
+ assert results[0].z_opt == 1
+ assert results[0].z_timer_all == 0
+ assert results[0].z_timer_incoming == 0
+ assert results[0].z_timer_last == 0
+ assert results[0].z_timer_lifetime == 0
+ assert results[0].z_timer_outgoing == 0
+ assert results[0].source == "/Users/user/Library/Application Support/CallHistoryDB/CallHistory.storedata"
+
+ assert results[1].table == "Z_PRIMARYKEY"
+ assert results[1].z_ent == 1
+ assert results[1].z_name == "CallDBProperties"
+ assert results[1].z_super == 0
+ assert results[1].z_max == 1
+ assert results[1].source == "/Users/user/Library/Application Support/CallHistoryDB/CallHistory.storedata"
+
+ assert results[5].ac_account_type_version is None
+ assert results[5].ns_auto_vacuum_level == 2
+ assert results[5].ns_persistence_framework_version == 1526
+ assert results[5].ns_persistence_maximum_framework_version == 1526
+ assert results[5].ns_store_model_version_checksum_key == "6DaLHrl7O3U+MTsaWar2wVaVZaX9wGEPdMNvdZH8pQo="
+ assert (
+ results[5].ns_store_model_version_hashes_digest
+ == "LEcn8D9uwY2SJgHgh77aZm8/vqfyybIcJvNNEfArEjU5Jsk+HqJ26e3bbK00b2Msn7RuITsNT8uEJbQejQdctA=="
+ )
+ assert results[5].ns_store_model_version_hashes_version == 3
+ assert results[5].ns_store_model_version_identifiers == "['43']"
+ assert results[5].ns_store_type == "SQLite"
+ assert results[5].plist_path == "Z_METADATA/Z_VERSION=1"
+ assert results[5].source == "/Users/user/Library/Application Support/CallHistoryDB/CallHistory.storedata"
+
+ assert results[6].plist_path == "Z_METADATA/Z_VERSION=1/NSStoreModelVersionHashes"
+
+ assert results[6].call_db_properties is not None
+ assert isinstance(results[6].call_db_properties, (bytes, bytearray))
+ assert results[6].call_record is not None
+ assert isinstance(results[6].call_record, (bytes, bytearray))
+ assert results[6].emergency_media_item is not None
+ assert isinstance(results[6].emergency_media_item, (bytes, bytearray))
+ assert results[6].handle is not None
+ assert isinstance(results[6].handle, (bytes, bytearray))
+ assert results[6].source == "/Users/user/Library/Application Support/CallHistoryDB/CallHistory.storedata"
+
+ assert results[7].table == "Z_METADATA"
+ assert results[7].z_version == 1
+ assert results[7].z_uuid == "A3ED16E2-9E8F-45FC-A3EA-1ADAEF54C44A"
+ assert results[7].source == "/Users/user/Library/Application Support/CallHistoryDB/CallHistory.storedata"
+
+ assert results[8].table == "Z_MODELCACHE"
+ assert results[8].source == "/Users/user/Library/Application Support/CallHistoryDB/CallHistory.storedata"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_code_signature_coderesources.py b/tests/plugins/os/unix/bsd/darwin/macos/test_code_signature_coderesources.py
new file mode 100644
index 0000000000..62360686cf
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_code_signature_coderesources.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.code_signature_coderesources import (
+ CodeSignatureCodeResourcesPlugin,
+)
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ ("names", "paths"),
+ [
+ (
+ (
+ "Host",
+ "FileProvider",
+ "Ethernet",
+ "EndpointSecurity",
+ "MobileDeviceUpdater",
+ "AudioDMAController_T8140",
+ ),
+ (
+ "/System/Library/Extensions/AppleUSBHostS5L8930X.kext/Contents/_CodeSignature/CodeResources",
+ "/System/Library/CoreServices/FileProvider-Feedback.app/Contents/_CodeSignature/CodeResources",
+ "/System/Library/Extensions/AppleUSBEthernet.kext/Contents/_CodeSignature/CodeResources",
+ "/System/Library/Extensions/EndpointSecurity.kext/Contents/_CodeSignature/CodeResources",
+ "/System/Library/PrivateFrameworks/MobileDevice.framework/Versions/A/Resources/MobileDeviceUpdater.app/Contents/_CodeSignature/CodeResources",
+ "/System/Library/Extensions/AudioDMAController_T8140.kext/Contents/_CodeSignature/CodeResources",
+ ),
+ )
+ ],
+)
+def test_code_signature_coderesources(
+ names: tuple[str, ...], paths: tuple[str, ...], target_unix: Target, fs_unix: VirtualFilesystem
+) -> None:
+ for name, path in zip(names, paths, strict=True):
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/code_signature/{name}")
+ fs_unix.map_file(f"{path}", data_file)
+
+ target_unix.add_plugin(CodeSignatureCodeResourcesPlugin)
+
+ results = list(target_unix.code_signature_coderesources())
+ results.sort(key=lambda r: (r.source, getattr(r, "plist_path", "")))
+
+ assert len(results) == 294
+
+ assert results[0].omit
+ assert results[0].weight == 20.0
+ assert results[0].plist_path == "rules/^.*"
+ assert (
+ results[0].source
+ == "/System/Library/CoreServices/FileProvider-Feedback.app/Contents/_CodeSignature/CodeResources"
+ )
+
+ assert results[3].cdhash == "\x18\udc8f\x03\x1f\udcb2f(\udcafD.F\udcdbK\udcc05\udc91B\x1f\x06\udca9"
+ assert results[3].requirement == 'identifier "com.apple.AppleUSBEthernet_kasan" and anchor apple'
+ assert results[3].plist_path == "files2/macOS/AppleUSBEthernet_kasan"
+ assert results[3].source == "/System/Library/Extensions/AppleUSBEthernet.kext/Contents/_CodeSignature/CodeResources"
+
+ assert results[7].cdhash == "B\udcd5uȈ\udcf1K\x03qd\udce6\udcf2T4L\udc95\udcc4\udce0\x06\udc9f"
+ assert results[7].requirement == 'identifier "com.apple.AppleUSBHostS5L8930X_kasan" and anchor apple'
+ assert results[7].plist_path == "files2/macOS/AppleUSBHostS5L8930X_kasan"
+ assert (
+ results[7].source
+ == "/System/Library/Extensions/AppleUSBHostS5L8930X.kext/Contents/_CodeSignature/CodeResources"
+ )
+
+ assert results[13].nested
+ assert results[13].weight == 0.0
+ assert (
+ results[13].plist_path
+ == "rules2/^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/" # noqa E501
+ )
+ assert (
+ results[13].source
+ == "/System/Library/Extensions/AudioDMAController_T8140.kext/Contents/_CodeSignature/CodeResources"
+ )
+
+ assert (
+ results[17].hash2
+ == "yϑ\udcc9rl>h%\udcbek\udccf{\udca5E\udc90\udcdf-\x00R\udcac\udcd8\udcc3m.\udce6,(;\udce1v\x00"
+ )
+ assert results[17].optional is None
+ assert results[17].plist_path == "files2/version.plist"
+ assert (
+ results[17].source == "/System/Library/Extensions/EndpointSecurity.kext/Contents/_CodeSignature/CodeResources"
+ )
+
+ assert results[29].optional
+ assert results[29].weight == 1000.0
+ assert results[29].plist_path == "rules2/^Resources/.*\\.lproj/"
+ assert (
+ results[29].source == "/System/Library/Extensions/EndpointSecurity.kext/Contents/_CodeSignature/CodeResources"
+ )
+
+ assert results[153].hash == "\udcd7p\udcdfIJ\udcc2\x0e\udcc9\x161ř{\udca3\udc89\udce7M\udcd7\udcf3Q"
+ assert results[153].optional
+ assert results[153].plist_path == "files/Resources/zh_TW.lproj/MobileDeviceUpdateController.strings"
+ assert (
+ results[153].source
+ == "/System/Library/PrivateFrameworks/MobileDevice.framework/Versions/A/Resources/MobileDeviceUpdater.app/Contents/_CodeSignature/CodeResources" # noqa E501
+ )
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_contents_info.py b/tests/plugins/os/unix/bsd/darwin/macos/test_contents_info.py
new file mode 100644
index 0000000000..110fca8bac
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_contents_info.py
@@ -0,0 +1,100 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.contents_info import ContentsInfoPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ ("names", "paths"),
+ [
+ (
+ (
+ "AppleEventLogHandler.plist",
+ "UnmountAssistantAgent.plist",
+ "AppleHPET.plist",
+ ),
+ (
+ "/System/Library/Extensions/AppleEventLogHandler.kext/Contents/Info.plist",
+ "/System/Library/CoreServices/UnmountAssistantAgent.app/Contents/Info.plist",
+ "/System/Library/Extensions/AppleHPET.kext/Contents/Info.plist",
+ ),
+ )
+ ],
+)
+def test_contents_info(
+ names: tuple[str, ...], paths: tuple[str, ...], target_unix: Target, fs_unix: VirtualFilesystem
+) -> None:
+ for name, path in zip(names, paths, strict=True):
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/contents_info/{name}")
+ fs_unix.map_file(f"{path}", data_file)
+
+ target_unix.add_plugin(ContentsInfoPlugin)
+
+ results = list(target_unix.contents_info())
+ results.sort(key=lambda r: (r.source, getattr(r, "plist_path", "")))
+
+ assert len(results) == 7
+
+ assert results[0].BuildMachineOSBuild == "23A344017"
+ assert results[0].CFBundleDevelopmentRegion == "English"
+ assert results[0].CFBundleExecutable == "UnmountAssistantAgent"
+ assert results[0].CFBundleIdentifier == "com.apple.UnmountAssistantAgent"
+ assert results[0].CFBundleInfoDictionaryVersion == "6.0"
+ assert results[0].CFBundleName == "UnmountAssistantAgent"
+ assert results[0].CFBundlePackageType == "APPL"
+ assert results[0].CFBundleShortVersionString == "5.0"
+ assert results[0].CFBundleSignature == "????"
+ assert results[0].CFBundleSupportedPlatforms == ["macOSX"]
+ assert results[0].CFBundleVersion == "5.0"
+ assert results[0].DTCompiler == "com.apple.compilers.llvm.clang.1_0"
+ assert results[0].DTPlatformBuild == ""
+ assert results[0].DTPlatformName == "macosx"
+ assert results[0].DTPlatformVersion == "26.4"
+ assert results[0].DTSDKBuild == "25E222"
+ assert results[0].DTSDKName == "macosx26.4.internal"
+ assert results[0].DTXcode == "2630"
+ assert results[0].DTXcodeBuild == "17E6107"
+ assert results[0].LSMinimumSystemVersion == "26.4"
+ assert results[0].LSUIElement
+ assert results[0].NSPrincipalClass == "NSApplication"
+ assert results[0].source == "/System/Library/CoreServices/UnmountAssistantAgent.app/Contents/Info.plist"
+
+ assert results[4].BuildMachineOSBuild == "23A344017"
+ assert results[4].CFBundleDevelopmentRegion == "English"
+ assert results[4].CFBundleExecutable == "AppleHPET"
+ assert results[4].CFBundleIdentifier == "com.apple.driver.AppleHPET"
+ assert results[4].CFBundleInfoDictionaryVersion == "6.0"
+ assert results[4].CFBundleName == "High Precision Event Timer Driver"
+ assert results[4].CFBundlePackageType == "KEXT"
+ assert results[4].CFBundleShortVersionString == "1.8"
+ assert results[4].CFBundleSignature == "????"
+ assert results[4].CFBundleSupportedPlatforms == ["macOSX"]
+ assert results[4].CFBundleVersion == "1.8"
+ assert results[4].DTCompiler == "com.apple.compilers.llvm.clang.1_0"
+ assert results[4].DTPlatformBuild == "25E245"
+ assert results[4].DTPlatformName == "macosx"
+ assert results[4].DTPlatformVersion == "26.4"
+ assert results[4].DTSDKBuild == "25E245"
+ assert results[4].DTSDKName == "macosx26.4.internal"
+ assert results[4].DTXcode == "2630"
+ assert results[4].DTXcodeBuild == "17E6107"
+ assert results[4].LSMinimumSystemVersion == "26.4"
+ assert results[4].NSHumanReadableCopyright == ("Copyright © 2005-2012 Apple Inc. All rights reserved.")
+ assert results[4].OSBundleRequired == "Root"
+ assert results[4].source == "/System/Library/Extensions/AppleHPET.kext/Contents/Info.plist"
+
+ assert results[6].com_apple_iokit_IOACPIFamily == "1.1.0"
+ assert results[6].com_apple_kpi_iokit == "9.0.0"
+ assert results[6].com_apple_kpi_libkern == "9.0.0"
+ assert results[6].com_apple_kpi_mach == "9.0.0"
+ assert results[6].com_apple_kpi_unsupported == "9.0.0"
+ assert results[6].plist_path == "OSBundleLibraries"
+ assert results[6].source == "/System/Library/Extensions/AppleHPET.kext/Contents/Info.plist"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_contents_version.py b/tests/plugins/os/unix/bsd/darwin/macos/test_contents_version.py
new file mode 100644
index 0000000000..e6dbba0f04
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_contents_version.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.contents_version import (
+ ContentsVersionPlugin,
+)
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ ("names", "paths"),
+ [
+ (
+ (
+ "UniversalControl.plist",
+ "IOBluetoothUI.plist",
+ "SwiftUICore.plist",
+ ),
+ (
+ "/System/Library/CoreServices/UniversalControl.app/Contents/version.plist",
+ "/System/Library/Frameworks/IOBluetoothUI.framework/Versions/A/Resources/version.plist",
+ "/System/Library/Frameworks/SwiftUICore.framework/Versions/A/Resources/version.plist",
+ ),
+ )
+ ],
+)
+def test_contents_version(
+ names: tuple[str, ...], paths: tuple[str, ...], target_unix: Target, fs_unix: VirtualFilesystem
+) -> None:
+ for name, path in zip(names, paths, strict=True):
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/contents_version/{name}")
+ fs_unix.map_file(path, data_file)
+
+ target_unix.add_plugin(ContentsVersionPlugin)
+
+ results = list(target_unix.contents_version())
+ results.sort(key=lambda r: r.source)
+
+ assert len(results) == 3
+
+ assert results[0].build_alias_of == "Ensemble"
+ assert results[0].build_version == 1983
+ assert results[0].cf_bundle_short_version_string == "1.0"
+ assert results[0].cf_bundle_version == "174.4.1"
+ assert results[0].project_name == "Ensemble_executables"
+ assert results[0].source_version == 174004001000000
+ assert results[0].source == "/System/Library/CoreServices/UniversalControl.app/Contents/version.plist"
+
+ assert results[1].build_version == 100
+ assert results[1].cf_bundle_short_version_string == "1.0"
+ assert results[1].cf_bundle_version == "1"
+ assert results[1].project_name == "MobileBluetooth"
+ assert results[1].source_version == 194026001000001
+ assert results[1].source == "/System/Library/Frameworks/IOBluetoothUI.framework/Versions/A/Resources/version.plist"
+
+ assert results[2].build_alias_of == "SwiftUI"
+ assert results[2].build_version == 12
+ assert results[2].cf_bundle_short_version_string == "7.4.27"
+ assert results[2].cf_bundle_version == "7.4.27"
+ assert results[2].project_name == "SwiftUICore"
+ assert results[2].source_version == 7004027000000
+ assert results[2].source == "/System/Library/Frameworks/SwiftUICore.framework/Versions/A/Resources/version.plist"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_directory_services_local_nodes.py b/tests/plugins/os/unix/bsd/darwin/macos/test_directory_services_local_nodes.py
new file mode 100644
index 0000000000..5326e66f0c
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_directory_services_local_nodes.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.directory_services_local_nodes import (
+ DirectoryServicesLocalNodesPlugin,
+)
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_files",
+ [
+ [
+ "sqlindex",
+ "sqlindex-wal",
+ ]
+ ],
+)
+def test_directory_services_local_nodes(test_files: list[str], target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ tz = timezone.utc
+ for test_file in test_files:
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/directory_services_local_nodes/{test_file}")
+ fs_unix.map_file(f"/var/db/dslocal/nodes/Default/{test_file}", data_file)
+
+ target_unix.add_plugin(DirectoryServicesLocalNodesPlugin)
+
+ results = list(target_unix.directory_services_local_nodes())
+
+ assert len(results) == 1452
+
+ results.sort(key=lambda r: r.tables)
+
+ assert results[0].tables == ["generateduid", "rec:groups"]
+ assert results[0].filetime == datetime(2026, 3, 20, 4, 25, 57, tzinfo=tz)
+ assert results[0].filename == "_appowner.plist"
+ assert results[0].recordtype == "groups"
+ assert results[0].value == "ABCDEFAB-CDEF-ABCD-EFAB-CDEF00000057"
+ assert results[0].source == "/var/db/dslocal/nodes/Default/sqlindex"
+
+ assert results[-69].tables == ["users"]
+ assert results[-69].filetime is None
+ assert results[-69].filename == "_appserveradm.plist"
+ assert results[-69].recordtype == "groups"
+ assert results[-69].value == "_mbsetupuser"
+ assert results[-69].source == "/var/db/dslocal/nodes/Default/sqlindex"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_duet_activity_scheduler.py b/tests/plugins/os/unix/bsd/darwin/macos/test_duet_activity_scheduler.py
new file mode 100644
index 0000000000..70bf8f9ee5
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_duet_activity_scheduler.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.duet_activity_scheduler import DuetActivitySchedulerPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_files",
+ [
+ [
+ "DuetActivitySchedulerClassC.db",
+ "DuetActivitySchedulerClassC.db-wal",
+ ]
+ ],
+)
+def test_duet_activity_scheduler(test_files: list[str], target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ for test_file in test_files:
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/duet_activity_scheduler/{test_file}")
+ fs_unix.map_file(f"/var/db/DuetActivityScheduler/{test_file}", data_file)
+
+ target_unix.add_plugin(DuetActivitySchedulerPlugin)
+
+ results = list(target_unix.duet_activity_scheduler())
+
+ assert len(results) == 55
+
+ assert results[0].table == "ZGROUP"
+ assert results[0].z_pk == 1
+ assert results[0].z_ent == 2
+ assert results[0].z_opt == 1
+ assert results[0].z_max_concurrent == 6
+ assert results[0].z_name == "com.apple.dasd.defaultNetwork"
+ assert results[0].source == "/var/db/DuetActivityScheduler/DuetActivitySchedulerClassC.db"
+
+ assert results[48].table == "Z_PRIMARYKEY"
+ assert results[48].z_ent == 1
+ assert results[48].z_name == "Activity"
+ assert results[48].z_super == 0
+ assert results[48].z_max == 0
+ assert results[48].source == "/var/db/DuetActivityScheduler/DuetActivitySchedulerClassC.db"
+
+ assert results[51].ns_persistence_maximum_framework_version == 1526
+ assert results[51].ns_store_model_version_identifiers == [""]
+ assert results[51].ns_store_type == "SQLite"
+ assert results[51].ns_auto_vacuum_level == 2
+ assert (
+ results[51].ns_store_model_version_hashes_digest
+ == "rvkNkhmOezVbzsczB2H+gkUsiGN7C2d7a9TtXbZPD0kn0MZYSVEGM64BycQewlVstp1ROUAOBjEmkbNTkiu6JA=="
+ )
+ assert results[51].ns_store_model_version_checksum_key == "rXdwmenydb+cl65S3tSy9rIL6lkwSXqL7UvaJVK21Lc="
+ assert results[51].ns_persistence_framework_version == 1526
+ assert results[51].ns_store_model_version_hashes_version == 3
+ assert results[51].plist_path == "Z_METADATA/Z_VERSION=1"
+ assert results[51].source == "/var/db/DuetActivityScheduler/DuetActivitySchedulerClassC.db"
+
+ assert results[52].activity is not None
+ assert isinstance(results[52].activity, (bytes, bytearray))
+ assert results[52].group_hash is not None
+ assert isinstance(results[52].group_hash, (bytes, bytearray))
+ assert results[52].trigger is not None
+ assert isinstance(results[52].trigger, (bytes, bytearray))
+ assert results[52].plist_path == "Z_METADATA/Z_VERSION=1/NSStoreModelVersionHashes"
+ assert results[52].source == "/var/db/DuetActivityScheduler/DuetActivitySchedulerClassC.db"
+
+ assert results[53].table == "Z_METADATA"
+ assert results[53].z_version == 1
+ assert results[53].z_uuid == "514A8E5F-DE48-4C3E-9129-3AF14DEAD0E1"
+ assert results[53].source == "/var/db/DuetActivityScheduler/DuetActivitySchedulerClassC.db"
+
+ assert results[54].table == "Z_MODELCACHE"
+ assert results[54].source == "/var/db/DuetActivityScheduler/DuetActivitySchedulerClassC.db"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_duet_interaction_c.py b/tests/plugins/os/unix/bsd/darwin/macos/test_duet_interaction_c.py
new file mode 100644
index 0000000000..e5a3585ca7
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_duet_interaction_c.py
@@ -0,0 +1,107 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.duet_interaction_c import DuetInteractionCPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_files",
+ [
+ [
+ "interactionC.db",
+ "interactionC.db-wal",
+ ]
+ ],
+)
+def test_duet_activity_scheduler(test_files: list[str], target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ for test_file in test_files:
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/duet_interaction_c/{test_file}")
+ fs_unix.map_file(f"/var/db/CoreDuet/People/{test_file}", data_file)
+
+ target_unix.add_plugin(DuetInteractionCPlugin)
+
+ results = list(target_unix.duet_interaction_c())
+
+ assert len(results) == 12
+
+ assert results[0].table == "ZMETADATA"
+ assert results[0].z_pk == 1
+ assert results[0].z_ent == 5
+ assert results[0].z_opt == 1
+ assert results[0].z_key == "migrateIMessageDomainIdentifiers"
+ assert results[0].z_value == "True"
+ assert results[0].source == "/var/db/CoreDuet/People/interactionC.db"
+
+ assert results[1].table == "ZVERSION"
+ assert results[1].z_pk == 1
+ assert results[1].z_ent == 6
+ assert results[1].z_opt == 1
+ assert results[1].z_number == 1
+ assert results[1].z_creation_date == datetime(2026, 5, 4, 11, 23, 21, 944464, tzinfo=timezone.utc)
+ assert results[1].z_key == "store_version"
+ assert results[1].source == "/var/db/CoreDuet/People/interactionC.db"
+
+ assert results[2].table == "Z_PRIMARYKEY"
+ assert results[2].z_ent == 1
+ assert results[2].z_name == "Attachment"
+ assert results[2].z_super == 0
+ assert results[2].z_max == 0
+ assert results[2].source == "/var/db/CoreDuet/People/interactionC.db"
+
+ assert results[8].ns_persistence_maximum_framework_version == 1526
+ assert results[8].ns_store_model_version_identifiers == ["15"]
+ assert results[8].ns_store_type == "SQLite"
+ assert results[8].ns_auto_vacuum_level == 2
+ assert (
+ results[8].ns_store_model_version_hashes_digest
+ == "xtIOAHN/XyhlY4uahAH4diGw8uqBPUzu3nHg0qTa308d9X2+IXWH5fIYFrZ8DkWCDbXQ46RPaENynVIpxse4Jw=="
+ )
+ assert results[8].ns_store_model_version_checksum_key == "yBhxwKvskbIdxbJOzzLgxhbLYTjrWz9otOnAd9BgKA0="
+ assert results[8].ns_persistence_framework_version == 1526
+ assert results[8].ns_store_model_version_hashes_version == 3
+ assert results[8].plist_path == "Z_METADATA/Z_VERSION=1"
+ assert results[8].source == "/var/db/CoreDuet/People/interactionC.db"
+
+ assert (
+ results[9].attachment_hash
+ == b"%6\xe0\xe5\x85u\x91Wf\xc7\xe8\xb8y\xe6\xfdf\x1a\xb9\x17t\x81h&e\xdc\xa9\xc3\xe1\xd3\x15\x9e\xe9"
+ )
+ assert (
+ results[9].contacts
+ == b"\x83\x87\x97\xd4\x08\xc9\xadN\xbc\xfa\xa8\xf6\x9e\xb0\x0e\xdf\x12\x96K\xb25\x1af\xef\xaaQ\xf3\x13I\x82\xfa\x94" # noqa E501
+ )
+ assert (
+ results[9].interactions
+ == b"\x92m\xbe\x9d\x12\xf0M\xf3\xa7\xf9(\xbd9\x96Y\xa7e\xff\x1f\x9fE\xa2{\xe1\x03\xd2|\xdb\x12s%\x86"
+ )
+ assert (
+ results[9].keywords_hash
+ == b'\xff/$fi\xef\xb0\x03\x9d\xfd\xc8%>D\x9f\xbd3"\xd39\x84\xff\xe0\xf4\xa1\xe7\xd1\xf8\xcc253'
+ )
+ assert (
+ results[9].metadata
+ == b"\x93\\\xfe\x85\x91lG\xd1\x83lc\xde\xdeCO\x92G\xd0/\x8c\xa1t\xe0)y\x1d\xd1\x86M0\xdf\xc4"
+ )
+ assert (
+ results[9].version_hash
+ == b"\x94\x07\xac\x82%\x9f22\x9c\x162\xe9\xc5\xdb7\xb9\x1e\xf8\x8c(\x8e\xb1\xd7JT\xfd*\xe0\xad\x7f5\x01"
+ )
+ assert results[9].plist_path == "Z_METADATA/Z_VERSION=1/NSStoreModelVersionHashes"
+ assert results[9].source == "/var/db/CoreDuet/People/interactionC.db"
+
+ assert results[10].table == "Z_METADATA"
+ assert results[10].z_version == 1
+ assert results[10].z_uuid == "DD35E9BD-94E0-4016-887B-B91BFA9FCF84"
+ assert results[10].source == "/var/db/CoreDuet/People/interactionC.db"
+
+ assert results[11].table == "Z_MODELCACHE"
+ assert results[11].source == "/var/db/CoreDuet/People/interactionC.db"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_duet_knowledge_c.py b/tests/plugins/os/unix/bsd/darwin/macos/test_duet_knowledge_c.py
new file mode 100644
index 0000000000..be350948b4
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_duet_knowledge_c.py
@@ -0,0 +1,229 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.helpers.record import UnixUserRecord
+from dissect.target.plugins.os.unix.bsd.darwin.macos.duet_knowledge_c import DuetKnowledgeCPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ ("names", "paths"),
+ [
+ (
+ (
+ "Main.db",
+ "Main.db-wal",
+ "User.db",
+ "User.db-wal",
+ ),
+ (
+ "/var/db/CoreDuet/Knowledge/knowledgeC.db",
+ "/var/db/CoreDuet/Knowledge/knowledgeC.db-wal",
+ "Users/user/Library/Application Support/Knowledge/knowledgeC.db",
+ "Users/user/Library/Application Support/Knowledge/knowledgeC.db-wal",
+ ),
+ ),
+ ],
+)
+def test_duet_knowledge_c(
+ names: tuple[str, ...],
+ paths: tuple[str, ...],
+ target_unix: Target,
+ fs_unix: VirtualFilesystem,
+) -> None:
+ user = UnixUserRecord(
+ name="user",
+ uid=501,
+ gid=20,
+ home="/Users/user",
+ shell="/bin/zsh",
+ )
+ target_unix.users = lambda: [
+ user,
+ ]
+ for name, path in zip(names, paths, strict=True):
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/duet_knowledge_c/{name}")
+ fs_unix.map_file(path, data_file)
+
+ target_unix.add_plugin(DuetKnowledgeCPlugin)
+
+ results = list(target_unix.duet_knowledge_c())
+ results.sort(key=lambda r: r.source)
+
+ assert len(results) == 1519
+
+ assert results[0].table == "ZKEYVALUE"
+ assert results[0].z_pk == 1
+ assert results[0].z_ent == 8
+ assert results[0].z_opt == 1
+ assert results[0].z_domain == "_DKKnowledgeStorage"
+ assert results[0].z_key == "_DKDeviceIdentifier"
+ assert results[0].z_value == "0CDFEFCE-422B-5A80-ABA8-FF214731A207"
+ assert results[0].source == "/Users/user/Library/Application Support/Knowledge/knowledgeC.db"
+
+ assert results[4].table == "ZOBJECT"
+ assert results[4].z_pk == 1
+ assert results[4].z_ent == 11
+ assert results[4].z_opt == 1
+ assert results[4].z_uuid_hash == -8894672422482144995
+ assert results[4].z_event is None
+ assert results[4].z_source_fk is None
+ assert results[4].z_category_type is None
+ assert results[4].z_integer_value is None
+ assert results[4].z_compatibility_version == 0
+ assert results[4].z_end_day_of_week == 2
+ assert results[4].z_end_second_of_day == 41368
+ assert not results[4].z_has_custom_metadata
+ assert results[4].z_has_structured_metadata
+ assert results[4].z_seconds_from_gmt == -25200
+ assert results[4].z_should_sync == 0
+ assert results[4].z_start_day_of_week == 2
+ assert results[4].z_start_second_of_day == 41357
+ assert results[4].z_value_class == 1
+ assert results[4].z_value_integer == 8601942810060236944
+ assert results[4].z_value_type_code == 6584185901589580638
+ assert results[4].z_structured_metadata == 1
+ assert results[4].z_value is None
+ assert results[4].z_9_value is None
+ assert results[4].z_identifier_type is None
+ assert results[4].z_quantity_type is None
+ assert results[4].z_creation_date == datetime(2026, 5, 4, 11, 30, 14, 945838, tzinfo=timezone.utc)
+ assert results[4].z_local_creation_date == datetime(2026, 5, 4, 11, 30, 14, 945838, tzinfo=timezone.utc)
+ assert results[4].z_confidence == 1
+ assert results[4].z_end_date == datetime(2026, 5, 4, 11, 29, 28, tzinfo=timezone.utc)
+ assert results[4].z_start_date == datetime(2026, 5, 4, 11, 29, 17, tzinfo=timezone.utc)
+ assert results[4].z_value_double == 8601942810060236800
+ assert results[4].z_double_value is None
+ assert results[4].z_uuid == "69EE812C-4254-4E54-BFB7-3ED3B5BEFB4C"
+ assert results[4].z_stream_name == "/app/usage"
+ assert results[4].z_value_string == "com.apple.SetupAssistant"
+ assert results[4].z_string is None
+ assert results[4].z_metadata is None
+ assert results[4].source == "/Users/user/Library/Application Support/Knowledge/knowledgeC.db"
+
+ assert results[701].table == "ZSOURCE"
+ assert results[701].z_pk == 1
+ assert results[701].z_ent == 14
+ assert results[701].z_opt == 1
+ assert results[701].z_user_id is None
+ assert results[701].z_bundle_id == "com.apple.Spotlight"
+ assert results[701].z_device_id is None
+ assert results[701].z_group_id is None
+ assert results[701].z_intent_id is None
+ assert results[701].z_item_id is None
+ assert results[701].z_source_id is None
+ assert results[701].source == "/Users/user/Library/Application Support/Knowledge/knowledgeC.db"
+
+ assert results[714].table == "ZSTRUCTUREDMETADATA"
+ assert results[714].z_pk == 1
+ assert results[714].z_ent == 15
+ assert results[714].z_opt == 262
+ assert results[714].source == "/Users/user/Library/Application Support/Knowledge/knowledgeC.db"
+
+ assert results[875].ns_persistence_maximum_framework_version == 1526
+ assert results[875].ns_store_model_version_identifiers == ["34"]
+ assert results[875].ns_store_type == "SQLite"
+ assert results[875].ns_auto_vacuum_level == 2
+ assert (
+ results[875].ns_store_model_version_hashes_digest
+ == "3qw03JLBtryClpLQRtPhA43k8KZaw9qHvu+RVzuPBYSEm8++KjboFwDAjFByeXrvryJrBSPo/o4LL6G9pmuo8Q=="
+ )
+ assert results[875].ns_store_model_version_checksum_key == "1nRfv9qJjn86Sz4iC1FpuA6z3NAw1YNPMMABL4ISiEA="
+ assert results[875].ns_persistence_framework_version == 1526
+ assert results[875].ns_store_model_version_hashes_version == 3
+ assert results[875].plist_path == "Z_METADATA/Z_VERSION=1"
+ assert results[875].source == "/Users/user/Library/Application Support/Knowledge/knowledgeC.db"
+
+ assert (
+ results[876].addition_change_set
+ == b"\x01\r!\xe0\xf4\xeb\x12VS\xa7\x9f\xa8\xaaF]m\xaa&*\xf4\x9f\x99\x1a%\x16y{\x98\x9b\x81\x80c"
+ )
+ assert (
+ results[876].category_hash
+ == b"\xfdXs \xdaJ\x1eo\t\x08\x10y\xc5\xa8\x80\xb3\x85$\x9c\xc2\x08\xef\r\x1d?\x96\xad5\xc0\xf3B\xa1"
+ )
+ assert (
+ results[876].contextual_change_registration
+ == b'\x8dg\x9b}\xb0\x7f\xe6sQ\xc9"\xc3>yd;n\xaf.,\xfd\xc8\x8fw\x06M\xb85+\xcd\xc0\xd7'
+ )
+ assert (
+ results[876].contextual_key_path
+ == b"\xd9\x16K\xa993y\xd6\x9bP\x03\xbew\x175\x8b\xa7\x16g@Up@9C\x85'*\x81\xf8\xa9M"
+ )
+ assert (
+ results[876].custom_metadata
+ == b"\xdd\xc4\x92x\xc5#.8\xfa4\x8b\xfe\xc6j\xa9P\xc8\xcbt\xb1\xca[\x06ze\xff\x12P\x14\r\x822"
+ )
+ assert (
+ results[876].deletion_change_set
+ == b"5\xf7X\xdd\xfdw\xaf\xef\x0f\xca\x15\x04\x85\xec\x17\x1d6\xc1H\x8c\x8b\x8e\x92\xa1\x18\xd8\xd2p\xec\xf3cH"
+ )
+ assert (
+ results[876].event
+ == b"\x92\x19\x0f7\xc3\x05q\xbb\x07#[\x18'\xde\x9e\xaf\x8e\x84h'[\xb2\x1bq\xcf\xfe\x88z\x01N\xf0\xf8"
+ )
+ assert (
+ results[876].histogram
+ == b"\xdby\x06D2\xade\x84z:\x8c+!\xb4\xdc\x1d\x02\xb9\x007\x866\x00\xad\x8d\xc4fz\xba\x05\xc7\xdf"
+ )
+ assert (
+ results[876].histogram_value
+ == b"N\x92&F\xacK\xce\xbc\xa1\x9d\xe1\xdf\xba\xa1X|\xe6DO\xebQ\x07\x8c2\xb0C7\xc66\x95}O"
+ )
+ assert (
+ results[876].identifier_hash
+ == b"\xa5\x0b\xd3\x1aJ7W\x05\xacp\xb7\x03\xdd\x85\x84\x04\xc2\xc6bOvy\x1ayZ\xb3Qm5^\x8a\xf8"
+ )
+ assert (
+ results[876].key_value
+ == b"\xf5\xe0z;\xbe\xdf~\xed\x10T4-^\x88\x95\x99\xd4e\xa8AZc\xdf\x0c?\x90 \xfc\x88\x06\x7fE"
+ )
+ assert (
+ results[876].z_object
+ == b"{\xe0\xb9\x12<\xb6\xe9\xfd\x03\x81'P\xce\xdd\x15c\xf7\xbc\x0bI\xb4\xd4\x98\xc2\xfa\x01TK\x8f\x81&u"
+ )
+ assert (
+ results[876].quantity
+ == b'l-&\xc2\xa4\xc6\xc4w\xeb"EC\x8a\xc6\x84\x91\x8fQ\x81\xaf\xfc\x7f\x137\x98\x04o\x89\xf0\x93\xf4\x94'
+ )
+ assert (
+ results[876].z_source
+ == b":`?\xb5\xfa\x1b\xfa\x1dJo\x05h\xf2\x83\x91(D3#\x9c.\xfa\x1d\xd6\xca\xd6\xca'\xa7\xf1\x8b\xb7"
+ )
+ assert (
+ results[876].structured_metadata
+ == b"+$\x00D\x94A\x91\xe8#\x96=\xe6B\x9e6\x8bY\xb4U\x81AD\x85Y\xff\x7fZ\xef\xde2\xa0<"
+ )
+ assert (
+ results[876].sync_peer
+ == b"\xb4q\xdc\xac\x9f\x14\x0c\xc6X\x00\x18i\xce\x80\x11\xfc\x8b\x1c\x85\x95\xf44\x17\x8a\xe1MC\xc3\x05\x809l"
+ )
+ assert results[876].plist_path == "Z_METADATA/Z_VERSION=1/NSStoreModelVersionHashes"
+ assert results[876].source == "/Users/user/Library/Application Support/Knowledge/knowledgeC.db"
+
+ assert results[877].table == "Z_METADATA"
+ assert results[877].z_version == 1
+ assert results[877].z_uuid == "D66E2FEA-1161-43F5-B00C-B877BBDE1A2D"
+ assert results[877].source == "/Users/user/Library/Application Support/Knowledge/knowledgeC.db"
+
+ assert results[878].table == "Z_MODELCACHE"
+ assert results[878].source == "/Users/user/Library/Application Support/Knowledge/knowledgeC.db"
+
+ assert results[879].table == "ZCONTEXTUALCHANGEREGISTRATION"
+ assert results[879].z_pk == 1
+ assert results[879].z_ent == 2
+ assert results[879].z_opt == 22
+ assert results[879].z_is_active == 1
+ assert results[879].z_is_multi_device_registration == 0
+ assert results[879].z_creation_date == datetime(2026, 5, 11, 9, 1, 51, 201296, tzinfo=timezone.utc)
+ assert results[879].z_identifier == "com.apple.das.apppolicy.appchanged"
+ assert results[879].z_properties == "<_CDContextualChangeRegistration>"
+ assert results[879].source == "/var/db/CoreDuet/Knowledge/knowledgeC.db"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_fs_events.py b/tests/plugins/os/unix/bsd/darwin/macos/test_fs_events.py
new file mode 100644
index 0000000000..211ced967a
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_fs_events.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.fs_events import FSEventsPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_file",
+ [
+ "fc0077112a6fa939",
+ ],
+)
+def test_fs_events(test_file: str, target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/{test_file}")
+ fs_unix.map_file(f"System/Volumes/Data/.fseventsd/{test_file}", data_file)
+
+ target_unix.add_plugin(FSEventsPlugin)
+
+ results = list(target_unix.fs_events())
+
+ assert len(results) == 2730
+
+ assert results[0].path == "Library/Caches/com.apple.amsengagementd.classicdatavault"
+ assert results[0].event_id == 18158644613167937534
+ assert results[0].event_flags == ["ItemCreated", "Unknown(0x01000000)"]
+ assert results[0].node_id == 634146
+ assert results[0].source == "/System/Volumes/Data/.fseventsd/fc0077112a6fa939"
+
+ assert results[1].path == "Library/Caches/com.apple.amsengagementd.classicdatavault/analytics/jetpackByteCode"
+ assert results[1].event_id == 18158644613167937776
+ assert results[1].event_flags == ["EventIdsWrapped", "HistoryDone", "Unknown(0x00800000)"]
+ assert results[1].node_id == 1672774
+ assert results[1].source == "/System/Volumes/Data/.fseventsd/fc0077112a6fa939"
+
+ assert results[-1].path == "private/var/root/Library/Preferences/com.apple.xpc.activity2.plist"
+ assert results[-1].event_id == 18158644613167949036
+ assert results[-1].event_flags == ["EventIdsWrapped", "Unknown(0x00800000)"]
+ assert results[-1].node_id == 1663163
+ assert results[-1].source == "/System/Volumes/Data/.fseventsd/fc0077112a6fa939"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_gkopaque.py b/tests/plugins/os/unix/bsd/darwin/macos/test_gkopaque.py
new file mode 100644
index 0000000000..61fe98690d
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_gkopaque.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.gkopaque import GatekeeperOpaqueConfigurationPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_file",
+ [
+ "gkopaque.db",
+ ],
+)
+def test_gkopaque(test_file: str, target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/{test_file}")
+ fs_unix.map_file(f"/var/db/gkopaque.bundle/Contents/Resources/{test_file}", data_file)
+
+ target_unix.add_plugin(GatekeeperOpaqueConfigurationPlugin)
+
+ results = list(target_unix.gkopaque())
+
+ assert len(results) == 74247
+
+ assert results[0].table == "whitelist"
+ assert results[0].current == b"\x00\x03'\xec\xe1\xfbZ'\xb5\xf5\xc5\x1a\x00\x99\x00\xb1\xe4\x85K\xb7"
+ assert results[0].opaque == b"\xcb\xe5k\x97\x84\x97N\n\x1c\x01Y\xc4\x1f9+wB\x1bM#"
+ assert results[0].source == "/var/db/gkopaque.bundle/Contents/Resources/gkopaque.db"
+
+ assert results[-1].table == "conditions"
+ assert results[-1].label == "google chrome (canary)"
+ assert results[-1].weight == 300
+ assert results[-1].conditions_source == "EQHXZ8M8AV"
+ assert results[-1].identifier == "com.google.Chrome.canary"
+ assert results[-1].version is None
+ assert results[-1].conditions == "{errors=[-67013]}"
+ assert results[-1].source == "/var/db/gkopaque.bundle/Contents/Resources/gkopaque.db"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_global_user_preferences.py b/tests/plugins/os/unix/bsd/darwin/macos/test_global_user_preferences.py
new file mode 100644
index 0000000000..2705f6730c
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_global_user_preferences.py
@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.helpers.record import UnixUserRecord
+from dissect.target.plugins.os.unix.bsd.darwin.macos.global_user_preferences import GlobalUserPreferencesPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ ("names", "paths"),
+ [
+ (
+ (
+ "user.plist",
+ "securityagent.plist",
+ "root.plist",
+ ),
+ (
+ "/Users/user/Library/Preferences/.GlobalPreferences.plist",
+ "/private/var/db/securityagent/Library/Preferences/.GlobalPreferences.plist",
+ "/private/var/root/Library/Preferences/.GlobalPreferences.plist",
+ ),
+ ),
+ ],
+)
+def test_global_user_preferences(
+ names: tuple[str, ...],
+ paths: tuple[str, ...],
+ target_unix: Target,
+ fs_unix: VirtualFilesystem,
+) -> None:
+ user = UnixUserRecord(
+ name="user",
+ uid=501,
+ gid=20,
+ home="/Users/user",
+ shell="/bin/zsh",
+ )
+ securityagent = UnixUserRecord(
+ name="securityagent",
+ uid=92,
+ gid=92,
+ home="/private/var/db/securityagent",
+ shell="/usr/bin/false",
+ )
+ root = UnixUserRecord(
+ name="root",
+ uid=0,
+ gid=0,
+ home="/private/var/root",
+ shell="/bin/sh",
+ )
+ target_unix.users = lambda: [
+ user,
+ securityagent,
+ root,
+ ]
+ for name, path in zip(names, paths, strict=True):
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/global_user_preferences/{name}")
+ fs_unix.map_file(path, data_file)
+
+ target_unix.add_plugin(GlobalUserPreferencesPlugin)
+
+ results = list(target_unix.global_user_preferences())
+ results.sort(key=lambda r: r.source)
+
+ assert len(results) == 4
+
+ assert results[0].AKLastLocale == "en_US@rg=nlzzzz"
+ assert results[0].com_apple_sound_beep_flash == 0
+ assert not results[0].AppleMiniaturizeOnDoubleClick
+ assert results[0].NSAutomaticPeriodSubstitutionEnabled
+ assert results[0].NSSpellCheckerDictionaryContainerTransitionComplete
+ assert results[0].com_apple_springing_delay == "0.5"
+ assert results[0].ACDMonthlyAnalyticsLastPosted == "796140692.59226"
+ assert results[0].AKLastIDMSEnvironment == 0
+ assert results[0].NSAutomaticCapitalizationEnabled
+ assert results[0].NSLinguisticDataAssetsRequestTime == "2026-03-25 14:12:53.295950"
+ assert results[0].AppleAntiAliasingThreshold == 4
+ assert results[0].com_apple_springing_enabled
+ assert results[0].AppleLocale == "en_US@rg=nlzzzz"
+ assert results[0].com_apple_trackpad_forceClick
+ assert results[0].NSLinguisticDataAssetsRequestLastInterval == "86400.0"
+ assert results[0].AppleLanguagesSchemaVersion == 5400
+ assert results[0].source == "/Users/user/Library/Preferences/.GlobalPreferences.plist"
+
+ assert results[1].replace == "omw"
+ assert results[1].on == 1
+ assert getattr(results[1], "with") == "On my way!"
+ assert results[1].plist_path == "NSUserDictionaryReplacementItems[0]"
+ assert results[1].source == "/Users/user/Library/Preferences/.GlobalPreferences.plist"
+
+ assert results[2].AppleKeyboardUIMode == 2
+ assert results[2].source == "/private/var/db/securityagent/Library/Preferences/.GlobalPreferences.plist"
+
+ assert results[3].AppleLocale == "en_US"
+ assert results[3].AppleKeyboardUIMode == 3
+ assert results[3].com_apple_sound_beep_flash == 0
+ assert results[3].source == "/private/var/root/Library/Preferences/.GlobalPreferences.plist"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_groups.py b/tests/plugins/os/unix/bsd/darwin/macos/test_groups.py
new file mode 100644
index 0000000000..9ddadfa42d
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_groups.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.groups import GroupPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_files",
+ [
+ ["nobody.plist", "_eligibilityd.plist", "_applepay.plist"],
+ ],
+)
+def test_groups(test_files: list[str], target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ for test_file in test_files:
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/groups/{test_file}")
+ fs_unix.map_file(f"/var/db/dslocal/nodes/Default/groups/{test_file}", data_file)
+
+ target_unix.add_plugin(GroupPlugin)
+
+ results = list(target_unix.groups())
+ results.sort(key=lambda r: r.realname)
+ assert len(results) == 3
+
+ assert results[0].generateduid == ["ABCDEFAB-CDEF-ABCD-EFAB-CDEFFFFFFFFE"]
+ assert results[0].members == []
+ assert results[0].smb_sid == ["S-1-0-0"]
+ assert results[0].gid == [-2]
+ assert results[0].name == ["nobody", "BUILTIN\\Nobody"]
+ assert results[0].realname == ["Nobody"]
+ assert results[0].source == "/var/db/dslocal/nodes/Default/groups/nobody.plist"
+
+ assert results[1].generateduid == ["ABCDEFAB-CDEF-ABCD-EFAB-CDEF00000129"]
+ assert results[1].members == ["_eligibilityd"]
+ assert results[1].smb_sid == []
+ assert results[1].gid == [297]
+ assert results[1].name == ["_eligibilityd"]
+ assert results[1].realname == ["OS Eligibility Daemon"]
+ assert results[1].source == "/var/db/dslocal/nodes/Default/groups/_eligibilityd.plist"
+
+ assert results[2].generateduid == ["ABCDEFAB-CDEF-ABCD-EFAB-CDEF00000104"]
+ assert results[2].members == []
+ assert results[2].smb_sid == []
+ assert results[2].gid == [260]
+ assert results[2].name == ["_applepay"]
+ assert results[2].realname == ["applepay Daemon"]
+ assert results[2].source == "/var/db/dslocal/nodes/Default/groups/_applepay.plist"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_identity_services.py b/tests/plugins/os/unix/bsd/darwin/macos/test_identity_services.py
new file mode 100644
index 0000000000..0de0c04320
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_identity_services.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.helpers.record import UnixUserRecord
+from dissect.target.plugins.os.unix.bsd.darwin.macos.identity_services import IdentityServicesPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_file",
+ [
+ "ids.db",
+ ],
+)
+def test_identity_services(test_file: str, target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ user = UnixUserRecord(
+ name="user",
+ uid=501,
+ gid=20,
+ home="/Users/user",
+ shell="/bin/zsh",
+ )
+ target_unix.users = lambda: [
+ user,
+ ]
+
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/{test_file}")
+ fs_unix.map_file(f"Users/user/Library/IdentityServices/{test_file}", data_file)
+
+ target_unix.add_plugin(IdentityServicesPlugin)
+
+ results = list(target_unix.identity_services())
+
+ assert len(results) == 2
+
+ assert results[0].table == "_SqliteDatabaseProperties"
+ assert results[0].key == "_ClientVersion"
+ assert results[0].value == "10027"
+ assert results[0].source == "/Users/user/Library/IdentityServices/ids.db"
+
+ assert results[1].table == "_SqliteDatabaseProperties"
+ assert results[1].key == "InternalMigration"
+ assert results[1].value == "100"
+ assert results[1].source == "/Users/user/Library/IdentityServices/ids.db"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_installation_history.py b/tests/plugins/os/unix/bsd/darwin/macos/test_installation_history.py
new file mode 100644
index 0000000000..2dad7ca294
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_installation_history.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.installation_history import InstallationHistoryPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_file",
+ [
+ "InstallHistory.plist",
+ ],
+)
+def test_aiport_preferences(test_file: str, target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ tz = timezone.utc
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/{test_file}")
+ fs_unix.map_file(f"/Library/Receipts/{test_file}", data_file)
+
+ target_unix.add_plugin(InstallationHistoryPlugin)
+
+ results = list(target_unix.installation_history())
+ assert len(results) == 1
+
+ assert results[0].ts == datetime(2026, 3, 25, 14, 7, 11, tzinfo=tz)
+ assert results[0].display_name == "macOS 26.4"
+ assert results[0].display_version == "26.4"
+ assert results[0].process_name == "softwareupdated"
+ assert results[0].source == "/Library/Receipts/InstallHistory.plist"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_keyboard_layout.py b/tests/plugins/os/unix/bsd/darwin/macos/test_keyboard_layout.py
new file mode 100644
index 0000000000..fd9380cb16
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_keyboard_layout.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.keyboard_layout import KeyboardLayoutPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_file",
+ [
+ "com.apple.HIToolbox.plist",
+ ],
+)
+def test_keyboard_layout(test_file: str, target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/{test_file}")
+ fs_unix.map_file(f"/Library/Preferences/{test_file}", data_file)
+
+ target_unix.add_plugin(KeyboardLayoutPlugin)
+
+ results = list(target_unix.keyboard_layout())
+ assert len(results) == 2
+
+ assert results[0].input_source_kind == "Keyboard Layout"
+ assert results[0].keyboard_layout_name == "U.S."
+ assert results[0].keyboard_layout_id == 0
+ assert results[0].enabled_layout
+ assert results[0].selected_layout
+ assert not results[0].current_layout
+ assert results[0].source == "/Library/Preferences/com.apple.HIToolbox.plist"
+
+ assert results[1].input_source_kind == "Keyboard Layout"
+ assert results[1].keyboard_layout_name == "Dutch"
+ assert results[1].keyboard_layout_id == 26
+ assert results[1].enabled_layout
+ assert not results[1].selected_layout
+ assert not results[1].current_layout
+ assert results[1].source == "/Library/Preferences/com.apple.HIToolbox.plist"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_keychain.py b/tests/plugins/os/unix/bsd/darwin/macos/test_keychain.py
new file mode 100644
index 0000000000..a52edc9074
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_keychain.py
@@ -0,0 +1,190 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.helpers.record import UnixUserRecord
+from dissect.target.plugins.os.unix.bsd.darwin.macos.keychain import KeychainPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_file",
+ [
+ "keychain-2.db",
+ ],
+)
+def test_keychain(test_file: str, target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ user = UnixUserRecord(
+ name="user",
+ uid=501,
+ gid=20,
+ home="/Users/user",
+ shell="/bin/zsh",
+ )
+ target_unix.users = lambda: [
+ user,
+ ]
+
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/{test_file}")
+ fs_unix.map_file(f"Users/user/Library/Keychains/E253F552-3A40-5010-9ACE-98662C9CFE20/{test_file}", data_file)
+
+ target_unix.add_plugin(KeychainPlugin)
+
+ results = list(target_unix.keychain())
+
+ assert len(results) == 100
+
+ assert results[0].table == "genp"
+ assert results[0].row_id == 1
+ assert results[0].cdat == datetime(2026, 3, 25, 14, 11, 26, 182470, tzinfo=timezone.utc)
+ assert results[0].mdat == datetime(2026, 3, 25, 14, 11, 26, 182470, tzinfo=timezone.utc)
+ assert results[0].desc is None
+ assert results[0].icmt is None
+ assert results[0].crtr is None
+ assert results[0].keychain_type is None
+ assert results[0].scrp is None
+ assert results[0].labl is None
+ assert results[0].alis is None
+ assert results[0].invi is None
+ assert results[0].nega is None
+ assert results[0].cusi is None
+ assert results[0].prot is None
+ assert results[0].acct == b"T\xccQt\xcfq\x04qd\xc4\xd1\x94\xb9^\xc6S\x95\xa1\xc5\xdd"
+ assert results[0].svce == b"\x84f\xd7\x7f7Qw)\x16>\x8c>}\x38\\z\xa7\tQ\x04"
+ assert results[0].gena is None
+ assert results[0].data is not None
+ assert results[0].agrp == "com.apple.security.indirect-unlock-key"
+ assert results[0].pdmn == "ak"
+ assert results[0].sync == 0
+ assert results[0].tomb == 0
+ assert results[0].sha1 == "\udc9b\udce4|7],\udc88\udcfc\udcfa\udce7\x05\udcfe\udcb0\udcf2<\udce6\udca2\udcf2`\udc99"
+ assert results[0].vwht == ""
+ assert results[0].tkid == ""
+ assert results[0].musr == ""
+ assert results[0].UUID == "FB64E7EF-96A7-4797-9883-E084735D7AC1"
+ assert results[0].sysb is None
+ assert results[0].pcss is None
+ assert results[0].pcsk is None
+ assert results[0].pcsi is None
+ assert results[0].persistref == b"\x9c\xf0v\x86\xd2\x93E\x1b\xb4G8\xa0V\xfc\xfd\xf5"
+ assert results[0].clip == 0
+ assert results[0].ggrp == ""
+ assert results[0].source == "/Users/user/Library/Keychains/E253F552-3A40-5010-9ACE-98662C9CFE20/keychain-2.db"
+
+ assert results[84].table == "sqlite_sequence"
+ assert results[84].name == "tversion"
+ assert results[84].seq == 1
+ assert results[84].source == "/Users/user/Library/Keychains/E253F552-3A40-5010-9ACE-98662C9CFE20/keychain-2.db"
+
+ assert results[88].table == "inet"
+ assert results[88].row_id == 1
+ assert results[88].cdat == datetime(2026, 3, 25, 14, 11, 26, 218230, tzinfo=timezone.utc)
+ assert results[88].mdat == datetime(2026, 3, 25, 14, 11, 26, 218230, tzinfo=timezone.utc)
+ assert results[88].desc == b"-\x9c\xd9\x85\x03\x1cG\x93\x81\x8e\xd6\xc9\xc6n\x91\xb7\xf9\x0b \xf6"
+ assert results[88].icmt is None
+ assert results[88].crtr is None
+ assert results[88].keychain_type is None
+ assert results[88].scrp is None
+ assert results[88].labl is None
+ assert results[88].alis is None
+ assert results[88].invi == 1
+ assert results[88].nega is None
+ assert results[88].cusi is None
+ assert results[88].prot is None
+ assert results[88].acct == b"\xd1*:s\x143|\xe6\x15nz\xe6\x06\xce\x1bs@\xe2\x87\x01"
+ assert results[88].sdmn == b"\xda9\xa3\xee^kK\r2U\xbf\xef\x95`\x18\x90\xaf\xd8\x07\t"
+ assert results[88].srvr == b"\xd1*:s\x143|\xe6\x15nz\xe6\x06\xce\x1bs@\xe2\x87\x01"
+ assert results[88].ptcl == "0"
+ assert results[88].atyp == b"\xda9\xa3\xee^kK\r2U\xbf\xef\x95`\x18\x90\xaf\xd8\x07\t"
+ assert results[88].port == 0
+ assert results[88].path_binary == b"\xec\xa4\xb9~\x03\x05\r\x96\x7fzl\xc4\xe0\xbb)\xbb\xaeV,\xa3"
+ assert results[88].data is not None
+ assert results[88].agrp == "com.apple.security.octagon"
+ assert results[88].pdmn == "cku"
+ assert results[88].sync == 0
+ assert results[88].tomb == 0
+ assert results[88].sha1 == "\udc8bB\x1dn\udcb4B\udcf9*\udcfdJ\udcc5\udcee\udce6ؤQ\udcae\udc97\udcbf\udcc2"
+ assert results[88].vwht == ""
+ assert results[88].tkid == ""
+ assert results[88].musr == ""
+ assert results[88].UUID == "7A4DAD90-B283-47BC-9AE4-1D237C3493D8"
+ assert results[88].sysb == 1
+ assert results[88].pcss is None
+ assert results[88].pcsk is None
+ assert results[88].pcsi is None
+ assert results[88].persistref == b"TZ\x99|\xfd\xd2H\xd2\xad\xf3\x04L\xb3\xfcq\x9e"
+ assert results[88].clip == 0
+ assert results[88].ggrp == ""
+ assert results[88].source == "/Users/user/Library/Keychains/E253F552-3A40-5010-9ACE-98662C9CFE20/keychain-2.db"
+
+ assert results[89].table == "keys"
+ assert results[89].row_id == 1
+ assert results[89].cdat == datetime(2026, 3, 25, 14, 11, 27, 254737, tzinfo=timezone.utc)
+ assert results[89].mdat == datetime(2026, 3, 25, 14, 11, 27, 254737, tzinfo=timezone.utc)
+ assert results[89].kcls == b"\x90i\xcax\xe7E\n(QsC\x1b>R\xc5\xc2R\x99\xe4s"
+ assert results[89].labl is None
+ assert results[89].alis is None
+ assert results[89].perm is None
+ assert results[89].priv is None
+ assert results[89].modi is None
+ assert results[89].klbl == b"com.apple.routined.security.database"
+ assert results[89].atag == b"\xda9\xa3\xee^kK\r2U\xbf\xef\x95`\x18\x90\xaf\xd8\x07\t"
+ assert results[89].crtr == 0
+ assert results[89].keychain_type == 0
+ assert results[89].bsiz == 0
+ assert results[89].esiz == 0
+ assert results[89].sdat == 0.0
+ assert results[89].edat == 0.0
+ assert results[89].sens is None
+ assert results[89].asen is None
+ assert results[89].extr is None
+ assert results[89].next is None
+ assert results[89].encr is None
+ assert results[89].decr is None
+ assert results[89].drve is None
+ assert results[89].sign is None
+ assert results[89].vrfy is None
+ assert results[89].snrc is None
+ assert results[89].vyrc is None
+ assert results[89].wrap is None
+ assert results[89].unwp is None
+ assert results[89].data is not None
+ assert results[89].agrp == "com.apple.routined"
+ assert results[89].pdmn == "ck"
+ assert results[89].sync == 1
+ assert results[89].tomb == 0
+ assert results[89].sha1 == "\udc9cc\udc98\udcd8\udcea0\udcb12bFL\udca4ot87\udcc7\udcf4\x7f\udc82"
+ assert results[89].vwht == ""
+ assert results[89].tkid == ""
+ assert results[89].musr == ""
+ assert results[89].UUID == "22A9A2ED-79C9-4665-80F5-021793509144"
+ assert results[89].sysb is None
+ assert results[89].pcss is None
+ assert results[89].pcsk is None
+ assert results[89].pcsi is None
+ assert results[89].persistref == b"\xaf\x1e\xaaxqDG\xae\x81\xdb\xb1\xce\x9c\xe9C\xb9"
+ assert results[89].clip == 0
+ assert results[89].ggrp == ""
+ assert results[89].source == "/Users/user/Library/Keychains/E253F552-3A40-5010-9ACE-98662C9CFE20/keychain-2.db"
+
+ assert results[94].table == "tversion"
+ assert results[94].row_id == 1
+ assert results[94].version == "12"
+ assert results[94].minor == 12
+ assert results[94].source == "/Users/user/Library/Keychains/E253F552-3A40-5010-9ACE-98662C9CFE20/keychain-2.db"
+
+ assert results[95].table == "metadatakeys"
+ assert results[95].keyclass == 6
+ assert results[95].actual_keyclass == 6
+ assert (
+ results[95].data
+ == "r\udcccߨ\udcfdeѽi\udcb5\x16\udcd5+\udcf7\udcfa\udca0\udce4\udce7\x13+\udc9d\udc8b\udcae\x06g\udcb4\udc9b\udca1ѡ\udca2\udca1aY摳K\udc83\udc80" # noqa E501
+ )
+ assert results[95].source == "/Users/user/Library/Keychains/E253F552-3A40-5010-9ACE-98662C9CFE20/keychain-2.db"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_launchers.py b/tests/plugins/os/unix/bsd/darwin/macos/test_launchers.py
new file mode 100644
index 0000000000..02c74db059
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_launchers.py
@@ -0,0 +1,314 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.helpers.record import UnixUserRecord
+from dissect.target.plugins.os.unix.bsd.darwin.macos.launchers import LaunchersPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ ("names", "paths"),
+ [
+ (
+ (
+ "com.apple.ecosystemagent.plist",
+ "com.openssh.ssh-agent.plist",
+ ),
+ (
+ "/System/Library/LaunchAgents/com.apple.ecosystemagent.plist",
+ "/Users/user/Library/LaunchAgents/com.openssh.ssh-agent.plist",
+ ),
+ ),
+ ],
+)
+def test_launch_agents(
+ names: tuple[str, ...],
+ paths: tuple[str, ...],
+ target_unix: Target,
+ fs_unix: VirtualFilesystem,
+) -> None:
+ user = UnixUserRecord(
+ name="user",
+ uid=501,
+ gid=20,
+ home="/Users/user",
+ shell="/bin/zsh",
+ )
+ target_unix.users = lambda: [user]
+
+ for name, path in zip(names, paths, strict=True):
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/launchers/{name}")
+ fs_unix.map_file(path, data_file)
+
+ target_unix.add_plugin(LaunchersPlugin)
+ results = list(target_unix.launch_agents())
+ results.sort(key=lambda r: (r.source, getattr(r, "plist_path", "") or ""))
+
+ assert len(results) == 6
+
+ assert results[0].label == "com.apple.ecosystemagent"
+ assert results[0].program is None
+ assert results[0].program_arguments == [
+ "/System/Library/PrivateFrameworks/Ecosystem.framework/Support/ecosystemagent"
+ ]
+ assert results[0].keep_alive is None
+ assert results[0].on_demand is None
+ assert results[0].disabled is None
+ assert results[0].run_at_load is None
+ assert results[0].launch_only_once is None
+ assert results[0].process_type == "Background"
+ assert results[0].wait is None
+ assert results[0].limit_load_to_session_type is None
+ assert results[0].limit_load_to_developer_mode is None
+ assert results[0].limit_load_from_variant is None
+ assert results[0].limit_load_to_variant is None
+ assert results[0].limit_load_from_boot_mode is None
+ assert results[0].limit_load_to_boot_mode == []
+ assert results[0].limit_load_from_hardware == []
+ assert results[0].limit_load_to_hardware == []
+ assert results[0].launch_events == []
+ assert results[0].mach_services == [
+ "('com.apple.ecosystem.agent.clear-notifications', True)",
+ "('com.apple.ecosystem.agent.notifications', True)",
+ "('com.apple.ecosystem.unsupportedapplicationlist', True)",
+ "('com.apple.usernotifications.delegate.com.apple.ecosystem.notifications', True)",
+ ]
+ assert results[0].enable_pressured_exit == "True"
+ assert results[0].enable_transactions
+ assert results[0].environment_variables == []
+ assert results[0].user_name is None
+ assert results[0].init_groups is None
+ assert results[0].group_name is None
+ assert results[0].start_interval is None
+ assert results[0].start_calendar_interval == []
+ assert results[0].throttle_interval is None
+ assert results[0].enable_globbing is None
+ assert results[0].standard_in_path is None
+ assert results[0].standard_out_path is None
+ assert results[0].standard_error_path is None
+ assert results[0].nice is None
+ assert results[0].abandon_process_group is None
+ assert results[0].low_priority_io
+ assert results[0].root_directory is None
+ assert results[0].working_directory is None
+ assert results[0].umask is None
+ assert results[0].time_out is None
+ assert results[0].exit_time_out is None
+ assert results[0].watch_paths == []
+ assert results[0].queue_directories == []
+ assert results[0].start_on_mount is None
+ assert results[0].soft_resource_limits == []
+ assert results[0].hard_resource_limits == []
+ assert results[0].debug is None
+ assert results[0].wait_for_debugger is None
+ assert results[0].path_state == []
+ assert results[0].other_job_enabled == []
+ assert results[0].low_priority_background_io is None
+ assert results[0].plist_path is None
+ assert results[0].source == "/System/Library/LaunchAgents/com.apple.ecosystemagent.plist"
+
+ assert results[1].un_setting_alerts is None
+ assert results[1].un_setting_always_show_previews is None
+ assert results[1].un_setting_lock_screen is None
+ assert results[1].un_setting_modal_alert_style is None
+ assert results[1].un_automatically_show_settings
+ assert results[1].un_setting_notification_center is None
+ assert results[1].un_daemon_should_receive_background_responses
+ assert results[1].un_suppress_user_authorization_prompt
+ assert results[1].plist_path == "UNUserNotificationCenter"
+ assert results[1].source == "/System/Library/LaunchAgents/com.apple.ecosystemagent.plist"
+
+ assert results[2].un_setting_alerts
+ assert not results[2].un_setting_always_show_previews
+ assert results[2].un_setting_lock_screen
+ assert results[2].un_setting_modal_alert_style
+ assert results[2].un_automatically_show_settings is None
+ assert results[2].un_setting_notification_center
+ assert results[2].un_daemon_should_receive_background_responses is None
+ assert results[2].un_suppress_user_authorization_prompt is None
+ assert results[2].plist_path == "UNUserNotificationCenter/UNDefaultSettings"
+ assert results[2].source == "/System/Library/LaunchAgents/com.apple.ecosystemagent.plist"
+
+ assert results[3].un_notification_icon_default == "notification-settings"
+ assert results[3].un_notification_icon_settings == "notification-settings"
+ assert results[3].plist_path == "UNUserNotificationCenter/UNNotificationIcons"
+ assert results[3].source == "/System/Library/LaunchAgents/com.apple.ecosystemagent.plist"
+
+ assert results[4].label == "com.openssh.ssh-agent"
+ assert results[4].program is None
+ assert results[4].program_arguments == ["/usr/bin/ssh-agent", "-l"]
+ assert results[4].keep_alive is None
+ assert results[4].on_demand is None
+ assert results[4].disabled is None
+ assert results[4].run_at_load is None
+ assert results[4].launch_only_once is None
+ assert results[4].process_type is None
+ assert results[4].wait is None
+ assert results[4].limit_load_to_session_type is None
+ assert results[4].limit_load_to_developer_mode is None
+ assert results[4].limit_load_from_variant is None
+ assert results[4].limit_load_to_variant is None
+ assert results[4].limit_load_from_boot_mode is None
+ assert results[4].limit_load_to_boot_mode == []
+ assert results[4].limit_load_from_hardware == []
+ assert results[4].limit_load_to_hardware == []
+ assert results[4].launch_events == []
+ assert results[4].mach_services == []
+ assert results[4].enable_pressured_exit is None
+ assert results[4].enable_transactions
+ assert results[4].environment_variables == []
+ assert results[4].user_name is None
+ assert results[4].init_groups is None
+ assert results[4].group_name is None
+ assert results[4].start_interval is None
+ assert results[4].start_calendar_interval == []
+ assert results[4].throttle_interval is None
+ assert results[4].enable_globbing is None
+ assert results[4].standard_in_path is None
+ assert results[4].standard_out_path is None
+ assert results[4].standard_error_path is None
+ assert results[4].nice is None
+ assert results[4].abandon_process_group is None
+ assert results[4].low_priority_io is None
+ assert results[4].root_directory is None
+ assert results[4].working_directory is None
+ assert results[4].umask is None
+ assert results[4].time_out is None
+ assert results[4].exit_time_out is None
+ assert results[4].watch_paths == []
+ assert results[4].queue_directories == []
+ assert results[4].start_on_mount is None
+ assert results[4].soft_resource_limits == []
+ assert results[4].hard_resource_limits == []
+ assert results[4].debug is None
+ assert results[4].wait_for_debugger is None
+ assert results[4].path_state == []
+ assert results[4].other_job_enabled == []
+ assert results[4].low_priority_background_io is None
+ assert results[4].plist_path is None
+ assert results[4].source == "/Users/user/Library/LaunchAgents/com.openssh.ssh-agent.plist"
+
+ assert results[5].socket_key is None
+ assert results[5].sock_type is None
+ assert results[5].sock_passive is None
+ assert results[5].sock_node_name is None
+ assert results[5].sock_service_name is None
+ assert results[5].sock_family is None
+ assert results[5].sock_protocol is None
+ assert results[5].sock_path_mode is None
+ assert results[5].sock_path_name is None
+ assert results[5].secure_socket_with_key == "SSH_AUTH_SOCK"
+ assert results[5].sock_path_owner is None
+ assert results[5].sock_path_group is None
+ assert results[5].bonjour is None
+ assert results[5].multicast_group is None
+ assert results[5].plist_path == "Sockets/Listeners"
+ assert results[5].source == "/Users/user/Library/LaunchAgents/com.openssh.ssh-agent.plist"
+
+
+@pytest.mark.parametrize(
+ ("names", "paths"),
+ [
+ (
+ ("org.cups.cupsd.plist",),
+ ("/System/Library/LaunchDaemons/org.cups.cupsd.plist",),
+ ),
+ ],
+)
+def test_launch_daemons(
+ names: tuple[str, ...],
+ paths: tuple[str, ...],
+ target_unix: Target,
+ fs_unix: VirtualFilesystem,
+) -> None:
+ for name, path in zip(names, paths, strict=True):
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/launchers/{name}")
+ fs_unix.map_file(path, data_file)
+
+ target_unix.add_plugin(LaunchersPlugin)
+ results = list(target_unix.launch_daemons())
+ results.sort(key=lambda r: (r.source, getattr(r, "plist_path", "") or ""))
+
+ assert len(results) == 2
+
+ assert results[0].label == "org.cups.cupsd"
+ assert results[0].program is None
+ assert results[0].program_arguments == ["/usr/sbin/cupsd", "-l"]
+ assert results[0].keep_alive == "[('PathState', {'/private/var/spool/cups/cache/org.cups.cupsd': True})]"
+ assert results[0].on_demand is None
+ assert results[0].disabled is None
+ assert results[0].run_at_load is None
+ assert results[0].launch_only_once is None
+ assert results[0].process_type == "Interactive"
+ assert results[0].wait is None
+ assert results[0].limit_load_to_session_type is None
+ assert results[0].limit_load_to_developer_mode is None
+ assert results[0].limit_load_from_variant is None
+ assert results[0].limit_load_to_variant is None
+ assert results[0].limit_load_from_boot_mode is None
+ assert results[0].limit_load_to_boot_mode == []
+ assert results[0].limit_load_from_hardware == []
+ assert results[0].limit_load_to_hardware == []
+ assert results[0].launch_events == []
+ assert results[0].mach_services == []
+ assert results[0].enable_pressured_exit is None
+ assert results[0].enable_transactions
+ assert results[0].environment_variables == [
+ "('CUPS_DEBUG_LOG', '/var/log/cups/debug_log')",
+ "('CUPS_DEBUG_LEVEL', '3')",
+ "('CUPS_DEBUG_FILTER', '^(cupsDo|cupsGet|cupsMake|cupsSet|http|_http|ipp|_ipp|mime).*')",
+ ]
+ assert results[0].user_name is None
+ assert results[0].init_groups is None
+ assert results[0].group_name is None
+ assert results[0].start_interval is None
+ assert results[0].start_calendar_interval == []
+ assert results[0].throttle_interval is None
+ assert results[0].enable_globbing is None
+ assert results[0].standard_in_path is None
+ assert results[0].standard_out_path is None
+ assert results[0].standard_error_path is None
+ assert results[0].nice is None
+ assert results[0].abandon_process_group is None
+ assert results[0].low_priority_io is None
+ assert results[0].root_directory is None
+ assert results[0].working_directory is None
+ assert results[0].umask is None
+ assert results[0].time_out is None
+ assert results[0].exit_time_out == 60
+ assert results[0].watch_paths == []
+ assert results[0].queue_directories == []
+ assert results[0].start_on_mount is None
+ assert results[0].soft_resource_limits == []
+ assert results[0].hard_resource_limits == []
+ assert results[0].debug is None
+ assert results[0].wait_for_debugger is None
+ assert results[0].path_state == []
+ assert results[0].other_job_enabled == []
+ assert results[0].low_priority_background_io is None
+ assert results[0].plist_path is None
+ assert results[0].source == "/System/Library/LaunchDaemons/org.cups.cupsd.plist"
+
+ assert results[1].socket_key is None
+ assert results[1].sock_type is None
+ assert results[1].sock_passive is None
+ assert results[1].sock_node_name is None
+ assert results[1].sock_service_name is None
+ assert results[1].sock_family is None
+ assert results[1].sock_protocol is None
+ assert results[1].sock_path_mode == 49663
+ assert results[1].sock_path_name == "/private/var/run/cupsd"
+ assert results[1].secure_socket_with_key is None
+ assert results[1].sock_path_owner is None
+ assert results[1].sock_path_group is None
+ assert results[1].bonjour is None
+ assert results[1].multicast_group is None
+ assert results[1].plist_path == "Sockets/Listeners[0]"
+ assert results[1].source == "/System/Library/LaunchDaemons/org.cups.cupsd.plist"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_locale.py b/tests/plugins/os/unix/bsd/darwin/macos/test_locale.py
new file mode 100644
index 0000000000..eb304dfc54
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_locale.py
@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+import os
+import shutil
+import tempfile
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.locale import LocalePlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ ("names", "paths"),
+ [
+ (
+ (
+ ".GlobalPreferences.plist",
+ ".AppleSetupDone",
+ "com.apple.timezone.auto.plist",
+ ),
+ (
+ "/Library/Preferences/.GlobalPreferences.plist",
+ "/private/var/db/.AppleSetupDone",
+ "/Library/Preferences/com.apple.timezone.auto.plist",
+ ),
+ ),
+ ],
+)
+def test_locale(
+ names: tuple[str, ...],
+ paths: tuple[str, ...],
+ target_unix: Target,
+ fs_unix: VirtualFilesystem,
+) -> None:
+ tz = timezone.utc
+ apple_setup_time = 1704067199
+
+ tmpdir = tempfile.mkdtemp()
+ try:
+ for name, path in zip(names, paths, strict=True):
+ if name == ".AppleSetupDone":
+ src = absolute_path("_data/plugins/os/unix/bsd/darwin/macos/locale/.AppleSetupDone")
+
+ tmp_file = os.path.join(tmpdir, name) # noqa: PTH118
+ shutil.copyfile(src, tmp_file)
+
+ os.utime(tmp_file, (apple_setup_time, apple_setup_time))
+ else:
+ tmp_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/locale/{name}")
+
+ fs_unix.map_file(path, tmp_file)
+
+ target_unix.fs.mount("/", fs_unix)
+ target_unix.os = "macos"
+ target_unix.add_plugin(LocalePlugin)
+
+ assert target_unix.timezone == "Europe/Amsterdam"
+ assert target_unix.language == ["en_US", "nl_NL"]
+ assert target_unix.install_date == datetime.fromtimestamp(apple_setup_time, tz=tz)
+ assert target_unix.location_services_active
+
+ finally:
+ target_unix.fs = None
+ os.system(f'rmdir /s /q "{tmpdir}"' if os.name == "nt" else f'rm -rf "{tmpdir}"')
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_login_items.py b/tests/plugins/os/unix/bsd/darwin/macos/test_login_items.py
new file mode 100644
index 0000000000..6c947dfaa6
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_login_items.py
@@ -0,0 +1,101 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.helpers.record import UnixUserRecord
+from dissect.target.plugins.os.unix.bsd.darwin.macos.login_items import LoginItemsPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ ("names", "paths"),
+ [
+ (
+ ("BackgroundItems-v16.btm",),
+ ("/var/db/com.apple.backgroundtaskmanagement/BackgroundItems-v16.btm",),
+ ),
+ ],
+)
+def test_login_items(
+ names: tuple[str, ...],
+ paths: tuple[str, ...],
+ target_unix: Target,
+ fs_unix: VirtualFilesystem,
+) -> None:
+ tz = timezone.utc
+ user = UnixUserRecord(
+ name="user",
+ uid=501,
+ gid=20,
+ home="/Users/user",
+ shell="/bin/zsh",
+ )
+ target_unix.users = lambda: [user]
+ for name, path in zip(names, paths, strict=True):
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/{name}")
+ fs_unix.map_file(path, data_file)
+
+ target_unix.add_plugin(LoginItemsPlugin)
+
+ results = list(target_unix.login_items())
+ results.sort(key=lambda r: (r.source, getattr(r, "plist_path", "")))
+
+ assert len(results) == 4
+
+ assert results[0].associated_bundle_identifiers is None
+ assert results[0].bookmark is not None
+ assert isinstance(results[0].bookmark, (bytes, bytearray))
+ assert results[0].bundle_identifier == "com.microsoft.VSCode"
+ assert results[0].container is None
+ assert results[0].designated_requirement == (
+ 'identifier "com.microsoft.VSCode" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] '
+ "/* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = UBF8T346G9" # noqa E501
+ )
+ assert results[0].developer_name is None
+ assert results[0].login_item_disposition == "['Enabled', 'Allowed']"
+ assert results[0].executable_modification_date == datetime(1970, 1, 1, 0, 0, tzinfo=tz)
+ assert results[0].executable_path is None
+ assert results[0].flags == 0
+ assert results[0].generation == 0
+ assert results[0].identifier == "2.com.microsoft.VSCode"
+ assert results[0].lightweight_requirement is not None
+ assert isinstance(results[0].lightweight_requirement, (bytes, bytearray))
+ assert results[0].modification_date == datetime(2026, 4, 29, 15, 46, 38, tzinfo=tz)
+ assert results[0].name == "Visual Studio Code.app"
+ assert results[0].program_arguments is None
+ assert results[0].sha256 is None
+ assert results[0].team_identifier == "UBF8T346G9"
+ assert results[0].login_item_type == "app"
+ assert results[0].url == "file:///Applications/Visual%20Studio%20Code.app/"
+ assert results[0].uuid == "6f541698-5211-4cf2-95b7-e97534baee39"
+ assert results[0].items == []
+ assert results[0].plist_path == "itemsByUserIdentifier/5C6F7FDD-02B2-498E-97B6-DE77293A8E90[0]"
+ assert results[0].source == "/var/db/com.apple.backgroundtaskmanagement/BackgroundItems-v16.btm"
+
+ assert results[1].generation == 2
+ assert results[1].background_app_refresh_load_count == 17
+ assert results[1].launch_services_items_imported
+ assert results[1].service_management_login_items_migrated
+ assert results[1].plist_path == "userSettingsByUserIdentifier/5C6F7FDD-02B2-498E-97B6-DE77293A8E90"
+ assert results[1].source == "/var/db/com.apple.backgroundtaskmanagement/BackgroundItems-v16.btm"
+
+ assert results[2].generation == 0
+ assert results[2].background_app_refresh_load_count == 13
+ assert not results[2].launch_services_items_imported
+ assert not results[2].service_management_login_items_migrated
+ assert results[2].plist_path == "userSettingsByUserIdentifier/FFFFEEEE-DDDD-CCCC-BBBB-AAAA00000000"
+ assert results[2].source == "/var/db/com.apple.backgroundtaskmanagement/BackgroundItems-v16.btm"
+
+ assert results[3].generation == 14
+ assert results[3].background_app_refresh_load_count == 41
+ assert not results[3].launch_services_items_imported
+ assert results[3].service_management_login_items_migrated
+ assert results[3].plist_path == "userSettingsByUserIdentifier/FFFFEEEE-DDDD-CCCC-BBBB-AAAAFFFFFFFE"
+ assert results[3].source == "/var/db/com.apple.backgroundtaskmanagement/BackgroundItems-v16.btm"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_login_window.py b/tests/plugins/os/unix/bsd/darwin/macos/test_login_window.py
new file mode 100644
index 0000000000..dbcfbcadfa
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_login_window.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.helpers.record import UnixUserRecord
+from dissect.target.plugins.os.unix.bsd.darwin.macos.login_window import LoginWindowPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ ("names", "paths"),
+ [
+ (
+ (
+ "loginwindow.plist",
+ "com.apple.loginwindow.E253F552-3A40-5010-9ACE-98662C9CFE20.plist",
+ "com.apple.loginwindow.16130786-970B-53D1-A07B-005E50471D95.plist",
+ ),
+ (
+ "/Users/user/Library/Preferences/loginwindow.plist",
+ "/Users/user/Library/Preferences/ByHost/com.apple.loginwindow.E253F552-3A40-5010-9ACE-98662C9CFE20.plist",
+ "/Users/user/Library/Preferences/ByHost/com.apple.loginwindow.16130786-970B-53D1-A07B-005E50471D95.plist",
+ ),
+ ),
+ ],
+)
+def test_login_window(
+ names: tuple[str, ...],
+ paths: tuple[str, ...],
+ target_unix: Target,
+ fs_unix: VirtualFilesystem,
+) -> None:
+ user = UnixUserRecord(
+ name="user",
+ uid=501,
+ gid=20,
+ home="/Users/user",
+ shell="/bin/zsh",
+ )
+ target_unix.users = lambda: [user]
+
+ for name, path in zip(names, paths, strict=True):
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/login_window/{name}")
+ fs_unix.map_file(path, data_file)
+
+ target_unix.add_plugin(LoginWindowPlugin)
+
+ results = list(target_unix.login_window())
+ results.sort(key=lambda r: r.source)
+
+ assert len(results) == 5
+
+ assert not results[0].hide
+ assert results[0].bundle_id == "com.apple.finder"
+ assert results[0].path == "/System/Library/CoreServices/Finder.app"
+ assert results[0].background_state == 2
+ assert results[0].plist_path == "TALAppsToRelaunchAtLogin[0]"
+ assert (
+ results[0].source
+ == "/Users/user/Library/Preferences/ByHost/com.apple.loginwindow.16130786-970B-53D1-A07B-005E50471D95.plist"
+ )
+
+ assert not results[3].mini_buddy_launch
+ assert (
+ results[3].source
+ == "/Users/user/Library/Preferences/ByHost/com.apple.loginwindow.E253F552-3A40-5010-9ACE-98662C9CFE20.plist"
+ )
+
+ assert results[4].build_version_as_string == "25E246"
+ assert results[4].build_version_stamp_as_number == 52698816
+ assert results[4].system_version_stamp_as_string == "26.4"
+ assert results[4].system_version_stamp_as_number == 436469760
+ assert results[4].source == "/Users/user/Library/Preferences/loginwindow.plist"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_notes.py b/tests/plugins/os/unix/bsd/darwin/macos/test_notes.py
new file mode 100644
index 0000000000..26e495635e
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_notes.py
@@ -0,0 +1,313 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.helpers.record import UnixUserRecord
+from dissect.target.plugins.os.unix.bsd.darwin.macos.notes import NotesPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ ("names", "paths"),
+ [
+ (
+ (
+ "NotesV7.storedata",
+ "NotesV7.storedata-wal",
+ "NoteStore.sqlite",
+ "NoteStore.sqlite-wal",
+ ),
+ (
+ "Users/user/Library/Containers/com.apple.Notes/Data/Library/Notes/NotesV7.storedata",
+ "Users/user/Library/Containers/com.apple.Notes/Data/Library/Notes/NotesV7.storedata-wal",
+ "Users/user/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite",
+ "Users/user/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite-wal",
+ ),
+ ),
+ ],
+)
+def test_notes(names: tuple[str, ...], paths: tuple[str, ...], target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ user = UnixUserRecord(
+ name="user",
+ uid=501,
+ gid=20,
+ home="/Users/user",
+ shell="/bin/zsh",
+ )
+ target_unix.users = lambda: [
+ user,
+ ]
+ for name, path in zip(names, paths, strict=True):
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/notes/{name}")
+ fs_unix.map_file(path, data_file)
+
+ target_unix.add_plugin(NotesPlugin)
+
+ results = list(target_unix.notes())
+ results.sort(key=lambda r: r.source)
+
+ assert len(results) == 1026
+
+ assert results[0].table == "ZACCOUNT"
+ assert results[0].z_pk == 1
+ assert results[0].z_ent == 4
+ assert results[0].z_opt == 2
+ assert not results[0].z_allow_insecure_authentication
+ assert results[0].z_did_choose_to_migrate
+ assert results[0].z_enabled
+ assert results[0].z_root_folder == 2
+ assert results[0].z6_root_folder == 6
+ assert results[0].z_trash_folder == 1
+ assert results[0].z_gmail_capabilities_support is None
+ assert results[0].z_port is None
+ assert results[0].z_security_layer_type is None
+ assert not results[0].z_migration_offered
+ assert results[0].z_account_description == "On My Mac"
+ assert results[0].z_email_address is None
+ assert results[0].z_full_name is None
+ assert results[0].z_parent_account_identifier is None
+ assert results[0].z_user_name is None
+ assert results[0].z_folder_hierarchy_sync_state is None
+ assert results[0].z_authentication is None
+ assert results[0].z_host_name is None
+ assert results[0].z_server_path_prefix is None
+ assert results[0].z_external_url is None
+ assert results[0].z_internal_url is None
+ assert results[0].z_last_used_autodiscover_url is None
+ assert results[0].z_tls_certificate is None
+ assert results[0].source == "/Users/user/Library/Containers/com.apple.Notes/Data/Library/Notes/NotesV7.storedata"
+
+ assert results[1].table == "ZFOLDER"
+ assert results[1].z_pk == 1
+ assert results[1].z_ent == 9
+ assert results[1].z_opt == 2
+ assert results[1].z_account == 1
+ assert results[1].z1_account == 4
+ assert results[1].z_parent is None
+ assert results[1].z6_parent is None
+ assert results[1].z_is_distinguished is None
+ assert results[1].z_alleged_highest_modification_sequence is None
+ assert results[1].z_computed_highest_modification_sequence is None
+ assert results[1].z_uid_next is None
+ assert results[1].z_uid_validity is None
+ assert results[1].z_trash_account == 1
+ assert results[1].z1_trash_account == 4
+ assert results[1].z_name == "Trash"
+ assert results[1].z_change_key is None
+ assert results[1].z_user_name is None
+ assert results[1].z_folder_id is None
+ assert results[1].z_sync_state is None
+ assert results[1].z_server_name is None
+ assert results[1].source == "/Users/user/Library/Containers/com.apple.Notes/Data/Library/Notes/NotesV7.storedata"
+
+ assert results[4].table == "Z_PRIMARYKEY"
+ assert results[4].z_ent == 1
+ assert results[4].z_name == "Account"
+ assert results[4].z_super == 0
+ assert results[4].z_max == 1
+ assert results[4].source == "/Users/user/Library/Containers/com.apple.Notes/Data/Library/Notes/NotesV7.storedata"
+
+ assert results[29].ac_account_type_version is None
+ assert results[29].ns_auto_vacuum_level == 2
+ assert results[29].ns_persistence_framework_version == 1526
+ assert results[29].ns_persistence_maximum_framework_version == 1526
+ assert results[29].ns_store_model_version_checksum_key == "kEYcIlyOEwm45cPXDVhZk/RhZ1zBhdqNxj73uVM6ANM="
+ assert (
+ results[29].ns_store_model_version_hashes_digest
+ == "oKU9BJZ8XlLnCDx32ddVJ7zUewwSZxitb1jZ2XSfZEqW7ZTynDfDUJKGaDF4E//G64SKKXQU253iSERK2dTzdA=="
+ )
+ assert results[29].ns_store_model_version_hashes_version == 3
+ assert results[29].ns_store_model_version_identifiers == "['']"
+ assert results[29].ns_store_type == "SQLite"
+ assert results[29].plist_path == "Z_METADATA/Z_VERSION=1"
+ assert results[29].source == "/Users/user/Library/Containers/com.apple.Notes/Data/Library/Notes/NotesV7.storedata"
+
+ assert results[30].account_hash is not None
+ assert isinstance(results[30].account_hash, (bytes, bytearray))
+ assert results[30].attachment_hash is not None
+ assert isinstance(results[30].attachment_hash, (bytes, bytearray))
+ assert results[30].ews_account is not None
+ assert isinstance(results[30].ews_account, (bytes, bytearray))
+ assert results[30].ews_folder is not None
+ assert isinstance(results[30].ews_folder, (bytes, bytearray))
+ assert results[30].ews_note is not None
+ assert isinstance(results[30].ews_note, (bytes, bytearray))
+ assert results[30].folder is not None
+ assert isinstance(results[30].folder, (bytes, bytearray))
+ assert results[30].folder_action is not None
+ assert isinstance(results[30].folder_action, (bytes, bytearray))
+ assert results[30].imap_account is not None
+ assert isinstance(results[30].imap_account, (bytes, bytearray))
+ assert results[30].imap_folder is not None
+ assert isinstance(results[30].imap_folder, (bytes, bytearray))
+ assert results[30].imap_note is not None
+ assert isinstance(results[30].imap_note, (bytes, bytearray))
+ assert results[30].insert_folder_action is not None
+ assert isinstance(results[30].insert_folder_action, (bytes, bytearray))
+ assert results[30].insert_note_action is not None
+ assert isinstance(results[30].insert_note_action, (bytes, bytearray))
+ assert results[30].local_account is not None
+ assert isinstance(results[30].local_account, (bytes, bytearray))
+ assert results[30].move_folder_action is not None
+ assert isinstance(results[30].move_folder_action, (bytes, bytearray))
+ assert results[30].move_note_action is not None
+ assert isinstance(results[30].move_note_action, (bytes, bytearray))
+ assert results[30].note is not None
+ assert isinstance(results[30].note, (bytes, bytearray))
+ assert results[30].note_action is not None
+ assert isinstance(results[30].note_action, (bytes, bytearray))
+ assert results[30].note_body is not None
+ assert isinstance(results[30].note_body, (bytes, bytearray))
+ assert results[30].offline_action is not None
+ assert isinstance(results[30].offline_action, (bytes, bytearray))
+ assert results[30].trash_folder is not None
+ assert isinstance(results[30].trash_folder, (bytes, bytearray))
+ assert results[30].update_folder_action is not None
+ assert isinstance(results[30].update_folder_action, (bytes, bytearray))
+ assert results[30].update_note_action is not None
+ assert isinstance(results[30].update_note_action, (bytes, bytearray))
+ assert results[30].plist_path == "Z_METADATA/Z_VERSION=1/NSStoreModelVersionHashes"
+ assert results[30].source == "/Users/user/Library/Containers/com.apple.Notes/Data/Library/Notes/NotesV7.storedata"
+
+ assert results[31].table == "Z_METADATA"
+ assert results[31].z_version == 1
+ assert results[31].z_uuid == "FCCEFDB3-D6A9-4BC1-A57A-9BFCE8592C33"
+ assert results[31].source == "/Users/user/Library/Containers/com.apple.Notes/Data/Library/Notes/NotesV7.storedata"
+
+ assert results[32].table == "Z_MODELCACHE"
+ assert results[32].source == "/Users/user/Library/Containers/com.apple.Notes/Data/Library/Notes/NotesV7.storedata"
+
+ assert results[33].table == "ACHANGE"
+ assert results[33].z_pk == 1
+ assert results[33].z_ent == 16001
+ assert results[33].z_opt is None
+ assert results[33].z_change_type == 0
+ assert results[33].z_entity == 6
+ assert results[33].z_entity_pk == 2
+ assert results[33].z_transaction_id == 1
+ assert results[33].z_columns is None
+ assert results[33].source == "/Users/user/Library/Containers/com.apple.Notes/Data/Library/Notes/NotesV7.storedata"
+
+ assert results[38].table == "ATRANSACTION"
+ assert results[38].z_pk == 1
+ assert results[38].z_ent == 16002
+ assert results[38].z_opt is None
+ assert results[38].z_author_ts is None
+ assert results[38].z_bundle_id_ts == 1
+ assert results[38].z_context_name_ts is None
+ assert results[38].z_process_id_ts == 2
+ assert results[38].z_timestamp == datetime(2026, 5, 4, 11, 35, 17, 303456, tzinfo=timezone.utc)
+ assert results[38].z_author is None
+ assert results[38].z_bundle_id is None
+ assert results[38].z_context_name is None
+ assert results[38].z_process_id is None
+ assert results[38].z_query_gen is None
+ assert results[38].source == "/Users/user/Library/Containers/com.apple.Notes/Data/Library/Notes/NotesV7.storedata"
+
+ assert results[40].table == "ATRANSACTIONSTRING"
+ assert results[40].z_pk == 1
+ assert results[40].z_ent == 16003
+ assert results[40].z_opt is None
+ assert results[40].z_name == "com.apple.Notes"
+ assert results[40].source == "/Users/user/Library/Containers/com.apple.Notes/Data/Library/Notes/NotesV7.storedata"
+
+ assert results[42].table == "ZICCLOUDSTATE"
+ assert results[42].z_pk == 1
+ assert results[42].z_ent == 2
+ assert results[42].z_opt == 1
+ assert results[42].z_current_local_version == 1
+ assert not results[42].z_in_cloud
+ assert results[42].z_latest_version_synced_to_cloud == 0
+ assert results[42].z_cloud_syncing_object is None
+ assert results[42].z3_cloud_syncing_object is None
+ assert results[42].z_local_version_date is not None
+ assert results[42].source == "/Users/user/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite"
+
+ assert results[48].table == "ZICCLOUDSYNCINGOBJECT"
+ assert results[48].z_pk == 1
+ assert results[48].z_ent == 14
+ assert results[48].z_opt == 3
+ assert results[48].z_cloud_state == 2
+ assert results[48].z_crypto_iteration_count == 0
+ assert not results[48].z_is_password_protected
+ assert not results[48].z_is_share_dirty
+ assert not results[48].marked_for_deletion
+ assert results[48].minimum_supported_notes_version == 0
+ assert not results[48].z_needs_initial_fetch_from_cloud
+ assert results[48].z_identifier == "LocalAccount"
+ assert results[48].source == "/Users/user/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite"
+
+ assert results[54].table == "ZICNOTEDATA"
+ assert results[54].z_pk == 1
+ assert results[54].z_ent == 19
+ assert results[54].z_opt == 114
+ assert results[54].z_note == 4
+ assert results[54].z_crypto_initialization_vector is None
+ assert results[54].z_crypto_tag is None
+ assert results[54].z_data is not None
+ assert isinstance(results[54].z_data, (bytes, bytearray))
+ assert b"This is another note" in results[54].z_data
+ assert results[54].source == "/Users/user/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite"
+
+ assert results[56].table == "ZICSEARCHINDEXSTATE"
+ assert results[56].z_pk == 1
+ assert results[56].z_ent == 21
+ assert results[56].z_opt == 15
+ assert results[56].z_state_value == 4
+ assert results[56].z_identifier.startswith("x-coredata://")
+ assert "/ICFolder/" in results[56].z_identifier
+ assert results[56].source == "/Users/user/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite"
+
+ assert results[87].ic_account is not None
+ assert isinstance(results[87].ic_account, (bytes, bytearray))
+ assert results[87].ic_account_data is not None
+ assert isinstance(results[87].ic_account_data, (bytes, bytearray))
+ assert results[87].ic_asset_signature is not None
+ assert isinstance(results[87].ic_asset_signature, (bytes, bytearray))
+ assert results[87].ic_attachment is not None
+ assert isinstance(results[87].ic_attachment, (bytes, bytearray))
+ assert results[87].ic_attachment_location is not None
+ assert isinstance(results[87].ic_attachment_location, (bytes, bytearray))
+ assert results[87].ic_attachment_preview_image is not None
+ assert isinstance(results[87].ic_attachment_preview_image, (bytes, bytearray))
+ assert results[87].ic_cloud_state is not None
+ assert isinstance(results[87].ic_cloud_state, (bytes, bytearray))
+ assert results[87].ic_cloud_syncing_object is not None
+ assert isinstance(results[87].ic_cloud_syncing_object, (bytes, bytearray))
+ assert results[87].ic_device_migration_state is not None
+ assert isinstance(results[87].ic_device_migration_state, (bytes, bytearray))
+ assert results[87].ic_folder is not None
+ assert isinstance(results[87].ic_folder, (bytes, bytearray))
+ assert results[87].ic_hashtag is not None
+ assert isinstance(results[87].ic_hashtag, (bytes, bytearray))
+ assert results[87].ic_inline_attachment is not None
+ assert isinstance(results[87].ic_inline_attachment, (bytes, bytearray))
+ assert results[87].ic_invitation is not None
+ assert isinstance(results[87].ic_invitation, (bytes, bytearray))
+ assert results[87].ic_legacy_tombstone is not None
+ assert isinstance(results[87].ic_legacy_tombstone, (bytes, bytearray))
+ assert results[87].ic_location is not None
+ assert isinstance(results[87].ic_location, (bytes, bytearray))
+ assert results[87].ic_media is not None
+ assert isinstance(results[87].ic_media, (bytes, bytearray))
+ assert results[87].ic_note is not None
+ assert isinstance(results[87].ic_note, (bytes, bytearray))
+ assert results[87].ic_note_container is not None
+ assert isinstance(results[87].ic_note_container, (bytes, bytearray))
+ assert results[87].ic_note_data is not None
+ assert isinstance(results[87].ic_note_data, (bytes, bytearray))
+ assert results[87].ic_note_participant is not None
+ assert isinstance(results[87].ic_note_participant, (bytes, bytearray))
+ assert results[87].ic_search_index_state is not None
+ assert isinstance(results[87].ic_search_index_state, (bytes, bytearray))
+ assert results[87].ic_server_change_token is not None
+ assert isinstance(results[87].ic_server_change_token, (bytes, bytearray))
+ assert results[87].plist_path == "Z_METADATA/Z_VERSION=1/NSStoreModelVersionHashes"
+ assert results[87].source == "/Users/user/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_periodic.py b/tests/plugins/os/unix/bsd/darwin/macos/test_periodic.py
new file mode 100644
index 0000000000..287e5d7162
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_periodic.py
@@ -0,0 +1,136 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.periodic import PeriodicPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ ("names", "paths"),
+ [
+ (
+ (
+ "199.rotate-fax",
+ "110.clean-tmps",
+ ),
+ (
+ "/etc/periodic/monthly/199.rotate-fax",
+ "/etc/periodic/daily/110.clean-tmps",
+ ),
+ ),
+ ],
+)
+def test_periodic_scripts(
+ names: tuple[str, ...],
+ paths: tuple[str, ...],
+ target_unix: Target,
+ fs_unix: VirtualFilesystem,
+) -> None:
+ for name, path in zip(names, paths, strict=True):
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/periodic/{name}")
+ fs_unix.map_file(path, data_file)
+
+ target_unix.add_plugin(PeriodicPlugin)
+
+ results = list(target_unix.periodic_scripts())
+ results.sort(key=lambda r: r.source)
+
+ assert len(results) == 2
+
+ assert results[0].source == "/etc/periodic/daily/110.clean-tmps"
+
+ assert results[1].source == "/etc/periodic/monthly/199.rotate-fax"
+
+
+@pytest.mark.parametrize(
+ ("names", "paths"),
+ [
+ (
+ ("periodic.conf",),
+ ("/etc/defaults/periodic.conf",),
+ ),
+ ],
+)
+def test_periodic_conf(
+ names: tuple[str, ...],
+ paths: tuple[str, ...],
+ target_unix: Target,
+ fs_unix: VirtualFilesystem,
+) -> None:
+ for name, path in zip(names, paths, strict=True):
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/periodic/{name}")
+ fs_unix.map_file(path, data_file)
+
+ target_unix.add_plugin(PeriodicPlugin)
+
+ results = list(target_unix.periodic_conf())
+ results.sort(key=lambda r: r.source)
+
+ assert len(results) == 4
+
+ assert results[0].local_periodic == "/usr/local/etc/periodic"
+ assert results[0].dir_output is None
+ assert results[0].dir_show_success is None
+ assert results[0].dir_show_info is None
+ assert results[0].dir_show_badconfig is None
+ assert results[0].anticongestion_sleeptime is None
+ assert results[0].source == "/etc/defaults/periodic.conf"
+
+ assert results[1].daily_clean_disks_enable is None
+ assert results[1].daily_clean_disks_files is None
+ assert results[1].daily_clean_disks_days is None
+ assert results[1].daily_clean_disks_verbose is None
+ assert results[1].daily_clean_tmps_enable
+ assert results[1].daily_clean_tmps_dirs == "/tmp"
+ assert results[1].daily_clean_tmps_days == 3
+ assert results[1].daily_clean_tmps_ignore == "$daily_clean_tmps_ignore quota.user quota.group"
+ assert results[1].daily_clean_tmps_verbose
+ assert results[1].daily_clean_preserve_enable is None
+ assert results[1].daily_clean_preserve_days is None
+ assert results[1].daily_clean_preserve_verbose is None
+ assert results[1].daily_clean_msgs_enable
+ assert results[1].daily_clean_msgs_days is None
+ assert results[1].daily_clean_rwho_enable
+ assert results[1].daily_clean_rwho_days == 7
+ assert results[1].daily_clean_rwho_verbose
+ assert results[1].daily_clean_hoststat_enable is None
+ assert results[1].daily_accounting_enable
+ assert not results[1].daily_accounting_compress
+ assert results[1].daily_accounting_save == 3
+ assert results[1].daily_accounting_flags == "-q"
+ assert results[1].daily_status_disks_enable
+ assert results[1].daily_status_disks_df_flags == "-l -h"
+ assert results[1].daily_status_network_enable
+ assert results[1].daily_status_network_usedns
+ assert results[1].daily_status_mailq_enable
+ assert not results[1].daily_status_mailq_shorten
+ assert results[1].daily_status_include_submit_mailq
+ assert results[1].daily_local == "/etc/daily.local"
+ assert results[1].source == "/etc/defaults/periodic.conf"
+
+ assert results[2].weekly_locate_enable is None
+ assert results[2].weekly_whatis_enable is None
+ assert results[2].weekly_noid_enable is None
+ assert results[2].weekly_noid_dirs is None
+ assert results[2].weekly_status_security_enable is None
+ assert results[2].weekly_status_security_inline is None
+ assert results[2].weekly_status_security_output is None
+ assert results[2].weekly_status_pkg_enable is None
+ assert results[2].pkg_version is None
+ assert results[2].pkg_version_index is None
+ assert results[2].weekly_local == "/etc/weekly.local"
+ assert results[2].source == "/etc/defaults/periodic.conf"
+
+ assert results[3].monthly_accounting_enable
+ assert results[3].monthly_status_security_enable is None
+ assert results[3].monthly_status_security_inline is None
+ assert results[3].monthly_status_security_output is None
+ assert results[3].monthly_local == "/etc/monthly.local"
+ assert results[3].source == "/etc/defaults/periodic.conf"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_resources_info_strings.py b/tests/plugins/os/unix/bsd/darwin/macos/test_resources_info_strings.py
new file mode 100644
index 0000000000..71262b533c
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_resources_info_strings.py
@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.resources_info_strings import ResourcesInfoStringsPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ ("names", "paths"),
+ [
+ (
+ ("WiFiAgent.strings", "OSvKernDSPLib.strings"),
+ (
+ "/System/Library/CoreServices/WiFiAgent.app/Contents/Resources/InfoPlist.strings",
+ "/System/Library/Extensions/OSvKernDSPLib.kext/Contents/Resources/InfoPlist.strings",
+ ),
+ )
+ ],
+)
+def test_resources_info_strings(
+ names: tuple[str, ...], paths: tuple[str, ...], target_unix: Target, fs_unix: VirtualFilesystem
+) -> None:
+ for name, path in zip(names, paths, strict=True):
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/resources_info_strings/{name}")
+ fs_unix.map_file(f"{path}", data_file)
+
+ target_unix.add_plugin(ResourcesInfoStringsPlugin)
+
+ results = list(target_unix.resources_info_strings())
+ results.sort(key=lambda r: (r.source, getattr(r, "plist_path", "")))
+ assert len(results) == 2
+
+ assert results[0].cf_bundle_name == "WiFiAgent"
+ assert results[0].cf_bundle_display_name == "Wi-Fi"
+ assert results[0].cf_bundle_identifier is None
+ assert results[0].cf_bundle_version is None
+ assert results[0].cf_bundle_package_type is None
+ assert results[0].cf_bundle_signature is None
+ assert results[0].cf_bundle_executable is None
+ assert results[0].cf_bundle_document_types == []
+ assert results[0].cf_bundle_short_version_string is None
+ assert results[0].ls_minimum_system_version is None
+ assert results[0].ns_human_readable_copyright is None
+ assert results[0].ns_main_nib_file is None
+ assert results[0].ns_principal_class is None
+ assert results[0].source == "/System/Library/CoreServices/WiFiAgent.app/Contents/Resources/InfoPlist.strings"
+
+ assert results[1].cf_bundle_name is None
+ assert results[1].cf_bundle_display_name is None
+ assert results[1].cf_bundle_identifier is None
+ assert results[1].cf_bundle_version is None
+ assert results[1].cf_bundle_package_type is None
+ assert results[1].cf_bundle_signature is None
+ assert results[1].cf_bundle_executable is None
+ assert results[1].cf_bundle_document_types == []
+ assert results[1].cf_bundle_short_version_string is None
+ assert results[1].ls_minimum_system_version is None
+ assert results[1].ns_human_readable_copyright == "Copyright © 2004 Apple Inc. All rights reserved."
+ assert results[1].ns_main_nib_file is None
+ assert results[1].ns_principal_class is None
+ assert results[1].source == "/System/Library/Extensions/OSvKernDSPLib.kext/Contents/Resources/InfoPlist.strings"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_resources_localizable_strings.py b/tests/plugins/os/unix/bsd/darwin/macos/test_resources_localizable_strings.py
new file mode 100644
index 0000000000..a31d7f2f95
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_resources_localizable_strings.py
@@ -0,0 +1 @@
+# TODO write tests for macOSResourcesLocalizableStringsPlugin
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_shadow.py b/tests/plugins/os/unix/bsd/darwin/macos/test_shadow.py
new file mode 100644
index 0000000000..b98b5cb759
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_shadow.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.shadow import ShadowPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_files",
+ [
+ ["_mbsetupuser.plist", "user.plist"],
+ ],
+)
+def test_passwords(test_files: list[str], target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ for test_file in test_files:
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/shadow/{test_file}")
+ fs_unix.map_file(f"/var/db/dslocal/nodes/Default/users/{test_file}", data_file)
+
+ target_unix.add_plugin(ShadowPlugin)
+
+ results = list(target_unix.passwords())
+ results.sort(key=lambda r: r.name)
+ assert len(results) == 2
+
+ assert results[0].name == "_mbsetupuser"
+ assert (
+ results[0].hash
+ == "46e8c10ca67d3d8a3998fa83b9dbb845d3a5c8b257d60272a22912a1a0fd6bff5d315b6f5b50577e465a8ae57f5a0f0fd52ea708d614141c428911d3273b67125d9599198590376ad38f43d6ed3a1173fd86c378b9879c5a026809c802df5ae5bd72e53bb033f9aa9e0e24600c03d0ec287e466f5c79eb1be42fac7afeee2b7e" # noqa: E501
+ )
+ assert results[0].salt == "225ea6a940b08c6985c792a7195ef008527b5c83829ca0850bdcae77e517c9b5"
+ assert results[0].iterations == 78125
+ assert results[0].algorithm == "SALTED-SHA512-PBKDF2"
+ assert results[0].source == "/var/db/dslocal/nodes/Default/users/_mbsetupuser.plist"
+
+ assert results[-1].name == "user"
+ assert (
+ results[-1].hash
+ == "f6e502079b9eb8b2f49b099c235aed2debb0b80084dca99f9a23db41dbfca88be0266bf62dfca5923ccd20d4c8f81140e2cb09b7951cce001ccb37b9fa3cee46b68a0edfc8e055e7f523feaec444f775eaddcf7d4e91e5e918a0dd715a4a749fd92974b023db4cb8851f4fdee3bd091755686be92a3f8ff906c6552907a8b0dc" # noqa: E501
+ )
+ assert results[-1].salt == "64e7869eef2d9bf35bc9b72fddcf90055be833eed2c6c7d58ca91f0c0754f5f6"
+ assert results[-1].iterations == 128205
+ assert results[-1].algorithm == "SALTED-SHA512-PBKDF2"
+ assert results[-1].source == "/var/db/dslocal/nodes/Default/users/user.plist"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_software_update_preferences.py b/tests/plugins/os/unix/bsd/darwin/macos/test_software_update_preferences.py
new file mode 100644
index 0000000000..d1d0552498
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_software_update_preferences.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.software_update_preferences import SoftwareUpdatePreferencesPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_file",
+ [
+ "com.apple.SoftwareUpdate.plist",
+ ],
+)
+def test_software_update_preferences(test_file: str, target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ tz = timezone.utc
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/{test_file}")
+ fs_unix.map_file(f"/Library/Preferences/{test_file}", data_file)
+
+ target_unix.add_plugin(SoftwareUpdatePreferencesPlugin)
+
+ results = list(target_unix.software_update_preferences())
+ assert len(results) == 1
+
+ assert results[0].last_result_code == 2
+ assert results[0].last_attempt_system_version == "26.4 (25E246)"
+ assert results[0].last_attempt_build_version == "26.4 (25E246)"
+ assert results[0].automatic_download
+ assert results[0].automatically_install_macos_updates
+ assert results[0].critical_update_install
+ assert results[0].config_data_install
+ assert results[0].recommended_updates == []
+ assert results[0].splat_enabled
+ assert not results[0].post_logout_notification
+ assert results[0].last_recommended_major_os_bundle_id == ""
+ assert results[0].primary_languages == ["en", "en-US"]
+ assert results[0].last_successful_date == datetime(2026, 3, 25, 14, 11, 47, tzinfo=tz)
+ assert results[0].last_full_successful_date == datetime(2026, 3, 25, 14, 11, 47, tzinfo=tz)
+ assert results[0].source == "/Library/Preferences/com.apple.SoftwareUpdate.plist"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_system_preferences.py b/tests/plugins/os/unix/bsd/darwin/macos/test_system_preferences.py
new file mode 100644
index 0000000000..84f613e9a8
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_system_preferences.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.system_preferences import SystemPreferencesPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_file",
+ [
+ "com.apple.airport.preferences.plist",
+ ],
+)
+def test_system_preferences(test_file: str, target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/{test_file}")
+ fs_unix.map_file(f"/Library/Preferences/SystemConfiguration/{test_file}", data_file)
+
+ target_unix.add_plugin(SystemPreferencesPlugin)
+
+ results = list(target_unix.system_preferences())
+ assert len(results) == 1
+
+ assert results[0].Counter == 2
+ assert results[0].DeviceUUID == "0527924E-C5F8-4703-BDDC-9283B6E9FDAE"
+ assert results[0].Version == 7200
+ assert results[0].PreferredOrder == []
+ assert results[0].source == "/Library/Preferences/SystemConfiguration/com.apple.airport.preferences.plist"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_tcc.py b/tests/plugins/os/unix/bsd/darwin/macos/test_tcc.py
new file mode 100644
index 0000000000..7d046045fb
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_tcc.py
@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.helpers.record import UnixUserRecord
+from dissect.target.plugins.os.unix.bsd.darwin.macos.tcc import TCCPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ ("names", "paths"),
+ [
+ (
+ (
+ "user.db",
+ "system.db",
+ ),
+ (
+ "/Users/user/Library/Application Support/com.apple.TCC/TCC.db",
+ "/Library/Application Support/com.apple.TCC/TCC.db",
+ ),
+ ),
+ ],
+)
+def test_tcc(
+ names: tuple[str, ...],
+ paths: tuple[str, ...],
+ target_unix: Target,
+ fs_unix: VirtualFilesystem,
+) -> None:
+ tz = timezone.utc
+
+ user = UnixUserRecord(
+ name="user",
+ uid=501,
+ gid=20,
+ home="/Users/user",
+ shell="/bin/zsh",
+ )
+ target_unix.users = lambda: [
+ user,
+ ]
+ for name, path in zip(names, paths, strict=True):
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/tcc/{name}")
+ fs_unix.map_file(path, data_file)
+
+ target_unix.add_plugin(TCCPlugin)
+
+ results = list(target_unix.tcc())
+ results.sort(key=lambda r: r.source)
+
+ assert len(results) == 51
+
+ assert results[0].table == "admin"
+ assert results[0].key == "version"
+ assert results[0].value == "32"
+ assert results[0].source == "/Library/Application Support/com.apple.TCC/TCC.db"
+
+ assert results[1].table == "access"
+ assert results[1].service == "kTCCServiceSystemPolicyAllFiles"
+ assert (
+ results[1].client
+ == "/System/Library/PrivateFrameworks/VoiceShortcuts.framework/Versions/A/Support/siriactionsd"
+ )
+ assert results[1].client_type == 1
+ assert results[1].auth_value == "Denied"
+ assert results[1].auth_reason == "Service Policy"
+ assert results[1].auth_version == 1
+ assert (
+ results[1].csreq
+ == b"\xfa\xde\x0c\x00\x00\x00\x004\x00\x00\x00\x01\x00\x00\x00\x06\x00\x00\x00\x02\x00\x00\x00\x16com.apple.siriactionsd\x00\x00\x00\x00\x00\x03" # noqa E501
+ )
+ assert results[1].policy_id is None
+ assert results[1].indirect_object_identifier == "UNUSED"
+ assert results[1].indirect_object_identifier_type is None
+ assert results[1].indirect_object_code_identity is None
+ assert results[1].flags == 0
+ assert results[1].last_modified == datetime(2026, 3, 25, 14, 13, 27, tzinfo=tz)
+ assert results[1].pid is None
+ assert results[1].pid_version is None
+ assert results[1].boot_uuid == "UNUSED"
+ assert results[1].last_reminded == datetime(1970, 1, 1, 0, 0, 0, tzinfo=tz)
+ assert results[1].source == "/Library/Application Support/com.apple.TCC/TCC.db"
+
+ assert results[-1].table == "integrity_flag"
+ assert results[-1].key == "integrity_flag"
+ assert results[-1].value == "0"
+ assert results[-1].source == "/Users/user/Library/Application Support/com.apple.TCC/TCC.db"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_text_replacements.py b/tests/plugins/os/unix/bsd/darwin/macos/test_text_replacements.py
new file mode 100644
index 0000000000..8a7088bb28
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_text_replacements.py
@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.helpers.record import UnixUserRecord
+from dissect.target.plugins.os.unix.bsd.darwin.macos.text_replacements import TextReplacementsPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_files",
+ [
+ [
+ "TextReplacements.db",
+ "TextReplacements.db-wal",
+ ]
+ ],
+)
+def test_text_replacements(test_files: list[str], target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ user = UnixUserRecord(
+ name="user",
+ uid=501,
+ gid=20,
+ home="/Users/user",
+ shell="/bin/zsh",
+ )
+ target_unix.users = lambda: [
+ user,
+ ]
+
+ for test_file in test_files:
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/text_replacements/{test_file}")
+ fs_unix.map_file(f"Users/user/Library/KeyboardServices/{test_file}", data_file)
+
+ target_unix.add_plugin(TextReplacementsPlugin)
+
+ results = list(target_unix.text_replacements())
+
+ assert len(results) == 8
+
+ assert results[0].table == "ZTEXTREPLACEMENTENTRY"
+ assert results[0].z_pk == 1
+ assert results[0].z_ent == 1
+ assert results[0].z_opt == 1
+ assert results[0].z_needs_save_to_cloud == 1
+ assert results[0].z_was_deleted == 0
+ assert results[0].z_timestamp == datetime(2026, 3, 25, 14, 12, 48, 339994, tzinfo=timezone.utc)
+ assert results[0].z_phrase == "On my way!"
+ assert results[0].z_shortcut == "omw"
+ assert results[0].z_unique_name == "CB36B9A8-B570-4972-BC6C-B12DAA7C0B97"
+ assert results[0].z_remote_record_info is None
+ assert results[0].source == "/Users/user/Library/KeyboardServices/TextReplacements.db"
+
+ assert results[1].table == "ZTRCLOUDKITSYNCSTATE"
+ assert results[1].z_pk == 1
+ assert results[1].z_ent == 2
+ assert results[1].z_opt == 1
+ assert results[1].z_did_pull_once == 0
+ assert results[1].z_fetch_change_token is None
+ assert results[1].source == "/Users/user/Library/KeyboardServices/TextReplacements.db"
+
+ assert results[2].table == "Z_PRIMARYKEY"
+ assert results[2].z_ent == 1
+ assert results[2].z_name == "TextReplacementEntry"
+ assert results[2].z_super == 0
+ assert results[2].z_max == 1
+ assert results[2].source == "/Users/user/Library/KeyboardServices/TextReplacements.db"
+
+ assert results[3].table == "Z_PRIMARYKEY"
+ assert results[3].z_ent == 2
+ assert results[3].z_name == "TRCloudKitSyncState"
+ assert results[3].z_super == 0
+ assert results[3].z_max == 1
+ assert results[3].source == "/Users/user/Library/KeyboardServices/TextReplacements.db"
+
+ assert results[4].ns_persistence_maximum_framework_version == 1526
+ assert results[4].ns_store_type == "SQLite"
+ assert results[4].ns_auto_vacuum_level == 2
+ assert results[4].ns_store_model_version_identifiers == [""]
+ assert results[4].ns_store_model_version_hashes_version == 3
+ assert results[4].ns_store_model_version_hashes_digest == (
+ "nb/qJ+hB9auf83oGYKFndhE+Etk/7JNbNosAbVi5Zu0biqTNK/UfC4ofTqRhou6nHBT1ci00ct9E+U9vnbhfPw=="
+ )
+ assert results[4].ns_store_model_version_checksum_key == ("c4ljuOCxf+SvLXrpw0Xcnwe7kkFsMpkUuv43N2r16m4=")
+ assert results[4].plist_path == "Z_METADATA/Z_VERSION=1"
+ assert results[4].source == "/Users/user/Library/KeyboardServices/TextReplacements.db"
+
+ assert results[5].tr_cloud_kit_sync_state == (
+ b"\xa2 \x8cuo\xd1V\xd2p\x89C#\xbd\xb1$\xf1F\x97X\xe9\x01\x1e\x02\xcb\xf5\xe5y$\x90\xc4\xee\xdf"
+ )
+ assert results[5].text_replacement_entry == (
+ b"#C\t\xd2l\xa7\xeaK##S\xae\xb7>\x82\xb5\xb5\xc8\xafB\xf4\t\xc4]\xa2)\xb0}+\xd9\xab\x03"
+ )
+ assert results[5].plist_path == "Z_METADATA/Z_VERSION=1/NSStoreModelVersionHashes"
+ assert results[5].source == "/Users/user/Library/KeyboardServices/TextReplacements.db"
+
+ assert results[6].table == "Z_METADATA"
+ assert results[6].z_version == 1
+ assert results[6].z_uuid == "555547C2-D9F5-4B51-8BEE-EEE6158CDDED"
+ assert results[6].source == "/Users/user/Library/KeyboardServices/TextReplacements.db"
+
+ assert results[7].table == "Z_MODELCACHE"
+ assert results[7].source == "/Users/user/Library/KeyboardServices/TextReplacements.db"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_time_machine.py b/tests/plugins/os/unix/bsd/darwin/macos/test_time_machine.py
new file mode 100644
index 0000000000..ba3d6ff551
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_time_machine.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.plugins.os.unix.bsd.darwin.macos.time_machine import TimeMachinePlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_file",
+ [
+ "com.apple.TimeMachine.plist",
+ ],
+)
+def test_aiport_preferences(test_file: str, target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/{test_file}")
+ fs_unix.map_file(f"/Library/Preferences/{test_file}", data_file)
+
+ target_unix.add_plugin(TimeMachinePlugin)
+
+ results = list(target_unix.time_machine())
+ assert len(results) == 2
+
+ assert results[0].last_destination_id is None
+ assert results[0].auto_backup_interval == 3600
+ assert results[0].host_uuids == ["543ECB5B-2A5B-5E41-8FE8-D1A0E9FF5F7E"]
+ assert not results[0].requires_ac_power
+ assert results[0].suspend_helper_activity_timestamp == datetime(1995, 6, 11, 7, 40, 38, tzinfo=timezone.utc)
+ assert results[0].backup_alias is not None
+ assert isinstance(results[0].backup_alias, (bytes, bytearray))
+ assert results[0].preferences_version == 6
+ assert results[0].auto_backup == 1
+ assert results[0].last_activity_backup == datetime(2026, 5, 11, 13, 7, 9, tzinfo=timezone.utc)
+ assert results[0].source == "/Library/Preferences/com.apple.TimeMachine.plist"
+
+ assert results[1].destination_uuids == ["6041C936-15F2-436F-9320-0BA84E6F27BC"]
+ assert results[1].last_known_volume_name == "Backups of Testbook1\u2019s MacBook Pro"
+ assert results[1].result == 0
+ assert results[1].filesystem_type_name == "apfs"
+ assert results[1].last_known_encryption_state == "Encrypted"
+ assert results[1].stable_local_snapshot_date == datetime(2026, 6, 11, 7, 41, 52, tzinfo=timezone.utc)
+ assert results[1].inheritance_decision == 0
+ assert results[1].destination_id == "D660D3DA-6567-49A9-A133-02E95682CFFE"
+ assert results[1].bytes_used == 385024
+ assert results[1].destination_version == 23
+ assert results[1].health_check_decision == 0
+ assert results[1].smb_conversion_state == 0
+ assert results[1].attempt_dates == [datetime(2026, 6, 11, 7, 41, 45, 410654, tzinfo=timezone.utc)]
+ assert results[1].bytes_available == 924195943880
+ assert results[1].source == "/Library/Preferences/com.apple.TimeMachine.plist"
diff --git a/tests/plugins/os/unix/bsd/darwin/macos/test_user_accounts.py b/tests/plugins/os/unix/bsd/darwin/macos/test_user_accounts.py
new file mode 100644
index 0000000000..6bd382ee6e
--- /dev/null
+++ b/tests/plugins/os/unix/bsd/darwin/macos/test_user_accounts.py
@@ -0,0 +1,166 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from dissect.target.helpers.record import UnixUserRecord
+from dissect.target.plugins.os.unix.bsd.darwin.macos.user_accounts import UserAccountsPlugin
+from tests._utils import absolute_path
+
+if TYPE_CHECKING:
+ from dissect.target.filesystem import VirtualFilesystem
+ from dissect.target.target import Target
+
+
+@pytest.mark.parametrize(
+ "test_files",
+ [
+ [
+ "Accounts4.sqlite",
+ "Accounts4.sqlite-wal",
+ ]
+ ],
+)
+def test_user_accounts(test_files: list[str], target_unix: Target, fs_unix: VirtualFilesystem) -> None:
+ user = UnixUserRecord(
+ name="user",
+ uid=501,
+ gid=20,
+ home="/Users/user",
+ shell="/bin/zsh",
+ )
+ target_unix.users = lambda: [
+ user,
+ ]
+ for test_file in test_files:
+ data_file = absolute_path(f"_data/plugins/os/unix/bsd/darwin/macos/user_accounts/{test_file}")
+ fs_unix.map_file(f"Users/user/Library/Accounts/{test_file}", data_file)
+
+ target_unix.add_plugin(UserAccountsPlugin)
+
+ results = list(target_unix.user_accounts())
+
+ assert len(results) == 290
+
+ assert results[0].table == "ZACCESSOPTIONSKEY"
+ assert results[0].z_pk == 1
+ assert results[0].z_ent == 1
+ assert results[0].z_opt == 1
+ assert results[0].z_enum_value == 0
+ assert results[0].z_name == "ACTencentWeiboAppIdKey"
+ assert results[0].source == "/Users/user/Library/Accounts/Accounts4.sqlite"
+
+ assert results[7].table == "Z_1OWNINGACCOUNTTYPES"
+ assert results[7].z_1_access_keys == 7
+ assert results[7].z_4_owning_account_types == 43
+ assert results[7].source == "/Users/user/Library/Accounts/Accounts4.sqlite"
+
+ assert results[19].table == "ZACCOUNT"
+ assert results[19].z_pk == 1
+ assert results[19].z_ent == 2
+ assert results[19].z_opt == 7
+ assert not results[19].z_active
+ assert not results[19].z_authenticated
+ assert results[19].z_supports_authentication
+ assert results[19].z_visible
+ assert not results[19].z_warming_up
+ assert results[19].z_account_type == 50
+ assert results[19].z_parent_account is None
+ assert results[19].z_date.isoformat() == "1995-03-25T14:11:32.168510+00:00"
+ assert results[19].z_last_credential_renewal_rejection_date is None
+ assert results[19].z_account_description is None
+ assert results[19].z_authentication_type is None
+ assert results[19].z_credential_type is None
+ assert results[19].z_identifier == "9DE5EA8C-7EE3-433C-9387-A1158923C75B"
+ assert results[19].z_modification_id == "581D5181-C65D-4598-80EE-7386D70A8799"
+ assert results[19].z_owning_bundle_id == "amsaccountsd"
+ assert results[19].z_username == "local"
+ assert results[19].z_dataclass_properties is None
+ assert results[19].source == "/Users/user/Library/Accounts/Accounts4.sqlite"
+
+ assert results[21].table == "Z_2ENABLEDDATACLASSES"
+ assert results[21].z_2_enabled_accounts == 2
+ assert results[21].z_7_enabled_dataclasses == 20
+ assert results[21].source == "/Users/user/Library/Accounts/Accounts4.sqlite"
+
+ assert results[22].table == "ZACCOUNTPROPERTY"
+ assert results[22].z_pk == 1
+ assert results[22].z_ent == 3
+ assert results[22].z_opt == 1
+ assert results[22].z_owner == 1
+ assert results[22].z_key == "isLocalAccount"
+ assert results[22].z_value == "True"
+ assert results[22].source == "/Users/user/Library/Accounts/Accounts4.sqlite"
+
+ assert results[36].table == "ZACCOUNTTYPE"
+ assert results[36].z_pk == 1
+ assert results[36].z_ent == 4
+ assert results[36].z_opt == 1
+ assert not results[36].z_obsolete
+ assert results[36].z_supports_authentication
+ assert results[36].z_supports_multiple_accounts
+ assert not results[36].z_visibility
+ assert results[36].z_account_type_description == "Gmail"
+ assert results[36].z_credential_protection_policy is None
+ assert results[36].z_credential_type == "oauth2"
+ assert results[36].z_identifier == "com.apple.account.Google"
+ assert results[36].z_owning_bundle_id == "com.apple.accountsd"
+ assert results[36].source == "/Users/user/Library/Accounts/Accounts4.sqlite"
+
+ assert results[92].table == "Z_4SUPPORTEDDATACLASSES"
+ assert results[92].z_4_supported_types == 55
+ assert results[92].z_7_supported_dataclasses == 27
+ assert results[92].source == "/Users/user/Library/Accounts/Accounts4.sqlite"
+
+ assert results[197].table == "Z_4SYNCABLEDATACLASSES"
+ assert results[197].z_4_syncable_types == 55
+ assert results[197].z_7_syncable_dataclasses == 27
+ assert results[197].source == "/Users/user/Library/Accounts/Accounts4.sqlite"
+
+ assert results[279].table == "Z_PRIMARYKEY"
+ assert results[279].z_ent == 1
+ assert results[279].z_name == "AccessOptionsKey"
+ assert results[279].z_super == 0
+ assert results[279].z_max == 7
+ assert results[279].source == "/Users/user/Library/Accounts/Accounts4.sqlite"
+
+ assert results[286].ac_account_type_version == 107
+ assert results[286].ns_auto_vacuum_level == 2
+ assert results[286].ns_persistence_framework_version == 1526
+ assert results[286].ns_persistence_maximum_framework_version == 1526
+ assert results[286].ns_store_model_version_checksum_key == "ZAPc9m2m45TI0HS9GepqhdVKmn8FObA8NpZcwc7UIT8="
+ assert (
+ results[286].ns_store_model_version_hashes_digest
+ == "6zOxtAC8CBiqUoJ8U+mC0mHIF9LkhkOGYval68MNS/V6S4tVudh/sDu45bnjvnLbb+I3Ouq7XXF3ZNolh1b4+A=="
+ )
+ assert results[286].ns_store_model_version_hashes_version == 3
+ assert results[286].ns_store_model_version_identifiers == "['30']"
+ assert results[286].ns_store_type == "SQLite"
+ assert results[286].plist_path == "Z_METADATA/Z_VERSION=1"
+ assert results[286].source == "/Users/user/Library/Accounts/Accounts4.sqlite"
+
+ assert results[287].access_options_key is not None
+ assert isinstance(results[287].access_options_key, (bytes, bytearray))
+ assert results[287].account_hash is not None
+ assert isinstance(results[287].account_hash, (bytes, bytearray))
+ assert results[287].account_property is not None
+ assert isinstance(results[287].account_property, (bytes, bytearray))
+ assert results[287].account_type is not None
+ assert isinstance(results[287].account_type, (bytes, bytearray))
+ assert results[287].authorization is not None
+ assert isinstance(results[287].authorization, (bytes, bytearray))
+ assert results[287].credential_item is not None
+ assert isinstance(results[287].credential_item, (bytes, bytearray))
+ assert results[287].dataclass is not None
+ assert isinstance(results[287].dataclass, (bytes, bytearray))
+ assert results[287].plist_path == "Z_METADATA/Z_VERSION=1/NSStoreModelVersionHashes"
+ assert results[287].source == "/Users/user/Library/Accounts/Accounts4.sqlite"
+
+ assert results[288].table == "Z_METADATA"
+ assert results[288].z_version == 1
+ assert results[288].z_uuid == "9802D604-BFA7-4F9D-A39F-0CF8E6FD0FAC"
+ assert results[288].source == "/Users/user/Library/Accounts/Accounts4.sqlite"
+
+ assert results[289].table == "Z_MODELCACHE"
+ assert results[289].source == "/Users/user/Library/Accounts/Accounts4.sqlite"
diff --git a/tests/plugins/os/unix/log/test_messages.py b/tests/plugins/os/unix/log/test_messages.py
index 1dbe65c1b7..43fb5dccf2 100644
--- a/tests/plugins/os/unix/log/test_messages.py
+++ b/tests/plugins/os/unix/log/test_messages.py
@@ -84,11 +84,11 @@ def test_unix_log_messages_compressed_timezone_year_rollover() -> None:
results = list(target.messages())
results.reverse()
- assert len(results) == 2
+ assert len(results) == 3
assert isinstance(results[0], type(MessagesRecord()))
- assert isinstance(results[1], type(MessagesRecord()))
+ assert isinstance(results[2], type(MessagesRecord()))
assert results[0].ts == datetime(2020, 12, 31, 3, 14, 0, tzinfo=ZoneInfo("America/Chicago"))
- assert results[1].ts == datetime(2021, 1, 1, 13, 37, 0, tzinfo=ZoneInfo("America/Chicago"))
+ assert results[2].ts == datetime(2021, 1, 1, 13, 37, 0, tzinfo=ZoneInfo("America/Chicago"))
def test_unix_log_messages_malformed_log_year_rollover(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None:
@@ -109,7 +109,7 @@ def test_unix_log_messages_malformed_log_year_rollover(target_unix_users: Target
target_unix_users.add_plugin(MessagesPlugin)
results = list(target_unix_users.messages())
- assert len(results) == 2
+ assert len(results) == 3
assert results[0].ts
assert results[0].service == "systemd"