Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
109 lines
5.6 KiB
Python
109 lines
5.6 KiB
Python
"""Retired callers must refuse before credential discovery or external actions."""
|
|
import argparse
|
|
import contextlib
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def console():
|
|
path = ROOT / 'tools/security-bootstrap-console/security_bootstrap_console.py'
|
|
spec = importlib.util.spec_from_file_location('retirement_console', path)
|
|
module = importlib.util.module_from_spec(spec); sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
@pytest.mark.parametrize('name', ['create-user.sh', 'break-glass.sh', 'dry-run-nonroot-user.sh'])
|
|
def test_retired_shell_never_sources_credentials_or_calls_external_tools(tmp_path, name):
|
|
calls = tmp_path / 'calls'; sourced = tmp_path / 'sourced'; private = tmp_path / 'private'
|
|
for service, field in [('lldap', 'LLDAP_LDAP_USER_PASS'), ('breakglass', 'BREAKGLASS_PASSWORD')]:
|
|
directory = private / service; directory.mkdir(parents=True, mode=0o700)
|
|
(directory / 'secrets.env').write_text(f"{field}='synthetic-private-marker'\ntouch '{sourced}'\n")
|
|
bin_dir = tmp_path / 'bin'; bin_dir.mkdir()
|
|
for command in ['kubectl', 'curl']:
|
|
executable = bin_dir / command
|
|
executable.write_text(f"#!/bin/sh\nprintf called >> '{calls}'\nexit 1\n"); executable.chmod(0o700)
|
|
env = dict(os.environ, PATH=str(bin_dir) + ':' + os.environ['PATH'],
|
|
KUBECTL=str(bin_dir / 'kubectl'), LLDAP_ADMIN_PASS='synthetic-private-marker')
|
|
env.pop('BASH_ENV', None)
|
|
if name == 'create-user.sh':
|
|
env.pop('LLDAP_ADMIN_PASS')
|
|
args = ['test-user', 'test@example.test', 'Test User', 'http://127.0.0.1:1', str(private)]
|
|
elif name == 'break-glass.sh': args = ['http://127.0.0.1:1', str(private)]
|
|
else: args = ['test-user', 'test@example.test', 'Test User']
|
|
result = subprocess.run(['/bin/bash', '-x', str(ROOT / 'sso-mfa/k8s/lldap' / name), *args],
|
|
env=env, cwd=tmp_path, capture_output=True, text=True, timeout=10)
|
|
assert result.returncode == 2
|
|
assert not calls.exists() and not sourced.exists()
|
|
assert 'synthetic-private-marker' not in result.stdout + result.stderr
|
|
assert 'retired' in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize('target,variable', [('security-bootstrap-onboarding-dry-run','SUBJECT'),
|
|
('security-bootstrap-onboarding-dry-run','EMAIL'),
|
|
('security-bootstrap-onboarding-dry-run','DISPLAY'),
|
|
('security-bootstrap-lifecycle-cleanup-dryrun-users','PATTERN')])
|
|
def test_make_retirement_never_interpolates_old_user_arguments(tmp_path, target, variable):
|
|
# Harmless leaf replacement isolates the Make interpolation boundary.
|
|
shutil.copyfile(ROOT / 'Makefile', tmp_path / 'Makefile')
|
|
leaf = tmp_path / 'sso-mfa/k8s/lldap/dry-run-nonroot-user.sh'; leaf.parent.mkdir(parents=True)
|
|
leaf.write_text('#!/bin/sh\nexit 2\n'); leaf.chmod(0o700)
|
|
marker = tmp_path / 'command-executed'
|
|
value = f'$(touch {marker})'
|
|
# Escape Make's dollar processing so the old recipe would hand $(...) to the shell.
|
|
result = subprocess.run(['make', '--no-print-directory', target, f'{variable}=$' + value],
|
|
cwd=tmp_path, capture_output=True, text=True, timeout=10)
|
|
assert result.returncode != 0
|
|
assert not marker.exists()
|
|
|
|
|
|
@pytest.mark.parametrize('command', ['onboarding-dry-run', 'lifecycle-cleanup-dryrun-users', 'onboarding-dry-run-claims'])
|
|
def test_console_retirement_returns_failure_without_delegation(command):
|
|
module = console(); output = io.StringIO()
|
|
with patch('subprocess.call') as call, patch('subprocess.run') as run, \
|
|
patch.object(module, 'load_metadata') as metadata, contextlib.redirect_stdout(output):
|
|
code = module.main([command])
|
|
assert code == 2
|
|
call.assert_not_called(); run.assert_not_called(); metadata.assert_not_called()
|
|
assert 'retired' in output.getvalue().lower()
|
|
|
|
|
|
@pytest.mark.parametrize('factory,validator', [('lifecycle_flow_template','print_validate_lifecycle_flow'),
|
|
('onboarding_dry_run_template','print_validate_onboarding_dry_run')])
|
|
def test_blank_template_is_not_success_evidence(tmp_path, factory, validator):
|
|
module = console(); record = getattr(module, factory)()
|
|
assert not any(value is True for value in record.values())
|
|
path = tmp_path / 'evidence.json'; path.write_text(json.dumps(record))
|
|
output = io.StringIO()
|
|
with contextlib.redirect_stdout(output): result = getattr(module, validator)(argparse.Namespace(evidence=str(path)))
|
|
assert result != 0
|
|
|
|
|
|
def test_console_guidance_routes_to_owner_without_legacy_execution():
|
|
module = console(); output = io.StringIO()
|
|
with contextlib.redirect_stdout(output): module.print_lifecycle_guide()
|
|
assert 'User Engine' in output.getvalue()
|
|
assert './create-user.sh' not in output.getvalue()
|
|
assert 'safe secret extract' not in output.getvalue()
|
|
payload = next(p for p in module.runbook_payloads({'openbao_initialized': True}) if p['name'] == 'User lifecycle dry-run (T06)')
|
|
assert payload['state'] == 'retired'
|
|
|
|
|
|
def test_direct_claims_compatibility_helper_never_infers_success():
|
|
module = console(); output = io.StringIO()
|
|
with contextlib.redirect_stdout(output):
|
|
result = module.print_dry_run_oidc_claims_verification('private-subject-marker', ['net-kingdom-admins'])
|
|
assert result == 2
|
|
assert 'private-subject-marker' not in output.getvalue()
|
|
assert 'net-kingdom-admins' not in output.getvalue()
|