Files
truf-server/tests/test_owned_process_boundary.py
2026-09-30 20:30:56 +03:00

242 lines
10 KiB
Python

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()