From 0c8430fa7e09b937fee5a3d9fa5c584fb0342c29 Mon Sep 17 00:00:00 2001 From: Brigs Date: Sun, 20 Sep 2026 11:04:30 -0400 Subject: [PATCH] Report the LAVA database by name, not by the examiner's own path An artifact that declares no search paths is handed '/_lava_artifacts.db' as its only files_found entry, because it reads rows an earlier artifact wrote rather than a file in the extraction. All 35 such artifacts in iLEAPP are in logarchive.py, and each returns that path back as its source_path. Context.get_relative_path stripped the data folder and nothing else. The LAVA database sits in the report folder, one level above the data folder, so the prefix never matched and the path was returned unchanged. The examiner's own output directory then reached the artifact page's "located at" line and the LAVA manifest's source_path, which travels with the report. Measured on a run over a log show export: before, 4 of 5 manifest entries and all 3 artifact pages carried the absolute path; after, none do and the manifest reads '_lava_artifacts.db', which is how the report already describes that file elsewhere. Row counts, every LAVA table, the TSV exports and the timeline database are identical before and after. Two runs of the unmodified tree differ in the same two bookkeeping tables, because the file path id is id() of a FileInfo object, so that difference is not this change. The fix is in get_relative_path rather than in the artifacts, so it covers all 35 at once and any later file the run writes into the report folder. The data folder is still tried first, because it sits inside the report folder and stripping the report folder first would leave every staged evidence path prefixed with 'data/'. Co-Authored-By: Claude Opus 5 --- .../test/scripts/test_lava_db_source_path.py | 148 ++++++++++++++++++ scripts/context.py | 48 ++++-- 2 files changed, 183 insertions(+), 13 deletions(-) create mode 100644 admin/test/scripts/test_lava_db_source_path.py diff --git a/admin/test/scripts/test_lava_db_source_path.py b/admin/test/scripts/test_lava_db_source_path.py new file mode 100644 index 000000000..e10d2a636 --- /dev/null +++ b/admin/test/scripts/test_lava_db_source_path.py @@ -0,0 +1,148 @@ +"""Pin what an artifact that declares no search paths records as its source path. + +An artifact whose `paths` is None searches for nothing. The main script hands it +'/_lava_artifacts.db' as its only files_found entry, because it reads +the rows an earlier artifact wrote into the LAVA database rather than any file in the +extraction. All 35 such artifacts in iLEAPP are in logarchive.py, and each returns that +path back as its source_path. + +`Context.get_relative_path` used to strip the data folder and nothing else. The LAVA +database sits in the report folder, one level above the data folder, so the prefix never +matched and the path was returned unchanged. The examiner's own output directory then +reached the artifact page's "located at" line and the LAVA manifest's `source_path`, +which travels with the report. + +Measured on a real run before the fix: 4 of 5 manifest entries and all 3 artifact pages +carried the absolute path. + +These tests drive `Context.get_relative_path` directly with both folders set the way a +run sets them, so they fail on the unfixed function rather than on a recorded baseline. +""" +import os +import pathlib +import sys +import unittest + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO_ROOT)) + +from scripts.context import Context # pylint: disable=wrong-import-position + +# The layout OutputParameters builds: the data folder is inside the report folder. +REPORT = os.path.join('/Users', 'examiner', 'Cases', 'iLEAPP_Output_2026') +DATA = os.path.join(REPORT, 'data') +LAVA_DB = os.path.join(REPORT, '_lava_artifacts.db') + + +class RelativePathTestCase(unittest.TestCase): + """Both folders set the way a run sets them.""" + + def setUp(self): + Context.clear() + Context._data_folder = DATA # pylint: disable=protected-access + Context._output_folder_base = REPORT # pylint: disable=protected-access + + def tearDown(self): + Context._data_folder = None # pylint: disable=protected-access + Context._output_folder_base = None # pylint: disable=protected-access + Context.clear() + + +class TestTheLavaDatabaseIsReportedByName(RelativePathTestCase): + """The file the runner hands a paths-None artifact.""" + + def test_the_lava_database_loses_the_examiners_report_folder(self): + self.assertEqual(Context.get_relative_path(LAVA_DB), '_lava_artifacts.db') + + def test_no_part_of_the_report_folder_survives(self): + got = Context.get_relative_path(LAVA_DB) + self.assertNotIn(REPORT, got) + self.assertFalse(os.path.isabs(got), f'still absolute: {got!r}') + + def test_another_file_the_run_writes_keeps_its_place_inside_the_report(self): + page = os.path.join(REPORT, '_HTML', 'logarchive_wifi_status.html') + self.assertEqual(Context.get_relative_path(page), + os.path.join('_HTML', 'logarchive_wifi_status.html')) + + +class TestTheDataFolderStillWins(RelativePathTestCase): + """The data folder is inside the report folder, so it has to be stripped first. + + Stripping the report folder first would leave 'data/' on the front of every staged + evidence path, which is the regression this ordering exists to prevent. + """ + + def test_a_staged_evidence_file_is_reported_without_the_data_prefix(self): + staged = os.path.join(DATA, 'private', 'var', 'mobile', 'Library', 'x.db') + self.assertEqual(Context.get_relative_path(staged), + os.path.join('private', 'var', 'mobile', 'Library', 'x.db')) + + def test_a_staged_path_does_not_come_back_prefixed_with_data(self): + staged = os.path.join(DATA, 'private', 'var', 'x.db') + got = Context.get_relative_path(staged) + self.assertFalse(got.startswith('data'), f'data prefix survived: {got!r}') + + def test_several_staged_paths_in_one_string_are_all_reduced(self): + joined = '\n'.join([os.path.join(DATA, 'a', 'one.db'), + os.path.join(DATA, 'b', 'two.db')]) + self.assertEqual(Context.get_relative_path(joined), + '\n'.join([os.path.join('a', 'one.db'), + os.path.join('b', 'two.db')])) + + +class TestNothingElseChanged(RelativePathTestCase): + """Values that carry neither prefix are still handed back untouched.""" + + def test_a_path_outside_both_folders_is_unchanged(self): + other = os.path.join('/Users', 'examiner', 'Desktop', 'notes.txt') + self.assertEqual(Context.get_relative_path(other), other) + + def test_an_already_relative_path_is_unchanged(self): + self.assertEqual(Context.get_relative_path('export/logarchive.json'), + 'export/logarchive.json') + + def test_an_empty_value_is_unchanged(self): + self.assertEqual(Context.get_relative_path(''), '') + self.assertIsNone(Context.get_relative_path(None)) + + +class TestWithNeitherFolderKnown(unittest.TestCase): + """The committed harness sets no folders, so the function stays a no-op there.""" + + def setUp(self): + Context.clear() + Context._data_folder = None # pylint: disable=protected-access + Context._output_folder_base = None # pylint: disable=protected-access + + def test_every_path_is_returned_unchanged(self): + self.assertEqual(Context.get_relative_path(LAVA_DB), LAVA_DB) + + def test_the_report_folder_alone_is_enough_to_reduce_the_lava_database(self): + Context._output_folder_base = REPORT # pylint: disable=protected-access + try: + self.assertEqual(Context.get_relative_path(LAVA_DB), '_lava_artifacts.db') + finally: + Context._output_folder_base = None # pylint: disable=protected-access + + +class TestTheRunPopulatesTheReportFolder(unittest.TestCase): + """set_output_params has to record the base, or the strip above never fires.""" + + def tearDown(self): + Context._output_params = None # pylint: disable=protected-access + Context._data_folder = None # pylint: disable=protected-access + Context._output_folder_base = None # pylint: disable=protected-access + + def test_both_folders_are_taken_from_the_output_parameters(self): + class FakeOutputParameters: # pylint: disable=too-few-public-methods + output_folder_base = REPORT + data_folder = DATA + + Context.set_output_params(FakeOutputParameters()) + self.assertEqual(Context._output_folder_base, REPORT) # pylint: disable=protected-access + self.assertEqual(Context._data_folder, DATA) # pylint: disable=protected-access + self.assertEqual(Context.get_relative_path(LAVA_DB), '_lava_artifacts.db') + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/context.py b/scripts/context.py index 687caf3c7..106e34065 100644 --- a/scripts/context.py +++ b/scripts/context.py @@ -29,6 +29,7 @@ class Context: _files_found = [] _filename_lookup_map = {} _data_folder = None + _output_folder_base = None _metadata = {} _installed_os_version = "" # Run-level, like the output parameters: set once from the CLI or GUI and @@ -46,6 +47,8 @@ def set_output_params(output_params): """ Context._output_params = output_params Context._data_folder = getattr(output_params, 'data_folder', None) + Context._output_folder_base = getattr( + output_params, 'output_folder_base', None) @staticmethod def set_data_folder(data_folder): @@ -544,27 +547,46 @@ def _normalize_source_path(source_path): @staticmethod def get_relative_path(full_path): """ - Converts a full on-disk path (from files_found) to a relative - extraction path by removing the global data_folder prefix. + Converts a full on-disk path into one that carries none of the + examiner's own filesystem layout. + + Two prefixes are stripped, in this order: + + 1. the data folder, where the seeker stages evidence, so a staged + file is reported by its path inside the extraction; + 2. the report folder, so a file the run itself writes is reported by + its path inside the report. + + The second case exists for artifacts that declare no search paths. The + main script hands those '/_lava_artifacts.db' as their + source file, because they read the rows a previous artifact wrote + rather than any file in the extraction. That path is outside the data + folder, so before this it was reported unchanged and the examiner's own + report directory reached both the "located at" line and the LAVA + manifest's source_path. + + The data folder is tried first because it sits inside the report + folder: stripping the report folder first would leave every staged + evidence path prefixed with 'data/'. Args: full_path (str): The full path to the file. Returns: - str: The relative extraction path, or the original path if - the data_folder is not available. + str: The relative path, or the original path if neither prefix is + known or present. """ - if not full_path or not Context._data_folder: + if not full_path: return full_path - if Context._data_folder in full_path: - # Strip the base path everywhere it appears, including inside path - # strings concatenated with arbitrary separators (', ', '; ', ...) - base = Context._data_folder - return (full_path.replace(base + '/', '') - .replace(base + '\\', '') - .replace(base, '') - .lstrip('/\\')) + for base in (Context._data_folder, Context._output_folder_base): + if base and base in full_path: + # Strip the base path everywhere it appears, including inside path + # strings concatenated with arbitrary separators (', ', '; ', ...) + return (full_path.replace(base + '/', '') + .replace(base + '\\', '') + .replace(base, '') + .lstrip('/\\')) return full_path