327 lines
17 KiB
Python
327 lines
17 KiB
Python
import ast
|
|
import ntpath
|
|
import os
|
|
from pathlib import Path
|
|
import posixpath
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from types import SimpleNamespace
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
STAGING_REFUSAL = (
|
|
'Docker development copy: runtime control is disabled until container isolation is ready. '
|
|
'See DOCKER_MIGRATION.md.'
|
|
)
|
|
CONTAINER_REFUSAL = (
|
|
'Docker development copy: runtime control is disabled outside the prepared container. '
|
|
'See DOCKER_MIGRATION.md.'
|
|
)
|
|
sys.path.insert(0, str(ROOT / 'app'))
|
|
|
|
import paths
|
|
|
|
|
|
def isolated_child_environment(temporary):
|
|
environment = {name: os.environ[name] for name in ('SystemRoot', 'WINDIR', 'COMSPEC', 'SystemDrive')
|
|
if name in os.environ}
|
|
environment.update({name: str(temporary) for name in ('TEMP', 'TMP', 'TMPDIR')})
|
|
environment.update(PYTHONDONTWRITEBYTECODE='1', PYTEST_DISABLE_PLUGIN_AUTOLOAD='1',
|
|
PYTEST_ADDOPTS='', PYTEST_PLUGINS='')
|
|
return environment
|
|
|
|
|
|
class PortablePathTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.environment = {}
|
|
self.operating_system = SimpleNamespace(
|
|
name='posix', path=posixpath, getenv=self.environment.get,
|
|
getcwd=lambda: '/unrelated-working-directory',
|
|
makedirs=mock.Mock(side_effect=AssertionError('path resolution must not write')),
|
|
)
|
|
self.enterContext(mock.patch.multiple(
|
|
paths, os=self.operating_system,
|
|
APP_DIR='/opt/truf/app', CANONICAL_ROOT='/opt/truf',
|
|
))
|
|
|
|
def test_defaults_are_checkout_local_and_independent_of_cwd(self):
|
|
resolved = paths.default_project_paths()
|
|
self.assertEqual(resolved['root_dir'], '/opt/truf')
|
|
self.assertEqual(resolved['project_dir'], '/opt/truf/app')
|
|
self.assertEqual(resolved['result_bundle_dir'], '/opt/truf/runtime/result_bundles')
|
|
self.assertEqual(paths.resolve_postgres_data_dir(), '/opt/truf/runtime/postgres/data')
|
|
self.operating_system.makedirs.assert_not_called()
|
|
|
|
def test_generated_paths_use_posix_separators(self):
|
|
config = paths.apply_path_config({})
|
|
for key, value in config['global'].items():
|
|
if key in {'database_url', 'dashboard_db_url', 'trufflehog_path'}:
|
|
continue
|
|
with self.subTest(key=key):
|
|
self.assertTrue(posixpath.isabs(value))
|
|
self.assertNotIn('\\', value)
|
|
for value in config['supervisor'].values():
|
|
self.assertTrue(posixpath.isabs(value))
|
|
self.assertNotIn('\\', value)
|
|
|
|
def test_environment_defaults_and_explicit_config_precedence(self):
|
|
self.environment.update({
|
|
'SCANNER_ROOT_DIR': '/environment/root',
|
|
'SCANNER_PROJECT_DIR': '/environment/app',
|
|
'SCANNER_RUNTIME_DIR': '/environment/runtime',
|
|
'SCANNER_RESULT_BUNDLE_DIR': '/environment/bundles',
|
|
'TRUFFLEHOG_WORK_DIR': '/environment/work',
|
|
})
|
|
resolved = paths.resolve_project_paths()
|
|
self.assertEqual(resolved['log_dir'], '/environment/runtime/logs')
|
|
self.assertEqual(resolved['result_bundle_dir'], '/environment/bundles')
|
|
self.assertEqual(resolved['work_dir'], '/environment/work')
|
|
explicit = {
|
|
'root_dir': '/configured/root', 'project_dir': '/configured/app',
|
|
'runtime_dir': '/configured/runtime', 'result_bundle_dir': '/configured/bundles',
|
|
'work_dir': '/configured/work',
|
|
}
|
|
resolved = paths.resolve_project_paths(explicit)
|
|
for key, value in explicit.items():
|
|
self.assertEqual(resolved[key], value)
|
|
|
|
def test_explicit_config_directory_and_relative_paths_are_preserved(self):
|
|
resolved = paths.resolve_project_paths(
|
|
{'root_dir': '..', 'project_dir': '.', 'work_dir': 'scratch'},
|
|
'/srv/settings/config.linux.yaml',
|
|
)
|
|
self.assertEqual(resolved['root_dir'], '/srv')
|
|
self.assertEqual(resolved['project_dir'], '/srv/settings')
|
|
self.assertEqual(resolved['work_dir'], '/srv/settings/scratch')
|
|
|
|
def test_windows_paths_are_rejected_before_command_or_relative_resolution(self):
|
|
for value in (
|
|
r'D:\truf', 'D:/truf', 'C:trufflehog', r'\\server\share',
|
|
'//server/share', r'\\?\C:\tools', r'\rooted', r'runtime\logs',
|
|
):
|
|
with self.subTest(value=value), self.assertRaisesRegex(paths.PathResolutionError, 'Windows path'):
|
|
paths.resolve_path(value, base_dir='/opt/truf/app', allow_command=True)
|
|
with self.assertRaises(paths.PathResolutionError):
|
|
paths.resolve_path('{external}', {'external': r'S:\scanner-work'})
|
|
for base in (r'D:\truf', 'D:/truf', r'\\server\share', r'runtime\state'):
|
|
with self.subTest(base=base):
|
|
with self.assertRaisesRegex(paths.PathResolutionError, 'Windows base path'):
|
|
paths.resolve_path('logs', base_dir=base)
|
|
for key in ('project_dir', 'config_dir'):
|
|
with self.assertRaises(paths.PathResolutionError):
|
|
paths.resolve_path('logs', {key: base})
|
|
self.environment['SCANNER_ROOT_DIR'] = r'D:\truf'
|
|
with self.assertRaises(paths.PathResolutionError):
|
|
paths.resolve_project_paths()
|
|
|
|
def test_windows_path_behavior_remains_available_on_windows(self):
|
|
self.operating_system.name = 'nt'
|
|
self.operating_system.path = ntpath
|
|
self.assertEqual(
|
|
paths.resolve_path('{root_dir}/runtime', {'root_dir': r'E:\development'}),
|
|
r'E:\development\runtime',
|
|
)
|
|
with mock.patch.object(ntpath, 'exists', return_value=True):
|
|
self.assertEqual(paths.default_trufflehog_path(), paths.DEFAULT_TRUFFLEHOG)
|
|
|
|
def test_linux_does_not_probe_the_windows_trufflehog_fallback(self):
|
|
with mock.patch.object(posixpath, 'exists', side_effect=AssertionError('Windows binary probe')):
|
|
self.assertEqual(paths.default_trufflehog_path(), 'trufflehog')
|
|
self.assertEqual(paths.resolve_path('trufflehog', allow_command=True), 'trufflehog')
|
|
|
|
def test_required_paths_and_unknown_placeholders_still_fail(self):
|
|
for value in (None, '', ' '):
|
|
with self.subTest(value=value), self.assertRaises(paths.PathResolutionError):
|
|
paths.resolve_path(value, required=True)
|
|
self.assertIsNone(paths.resolve_path(None))
|
|
with self.assertRaisesRegex(paths.PathResolutionError, 'Unknown path placeholder'):
|
|
paths.resolve_path('{missing}/file')
|
|
|
|
def test_managed_database_urls_keep_precedence_and_are_not_filesystem_paths(self):
|
|
managed = 'postgresql://fixture:fixture%5Cvalue@127.0.0.1:5432/fixture'
|
|
self.environment['TRUF_MANAGED_POSTGRES_DSN'] = managed
|
|
resolved = paths.resolve_project_paths({
|
|
'database_url': 'postgresql://other.invalid/other',
|
|
'dashboard_db_url': 'postgresql://other.invalid/dashboard',
|
|
})
|
|
self.assertEqual(resolved['database_url'], managed)
|
|
self.assertEqual(resolved['dashboard_db_url'], managed)
|
|
|
|
def test_linux_profile_resolves_without_windows_storage(self):
|
|
import yaml
|
|
|
|
config = yaml.safe_load((ROOT / 'app' / 'config.linux.yaml').read_text(encoding='utf-8'))
|
|
resolved = paths.apply_path_config(config, '/opt/truf/app/config.linux.yaml')
|
|
expected = {
|
|
'root_dir': '/opt/truf', 'project_dir': '/opt/truf/app',
|
|
'runtime_dir': '/data/runtime-linux', 'postgres_data_dir': '/data/postgres-linux',
|
|
'postgres_bin_dir': '/usr/lib/postgresql/16/bin',
|
|
'result_bundle_dir': '/data/scanner-result-bundles', 'work_dir': '/data/scanner-work',
|
|
'control_dir': '/run/truf/control', 'secrets_file': '/data/config/secrets.yaml',
|
|
}
|
|
for key, value in expected.items():
|
|
self.assertEqual(resolved['global'][key], value)
|
|
for key in ('summary_tsv', 'summary_json', 'alive_summary_tsv'):
|
|
value = paths.resolve_optional_path(resolved['keychecks'][key], resolved['global'])
|
|
self.assertTrue(value.startswith('/data/runtime-linux/keychecks/'))
|
|
self.assertEqual(resolved['supervisor']['control_host'], '127.0.0.1')
|
|
self.assertEqual(resolved['supervisor']['control_dir'], '/run/truf/control')
|
|
self.assertEqual(resolved['supervisor']['instance_file'], '/run/truf/control/supervisor.instance.json')
|
|
self.assertEqual(resolved['global']['max_active_scans'], 1)
|
|
self.assertEqual(resolved['global']['opportunistic_scan_slots'], 0)
|
|
self.assertFalse(resolved['supervisor']['dashboard']['enabled'])
|
|
self.operating_system.makedirs.assert_not_called()
|
|
|
|
|
|
class DockerStagingTests(unittest.TestCase):
|
|
def test_native_checkout_default_is_derived_from_this_copy(self):
|
|
self.assertEqual(Path(paths.CANONICAL_ROOT), ROOT)
|
|
self.assertEqual(Path(paths.APP_DIR), ROOT / 'app')
|
|
self.assertFalse((ROOT / 'app' / 'config.yaml').exists())
|
|
|
|
def test_python_entrypoints_refuse_before_application_imports(self):
|
|
for name in ('runtime_bootstrap.py', 'supervisor.py', 'postgres_runtime.py'):
|
|
entrypoint = ROOT / 'app' / name
|
|
canonical = '/opt/truf/app/' + name
|
|
expected = ast.parse(
|
|
"import sys\nimport os\nif __name__ == '__main__':\n"
|
|
" if sys.platform != 'linux' or not os.path.isfile('/.dockerenv') "
|
|
f'or os.path.abspath(__file__) != {canonical!r}:\n'
|
|
f' raise SystemExit({CONTAINER_REFUSAL!r})\n'
|
|
' import runpy\n'
|
|
" runpy.run_path('/opt/truf/app/container_runtime.py')['require_container']()\n"
|
|
).body
|
|
tree = ast.parse(entrypoint.read_text(encoding='utf-8'))
|
|
statements = tree.body
|
|
if (isinstance(statements[0], ast.Expr)
|
|
and isinstance(statements[0].value, ast.Constant)
|
|
and isinstance(statements[0].value.value, str)):
|
|
statements = statements[1:]
|
|
with self.subTest(entrypoint=name):
|
|
self.assertEqual(
|
|
[ast.dump(node) for node in statements[:3]],
|
|
[ast.dump(node) for node in expected],
|
|
)
|
|
# Execute only the AST-proved guard, never the operational body.
|
|
guard = compile(ast.Module(body=[statements[2]], type_ignores=[]), str(entrypoint), 'exec')
|
|
for platform, dockerenv, filename in (
|
|
('win32', True, canonical), ('linux', False, canonical),
|
|
('linux', True, '/tmp/' + name), ('linux', True, canonical),
|
|
):
|
|
require = mock.Mock()
|
|
runpy = SimpleNamespace(run_path=mock.Mock(return_value={'require_container': require}))
|
|
importer = mock.Mock(return_value=runpy)
|
|
namespace = {
|
|
'__name__': '__main__', '__file__': filename,
|
|
'sys': SimpleNamespace(platform=platform),
|
|
'os': SimpleNamespace(path=SimpleNamespace(
|
|
isfile=lambda path: dockerenv, abspath=lambda path: filename,
|
|
)),
|
|
'__builtins__': {'SystemExit': SystemExit, '__import__': importer},
|
|
}
|
|
if platform == 'linux' and dockerenv and filename == canonical:
|
|
exec(guard, namespace)
|
|
runpy.run_path.assert_called_once_with('/opt/truf/app/container_runtime.py')
|
|
require.assert_called_once_with()
|
|
self.assertEqual([call.args[0] for call in importer.call_args_list], ['runpy'])
|
|
require.side_effect = RuntimeError('unsafe container layout')
|
|
with self.assertRaisesRegex(RuntimeError, 'unsafe container layout'):
|
|
exec(guard, namespace)
|
|
else:
|
|
with self.assertRaisesRegex(SystemExit, 'disabled outside the prepared container'):
|
|
exec(guard, namespace)
|
|
importer.assert_not_called()
|
|
require.assert_not_called()
|
|
# Windows is positively a host refusal case. In the Linux image,
|
|
# keep the full AST/mock coverage above without invoking its CLI.
|
|
if sys.platform == 'win32':
|
|
with tempfile.TemporaryDirectory(prefix='container-cli-refusal-') as temporary:
|
|
completed = subprocess.run(
|
|
[sys.executable, '-I', '-S', '-B', str(entrypoint), '--stop-background'],
|
|
cwd=temporary, env=isolated_child_environment(temporary),
|
|
stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=15,
|
|
)
|
|
self.assertEqual(completed.returncode, 1)
|
|
self.assertIn(CONTAINER_REFUSAL, completed.stderr)
|
|
self.assertNotIn('Traceback', completed.stderr)
|
|
|
|
@unittest.skipUnless(os.name == 'nt', 'Windows PowerShell parser required')
|
|
def test_all_copied_powershell_tools_have_static_refusals(self):
|
|
powershell = shutil.which('powershell.exe')
|
|
self.assertIsNotNone(powershell)
|
|
entrypoints = sorted(ROOT.glob('*.ps1'))
|
|
self.assertTrue(entrypoints)
|
|
# Never execute operational PowerShell scripts to test their safety guard.
|
|
check = r"""
|
|
$ErrorActionPreference = 'Stop'
|
|
foreach ($file in @(__FILES__)) {
|
|
$errors = $null
|
|
$tokens = $null
|
|
$tree = [System.Management.Automation.Language.Parser]::ParseFile($file, [ref]$tokens, [ref]$errors)
|
|
if ($errors.Count -ne 0) { throw 'PowerShell syntax error' }
|
|
if ($tree.BeginBlock -or $tree.ProcessBlock -or $tree.DynamicParamBlock) { throw 'Unexpected startup block' }
|
|
$statements = @($tree.EndBlock.Statements)
|
|
if ($statements.Count -lt 2) { throw 'Missing startup refusal' }
|
|
if ($statements[0].Extent.Text -cne '$ErrorActionPreference = ''Stop''') { throw 'Unexpected startup statement' }
|
|
if ($statements[1] -isnot [System.Management.Automation.Language.ThrowStatementAst]) { throw 'Missing startup throw' }
|
|
if ($statements[1].Extent.Text -cne __THROW__) { throw 'Unexpected refusal expression' }
|
|
foreach ($parameter in @($tree.ParamBlock.Parameters)) {
|
|
if ($parameter.DefaultValue -and
|
|
$parameter.DefaultValue -isnot [System.Management.Automation.Language.ConstantExpressionAst] -and
|
|
$parameter.DefaultValue -isnot [System.Management.Automation.Language.StringConstantExpressionAst]) {
|
|
throw 'Nonliteral parameter default before refusal'
|
|
}
|
|
}
|
|
$attributes = $tree.FindAll({ param($node)
|
|
$node -is [System.Management.Automation.Language.AttributeAst] -or
|
|
$node -is [System.Management.Automation.Language.TypeConstraintAst]
|
|
}, $true)
|
|
foreach ($attribute in $attributes) {
|
|
if ($attribute.Extent.StartOffset -lt $statements[1].Extent.StartOffset -and
|
|
$attribute.TypeName.FullName -notin @('CmdletBinding', 'ValidateRange', 'string', 'int', 'switch')) {
|
|
throw 'Unexpected parameter attribute before refusal'
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
files = ','.join("'" + str(path).replace("'", "''") + "'" for path in entrypoints)
|
|
expected_throw = "throw '" + STAGING_REFUSAL + "'"
|
|
check = check.replace('__FILES__', files).replace('__THROW__', "'" + expected_throw.replace("'", "''") + "'")
|
|
with tempfile.TemporaryDirectory(prefix='powershell-ast-') as temporary:
|
|
completed = subprocess.run(
|
|
[powershell, '-NoProfile', '-Command', check],
|
|
cwd=temporary, env=isolated_child_environment(temporary),
|
|
stdin=subprocess.DEVNULL, capture_output=True, timeout=15,
|
|
)
|
|
self.assertEqual(completed.returncode, 0, completed.stderr)
|
|
|
|
def test_docker_context_exceptions_are_explicit_source_files_only(self):
|
|
rules = [line.strip() for line in (ROOT / '.dockerignore').read_text(encoding='ascii').splitlines()
|
|
if line.strip() and not line.startswith('#')]
|
|
self.assertEqual(rules[0], '**')
|
|
allowed = {line[1:] for line in rules if line.startswith('!')}
|
|
for relative in allowed:
|
|
with self.subTest(relative=relative):
|
|
self.assertNotRegex(relative, r'[*?\[\\]')
|
|
self.assertFalse(relative.endswith('/'))
|
|
self.assertNotIn('..', Path(relative).parts)
|
|
if relative != 'Dockerfile':
|
|
self.assertTrue((ROOT / relative).is_file())
|
|
for source in (ROOT / 'app').rglob('*.py'):
|
|
self.assertIn(source.relative_to(ROOT).as_posix(), allowed)
|
|
for forbidden in (
|
|
'.env.postgres', 'app/secrets.yaml', 'app/secrets.yaml.bak',
|
|
'app/keycheckers/gemini/gem.txt', 'app/scanner.db', 'app/credentials.json',
|
|
'runtime/postgres/data/PG_VERSION', 'runtime/results/found_secrets.jsonl',
|
|
'.git/config', '.opencode/package.json',
|
|
):
|
|
self.assertNotIn(forbidden, allowed)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|