net-kingdom/tools/service-binds/write-role-audit.py
tegwick 7031c97442 write-role-audit: report write-policy request times and orphan first_seen; check period is required
The first live read (NK-WP-0058-T11) counted the attended 2026-11-04 evening logins because
the incident default started at the window start. Times make the judgment checkable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 943911@bnt-lap001
Assistant-Session: fc322940-1c8e-4bed-ab39-86cec99f23a0
2026-11-05 16:53:54 +01:00

165 lines
8.4 KiB
Python

#!/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/<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())