Files
lmxopcua/scripts/validation/opcua-lifecycle-memory/native-retention/run.py
T

169 lines
9.0 KiB
Python

#!/usr/bin/env python3
"""Bounded attribution-only macOS SDK private-key reload/registry controls."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
HERE = Path(__file__).resolve().parent
STAGES = ['ready', 'first', 'repeated', 'disposed', 'idle']
def save(path, value):
path.write_text(json.dumps(value, indent=2) + '\n')
def snapshots(pid, root, stage):
# These raw, no-content inventory files remain private/local and are not release artifacts.
heap = subprocess.run(['/usr/bin/heap', '--noContent', '-s', str(pid)], text=True, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, timeout=20, check=True)
(root / ('heap-' + stage + '.txt')).write_text(heap.stdout)
match = re.search(r'^\s*(\d+)\s+(\d+)\s+[\d.]+\s+Security::AppleDatabase\s', heap.stdout, re.MULTILINE)
if not match:
raise RuntimeError('AppleDatabase inventory class missing; diagnostic cannot silently report zero')
descriptors = subprocess.run(['/usr/sbin/lsof', '-a', '-p', str(pid), '-Fnf'], text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=5, check=True)
(root / ('lsof-' + stage + '.txt')).write_text(descriptors.stdout)
total = pki = temporary = keychain = 0
in_descriptor = False
pki_root = (root / 'pki').resolve()
tmp_root = (root / 'tmp').resolve()
for line in descriptors.stdout.splitlines():
if line.startswith('f'):
in_descriptor = len(line) > 1 and line[1].isdigit()
total += int(in_descriptor)
elif in_descriptor and line.startswith('n/'):
target = Path(line[1:].removesuffix(' (deleted)')).resolve()
pki += int(target == pki_root or pki_root in target.parents)
in_temp = target == tmp_root or tmp_root in target.parents
temporary += int(in_temp)
keychain += int(in_temp and '.keychain' in target.name)
if pki:
raise RuntimeError('Quiescent task-owned PKI descriptors are open')
if stage in ['disposed', 'idle'] and keychain:
raise RuntimeError('Owned temporary keychain descriptors remain after application disposal')
return {'stage': stage, 'AppleDatabaseCount': int(match[1]), 'AppleDatabaseShallowBytes': int(match[2]),
'fdCount': total, 'pkiFdCount': pki, 'temporaryDirectoryFdCount': temporary,
'temporaryKeychainFdCount': keychain,
'temporaryKeychainFileCount': sum(p.is_file() and '.keychain' in p.name for p in (root / 'tmp').rglob('*'))}
def arm(app, root, mode):
(root / 'tmp').mkdir()
(root / 'data').mkdir()
env = dict(os.environ, OPCUA_CERTIFICATE_LEAK_TRACKING='1', TMPDIR=str(root / 'tmp'), XDG_DATA_HOME=str(root / 'data'))
with (root / 'samples.jsonl').open('w') as log:
process = subprocess.Popen(['dotnet', str(app), mode, str(root)], stdout=log, stderr=subprocess.STDOUT, env=env)
try:
deadline = time.monotonic() + 180
values = []
for stage in STAGES:
while True:
text = (root / 'samples.jsonl').read_text()
rows = [json.loads(line) for line in text.split('\n')[:-1]]
if any(row.get('kind') == 'failed' for row in rows):
raise RuntimeError('Control failed; see samples.jsonl')
matching = [row for row in rows if row.get('stage') == stage]
if matching:
values.append({**matching[0], **snapshots(process.pid, root, stage)})
(root / ('continue-' + stage)).touch()
break
if process.poll() is not None or time.monotonic() >= deadline:
raise RuntimeError('Control checkpoint did not complete within bound: ' + stage)
time.sleep(.05)
status = process.wait(timeout=15)
rows = [json.loads(line) for line in (root / 'samples.jsonl').read_text().splitlines()]
complete = [row for row in rows if row.get('kind') == 'complete']
if status or len(complete) != 1 or complete[0]['operations'] != 200:
raise RuntimeError('Control failed or acquisition count changed')
result = {'exitCode': status, 'complete': complete[0], 'samples': values,
'nativeCountIsNotOpenHandleOrExclusiveRssAttribution': True,
'registryArmIsNotApprovedProductionMitigation': True}
save(root / 'result.json', result)
return result
finally:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--references', required=True, type=Path, help='Actual immutable published SDK2 DLL directory')
parser.add_argument('--output', required=True, type=Path, help='Fresh private output; contains PKI and native snapshots')
options = parser.parse_args()
if sys.platform != 'darwin':
parser.error('This native attribution control requires macOS')
out = options.output.resolve()
out.mkdir(parents=True, exist_ok=False)
os.chmod(out, 0o700)
sdk = sorted(options.references.resolve().glob('Opc.Ua*.dll'))
if not sdk:
raise RuntimeError('Missing actual SDK assemblies')
project = ET.Element('Project', Sdk='Microsoft.NET.Sdk')
props = ET.SubElement(project, 'PropertyGroup')
for k, v in {'OutputType': 'Exe', 'TargetFramework': 'net10.0', 'ImplicitUsings': 'enable', 'Nullable': 'enable',
'EnableDefaultCompileItems': 'false', 'TreatWarningsAsErrors': 'true'}.items():
ET.SubElement(props, k).text = v
items = ET.SubElement(project, 'ItemGroup')
ET.SubElement(items, 'FrameworkReference', Include='Microsoft.AspNetCore.App')
ET.SubElement(items, 'Compile', Include=str(HERE / 'ReloadControl.cs'))
for dll in sdk:
ref = ET.SubElement(items, 'Reference', Include=dll.stem)
ET.SubElement(ref, 'HintPath').text = str(dll)
ET.indent(project)
ET.ElementTree(project).write(out / 'Control.csproj', encoding='unicode')
sha = lambda p: hashlib.sha256(p.read_bytes()).hexdigest()
provenance = {'controlSourceSha256': sha(HERE / 'ReloadControl.cs'), 'runnerSourceSha256': sha(HERE / 'run.py'),
'repetitionsPerArm': 200, 'stages': STAGES, 'idleSeconds': 5, 'fullSoakGate': False,
'leakTracking': True, 'trackingOverhead': 'Both A/B processes capture allocation stacks; additional allocation and GC overhead is included in observed memory.',
'nativeReferences': {p.name: sha(p) for p in sdk}, 'hostDotnet': subprocess.check_output(['dotnet', '--info'], text=True),
'os': subprocess.check_output(['sw_vers'], text=True), 'architecture': os.uname().machine}
save(out / 'provenance.json', provenance)
with (out / 'build.log').open('w') as log:
subprocess.run(['dotnet', 'build', str(out / 'Control.csproj'), '-c', 'Release', '-m:1', '--nologo'],
stdout=log, stderr=subprocess.STDOUT, timeout=120, check=True)
app = out / 'bin/Release/net10.0/Control.dll'
seed = out / 'seed'
seed.mkdir()
(seed / 'tmp').mkdir()
(seed / 'data').mkdir()
results = {}
try:
with (seed / 'seed.jsonl').open('w') as log:
subprocess.run(['dotnet', str(app), 'seed', str(seed)], stdout=log, stderr=subprocess.STDOUT, timeout=60, check=True,
env=dict(os.environ, TMPDIR=str(seed / 'tmp'), XDG_DATA_HOME=str(seed / 'data')))
seed_rows = [json.loads(x) for x in (seed / 'seed.jsonl').read_text().splitlines()]
assert len(seed_rows) == 1 and seed_rows[0]['kind'] == 'seed'
provenance['cryptographyRuntime'] = seed_rows[0]['cryptographyRuntime']
save(out / 'provenance.json', provenance)
for mode in ['store', 'registry']:
root = out / mode
root.mkdir()
shutil.copytree(seed / 'pki', root / 'pki')
results[mode] = arm(app, root, mode)
assert results['store']['complete']['sameLeafSha256'] == results['registry']['complete']['sameLeafSha256'] == seed_rows[0]['leafSha256']
save(out / 'result.json', {'status': 'passed', 'seed': seed_rows[0], 'arms': results, 'fullSoakGate': False})
finally:
for root in [seed, out / 'store', out / 'registry']:
for name in ['pki', 'tmp', 'data']:
if (root / name).exists():
shutil.rmtree(root / name)
save(out / 'fixture-cleanup.json', {'allOwnedFixtureDirectoriesRemoved': all(not (p / name).exists() for p in [seed, out / 'store', out / 'registry'] for name in ['pki', 'tmp', 'data'])})
print(out)
if __name__ == '__main__':
main()