write-role-audit.py reads the file audit log on stdin and reports each interval in which the write role existed, with logins, write-policy requests and failed kubernetes logins. The pilot's own login is the positive control. Receipt holds times, counts and SA names only. 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
165 lines
8.3 KiB
Python
165 lines
8.3 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 --receipt EVIDENCE/NN-write-role-audit.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']),
|
|
'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, default=when('2026-11-04T22:39:00Z'),
|
|
help='start of the period to judge (default: BEA-2026-0002 window start)')
|
|
p.add_argument('--check-to', type=when, default=when('2026-11-05T08:25:00Z'),
|
|
help='end of the period to judge (default: just after the late close)')
|
|
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())
|