Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 7m57s
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
223 lines
12 KiB
Python
223 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Synthetic native reset prerequisite, with no ports, host mounts or external routes.
|
|
|
|
Native Authelia and LLDAP, not a browser/OIDC/app/MFA acceptance test.
|
|
Private fixture values stay in pipes or disposable container tmpfs.
|
|
"""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import subprocess
|
|
import tarfile
|
|
import tempfile
|
|
import urllib.parse
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
AUTHELIA = 'sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54'
|
|
LLDAP = 'sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d'
|
|
CLIENT = 'sha256:512819463a1902b43a615beee14be7bf97e6a05e016385082b9380692ad1a8cd'
|
|
HERE = Path(__file__).resolve().parent
|
|
PROBE = (HERE / 'native_recovery/probe.py').read_text()
|
|
|
|
|
|
def docker(*args, data=None, check=True):
|
|
result = subprocess.run(['docker', '--context', 'default', *args], input=data,
|
|
capture_output=True, timeout=90,
|
|
env={'PATH': os.environ['PATH']})
|
|
if check and result.returncode:
|
|
raise RuntimeError('docker_' + args[0] + '_refused')
|
|
return result
|
|
|
|
|
|
def archive(files):
|
|
buffer = io.BytesIO()
|
|
with tarfile.open(fileobj=buffer, mode='w') as tar:
|
|
for name, data in files.items():
|
|
item = tarfile.TarInfo(name)
|
|
item.size, item.mode, item.uid, item.gid = len(data), 0o600, 1000, 1000
|
|
tar.addfile(item, io.BytesIO(data))
|
|
return buffer.getvalue()
|
|
|
|
|
|
def confined(name, network):
|
|
value = json.loads(docker('inspect', name).stdout)[0]
|
|
host = value['HostConfig']
|
|
if network.startswith('container:'):
|
|
anchor = json.loads(docker('inspect', network.removeprefix('container:')).stdout)[0]
|
|
network = 'container:' + anchor['Id']
|
|
if (host['NetworkMode'] != network or not host['ReadonlyRootfs'] or host['Privileged']
|
|
or host.get('Binds') or host.get('PortBindings') or host.get('Devices')
|
|
or 'ALL' not in host['CapDrop'] or 'no-new-privileges' not in host['SecurityOpt']
|
|
or value['Config']['User'] != '1000:1000'
|
|
or any(m['Type'] != 'tmpfs' for m in value['Mounts'])):
|
|
raise RuntimeError('native_container_boundary')
|
|
|
|
|
|
def container(name, image, network, env):
|
|
args = ['run', '-d', '--pull=never', '--name', name, '--network', network,
|
|
'--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
|
|
'--user', '1000:1000', '--memory', '256m', '--cpus', '1', '--pids-limit', '128']
|
|
for path, size in [('/data', 268435456), ('/run/fixture', 1048576),
|
|
('/config', 1048576), ('/tmp', 16777216)]:
|
|
args += ['--tmpfs', path
|
|
+ f':rw,nosuid,nodev,noexec,size={size},uid=1000,gid=1000,mode=0700']
|
|
for key, value in env.items():
|
|
args += ['--env', key + '=' + value]
|
|
docker(*args, '--entrypoint', '/bin/sh', image, '-c', 'sleep 1800')
|
|
confined(name, network)
|
|
|
|
|
|
def probe(anchor, value):
|
|
result = docker('run', '-i', '--rm', '--name', anchor + '-probe', '--pull=never',
|
|
'--network', 'container:' + anchor,
|
|
'--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
|
|
'--memory', '128m', '--cpus', '1', '--pids-limit', '64',
|
|
'--entrypoint', 'python', CLIENT, '-c', PROBE,
|
|
data=json.dumps(value).encode(), check=False)
|
|
body = json.loads(result.stdout)
|
|
if result.returncode:
|
|
raise RuntimeError('native_' + body.get('stage', 'probe_failed'))
|
|
return body
|
|
|
|
|
|
def configuration(bind_password):
|
|
return {
|
|
'server': {'address': 'tcp://127.0.0.1:9091', 'disable_healthcheck': True},
|
|
'log': {'level': 'info'}, 'ntp': {'disable_startup_check': True},
|
|
'identity_validation': {'reset_password': {'jwt_secret': secrets.token_hex(32)}},
|
|
'authentication_backend': {'ldap': {
|
|
'implementation': 'lldap', 'address': 'ldap://127.0.0.1:3890',
|
|
'base_dn': 'dc=example,dc=test', 'additional_users_dn': 'ou=people',
|
|
'users_filter': '(&(|({username_attribute}={input})({mail_attribute}={input}))'
|
|
'(objectClass=inetOrgPerson)(!({username_attribute}=svc-*)))',
|
|
'additional_groups_dn': 'ou=groups', 'groups_filter': '(member={dn})',
|
|
'user': 'uid=svc-auth,ou=people,dc=example,dc=test', 'password': bind_password}},
|
|
'access_control': {'default_policy': 'one_factor'},
|
|
'session': {'secret': secrets.token_hex(32), 'cookies': [{
|
|
'domain': 'recovery.test', 'authelia_url': 'https://auth.recovery.test'}]},
|
|
'storage': {'encryption_key': secrets.token_hex(32),
|
|
'local': {'path': '/data/db.sqlite3'}},
|
|
'notifier': {'filesystem': {'filename': '/data/notifications.txt'}}}
|
|
|
|
|
|
def rehearse():
|
|
if not __debug__:
|
|
raise RuntimeError('acceptance_assertions_must_be_enabled')
|
|
context = json.loads(docker('context', 'inspect', 'default').stdout)[0]
|
|
if context['Endpoints']['docker']['Host'] != 'unix:///var/run/docker.sock':
|
|
raise RuntimeError('local_docker_socket_required')
|
|
for image in (LLDAP, AUTHELIA, CLIENT):
|
|
assert json.loads(docker('image', 'inspect', image).stdout)[0]['Id'] == image
|
|
name = 'csoc-native-reset-' + uuid.uuid4().hex[:12]
|
|
auth = name + '-auth'
|
|
values = {key: secrets.token_urlsafe(24) for key in [
|
|
'admin_password', 'bind_password', 'old_password', 'new_password']}
|
|
result = {}
|
|
try:
|
|
container(name, LLDAP, 'none', {
|
|
'LLDAP_JWT_SECRET_FILE': '/run/fixture/jwt',
|
|
'LLDAP_LDAP_USER_PASS_FILE': '/run/fixture/admin-password',
|
|
'LLDAP_LDAP_BASE_DN': 'dc=example,dc=test',
|
|
'LLDAP_LDAP_HOST': '127.0.0.1', 'LLDAP_HTTP_HOST': '127.0.0.1'})
|
|
docker('exec', '-i', name, 'tar', '-xf', '-', '-C', '/run/fixture', data=archive({
|
|
'jwt': secrets.token_hex(32).encode(),
|
|
'admin-password': values['admin_password'].encode(),
|
|
'lldap.toml': ('database_url = "sqlite:///data/users.db?mode=rwc"\nkey_seed = '
|
|
+ json.dumps(secrets.token_hex(32)) + '\n').encode()}))
|
|
docker('exec', '-d', name, '/bin/sh', '-c',
|
|
'exec /app/lldap run --config-file /run/fixture/lldap.toml '
|
|
'> /run/fixture/server.log 2>&1')
|
|
prepared = probe(name, {**values, 'mode': 'bootstrap'})
|
|
for user, password in [('alice', values['old_password']),
|
|
('svc-auth', values['bind_password'])]:
|
|
docker('exec', '-i', name, '/bin/sh', '-c',
|
|
'read -r token; read -r LLDAP_USER_PASSWORD; export LLDAP_USER_PASSWORD; '
|
|
'exec /app/lldap_set_password --base-url http://127.0.0.1:17170 '
|
|
'--token "$token" --username ' + user,
|
|
data=(prepared['admin_token'] + '\n' + password + '\n').encode())
|
|
result['scenarios'] = []
|
|
for role in ['lldap_strict_readonly', 'lldap_password_manager']:
|
|
assigned = probe(name, {**values, 'mode': 'bind_role', 'role': role})
|
|
container(auth, AUTHELIA, 'container:' + name, {})
|
|
docker('exec', '-i', auth, 'tar', '-xf', '-', '-C', '/run/fixture', data=archive({
|
|
'configuration.yml': json.dumps(configuration(values['bind_password'])).encode()}))
|
|
docker('exec', '-d', auth, '/bin/sh', '-c',
|
|
'exec /app/authelia --config /run/fixture/configuration.yml '
|
|
'> /run/fixture/server.log 2>&1')
|
|
requested = probe(name, {**values, 'mode': 'reset_request'})
|
|
# Notification and usable handle stay private. No inbox/SMTP claim.
|
|
notification = docker('exec', auth, 'cat', '/data/notifications.txt').stdout.decode()
|
|
links = re.findall(r'https://auth\.recovery\.test/reset-password/step2\?[^\s<>]+',
|
|
notification)
|
|
assert links, 'native_challenge_missing'
|
|
reset_token = urllib.parse.parse_qs(urllib.parse.urlsplit(links[0]).query)['token'][0]
|
|
settled = probe(name, {**values, 'mode': 'reset_finish', 'reset_token': reset_token})
|
|
expected = role == 'lldap_password_manager'
|
|
assert settled['new_password_accepted_after'] is expected
|
|
assert settled['old_password_accepted_after'] is not expected
|
|
assert (settled['status'] == 'reset_completed') is expected
|
|
result['scenarios'].append({'bind_role': role, **requested, **settled,
|
|
'directory_group_creation_refused':
|
|
assigned['directory_group_creation_refused'],
|
|
'native_reset_notification_present': True})
|
|
docker('rm', '-f', '-v', auth)
|
|
except Exception:
|
|
fd, diagnostic = tempfile.mkstemp(prefix='csoc-native-reset-diagnostic-', suffix='.txt')
|
|
with os.fdopen(fd, 'w') as stream:
|
|
for current in [name, auth]:
|
|
log = docker('exec', current, 'cat', '/run/fixture/server.log', check=False)
|
|
text = log.stdout.decode(errors='replace')
|
|
for secret in values.values():
|
|
text = text.replace(secret, '<fixture-value>')
|
|
text = re.sub(r'[A-Za-z0-9_+/=-]{32,}', '<redacted>', text)
|
|
stream.write(current + '\n' + text + '\n')
|
|
print('Private redacted fixture diagnostic: ' + diagnostic)
|
|
raise
|
|
finally:
|
|
for current in [name + '-probe', auth, name]:
|
|
docker('rm', '-f', '-v', current, check=False)
|
|
result['cleanup_verified'] = all(not docker(
|
|
'ps', '-aq', '--filter', 'name=^/' + n + '$').stdout.strip()
|
|
for n in [name + '-probe', auth, name])
|
|
if not result['cleanup_verified']:
|
|
raise RuntimeError('fixture_cleanup_requires_review')
|
|
if any(secret in json.dumps(result) for secret in values.values()):
|
|
raise RuntimeError('fixture_value_in_receipt')
|
|
return result
|
|
|
|
|
|
if __name__ == '__main__':
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--output', type=Path, required=True)
|
|
args = parser.parse_args()
|
|
source_path = args.output.with_suffix('.sources.txt')
|
|
if args.output.exists() or source_path.exists():
|
|
raise SystemExit('Fresh receipt and source manifest required')
|
|
findings = rehearse()
|
|
manifest = ''.join(str(p.relative_to(HERE.parent)) + ': '
|
|
+ hashlib.sha256(p.read_bytes()).hexdigest() + '\n'
|
|
for p in [Path(__file__), HERE / 'native_recovery/probe.py']).encode()
|
|
receipt = {'schema': 'coulomb.native-reset-prerequisite.v1', 'data': 'synthetic',
|
|
'status': 'native_bind_role_reset_boundary_verified',
|
|
'observed_at': datetime.now(UTC).isoformat(),
|
|
'images': {'lldap': LLDAP, 'authelia': AUTHELIA, 'probe': CLIENT},
|
|
'production_access': False, 'browser_app_or_mfa_acceptance': False,
|
|
'provider_smtp_contacted': False, 'notifier': 'private_tmpfs_file',
|
|
'no_ports_host_mounts_or_external_routes': True,
|
|
'backend_container_flags_verified_before_fixture_import': True,
|
|
'owner_runtime_or_permission_change_performed': False,
|
|
'findings': findings,
|
|
'source_manifest_sha256': hashlib.sha256(manifest).hexdigest()}
|
|
with source_path.open('xb') as stream:
|
|
stream.write(manifest)
|
|
with args.output.open('x') as stream:
|
|
json.dump(receipt, stream, indent=2)
|
|
stream.write('\n')
|
|
print(json.dumps(findings))
|