#!/usr/bin/env python3 """Silent read-only inventory in the existing Platform Backup-account procedure. Runs only inside the attended platform-admin envelope. Reuses Platform's owner custody accessor and fixed provider/account contract; no age key is read and no backup is downloaded, uploaded, deleted, rotated or granted. """ import argparse import base64 from datetime import datetime, timezone import hashlib import json import os from pathlib import Path import re import sys import urllib.parse import urllib.request import xml.etree.ElementTree as ET ROOT=Path(__file__).resolve().parents[2] OWNER=ROOT.parent/'railiance-platform/scripts' sys.path.insert(0,str(OWNER)) from state_hub_preflight_lane import bao, data from migrate_nextcloud_backup_account import HOST, OPERATOR_PATH BASE='/remote.php/dav/files/Backup/railiance-backups/' LIMIT=4*1024*1024 NAME=re.compile(r'[A-Za-z0-9._-]{1,160}\Z') class Refused(Exception):pass class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self,*args,**kwargs):raise Refused() def parse_listing(raw, relative): if len(raw)>LIMIT:raise Refused() rows=[] for item in ET.fromstring(raw).findall('{DAV:}response'): href=urllib.parse.urlsplit(item.findtext('{DAV:}href') or '') if href.netloc and href.netloc!=urllib.parse.urlsplit(HOST).netloc:raise Refused() path=urllib.parse.unquote(href.path) if not path.startswith(BASE+relative):raise Refused() name=path[len(BASE+relative):].rstrip('/') if not name:continue if not NAME.fullmatch(name) or name in {'.','..'}:raise Refused() for row in item.findall('{DAV:}propstat'): if ' 200 ' not in (row.findtext('{DAV:}status') or ''):continue prop=row.find('{DAV:}prop') if prop is None:continue folder=prop.find('{DAV:}resourcetype/{DAV:}collection') is not None size=prop.findtext('{DAV:}getcontentlength') if size is not None and not size.isdigit():raise Refused() # Export only known-safe operational names/size, not arbitrary DAV # properties, URLs, account metadata or raw provider responses. if folder or name.endswith(('.age','.json','.yaml')): rows.append({'name':name,'directory':folder,'bytes':int(size) if size else None}) if len(rows)>10000 or len({r['name'] for r in rows})!=len(rows):raise Refused() return sorted(rows,key=lambda x:x['name']) def listing(relative,credentials): if relative and (not relative.endswith('/') or not NAME.fullmatch(relative[:-1])):raise Refused() auth=base64.b64encode((credentials['BACKUP_USERNAME']+':'+credentials['BACKUP_PASSWORD']).encode()).decode() req=urllib.request.Request(HOST+BASE+relative, data=b'', method='PROPFIND',headers={'Authorization':'Basic '+auth,'Depth':'1','Content-Type':'application/xml'}) opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect()) with opener.open(req,timeout=30) as response: if response.status!=207 or response.url!=HOST+BASE+relative:raise Refused() raw=response.read(LIMIT+1) return parse_listing(raw,relative) def caller_credentials(): # Lookup payload contains a token: retain only safe caller/role facts. identity=data(bao(['token','lookup','-format=json']))['data'] if 'platform-admin' not in identity.get('policies',[]) or 'root' in identity.get('policies',[]):raise Refused() if not str(identity.get('path','')).startswith('auth/netkingdom/'):raise Refused() credentials=data(bao(['read','-format=json',OPERATOR_PATH]))['data']['data'] if credentials.get('BACKUP_USERNAME')!='Backup' or not isinstance(credentials.get('BACKUP_PASSWORD'),str) or not credentials['BACKUP_PASSWORD']:raise Refused() return identity,credentials def inventory(): identity,credentials=caller_credentials() root=listing('',credentials) folders={} for row in root: if row['directory'] and re.match(r'^(?:r01-|core-|identity|sso|bootstrap|net-kingdom|openbao)',row['name']): if len(folders)>=32:raise Refused() folders[row['name']]=listing(row['name']+'/',credentials) return {'root':root,'folders':folders,'caller_auth_path':identity['path'], 'caller_entity_id':identity.get('entity_id'),'scope':'Backup/railiance-backups root and bounded identity/database subfolders; other accounts/folders unassessed'} def main(): p=argparse.ArgumentParser(description=__doc__);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={'schema':'netkingdom.storage-share-inventory.v1','status':'failed','started_at':datetime.now(timezone.utc).isoformat(), 'read_only':True,'age_keys_read':False,'backup_contents_downloaded':False,'secret_values_exported':False, 'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), 'owner_sources':[{'path':str(OWNER/name),'sha256':hashlib.sha256((OWNER/name).read_bytes()).hexdigest()} for name in ['state_hub_preflight_lane.py','migrate_nextcloud_backup_account.py']]} try:r.update(inventory());r['status']='metadata_inventory_complete' except Exception:r['error']='inventory_failed_diagnostics_suppressed' finally: r['finished_at']=datetime.now(timezone.utc).isoformat() with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n') return int(r['status']=='failed') if __name__=='__main__':raise SystemExit(main())