67 lines
3.7 KiB
Python
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)
|