#!/usr/bin/env python3 """Value-free read of OpenBao's file audit log for the bounded executor's write role (NK-WP-0058-T11). Reads the audit log as JSON lines on stdin. The founder streams it from the pod; nothing is stored on the way: kubectl -n openbao exec openbao-0 -- cat /openbao/audit/openbao-audit.log \ | python3 tools/service-binds/write-role-audit.py --check-from T1 --check-to T2 --receipt EVIDENCE/NN-....json It reports every interval in which the write role existed: from a successful create/update of auth/kubernetes/role/ to its successful delete. For each interval it reports: logins successful auth/kubernetes/login whose response names this role (auth metadata, plaintext) policy_requests other successful requests made with a token carrying the write policy failed_k8s_logins failed kubernetes logins in the interval; the audit HMACs the requested role, so these are context, not attribution The receipt holds times, counts, operations and ServiceAccount names. It holds no request or response data, tokens, accessors or HMACs. Exit code 0 means the log parsed. The result line says whether a login used the role inside --check-from/--check-to, and whether the role still exists at the end of the log. """ import argparse import hashlib import json import os import sys from datetime import datetime, timezone from pathlib import Path ROLE = 'net-kingdom-bind-executor-svc-authelia-write' # executor-lanes.py LANES['write']['role'] (= policy) LOGIN = 'auth/kubernetes/login' WRITES = {'create', 'update'} def when(text): """Parse OpenBao's UTC time ('...Z', up to nanoseconds) into an aware datetime.""" if not text.endswith('Z'): raise ValueError('time_not_utc') head, _, frac = text[:-1].partition('.') stamp = datetime.fromisoformat(head + ('.' + frac[:6].ljust(6, '0') if frac else '')) return stamp.replace(tzinfo=timezone.utc) def iso(stamp): return stamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ') if stamp else None def auth_of(entry): response = entry.get('response') or {} return response.get('auth') or entry.get('auth') or {} def analyse(lines, role=ROLE, policy=None): policy = policy or role stats = {'lines': 0, 'unparseable': 0, 'first': None, 'last': None} intervals, current = [], None for raw in lines: raw = raw.strip() if not raw: continue stats['lines'] += 1 try: entry = json.loads(raw) stamp = when(entry['time']) except (ValueError, KeyError, TypeError): stats['unparseable'] += 1 continue stats['first'] = min(stats['first'] or stamp, stamp) stats['last'] = max(stats['last'] or stamp, stamp) if entry.get('type') != 'response': continue # each request is judged once, by its response request = entry.get('request') or {} path, operation = request.get('path', ''), request.get('operation', '') if entry.get('error'): if path == LOGIN and current is not None: current['failed_k8s_logins'] += 1 continue if path == 'auth/kubernetes/role/' + role and (operation in WRITES or operation == 'delete'): if operation in WRITES and current is None: current = {'opened': stamp, 'closed': None, 'logins': [], 'policy_requests': [], 'failed_k8s_logins': 0, 'service_accounts': set()} intervals.append(current) elif operation == 'delete' and current is not None: current['closed'], current = stamp, None continue auth = auth_of(entry) meta = auth.get('metadata') or {} if path == LOGIN: if meta.get('role') == role: target = current if current is not None else _orphan(intervals, stamp) target['logins'].append(stamp) target['service_accounts'].add('%s/%s' % (meta.get('service_account_namespace', '?'), meta.get('service_account_name', '?'))) continue policies = set(auth.get('policies') or []) | set(auth.get('token_policies') or []) if policy in policies: target = current if current is not None else _orphan(intervals, stamp) target['policy_requests'].append(stamp) return stats, intervals def _orphan(intervals, stamp): """Activity under the role with no open interval: the log missed the create. Keep it visible.""" orphan = {'opened': None, 'closed': None, 'logins': [], 'policy_requests': [], 'failed_k8s_logins': 0, 'service_accounts': set(), 'orphan': True, 'first_seen': stamp} intervals.append(orphan) return orphan def overlaps(interval, start, end, log_last): opened = interval.get('opened') or interval.get('first_seen') closed = interval.get('closed') or log_last return opened <= end and closed >= start def report(stats, intervals, check_from=None, check_to=None): out = {'log_lines': stats['lines'], 'log_unparseable': stats['unparseable'], 'log_first_time': iso(stats['first']), 'log_last_time': iso(stats['last']), 'intervals': []} for item in intervals: out['intervals'].append({ 'opened': iso(item['opened']), 'closed': iso(item['closed']), 'orphan': item.get('orphan', False), 'logins': len(item['logins']), 'login_times': [iso(t) for t in item['logins'][:20]], 'login_service_accounts': sorted(item['service_accounts']), 'policy_requests': len(item['policy_requests']), 'policy_request_times': [iso(t) for t in item['policy_requests'][:20]], 'first_seen': iso(item.get('first_seen')), 'failed_k8s_logins': item['failed_k8s_logins']}) out['role_present_at_log_end'] = bool(intervals and intervals[-1]['closed'] is None and not intervals[-1].get('orphan')) if check_from and check_to: checked = [i for i in intervals if stats['last'] and overlaps(i, check_from, check_to, stats['last'])] logins = [t for i in checked for t in i['logins'] if check_from <= t <= check_to] requests = sum(check_from <= t <= check_to for i in checked for t in i['policy_requests']) out['check'] = {'from': iso(check_from), 'to': iso(check_to), 'log_covers_check': bool(stats['first'] and stats['first'] <= check_from and stats['last'] >= check_to), 'intervals_overlapping': len(checked), 'logins_in_check': len(logins), 'policy_requests_in_check': requests, 'no_login_in_check': not logins and requests == 0} return out def main(): p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument('--role', default=ROLE) p.add_argument('--check-from', type=when, required=True, help='start of the period to judge (UTC, ...Z)') p.add_argument('--check-to', type=when, required=True, help='end of the period to judge (UTC, ...Z)') p.add_argument('--receipt', required=True) a = p.parse_args() fd = os.open(a.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) r = {'action': 'write-role-audit', 'role': a.role, 'status': 'failed', 'values_recorded': False, 'source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest()} try: stats, intervals = analyse(sys.stdin, role=a.role) if not stats['lines']: raise ValueError('empty_audit_input') r.update(report(stats, intervals, a.check_from, a.check_to), status='read') except Exception as error: # value-free: name the failure, never echo input r['reason'] = str(error) if isinstance(error, ValueError) and str(error).isidentifier() \ else type(error).__name__ finally: with os.fdopen(fd, 'w') as out: json.dump(r, out, indent=2, sort_keys=True); out.write('\n') check = r.get('check', {}) print(json.dumps({'status': r['status'], 'reason': r.get('reason'), 'covers': check.get('log_covers_check'), 'no_login_in_check': check.get('no_login_in_check'), 'intervals': len(r.get('intervals', [])), 'role_present_at_log_end': r.get('role_present_at_log_end')})) return int(r['status'] != 'read') if __name__ == '__main__': raise SystemExit(main())