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

624 lines
27 KiB
Python

import base64
from contextlib import contextmanager
import json
import os
from pathlib import Path
import sys
import tempfile
import time
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
APP_DIR = ROOT / 'app'
sys.path.insert(0, str(APP_DIR))
import scanner
import console_runner
class Response:
def __init__(self, payload=None, content=b'', status_code=200):
self._payload = payload
self.content = content
self.status_code = status_code
self.headers = {}
self.text = ''
def json(self):
return self._payload
def raise_for_status(self):
return None
def close(self):
return None
class ScannerDiagnosticBoundTests(unittest.TestCase):
@staticmethod
def trufflehog_message(message, level='info-0'):
return json.dumps({'level': level, 'msg': message})
def test_gitlab_missing_repository_is_a_terminal_target_skip(self):
response = mock.Mock(status_code=404, text='404 Repository Not Found', headers={})
response.iter_content.return_value = [b'{"message":"404 Repository Not Found"}']
response.raise_for_status.side_effect = scanner.requests.exceptions.HTTPError(response=response)
with mock.patch.object(scanner, 'api_request', return_value=response), \
mock.patch.object(scanner, 'run_command_streamed') as command:
result = scanner.scan_git_repo(
'https://gitlab.com/example/deleted.git', provider='gitlab',
max_commit_age_days=90, skip_if_commit_lookup_fails=False,
)
self.assertIn('no repository', result['skipped'])
self.assertFalse(result.get('source_failure', False))
self.assertFalse(result['errors'])
command.assert_not_called()
def test_go_memory_limit_is_terminal_for_one_target(self):
stderr = 'fatal error: out of memory\n' + ('runtime stack frame\n' * 100)
result = scanner.apply_trufflehog_diagnostics({}, stderr, 2, 'docker')
self.assertEqual(result['error_class'], 'memory_limit')
self.assertFalse(result['retryable'])
self.assertFalse(result.get('source_failure', False))
self.assertEqual(len(result['errors']), 1)
def test_docker_manifest_unauthorized_is_target_scoped(self):
stderr = json.dumps({
'level': 'error',
'msg': 'error processing image',
'error': 'GET https://index.docker.io/v2/example/image/manifests/sha256:abc: UNAUTHORIZED: authentication required',
})
result = scanner.apply_trufflehog_diagnostics({}, stderr, 1, 'docker')
self.assertEqual(result['error_class'], 'docker_registry_access')
self.assertFalse(result['retryable'])
self.assertFalse(result.get('source_failure', False))
def test_non_docker_unauthorized_remains_source_auth_failure(self):
result = scanner.apply_trufflehog_diagnostics(
{}, 'authentication failed: bad credentials', 1, 'git',
)
self.assertEqual(result['error_class'], 'source_auth')
self.assertTrue(result['source_failure'])
self.assertTrue(result['source_failure_auth_related'])
def test_command_output_capacity_error_is_terminal_for_one_target(self):
severity, error_class, retryable = scanner._trufflehog_diagnostic_policy(
'Error: TruffleHog stdout exceeded 33554432 bytes',
'git',
-1,
)
self.assertEqual((severity, error_class, retryable), ('error', 'output_limit', False))
status, available_after, attempts, max_attempts = console_runner.queue_error_disposition(
None,
'github',
'github',
'target',
{'errors': ['oversized'], 'error_class': 'output_limit', 'retryable': False},
mock.Mock(target_retry_max_attempts=3),
{'attempts': 1},
)
self.assertEqual((status, available_after, attempts, max_attempts), ('failed', None, 1, 3))
def test_complete_docker_process_records_completion(self):
result = scanner.apply_trufflehog_diagnostics(
{'findings': [], 'errors': []},
self.trufflehog_message('finished scanning'),
0,
'docker',
)
self.assertFalse(result['errors'])
self.assertTrue(result['scan_meta']['trufflehog_finished'])
def test_docker_zero_exit_without_completion_is_retryable(self):
result = scanner.apply_trufflehog_diagnostics(
{'findings': [], 'errors': []},
self.trufflehog_message('running source'),
0,
'docker',
)
self.assertEqual(result['error_class'], 'command_incomplete')
self.assertTrue(result['retryable'])
self.assertFalse(result['scan_meta']['trufflehog_finished'])
def test_docker_one_exit_without_completion_preserves_findings_and_retries(self):
finding = {'DetectorName': 'Example'}
result = scanner.apply_trufflehog_diagnostics(
{'findings': [finding], 'errors': []},
self.trufflehog_message('running source'),
1,
'docker',
)
self.assertEqual(result['findings'], [finding])
self.assertEqual(result['error_class'], 'command_incomplete')
self.assertTrue(result['retryable'])
args = mock.Mock(
target_retry_max_attempts=3,
target_retry_base_delay_sec=60,
target_retry_max_delay_sec=3600,
)
first = console_runner.queue_error_disposition(
None, 'dockerhub', 'docker', 'target', result, args, {'attempts': 1},
)
exhausted = console_runner.queue_error_disposition(
None, 'dockerhub', 'docker', 'target', result, args, {'attempts': 3},
)
self.assertEqual((first[0], first[2], first[3]), ('deferred', 1, 3))
self.assertIsNotNone(first[1])
self.assertEqual(exhausted, ('failed', None, 3, 3))
def test_docker_timeout_preserves_findings_and_stops_at_attempt_limit(self):
finding = {'DetectorName': 'PartialFinding'}
result = scanner.apply_trufflehog_diagnostics(
{'findings': [finding], 'errors': []},
'Command timed out after 600 seconds',
-1,
'docker',
)
args = mock.Mock(
target_retry_max_attempts=3,
target_timeout_retry_delay_sec=21600,
)
first = console_runner.queue_error_disposition(
None, 'dockerhub', 'docker', 'target', result, args, {'attempts': 1},
)
exhausted = console_runner.queue_error_disposition(
None, 'dockerhub', 'docker', 'target', result, args, {'attempts': 3},
)
self.assertEqual(result['findings'], [finding])
self.assertEqual(result['error_class'], 'timeout')
self.assertEqual((first[0], first[2], first[3]), ('deferred', 1, 3))
self.assertIsNotNone(first[1])
self.assertEqual(exhausted, ('failed', None, 3, 3))
self.assertFalse(console_runner.queue_result_resets_attempts(result))
self.assertTrue(console_runner.queue_result_resets_attempts({
'source_failure': True,
'retryable': True,
'error_class': 'remote_transient',
}))
def test_unexplained_exit_after_nonfatal_warning_is_retryable(self):
result = scanner.apply_trufflehog_diagnostics(
{'findings': [], 'errors': []},
self.trufflehog_message('a detector ignored the context timeout', level='error'),
1,
'git',
)
self.assertEqual(result['error_class'], 'command_exit')
self.assertTrue(result['retryable'])
self.assertEqual(result['warning_classes'], ['detector_timeout'])
def test_docker_nonzero_exit_after_completion_is_retryable_wrapper_exit(self):
result = scanner.apply_trufflehog_diagnostics(
{'findings': [], 'errors': []},
self.trufflehog_message('finished scanning'),
1,
'docker',
)
self.assertEqual(result['error_class'], 'wrapper_exit')
self.assertTrue(result['retryable'])
self.assertTrue(result['scan_meta']['trufflehog_finished'])
def test_coverage_warnings_preserve_fatal_error_retry_policy(self):
finding = {'DetectorName': 'PartialFinding'}
for message, detail, source, warning_class in (
('error processing layer', 'gzip: invalid header', 'docker', 'docker_layer_gzip'),
('non-critical error processing chunk', 'invalid archive', 'git', 'chunk_processing'),
):
for error, error_class, retryable in (
('connection reset', 'network', True),
('unknown flag', 'source_configuration', False),
):
with self.subTest(warning_class=warning_class, error_class=error_class):
warning = json.dumps({'level': 'error', 'msg': message, 'error': detail})
fatal = json.dumps({'level': 'error', 'msg': 'source failed', 'error': error})
stderr = '\n'.join((warning, fatal, self.trufflehog_message('finished scanning')))
result = scanner.apply_trufflehog_diagnostics(
{'findings': [finding], 'errors': []}, stderr, 0, source,
)
self.assertEqual(result['error_class'], error_class)
self.assertEqual(result['retryable'], retryable)
self.assertEqual(result['warning_classes'], [warning_class])
self.assertTrue(result['degraded'])
self.assertEqual(result['findings'], [finding])
self.assertFalse(result['scan_meta']['trufflehog_warnings_retryable'])
def test_docker_detector_context_timeout_is_nonfatal_degraded_coverage(self):
stderr = '\n'.join((
self.trufflehog_message('a detector ignored the context timeout', level='error'),
self.trufflehog_message('finished scanning'),
))
result = scanner.apply_trufflehog_diagnostics(
{'findings': [], 'errors': []}, stderr, 0, 'docker',
)
self.assertFalse(result['errors'])
self.assertTrue(result['degraded'])
self.assertEqual(result['warning_classes'], ['detector_timeout'])
self.assertTrue(result['scan_meta']['trufflehog_finished'])
self.assertEqual(scanner.target_status(result), 'degraded')
def test_other_docker_timeout_remains_retryable_error(self):
stderr = '\n'.join((
json.dumps({'level': 'error', 'msg': 'error processing image', 'error': 'request timed out'}),
self.trufflehog_message('finished scanning'),
))
result = scanner.apply_trufflehog_diagnostics(
{'findings': [], 'errors': []}, stderr, 0, 'docker',
)
self.assertEqual(result['error_class'], 'timeout')
self.assertTrue(result['retryable'])
def test_non_docker_unexplained_exit_uses_bounded_retry_policy(self):
result = scanner.apply_trufflehog_diagnostics(
{'findings': [], 'errors': []},
self.trufflehog_message('running source'),
1,
'git',
)
self.assertEqual(result['error_class'], 'command_exit')
self.assertTrue(result['retryable'])
self.assertEqual(result['scan_meta']['trufflehog_returncode'], 1)
self.assertEqual(result['scan_meta']['diagnostic_lines_processed'], 1)
args = mock.Mock(
target_retry_max_attempts=3,
target_retry_base_delay_sec=60,
target_retry_max_delay_sec=3600,
)
first = console_runner.queue_error_disposition(
None, 'github', 'github', 'target', result, args, {'attempts': 1},
)
exhausted = console_runner.queue_error_disposition(
None, 'github', 'github', 'target', result, args, {'attempts': 3},
)
self.assertEqual((first[0], first[2], first[3]), ('deferred', 1, 3))
self.assertIsNotNone(first[1])
self.assertEqual(exhausted, ('failed', None, 3, 3))
@classmethod
def setUpClass(cls):
scanner.initialize_scanner_runtime(preflight_complete=True, register_cleanup=False)
def test_sixteen_mib_repeated_errors_have_bounded_policy_work_and_projection(self):
class NoSplitLines(str):
def splitlines(self, *args, **kwargs):
raise AssertionError('splitlines materialized diagnostic output')
repetitions = (16 * 1024 * 1024 // len('error\n')) + 1
stderr = NoSplitLines('error\n' * repetitions)
with mock.patch.object(
scanner,
'_trufflehog_diagnostic_policy',
wraps=scanner._trufflehog_diagnostic_policy,
) as policy:
result = scanner.apply_trufflehog_diagnostics({}, stderr, 1, 'git')
self.assertLessEqual(policy.call_count, scanner.scan_config.trufflehog_diagnostic_max_errors + 1)
self.assertLessEqual(len(result['errors']), scanner.scan_config.trufflehog_diagnostic_max_errors)
self.assertEqual(sum('output_limit' in line for line in result['errors']), 1)
self.assertTrue(all(len(line) <= scanner.scan_config.trufflehog_diagnostic_max_line_chars for line in result['errors']))
self.assertEqual(result['error_class'], 'source_resource')
self.assertEqual(result['source_failure_category'], 'source_resource')
self.assertTrue(result['scan_meta']['diagnostic_output_limited'])
def test_oversized_single_line_is_not_classified_or_retained(self):
stderr = 'x' * (16 * 1024 * 1024)
with mock.patch.object(scanner, '_trufflehog_diagnostic_policy') as policy:
result = scanner.apply_trufflehog_diagnostics({}, stderr, 1, 'git')
policy.assert_not_called()
self.assertEqual(len(result['errors']), 1)
self.assertIn('output_limit', result['errors'][0])
self.assertNotIn('x' * 100, result['errors'][0])
def test_canonical_limits_are_loaded_and_applied(self):
config = console_runner.load_config(str(APP_DIR / 'config.yaml'))
configured = config['global']
self.assertEqual(configured['trufflehog_diagnostic_max_lines'], 2000)
self.assertEqual(configured['postman_discovery_max_artifacts_per_page'], 100)
self.assertEqual(configured['postman_cache_lock_timeout_sec'], 300)
self.assertEqual(configured['target_claim_batch_size'], 1)
self.assertEqual(config['sources']['github']['workers'], 1)
self.assertFalse(config['sources']['github']['enabled'])
self.assertEqual(config['sources']['gitlab']['workers'], 1)
self.assertEqual(config['sources']['huggingface']['workers'], 1)
self.assertEqual(config['sources']['huggingface']['discovery_request_attempts'], 3)
self.assertEqual(config['sources']['huggingface']['discovery_retry_delay'], 5)
self.assertEqual(config['sources']['dockerhub']['workers'], 2)
self.assertEqual(config['sources']['dockerhub']['pages'], 30)
self.assertEqual(config['sources']['dockerhub']['per_page'], 100)
self.assertEqual(
config['sources']['dockerhub']['docker_repository_refresh_max_per_cycle'],
0,
)
self.assertEqual(config['sources']['dockerhub']['tag_fetch_workers'], 4)
self.assertEqual(config['sources']['dockerhub']['timeout'], 600)
self.assertEqual(config['sources']['dockerhub']['trufflehog_concurrency'], 4)
self.assertTrue(config['sources']['dockerhub']['refresh_registry'])
self.assertEqual(config['sources']['dockerhub']['trufflehog_job_memory_limit_bytes'], 6 * 1024 * 1024 * 1024)
self.assertFalse(config['sources']['package_git']['enabled'])
self.assertEqual(config['sources']['package_git']['max_targets'], 30)
self.assertEqual(config['sources']['package_git']['target_claim_order'], 'balanced')
self.assertFalse(config['sources']['github_actions']['enabled'])
self.assertEqual(config['sources']['github_actions']['workers'], 1)
self.assertTrue(config['sources']['github_actions']['refresh_registry'])
self.assertEqual(config['sources']['github_actions']['target_claim_order'], 'balanced')
self.assertFalse(config['supervisor']['sources']['github_actions']['enabled'])
self.assertNotIn('github_actions', config['supervisor']['enabled_sources'])
self.assertNotIn('github', config['supervisor']['enabled_sources'])
enabled_source_count = sum(
source != 'pypi' and bool(source_config.get('enabled', True))
for source, source_config in console_runner.load_config(str(APP_DIR / 'config.yaml'))['sources'].items()
)
self.assertGreaterEqual(
configured['result_bundle_max_total_bytes'],
3 * configured['result_bundle_max_event_bytes'],
)
previous = scanner.scan_config.__dict__.copy()
try:
console_runner.apply_global_config({
'trufflehog_diagnostic_max_errors': 17,
'postman_discovery_max_elapsed_sec': 2.5,
})
self.assertEqual(scanner.scan_config.trufflehog_diagnostic_max_errors, 17)
self.assertEqual(scanner.scan_config.postman_discovery_max_elapsed_sec, 2.5)
finally:
scanner.scan_config.__dict__.clear()
scanner.scan_config.__dict__.update(previous)
class NonPackagePostmanDiscoveryBoundTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
scanner.initialize_scanner_runtime(preflight_complete=True, register_cleanup=False)
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
self.cache_dir = os.path.join(self.temp_dir.name, 'cache')
scanner.ensure_private_directory(self.cache_dir, reject_reparse=True)
self.patchers = (
mock.patch.object(scanner.scan_config, 'runtime_dir', self.temp_dir.name),
mock.patch.object(scanner.scan_config, 'postman_cache_dir', self.cache_dir),
mock.patch.object(scanner.scan_config, 'postman_cache_max_items', 10000),
mock.patch.object(scanner.scan_config, 'postman_cache_max_bytes', 64 * 1024 * 1024),
mock.patch.object(scanner.scan_config, 'postman_cache_min_free_bytes', 0),
)
for patcher in self.patchers:
patcher.start()
self.addCleanup(patcher.stop)
@staticmethod
def _json_content(index):
return json.dumps({'value': index}, separators=(',', ':')).encode('ascii')
def test_code_search_batches_one_cache_scan_and_stops_at_shared_count(self):
items = [
{
'repository': {'full_name': 'owner/repo'},
'path': f'{index}.postman_collection.json',
'sha': f'sha-{index}',
'url': f'https://api.github.test/content/{index}',
'html_url': f'https://github.test/owner/repo/{index}',
}
for index in range(20)
]
def request(_pool, _method, url, **kwargs):
if url.endswith('/search/code'):
self.assertIsNone(kwargs.get('use_proxy'))
return Response({'items': items})
self.assertIs(kwargs['use_proxy'], False)
index = int(url.rsplit('/', 1)[-1])
content = self._json_content(index)
return Response({
'size': len(content),
'encoding': 'base64',
'content': base64.b64encode(content).decode('ascii'),
})
with mock.patch.object(scanner.GitHubTokenPool, 'request', autospec=True, side_effect=request) as requests, \
mock.patch.object(scanner, 'postman_cache_usage', wraps=scanner.postman_cache_usage) as usage:
targets = scanner.fetch_github_postman_targets(
'fixture', pages=1, per_page=100, token='token', search_kinds='collection',
cache_dir=self.cache_dir, max_file_age_days=0, max_artifact_size_mb=1,
discovery_max_artifacts=5, discovery_max_artifacts_per_page=5,
discovery_max_bytes=1024 * 1024, discovery_max_elapsed_sec=10,
)
self.assertEqual(len(targets), 5)
self.assertEqual(requests.call_count, 6)
self.assertEqual(usage.call_count, 1)
def test_gist_page_batches_one_cache_scan_and_bounds_raw_fetches(self):
files = {
f'.env-{index}': {
'size': 20,
'raw_url': f'https://gist.test/raw/{index}',
'type': 'text/plain',
}
for index in range(20)
}
gists = [{'id': 'gist-1', 'files': files, 'html_url': 'https://gist.test/gist-1'}]
def request(_method, url, **kwargs):
if url.endswith('/gists/public'):
self.assertIsNone(kwargs.get('use_proxy'))
return Response(gists)
self.assertIs(kwargs['use_proxy'], False)
return Response(content=self._json_content(int(url.rsplit('/', 1)[-1])))
with mock.patch.object(scanner, 'api_request', side_effect=request) as requests, \
mock.patch.object(scanner, 'postman_cache_usage', wraps=scanner.postman_cache_usage) as usage:
targets = scanner.fetch_github_gist_targets(
pages=1, cache_dir=self.cache_dir, max_file_size_mb=1,
discovery_max_artifacts=10, discovery_max_artifacts_per_page=4,
discovery_max_bytes=1024 * 1024, discovery_max_elapsed_sec=10,
)
self.assertEqual(len(targets), 4)
self.assertEqual(requests.call_count, 5)
self.assertEqual(usage.call_count, 1)
def test_gharchive_hour_batches_one_cache_scan_and_bounds_raw_fetches(self):
event = {
'type': 'PushEvent',
'repo': {'name': 'owner/repo'},
'payload': {
'commits': [{
'sha': 'abc123',
'added': [f'.env-{index}' for index in range(20)],
}],
},
}
@contextmanager
def archive_reader(*args, **kwargs):
yield 'fixture-archive'
def request(_method, url, **kwargs):
index = int(url.rsplit('-', 1)[-1])
self.assertIs(kwargs['use_proxy'], False)
return Response(content=self._json_content(index))
with mock.patch.object(scanner, 'cached_gharchive_hour_reader', side_effect=archive_reader), \
mock.patch.object(scanner, 'iter_gharchive_lines', return_value=[json.dumps(event).encode('utf-8')]), \
mock.patch.object(scanner, 'api_request', side_effect=request) as requests, \
mock.patch.object(scanner, 'postman_cache_usage', wraps=scanner.postman_cache_usage) as usage:
targets = scanner.fetch_github_archive_file_targets(
hours_back=1, max_files=20, cache_dir=self.cache_dir, max_file_size_mb=1,
discovery_max_artifacts=10, discovery_max_artifacts_per_page=3,
discovery_max_bytes=1024 * 1024, discovery_max_elapsed_sec=10,
)
self.assertEqual(len(targets), 3)
self.assertEqual(requests.call_count, 3)
self.assertEqual(usage.call_count, 1)
def test_cache_scan_receives_deadline_and_deadline_is_fail_soft(self):
gists = [{
'id': 'gist-1',
'files': {'.env': {'size': 10, 'raw_url': 'https://gist.test/raw/1', 'type': 'text/plain'}},
}]
def request(_method, url, **kwargs):
return Response(gists) if url.endswith('/gists/public') else Response(content=b'{"ok":1}')
deadlines = []
def expired_usage(root, deadline=None):
deadlines.append(deadline)
raise scanner._PostmanHarvestDeadlineReached()
with mock.patch.object(scanner, 'api_request', side_effect=request), \
mock.patch.object(scanner, 'postman_cache_usage', side_effect=expired_usage) as usage, \
self.assertLogs(scanner.logger, level='WARNING') as captured:
targets = scanner.fetch_github_gist_targets(
pages=1, cache_dir=self.cache_dir, max_file_size_mb=1,
discovery_max_elapsed_sec=10,
)
self.assertEqual(targets, [])
self.assertEqual(usage.call_count, 1)
self.assertTrue(deadlines[0] is not None)
self.assertTrue(any('deadline' in line.lower() for line in captured.output))
def test_hundred_thousand_entry_cache_scan_stops_after_bounded_operations(self):
class Entry:
name = 'artifact.json'
path = os.path.join(self.cache_dir, 'artifact.json')
@staticmethod
def is_symlink():
return False
@staticmethod
def is_dir(follow_symlinks=False):
return False
@staticmethod
def is_file(follow_symlinks=False):
return True
@staticmethod
def stat(follow_symlinks=False):
return type('Stat', (), {'st_size': 1})()
class Entries:
def __init__(self):
self.calls = 0
def __enter__(self):
return self
def __exit__(self, *args):
return False
def __iter__(self):
return self
def __next__(self):
if self.calls >= 100000:
raise StopIteration
self.calls += 1
return Entry()
entries = Entries()
clock = 0.0
def advancing_clock():
nonlocal clock
clock += 0.001
return clock
started = time.perf_counter()
with mock.patch.object(scanner.os, 'scandir', return_value=entries), \
mock.patch.object(scanner, 'is_reparse_point', return_value=False), \
mock.patch.object(scanner.time, 'monotonic', side_effect=advancing_clock), \
self.assertRaises(scanner._PostmanHarvestDeadlineReached):
scanner.postman_cache_usage(self.cache_dir, deadline=0.01)
self.assertLess(entries.calls, 20)
self.assertLess(time.perf_counter() - started, 0.5)
def test_cache_capacity_corruption_remains_fail_closed(self):
gists = [{
'id': 'gist-1',
'files': {'.env': {'size': 10, 'raw_url': 'https://gist.test/raw/1', 'type': 'text/plain'}},
}]
def request(_method, url, **kwargs):
return Response(gists) if url.endswith('/gists/public') else Response(content=b'{"ok":1}')
with mock.patch.object(scanner, 'api_request', side_effect=request), \
mock.patch.object(
scanner, 'postman_cache_usage',
side_effect=scanner.PostmanCacheCapacityError('corrupt linked cache entry'),
), self.assertRaisesRegex(scanner.PostmanCacheCapacityError, 'corrupt'):
scanner.fetch_github_gist_targets(
pages=1, cache_dir=self.cache_dir, max_file_size_mb=1,
discovery_max_elapsed_sec=10,
)
if __name__ == '__main__':
unittest.main()