diff --git a/dissect/target/plugins/filesystem/ini.py b/dissect/target/plugins/filesystem/ini.py new file mode 100644 index 0000000000..13023b01b6 --- /dev/null +++ b/dissect/target/plugins/filesystem/ini.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import io +from typing import TYPE_CHECKING + +from dissect.target.exceptions import ConfigurationParsingError, FileNotFoundError, UnsupportedPluginError +from dissect.target.helpers import configutil +from dissect.target.helpers.record import TargetRecordDescriptor +from dissect.target.plugin import Plugin, arg, export + +if TYPE_CHECKING: + from collections.abc import Iterator + + from dissect.target.helpers.fsutil import TargetPath + +IniRecord = TargetRecordDescriptor( + "filesystem/iniFileRecord", + [ + ("datetime", "atime"), + ("datetime", "mtime"), + ("datetime", "ctime"), + ("string", "section"), + ("string", "key"), + ("string", "value"), + ("path", "path"), + ], +) + + +class IniPlugin(Plugin): + """INI file plugin. + + This plugin scans target filesystems for INI configuration files and parses them into + structured records. It handles both UTF-8 and UTF-16 encoded INI files + """ + + def check_compatible(self) -> None: + if not len(self.target.fs.mounts): + raise UnsupportedPluginError("No filesystems found on target") + + def on_error(self, error: Exception) -> None: + """Error handler for filesystem traversal. Logs warnings for permission errors and other exceptions, + but continues traversal. + + Args: + error: ``Exception`` the exception thrown during filesystem traversal. + """ + if isinstance(error, PermissionError): + self.target.log.warning("Permission denied while scanning for ini files: %s", error) + self.target.log.debug("", exc_info=error) + return + + self.target.log.warning("Exception while scanning for ini files: %s", error) + self.target.log.debug("", exc_info=error) + + def _iter_ini_files(self, path: str) -> Iterator[TargetPath]: + """Find all INI files under the given path. + + Handles both explicit file paths and directory traversal. Continues traversal even if + permission errors are encountered on individual directories. + + Args: + path: ``string`` of Target filesystem path to scan. Can be a file or directory. + + Returns: + Iterator yields ``TargetPath`` + """ + target_path = self.target.fs.path(path) + if not target_path.exists(): + self.target.log.error("Provided path %s does not exist on target", target_path) + return + + if target_path.is_file(): + if target_path.suffix.lower() == ".ini": + yield target_path + return + + for root, _dirs, files in self.target.fs.walk(path, onerror=self.on_error): + root_path = self.target.fs.path(root) + for file_name in files: + if file_name.lower().endswith(".ini"): + yield root_path.joinpath(file_name) + + @export(record=IniRecord) + @arg("-p", "--path", default="/", help="path to an .ini file or directory in target") + def ini(self, path: str = "/") -> Iterator[IniRecord]: + """Scan for and parse INI files, yielding structured records. + + This method recursively discovers INI configuration files under the specified path, + parses them, and yields an IniRecord for each + key-value pair found. + + Args: + path: ``string`` of Target filesystem path to scan (default "/"). Can be a file or directory. + + Returns: + Iterator yields ``IniRecord``: One record per key-value pair in discovered INI files. + """ + for ini_file_path in self._iter_ini_files(path): + try: + config = _parse_ini(ini_file_path) + stat = ini_file_path.stat() + except FileNotFoundError as e: + # File may disappear between compatibility check and parse. + self.target.log.warning("File not found: %s", ini_file_path) + self.target.log.debug("", exc_info=e) + continue + except Exception as e: + self.target.log.warning("Exception generating ini record for %s: %s", ini_file_path, e) + self.target.log.debug("", exc_info=e) + continue + + for section_name, section in config.items(): + for key, value in section.items(): + yield IniRecord( + atime=stat.st_atime, + mtime=stat.st_mtime, + ctime=stat.st_ctime, + section=section_name, + key=key, + value=str(value), + path=ini_file_path, + _target=self.target, + ) + + +def _parse_ini(ini_file_path: TargetPath) -> configutil.ConfigurationParser: + """Parse an INI file, with automatic fallback for UTF-16 encoded files. + + First attempts to parse the file with the default UTF-8 encoding. If a ConfigurationParsingError, + retries using UTF-16 decoding + + Args: + ini_file_path: ``TargetPath`` to the INI file to parse. + + Returns: + ConfigurationParser: ``ConfigurationParser`` Parsed INI configuration object. + """ + try: + return configutil.parse(ini_file_path, hint="ini") + except (UnicodeDecodeError, ConfigurationParsingError): + # Many Windows INI files are UTF-16 + with ini_file_path.open("rb") as f: + raw_data = f.read() + text_data = raw_data.decode("utf-16") + + parser = configutil.Ini() + parser.read_file(io.StringIO(text_data)) + return parser diff --git a/tests/_data/plugins/filesystem/ini/not_ini.txt b/tests/_data/plugins/filesystem/ini/not_ini.txt new file mode 100644 index 0000000000..5e2f0c6ff0 --- /dev/null +++ b/tests/_data/plugins/filesystem/ini/not_ini.txt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c898b0ed4b7a4f14e2719ef0fbfd36ec88d8f9236b8fc2adb6b8cd7d8f89e4bf +size 24 diff --git a/tests/_data/plugins/filesystem/ini/shutdown.ini b/tests/_data/plugins/filesystem/ini/shutdown.ini new file mode 100644 index 0000000000..f0f725cc4f --- /dev/null +++ b/tests/_data/plugins/filesystem/ini/shutdown.ini @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12cee45f24504bb594261169a710ee217cb4a45b5494362c2bb05eaa8840e5bb +size 34 diff --git a/tests/_data/plugins/filesystem/ini/startup.ini b/tests/_data/plugins/filesystem/ini/startup.ini new file mode 100644 index 0000000000..a1006e9027 --- /dev/null +++ b/tests/_data/plugins/filesystem/ini/startup.ini @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1485f85bd22a2c1867406c360ec56bc7d9471e02bfab364847134a01319d705 +size 61 diff --git a/tests/_data/plugins/filesystem/ini/utf16.ini b/tests/_data/plugins/filesystem/ini/utf16.ini new file mode 100644 index 0000000000..ec049d15f4 --- /dev/null +++ b/tests/_data/plugins/filesystem/ini/utf16.ini @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72e876ed59044113a27c358b2729721d2d0cf6ceddb166a0bcc17d1518d8a2b2 +size 68 diff --git a/tests/plugins/filesystem/test_ini.py b/tests/plugins/filesystem/test_ini.py new file mode 100644 index 0000000000..689a72c632 --- /dev/null +++ b/tests/plugins/filesystem/test_ini.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from dissect.target.plugins.filesystem.ini import IniPlugin +from tests._utils import absolute_path + +if TYPE_CHECKING: + from dissect.target.filesystem import VirtualFilesystem + from dissect.target.target import Target + + +@pytest.fixture +def target_ini(target_unix: Target, fs_unix: VirtualFilesystem) -> Target: + fs_unix.map_dir("/etc/config", absolute_path("_data/plugins/filesystem/ini")) + + target_unix.add_plugin(IniPlugin) + return target_unix + + +def test_ini_parses_records(target_ini: Target) -> None: + """Test INI file discovery and parsing from a directory.""" + records = list(target_ini.ini("/etc/config")) + + assert len(records) == 6 + + by_key = {(record.section, record.key): record for record in records} + + assert by_key[("Run", "Program")].value == "calc.exe" + assert by_key[("Run", "NoValue")].value == "None" + assert by_key[("Display", "Theme")].value == "Dark" + assert by_key[("Shutdown", "Script")].value == "cleanup.cmd" + + paths = {str(record.path).lower() for record in records} + assert "/etc/config/startup.ini" in paths + assert "/etc/config/shutdown.ini" in paths + assert "/etc/config/not_ini.txt" not in paths + + +def test_ini_parses_explicit_file(target_ini: Target) -> None: + """Test parsing a single explicitly specified INI file.""" + records = list(target_ini.ini("/etc/config/startup.ini")) + + assert len(records) == 3 + assert {record.section for record in records} == {"Run", "Display"} + assert all(str(record.path).lower().endswith("startup.ini") for record in records) + + +def test_ini_parses_utf16_encoded_file(target_ini: Target) -> None: + """Test parsing UTF-16 encoded INI files.""" + records = list(target_ini.ini("/etc/config/utf16.ini")) + + assert len(records) == 2 + by_key = {(record.section, record.key): record for record in records} + assert by_key[("Setting", "Timeout")].value == "30" + assert by_key[("Setting", "Delay")].value == "60" + assert all(str(record.path).lower().endswith("utf16.ini") for record in records)