Files
archy/scripts/probe-container-confinement.py
T

67 lines
3.7 KiB
Python

#!/usr/bin/env python3
"""Disposable Podman confinement probe using an explicitly selected local image.
No pulls, host profile loading, app/service changes, or shared bind mounts. A
positive seccomp probe does not certify AppArmor, Docker, or an installed image.
"""
import argparse
import json
from pathlib import Path
import subprocess
import tempfile
import uuid
def probe(image):
inspect = subprocess.run(['podman', 'image', 'inspect', '--format', '{{.Id}}', image],
check=True, capture_output=True, text=True, timeout=30)
image_id = inspect.stdout.strip()
security = subprocess.run(['podman', 'info', '--format', '{{json .Host.Security}}'],
check=True, capture_output=True, text=True, timeout=30)
report = {'image_id': image_id, 'host_security': json.loads(security.stdout), 'checks': {}}
with tempfile.TemporaryDirectory(prefix='archy-confinement-') as work:
profile = Path(work) / 'deny-uname.json'
profile.write_text(json.dumps({'defaultAction': 'SCMP_ACT_ALLOW', 'syscalls': [
{'names': ['uname'], 'action': 'SCMP_ACT_ERRNO', 'errnoRet': 1}]}))
def run(options, command):
name = 'archy-confinement-' + uuid.uuid4().hex
args = ['podman', 'run', '--rm', '--pull=never', '--name', name, '--network=none',
'--cap-drop=ALL', '--read-only', '--pids-limit=64', '--memory=128m',
'--security-opt=no-new-privileges=true', *options, image_id, *command]
try:
return subprocess.run(args, capture_output=True, text=True, timeout=45)
finally:
# Scope cleanup to this unique probe only, including timeout.
subprocess.run(['podman', 'rm', '--force', '--ignore', name], capture_output=True, timeout=30)
baseline = run(['--security-opt=seccomp=/usr/share/containers/seccomp.json'], ['uname', '-s'])
report['checks']['baseline_command_succeeds'] = baseline.returncode == 0
denied = run(['--security-opt=seccomp=' + str(profile)], ['uname', '-s'])
report['checks']['seccomp_denies_uname'] = denied.returncode != 0 and 'Operation not permitted' in denied.stderr
state = run(['--security-opt=seccomp=' + str(profile)], ['sh', '-c', 'cat /proc/self/status'])
report['checks']['kernel_no_new_privileges_and_seccomp'] = (state.returncode == 0
and 'NoNewPrivs:\t1' in state.stdout and 'Seccomp:\t2' in state.stdout)
missing = run(['--security-opt=apparmor=archipelago-probe-not-installed'], ['true'])
report['checks']['missing_or_unavailable_apparmor_refuses'] = missing.returncode != 0 and 'apparmor' in missing.stderr.lower()
report['apparmor_refusal'] = missing.stderr.strip()
report['apparmor_positive_qualified'] = False
report['docker_qualified'] = False
return report
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--image', required=True, help='existing local image containing sh, uname and cat')
parser.add_argument('--receipt', type=Path, required=True)
args = parser.parse_args()
if args.receipt.exists():
raise SystemExit('Refusing to overwrite an existing probe receipt')
try:
report = probe(args.image)
except (subprocess.SubprocessError, OSError) as error:
report = {'checks': {'probe_completed': False}, 'error': str(error),
'apparmor_positive_qualified': False, 'docker_qualified': False}
args.receipt.parent.mkdir(parents=True, exist_ok=True)
args.receipt.write_text(json.dumps(report, indent=2) + '\n')
print(json.dumps(report, indent=2))
raise SystemExit(0 if all(report['checks'].values()) else 1)