136 lines
7.5 KiB
Python
136 lines
7.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Bounded, attribution-only native Apple keychain lifecycle, without PFX/SDK/CLR."""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
STAGES = ['before', 'first', 'repeated', 'idle']
|
|
|
|
|
|
def save(path, value):
|
|
path.write_text(json.dumps(value, indent=2) + '\n')
|
|
|
|
|
|
def complete_rows(path):
|
|
return [json.loads(line) for line in path.read_text().split('\n')[:-1]]
|
|
|
|
|
|
def snapshot(pid, task, output, stage):
|
|
heap = subprocess.run(['/usr/bin/heap', '--noContent', '-s', str(pid)], text=True, stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT, timeout=20, check=True)
|
|
(output / ('heap-' + stage + '.txt')).write_text(heap.stdout)
|
|
database = re.search(r'^\s*(\d+)\s+(\d+)\s+[\d.]+\s+Security::AppleDatabase\s', heap.stdout, re.MULTILINE)
|
|
manager = re.search(r'^\s*(\d+)\s+(\d+)\s+[\d.]+\s+Security::AppleDatabaseManager\s', heap.stdout, re.MULTILINE)
|
|
if database is None and manager is None:
|
|
raise RuntimeError('Native inventory could not classify the database or its manager; do not silently infer zero')
|
|
descriptors = subprocess.run(['/usr/sbin/lsof', '-a', '-p', str(pid), '-Fnf'], text=True,
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=5, check=True)
|
|
(output / ('lsof-' + stage + '.txt')).write_text(descriptors.stdout)
|
|
total = temp = keychain = 0
|
|
in_descriptor = False
|
|
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/'):
|
|
path = Path(line[1:].removesuffix(' (deleted)')).resolve()
|
|
owned = path == task / 'tmp' or task / 'tmp' in path.parents
|
|
temp += int(owned)
|
|
keychain += int(owned and '.keychain' in path.name)
|
|
return {'AppleDatabaseCount': int(database[1]) if database else 0,
|
|
'AppleDatabaseShallowBytes': int(database[2]) if database else 0,
|
|
'AppleDatabaseClassAbsent': database is None,
|
|
'databaseManagerClassPresent': manager is not None, 'fdCount': total,
|
|
'ownedTemporaryFdCount': temp, 'ownedKeychainFdCount': keychain,
|
|
'ownedKeychainFileCount': sum(p.is_file() and '.keychain' in p.name for p in (task / 'tmp').rglob('*'))}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--output', required=True, type=Path, help='Fresh private output directory')
|
|
options = parser.parse_args()
|
|
if sys.platform != 'darwin':
|
|
parser.error('Native control requires macOS')
|
|
output = options.output.resolve()
|
|
output.mkdir(parents=True, exist_ok=False)
|
|
os.chmod(output, 0o700)
|
|
task = output / 'task'
|
|
binary = output / 'PureAppleControl'
|
|
command = ['clang', '-std=c11', '-O0', '-g', '-Wall', '-Wextra', str(HERE / 'PureAppleControl.c'),
|
|
'-framework', 'Security', '-framework', 'CoreFoundation', '-o', str(binary)]
|
|
source_hash = hashlib.sha256((HERE / 'PureAppleControl.c').read_bytes()).hexdigest()
|
|
provenance = {'sourceSha256': source_hash, 'runnerSourceSha256': hashlib.sha256((HERE / 'pure_apple.py').read_bytes()).hexdigest(), 'compileCommand': command,
|
|
'compiler': subprocess.check_output(['clang', '--version'], text=True),
|
|
'os': subprocess.check_output(['sw_vers'], text=True), 'architecture': os.uname().machine,
|
|
'repetitions': 200, 'idleSeconds': 5, 'stages': STAGES, 'fullSoakGate': False,
|
|
'readOnlyKeychainQueries': ['SecKeychainCopyDefault', 'SecKeychainCopySearchList', 'SecKeychainGetPath'],
|
|
'doesNotCallAnyDefaultOrSearchListSetter': True}
|
|
save(output / 'provenance.json', provenance)
|
|
with (output / 'build.log').open('w') as log:
|
|
subprocess.run(command, stdout=log, stderr=subprocess.STDOUT, timeout=60, check=True)
|
|
provenance['binarySha256'] = hashlib.sha256(binary.read_bytes()).hexdigest()
|
|
provenance['linkedLibraries'] = subprocess.check_output(['otool', '-L', str(binary)], text=True)
|
|
if 'libcoreclr' in provenance['linkedLibraries'] or 'Opc.Ua' in provenance['linkedLibraries']:
|
|
raise RuntimeError('Pure Apple control must not load SDK or CLR')
|
|
save(output / 'provenance.json', provenance)
|
|
task.mkdir(mode=0o700)
|
|
(task / 'tmp').mkdir(mode=0o700)
|
|
started = time.monotonic()
|
|
with (output / 'samples.jsonl').open('w') as log:
|
|
process = subprocess.Popen([str(binary), str(task)], stdout=log, stderr=subprocess.STDOUT,
|
|
env=dict(os.environ, TMPDIR=str(task / 'tmp')))
|
|
try:
|
|
deadline = time.monotonic() + 180
|
|
samples = []
|
|
for stage in STAGES:
|
|
while True:
|
|
rows = complete_rows(output / 'samples.jsonl')
|
|
if any(row['kind'] == 'failed' for row in rows):
|
|
raise RuntimeError('Native control failed; preserve raw status rows')
|
|
checkpoints = [row for row in rows if row.get('stage') == stage and row['kind'] == 'checkpoint']
|
|
if checkpoints:
|
|
samples.append({**checkpoints[0], **snapshot(process.pid, task, output, stage)})
|
|
(task / ('continue-' + stage)).touch()
|
|
break
|
|
if process.poll() is not None or time.monotonic() >= deadline:
|
|
raise RuntimeError('Native checkpoint exceeded bound: ' + stage)
|
|
time.sleep(.05)
|
|
status = process.wait(timeout=15)
|
|
rows = complete_rows(output / 'samples.jsonl')
|
|
operations = [r for r in rows if r['kind'] == 'operation']
|
|
fingerprints = [r for r in rows if r['kind'] == 'fingerprint']
|
|
complete = [r for r in rows if r['kind'] == 'complete']
|
|
if status or len(operations) != 200 or len(fingerprints) != 2 or len(complete) != 1:
|
|
raise RuntimeError('Incomplete native execution count')
|
|
if any(r['createStatus'] or r['deleteStatus'] or not r['privatePathVerified'] or not r['releasedExactlyOnce'] for r in operations):
|
|
raise RuntimeError('Native lifecycle status/ownership failed')
|
|
if any(samples[-1][k] for k in ['ownedKeychainFdCount', 'ownedKeychainFileCount']):
|
|
raise RuntimeError('Private native keychain files/descriptors retained')
|
|
result = {'status': 'passed', 'exitCode': status, 'wallSecondsIncludingProbes': time.monotonic() - started,
|
|
'samples': samples, 'fingerprints': fingerprints, 'complete': complete[0], 'fullSoakGate': False,
|
|
'nativeInventoryIsNotOpenHandlesOrExclusiveRss': True, 'nativeDeleteOsStatusesAllZero': True}
|
|
save(output / 'result.json', result)
|
|
finally:
|
|
if process.poll() is None:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill(); process.wait(timeout=5)
|
|
save(output / 'fixture-before-harness-cleanup.json', {'remainingOwnedKeychainFiles': sum(p.is_file() and '.keychain' in p.name for p in (task / 'tmp').rglob('*'))})
|
|
shutil.rmtree(task)
|
|
save(output / 'fixture-cleanup.json', {'ownedFixtureRootRemoved': not task.exists()})
|
|
print(output)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|