Harden pre-production deployment with external environment and preflights
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
.env
|
||||
.env.*
|
||||
*.env
|
||||
*.env.*
|
||||
*.swp
|
||||
*.swo
|
||||
secrets/
|
||||
**/*service-account*.json
|
||||
**/*service_account*.json
|
||||
|
||||
@@ -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())
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user