import ast import os from pathlib import Path import subprocess import sys import tempfile from types import SimpleNamespace import unittest from unittest import mock import venv ROOT = Path(__file__).resolve().parents[1] APP_DIR = ROOT / 'app' sys.path.insert(0, str(APP_DIR)) import owned_process CANONICAL_DSN_KEYS = ( 'TRUF_MANAGED_POSTGRES_DSN', 'SCANNER_DB_URL', 'DATABASE_URL', 'SCANNER_DASHBOARD_DB_URL', 'KEYCHECK_DB_URL', ) LIBPQ_PRIVATE_KEYS = ( 'PGPASSWORD', 'PGUSER', 'PGDATABASE', 'PGHOST', 'PGHOSTADDR', 'PGPORT', 'PGSERVICE', 'PGSERVICEFILE', 'PGPASSFILE', 'PGOPTIONS', 'PGSSLMODE', 'PGSSLKEY', 'PGSSLCERT', 'PGSSLROOTCERT', ) def mixed_case(value): return ''.join(character.lower() if index % 2 else character for index, character in enumerate(value)) def sensitive_environment(): keys = CANONICAL_DSN_KEYS + LIBPQ_PRIVATE_KEYS + ( 'TRUF_POSTGRES_PASSWORD', 'TRUF_POSTGRES_FUTURE_CREDENTIAL', ) return {mixed_case(key): f'secret-{index}' for index, key in enumerate(keys)} def assert_credentials_absent(test_case, environment): normalized = {str(key).upper() for key in environment} test_case.assertTrue(normalized.isdisjoint(CANONICAL_DSN_KEYS)) test_case.assertTrue(normalized.isdisjoint(LIBPQ_PRIVATE_KEYS)) test_case.assertFalse(any(key.startswith('TRUF_POSTGRES_') for key in normalized)) class OwnedProcessHostBoundaryTests(unittest.TestCase): def test_host_command_puts_isolation_flags_before_script_and_control_handles(self): host = mock.Mock(pid=701) prefix = 'windows-filetime:' if os.name == 'nt' else 'proc-start-ticks:' identities = [ {'pid': pid, 'creation_time': prefix + '123', 'creation_time_unix': 1700000000.0, 'executable': os.path.abspath(sys.executable)} for pid in (701, 702) ] with mock.patch.object(owned_process.subprocess, 'Popen', return_value=host) as popen, \ mock.patch.object(owned_process, '_write_packet'), \ mock.patch.object( owned_process.OwnedProcess, '_wait_for_startup', return_value={ 'ok': True, 'pid': 702, 'job_membership_verified': True, 'payload_identity': identities[1], 'host_identity': identities[0], 'job_members': identities, 'job_accounting': {'child_subreaper_verified': True}, }, ): process = owned_process.OwnedProcess(['payload']) command = popen.call_args.args[0] host_python = sys._base_executable if os.name == 'nt' else sys.executable self.assertEqual(command[:4], [host_python, '-I', '-S', '-B']) self.assertEqual(command[4], os.path.abspath(owned_process.__file__)) self.assertEqual(command[5], owned_process._HOST_FLAG) self.assertEqual(len(command[6:]), 2) if os.name != 'nt': self.assertIs(popen.call_args.kwargs['start_new_session'], True) process.terminate() def test_nonisolated_host_rejects_before_reading_control_packet(self): flags = SimpleNamespace(isolated=0, no_site=0, dont_write_bytecode=0) with mock.patch.object(owned_process.sys, 'flags', flags), \ mock.patch.object(owned_process, '_open_inherited_fd', side_effect=(40, 41)), \ mock.patch.object(owned_process, '_read_packet') as read_packet, \ mock.patch.object(owned_process, '_write_packet'), \ mock.patch.object(owned_process.signal, 'signal'), \ mock.patch.object(owned_process.os, 'close'): self.assertEqual(owned_process._host_main('control', 'status'), 127) read_packet.assert_not_called() def test_host_module_imports_only_the_standard_library(self): tree = ast.parse(Path(owned_process.__file__).read_text(encoding='utf-8')) imports = set() for node in ast.walk(tree): if isinstance(node, ast.Import): imports.update(alias.name.partition('.')[0] for alias in node.names) elif isinstance(node, ast.ImportFrom) and node.module: imports.add(node.module.partition('.')[0]) self.assertEqual(imports - set(sys.stdlib_module_names), set()) def test_host_ignores_sitecustomize_and_pth_from_its_temporary_venv(self): payload_python = sys.executable with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) venv_dir = temp_path / 'venv' venv.EnvBuilder(with_pip=False).create(venv_dir) if os.name == 'nt': venv_python = venv_dir / 'Scripts' / 'python.exe' site_packages = venv_dir / 'Lib' / 'site-packages' else: venv_python = venv_dir / 'bin' / 'python' version = f'python{sys.version_info.major}.{sys.version_info.minor}' site_packages = venv_dir / 'lib' / version / 'site-packages' site_marker = temp_path / 'site.marker' pth_marker = temp_path / 'pth.marker' (site_packages / 'sitecustomize.py').write_text( 'import os\nopen(os.environ["OWNED_PROCESS_SITE_MARKER"], "w").write("ran")\n', encoding='ascii', ) (site_packages / 'malicious.pth').write_text( 'import os; open(os.environ["OWNED_PROCESS_PTH_MARKER"], "w").write("ran")\n', encoding='ascii', ) # Prefer the fixture over a distribution's stdlib sitecustomize. marker_environment = { 'PYTHONPATH': str(site_packages), 'OWNED_PROCESS_SITE_MARKER': str(site_marker), 'OWNED_PROCESS_PTH_MARKER': str(pth_marker), } control_environment = os.environ.copy() control_environment.update(marker_environment) subprocess.run( [str(venv_python), '-c', 'pass'], env=control_environment, check=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) self.assertTrue(site_marker.exists()) self.assertTrue(pth_marker.exists()) site_marker.unlink() pth_marker.unlink() with mock.patch.dict(os.environ, marker_environment, clear=False), \ mock.patch.object(owned_process.sys, 'executable', str(venv_python)): process = owned_process.OwnedProcess( [payload_python, '-I', '-S', '-B', '-c', 'import sys; sys.stdin.buffer.read()'], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) self.assertEqual(process.host_identity['pid'], process.host_pid) if os.name == 'nt': self.assertEqual(process._host_process.args[0], sys._base_executable) process.communicate(timeout=10) self.assertEqual(process.returncode, 0) self.assertFalse(site_marker.exists()) self.assertFalse(pth_marker.exists()) class CredentialBoundaryTests(unittest.TestCase): def test_host_environment_strips_mixed_case_database_credentials_only(self): environment = sensitive_environment() environment.update({ mixed_case('TRUF_SUPERVISOR_TOKEN'): 'supervisor-secret', 'UNRELATED_SETTING': 'preserved', }) with mock.patch.object(owned_process.os, 'environ', environment): host_environment = owned_process._host_environment() assert_credentials_absent(self, host_environment) self.assertNotIn(mixed_case('TRUF_SUPERVISOR_TOKEN'), host_environment) self.assertEqual(host_environment['UNRELATED_SETTING'], 'preserved') def test_scanner_strips_serialized_external_payload_without_mutating_trusted_env(self): import scanner environment = sensitive_environment() environment['UNRELATED_SETTING'] = 'preserved' class CompleteProcess: pid = 703 returncode = 0 job_membership_verified = True payload_identity = {'pid': 703} @staticmethod def poll(): return 0 trusted_dsn = 'postgresql://trusted/managed' with tempfile.TemporaryDirectory() as temp_dir, \ mock.patch.dict( os.environ, {'TRUF_MANAGED_POSTGRES_DSN': trusted_dsn}, clear=False, ), \ mock.patch.object(scanner, 'scoped_scan_slot_lease', return_value=(True, None)), \ mock.patch.object(scanner, 'create_command_work_dir', return_value=temp_dir), \ mock.patch.object(scanner, '_shared_staging_owners', return_value=[]), \ mock.patch.object(scanner, 'cleanup_command_work_dir'), \ mock.patch.object(scanner, 'require_trufflehog_launch_authority'), \ mock.patch.object(scanner, 'write_temp_owner'), \ mock.patch.object(scanner, 'OwnedProcess', return_value=CompleteProcess()) as process_factory, \ mock.patch.object(scanner.scan_config, 'trufflehog_job_memory_limit_bytes', 1024): self.assertEqual(scanner.run_command(['fixture'], 5, env=environment), ('', '', 0)) self.assertEqual(os.environ['TRUF_MANAGED_POSTGRES_DSN'], trusted_dsn) process_factory.assert_called_once() payload_environment = process_factory.call_args.kwargs['env'] serialized_environment = owned_process._serializable_environment(payload_environment) assert_credentials_absent(self, serialized_environment) self.assertEqual(serialized_environment['UNRELATED_SETTING'], 'preserved') if __name__ == '__main__': unittest.main()