Harden pre-production deployment with external environment and preflights

This commit is contained in:
2026-09-16 00:22:19 +02:00
parent 5d3ae10036
commit 2813795c9d
17 changed files with 1002 additions and 76 deletions
+4
View File
@@ -1,5 +1,9 @@
.env
.env.*
*.env
*.env.*
*.swp
*.swo
secrets/
**/*service-account*.json
**/*service_account*.json
+62
View File
@@ -0,0 +1,62 @@
"""Read-only bug-report connectivity check. Never emits response bodies or tokens."""
import re
from urllib.parse import quote, urlsplit
import httpx
from gitea_service import GiteaConfig, GiteaError
def check(config=None, transport=None):
config = config or GiteaConfig.from_env()
config.validate()
try:
parsed = urlsplit(config.url)
if (not parsed.hostname or re.search(r'\s|\\', config.url) or
parsed.port == 0 or config.owner in {'.', '..'} or config.repo in {'.', '..'}):
raise GiteaError('configuration')
except ValueError:
raise GiteaError('configuration') from None
try:
with httpx.Client(base_url=config.url.rstrip('/') + '/api/v1/',
headers={'Authorization': 'token ' + config.token,
'Accept': 'application/json'},
timeout=httpx.Timeout(15.0, connect=5.0),
follow_redirects=False, trust_env=False, transport=transport) as client:
identity = client.get('user')
identity.raise_for_status()
user = identity.json()
if not isinstance(user, dict) or not isinstance(user.get('login'), str):
raise GiteaError('invalid_response')
if user['login'] != 'metalcircle-bot':
raise GiteaError('wrong_account')
# Test the issue resource using the existing write:issue scope.
# GET repository metadata would require an additional read:repository scope.
repository = 'repos/' + quote(config.owner, safe='') + '/' + quote(config.repo, safe='')
response = client.get(repository + '/issues', params={'limit': 1, 'page': 1})
response.raise_for_status()
if not isinstance(response.json(), list):
raise GiteaError('invalid_response')
except GiteaError:
raise
except httpx.HTTPStatusError as error:
raise GiteaError('http_' + str(error.response.status_code)) from None
except (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError):
raise GiteaError('network_or_timeout') from None
except (ValueError, TypeError, KeyError, httpx.HTTPError):
raise GiteaError('invalid_response') from None
def main():
try:
check()
except GiteaError as error:
print('FAIL: gitea_' + error.kind)
return 1
print('PASS: Gitea reachable; metalcircle-bot verified; repository issues readable. '
'Read-only check; issue creation and attachments not tested.')
return 0
if __name__ == '__main__':
raise SystemExit(main())
+77
View File
@@ -0,0 +1,77 @@
from contextlib import redirect_stdout
from dataclasses import replace
from io import StringIO
import unittest
from unittest.mock import patch
import httpx
from gitea_service import GiteaConfig, GiteaError
import gitea_preflight
class GiteaPreflightTests(unittest.TestCase):
config = GiteaConfig('https://gitea.invalid', 'synthetic-private-value', 'kai', 'pingu-concerts')
def test_only_get_requests_with_existing_scopes_and_no_payload_logging(self):
requests = []
def handler(request):
requests.append(request)
self.assertEqual(request.method, 'GET')
self.assertNotIn(self.config.token, str(request.url))
self.assertEqual(request.headers['Authorization'], 'token ' + self.config.token)
if request.url.path.endswith('/user'):
return httpx.Response(200, json={'login': 'metalcircle-bot'})
self.assertEqual(request.url.path, '/api/v1/repos/kai/pingu-concerts/issues')
self.assertEqual(request.url.params['limit'], '1')
return httpx.Response(200, json=[{'body': 'private report contents'}])
output = StringIO()
with redirect_stdout(output):
gitea_preflight.check(self.config, httpx.MockTransport(handler))
self.assertEqual(output.getvalue(), '')
self.assertEqual(len(requests), 2)
def test_missing_gitea_values_and_invalid_configuration_never_contact_network(self):
for field in ('url', 'token', 'owner', 'repo'):
with self.subTest(field=field), self.assertRaisesRegex(GiteaError, '^configuration$'):
gitea_preflight.check(replace(self.config, **{field: ''}))
for field, value in (('url', 'file:///tmp/test'), ('url', 'https://user:pass@gitea.invalid'),
('url', 'https://gitea.invalid:invalid'), ('url', 'https://bad host'),
('url', 'https://gitea.invalid?token=secret'), ('owner', '../owner'),
('repo', '..'), ('repo', 'some/repo')):
with self.subTest(field=field, value=value), self.assertRaisesRegex(GiteaError, '^configuration$'):
gitea_preflight.check(replace(self.config, **{field: value}))
def test_wrong_account_stops_before_repository_access(self):
def handler(request):
self.assertTrue(request.url.path.endswith('/user'))
return httpx.Response(200, json={'login': 'kai'})
with self.assertRaisesRegex(GiteaError, '^wrong_account$'):
gitea_preflight.check(self.config, httpx.MockTransport(handler))
def test_http_failures_are_sanitized_for_both_requests(self):
for endpoint in ('/user', '/issues'):
for status in (401, 403, 404, 500, 503, 302):
def handler(request):
if request.url.path.endswith(endpoint):
return httpx.Response(status, text=self.config.token + ' private response')
return httpx.Response(200, json={'login': 'metalcircle-bot'})
with self.subTest(endpoint=endpoint, status=status):
with self.assertRaisesRegex(GiteaError, '^http_' + str(status) + '$'):
gitea_preflight.check(self.config, httpx.MockTransport(handler))
def test_timeout_network_and_invalid_response_are_sanitized(self):
for error in (httpx.ConnectError(self.config.token), httpx.ReadTimeout(self.config.token)):
def handler(request):
raise error
with self.assertRaisesRegex(GiteaError, '^network_or_timeout$'):
gitea_preflight.check(self.config, httpx.MockTransport(handler))
for response in (httpx.Response(200, text='invalid json'), httpx.Response(200, json=[])):
with self.assertRaisesRegex(GiteaError, '^invalid_response$'):
gitea_preflight.check(self.config, httpx.MockTransport(lambda request: response))
def test_cli_output_only_contains_safe_categories(self):
output = StringIO()
with patch.object(gitea_preflight, 'check', side_effect=GiteaError('http_403')), redirect_stdout(output):
self.assertEqual(gitea_preflight.main(), 1)
self.assertEqual(output.getvalue(), 'FAIL: gitea_http_403\n')
+5
View File
@@ -73,3 +73,8 @@ class PushPreflightTests(unittest.TestCase):
with self.subTest(target=target), patch(target, side_effect=ValueError('sensitive SDK details')):
with self.assertRaisesRegex(push_preflight.PreflightError, '^credential_missing_unreadable_or_invalid$'):
push_preflight.check(self.account)
def test_missing_credential_fails_without_disclosing_path_or_sdk_details(self):
with patch('push_preflight.Path.stat', side_effect=FileNotFoundError('private filename')):
with self.assertRaisesRegex(push_preflight.PreflightError, '^credential_missing_unreadable_or_invalid$'):
push_preflight.check(self.account)