92 lines
5.3 KiB
Python
Executable File
92 lines
5.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Check rendered security relationships; optionally smoke-test that exact runtime."""
|
|
import base64, json, os, pathlib, socket, subprocess, sys, tempfile, time, urllib.request
|
|
import yaml
|
|
ROOT = pathlib.Path(__file__).resolve().parent
|
|
os.chdir(ROOT)
|
|
def run(*args, **kw):
|
|
return subprocess.check_output(args, text=True, **kw).strip()
|
|
subprocess.run(['helm', 'lint', 'chart'], check=True)
|
|
docs = list(yaml.safe_load_all(run('helm', 'template', 'smoke', 'chart')))
|
|
objects = {x['kind']: x for x in docs}
|
|
pod = objects['Deployment']['spec']['template']['spec']
|
|
app = pod['containers'][0]
|
|
assert not pod['automountServiceAccountToken'] and not pod['enableServiceLinks']
|
|
assert pod['securityContext']['runAsNonRoot']
|
|
assert pod['securityContext']['seccompProfile']['type'] == 'RuntimeDefault'
|
|
assert pod['dnsPolicy'] == 'None' and not pod['dnsConfig'].get('searches')
|
|
assert all(not x.get(k) for k in ['hostNetwork', 'hostPID', 'hostIPC'] for x in [pod])
|
|
assert not any('hostPath' in x or 'persistentVolumeClaim' in x for x in pod['volumes'])
|
|
for container in pod['initContainers'] + pod['containers']:
|
|
s = container['securityContext']
|
|
assert s['readOnlyRootFilesystem'] and not s['allowPrivilegeEscalation']
|
|
assert s['capabilities']['drop'] == ['ALL']
|
|
assert app['image'] == pod['initContainers'][0]['image']
|
|
assert objects['NetworkPolicy']['spec']['ingress'] == []
|
|
assert objects['NetworkPolicy']['spec']['egress'] == []
|
|
assert objects['Service']['spec']['type'] == 'ClusterIP'
|
|
assert objects['Service']['spec']['selector'] == objects['Deployment']['spec']['selector']['matchLabels']
|
|
assert set(['startupProbe', 'readinessProbe', 'livenessProbe']) <= app.keys()
|
|
assert not any(x['kind'] == 'Ingress' for x in docs)
|
|
print('Rendered security and service relationships passed', flush=True)
|
|
if '--runtime' not in sys.argv:
|
|
sys.exit(0)
|
|
with tempfile.TemporaryDirectory(prefix='oldipmi-test-') as tmp:
|
|
os.chmod(tmp, 0o755)
|
|
dirs = {}
|
|
for volume in pod['volumes']:
|
|
path = pathlib.Path(tmp) / volume['name']
|
|
path.mkdir(mode=0o777)
|
|
path.chmod(0o777)
|
|
dirs[volume['name']] = path
|
|
if 'configMap' in volume:
|
|
for name, data in objects['ConfigMap']['data'].items():
|
|
(path / name).write_text(data)
|
|
def container_args(container):
|
|
args = ['podman', 'run', '--read-only', '--read-only-tmpfs=false', '--cap-drop=ALL',
|
|
'--security-opt=no-new-privileges', '--userns=keep-id',
|
|
'--user', str(container['securityContext'].get('runAsUser', pod['securityContext']['runAsUser']))]
|
|
for mount in container['volumeMounts']:
|
|
args += ['-v', str(dirs[mount['name']]) + ':' + mount['mountPath'] + (':ro' if mount.get('readOnly') else '')]
|
|
return args
|
|
init = pod['initContainers'][0]
|
|
subprocess.run(container_args(init) + ['--rm', '--network=none', init['image']] + init['command'] + init['args'], check=True)
|
|
args = container_args(app) + ['-d', '--workdir', app['workingDir'], '-p', '127.0.0.1::' + str(app['ports'][0]['containerPort'])]
|
|
for env in app['env']:
|
|
args += ['-e', env['name'] + '=' + env['value']]
|
|
cid = run(*args, app['image'], *app['command'])
|
|
try:
|
|
port = int(run('podman', 'port', cid).split(':')[-1])
|
|
for attempt in range(30):
|
|
try:
|
|
with urllib.request.urlopen(f'http://127.0.0.1:{port}' + app['readinessProbe']['httpGet']['path'], timeout=2) as response:
|
|
assert response.status == 200
|
|
break
|
|
except Exception:
|
|
time.sleep(1)
|
|
else:
|
|
raise AssertionError('noVNC did not start')
|
|
# A real WebSocket upgrade must reach the local X11 VNC server.
|
|
with socket.create_connection(('127.0.0.1', port), timeout=10) as ws:
|
|
key = base64.b64encode(os.urandom(16)).decode()
|
|
ws.sendall((f'GET /websockify HTTP/1.1\r\nHost: localhost:{port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: binary\r\n\r\n').encode())
|
|
received = b''
|
|
while b'RFB ' not in received:
|
|
data = ws.recv(4096)
|
|
assert data, 'VNC handshake closed early'
|
|
received += data
|
|
assert b'101 Switching Protocols' in received
|
|
listeners = run('podman', 'exec', cid, 'netstat', '-lntp')
|
|
vnc_listeners = [line.split()[3] for line in listeners.splitlines() if 'x11vnc' in line]
|
|
assert vnc_listeners and all(address.startswith('127.0.0.1:') for address in vnc_listeners), listeners
|
|
status = run('podman', 'exec', cid, 'cat', '/proc/1/status')
|
|
assert 'NoNewPrivs:\t1' in status and 'CapEff:\t0000000000000000' in status
|
|
assert run('podman', 'exec', cid, 'id', '-u') != '0'
|
|
processes = run('podman', 'exec', cid, 'ps', '-eo', 'args')
|
|
assert '/usr/lib/chromium-browser/chromium-browser' in processes
|
|
assert '/usr/bin/Xvfb' in processes
|
|
print('Non-root read-only runtime, browser, HTTP, WebSocket/RFB, and zero capabilities passed')
|
|
finally:
|
|
subprocess.run(['podman', 'rm', '-f', cid], check=True, stdout=subprocess.DEVNULL)
|
|
subprocess.run(container_args(init) + ['--rm', '--network=none', init['image'], '/bin/sh', '-ec', 'rm -rf /opt/novnc/site'], check=True)
|