110 lines
5.6 KiB
Python
110 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Read-only actual-account probes. Never prints command output or credentials."""
|
|
import argparse
|
|
import grp
|
|
import hashlib
|
|
import re
|
|
import json
|
|
import os
|
|
import pwd
|
|
import subprocess
|
|
|
|
IDENTITY = 'system:serviceaccount:custodian-agents:custodian-codex'
|
|
DENIALS = [
|
|
('get', 'secrets'), ('list', 'secrets'), ('watch', 'secrets'),
|
|
('get', 'configmaps'), ('get', 'pods'), ('get', 'pods/log'),
|
|
('create', 'pods/exec'), ('create', 'pods/attach'), ('create', 'pods'),
|
|
('patch', 'deployments.apps'), ('create', 'serviceaccounts/token'),
|
|
('patch', 'applications.argoproj.io'),
|
|
]
|
|
|
|
CLUSTER_DENIALS = [('impersonate', 'users'),
|
|
('bind', 'clusterroles.rbac.authorization.k8s.io'),
|
|
('escalate', 'clusterroles.rbac.authorization.k8s.io')]
|
|
|
|
|
|
def inventory_valid(inventory):
|
|
if not isinstance(inventory, dict):
|
|
return False
|
|
namespaces = inventory.get('namespaces')
|
|
return (inventory.get('cluster') == 'railiance01'
|
|
and inventory.get('complete') is True
|
|
and isinstance(inventory.get('supervisor_receipt_ref'), str)
|
|
and bool(inventory['supervisor_receipt_ref'].strip())
|
|
and isinstance(namespaces, list) and bool(namespaces)
|
|
and all(isinstance(n, str) and len(n) <= 63
|
|
and re.fullmatch(r'[a-z0-9](?:[a-z0-9-]*[a-z0-9])?', n)
|
|
for n in namespaces)
|
|
and len(set(namespaces)) == len(namespaces))
|
|
|
|
|
|
def run(argv):
|
|
try:
|
|
p = subprocess.run(argv, capture_output=True, timeout=20, check=False)
|
|
return p.returncode, p.stdout
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return None, b''
|
|
|
|
|
|
def probe(kubectl, context, account, inventory):
|
|
if not inventory_valid(inventory):
|
|
return {"status": "refused_namespace_inventory", "passed": False}
|
|
# Abort before even auth-only Kubernetes requests under the old account.
|
|
if os.geteuid() == 0 or pwd.getpwuid(os.geteuid()).pw_name != account:
|
|
return {'status': 'refused_wrong_account', 'passed': False}
|
|
base = [kubectl, '--context', context, '--request-timeout=10s', 'auth']
|
|
code, output = run(base + ['whoami', '-o', 'json'])
|
|
try:
|
|
info = json.loads(output)['status']['userInfo']
|
|
identity = code == 0 and info['username'] == IDENTITY and 'system:masters' not in info.get('groups', [])
|
|
except (KeyError, TypeError, ValueError):
|
|
identity = False
|
|
if not identity:
|
|
return {'status': 'refused_wrong_identity', 'passed': False}
|
|
checks = {}
|
|
for verb, resource in DENIALS:
|
|
code, output = run(base + ['can-i', verb, resource, '--all-namespaces'])
|
|
checks[f'all-namespaces:{verb}:{resource}'] = code == 1 and output.strip() == b'no'
|
|
# An all-namespaces denial can coexist with a namespaced RoleBinding grant.
|
|
# Probe each namespace supplied in the separately supervised complete inventory.
|
|
for namespace in inventory['namespaces']:
|
|
for verb, resource in DENIALS:
|
|
code, output = run(base + ['can-i', verb, resource, '--namespace', namespace])
|
|
checks[f'{namespace}:{verb}:{resource}'] = code == 1 and output.strip() == b'no'
|
|
for verb, resource in CLUSTER_DENIALS:
|
|
code, output = run(base + ['can-i', verb, resource])
|
|
checks[f'cluster:{verb}:{resource}'] = code == 1 and output.strip() == b'no'
|
|
dangerous = {'sudo', 'wheel', 'docker', 'lxd', 'incus-admin', 'disk', 'libvirt'}
|
|
groups = {grp.getgrgid(gid).gr_name for gid in set(os.getgroups()) | {os.getegid()}}
|
|
checks['no_privileged_groups'] = not (groups & dangerous)
|
|
checks['host_admin_kubeconfig_unreadable'] = not os.access('/etc/rancher/k3s/k3s.yaml', os.R_OK)
|
|
checks['legacy_admin_home_unsearchable'] = not os.access('/home/tegwick', os.X_OK)
|
|
checks['root_home_unsearchable'] = not os.access('/root', os.X_OK)
|
|
for label, path in [('docker', '/var/run/docker.sock'), ('containerd', '/run/k3s/containerd/containerd.sock')]:
|
|
checks[f'{label}_socket_not_writable'] = not os.access(path, os.W_OK)
|
|
# This is a partial negative proof, never assignment admission on its own.
|
|
return {'status': 'negative_probes_passed' if all(checks.values()) else 'negative_probes_failed',
|
|
'passed': all(checks.values()), 'checks': checks, 'assignment_admitted': False,
|
|
'namespace_inventory_sha256': hashlib.sha256(json.dumps(inventory, sort_keys=True, separators=(',', ':')).encode()).hexdigest(),
|
|
'namespace_count': len(inventory['namespaces']),
|
|
'supervisor_receipt_ref': inventory['supervisor_receipt_ref'],
|
|
'remaining': ['supervisor_verified_complete_current_namespace_inventory', 'supervisor_verified_sudoers_and_ACLs', 'all_external_tool_and_credential_paths',
|
|
'old_admin_SSH_denial', 'exact_approved_action_and_changed_action_refusal',
|
|
'actual_interactive_runtime_admission']}
|
|
|
|
|
|
if __name__ == '__main__':
|
|
p = argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument('--kubectl', default='/usr/local/bin/kubectl')
|
|
p.add_argument('--context', required=True)
|
|
p.add_argument('--namespace-inventory', required=True, help='Supervisor-supplied complete current namespace inventory JSON')
|
|
p.add_argument('--account', default='custodian-agt', choices=['custodian-agt'])
|
|
a = p.parse_args()
|
|
try:
|
|
with open(a.namespace_inventory, encoding='utf-8') as stream:
|
|
inventory = json.load(stream)
|
|
except (OSError, ValueError):
|
|
inventory = None
|
|
receipt = probe(a.kubectl, a.context, a.account, inventory)
|
|
print(json.dumps(receipt, sort_keys=True))
|
|
raise SystemExit(0 if receipt['passed'] else 1)
|