gongxiangp2/scripts/desktop-release-admission.py

241 lines
10 KiB
Python

#!/usr/bin/env python3
"""Project-scoped, read-only traffic admission; attestation never calls a model."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
import tempfile
import time
import urllib.request
BASE = Path('/mnt/PoolOne/apps/youyidian-3d')
POLICY = BASE / 'release-policy/desktop-admission'
RECEIPTS = POLICY / 'receipts'
ENV_FILE = BASE / '.env'
PRIVATE = BASE / 'detail-generator-private'
PROXY = BASE / 'local-proxy/youyidian-3d-material/nginx.conf'
NETWORK = 'youyidian-3d_app-network'
NAMES = {'youyidian-3d-material-blue', 'youyidian-3d-material-green'}
VERSION = 1
class Rejected(Exception):
"""Only fixed, non-secret reason codes may leave this process."""
def require(condition, code):
if not condition:
raise Rejected(code)
def command(args):
try:
result = subprocess.run(args, capture_output=True, text=True, timeout=12)
require(result.returncode == 0, 'runtime_inspection_failed')
return result.stdout
except (OSError, subprocess.TimeoutExpired):
raise Rejected('runtime_inspection_failed') from None
def physical(path, directory=False, owner=None, mode=None):
"""Never follow a substituted private store, policy file or ancestor."""
path = Path(path)
for part in (path, *path.parents):
require(not part.is_symlink(), 'symlink_rejected')
try:
value = path.stat()
except OSError:
raise Rejected('required_path_missing') from None
require(stat.S_ISDIR(value.st_mode) if directory else stat.S_ISREG(value.st_mode),
'path_type_mismatch')
if owner is not None:
require((value.st_uid, value.st_gid) == owner, 'path_owner_mismatch')
if mode is not None:
require(stat.S_IMODE(value.st_mode) == mode, 'path_mode_mismatch')
return value
def enabled_from_env(text):
# Accept the unambiguous subset of Docker --env-file syntax. In particular,
# quotes/comments in a value are literal, not dotenv syntax. Ambiguity stops
# the release instead of silently turning authorization off.
values = []
for line in text.removeprefix('\ufeff').splitlines():
line = line.lstrip()
if not line or line.startswith('#'):
continue
key, sep, value = line.partition('=')
if key == 'DETAIL_GENERATOR_ENABLED':
require(sep and value in ('true', 'false'), 'approved_flag_invalid')
values.append(value)
elif key.strip() == 'DETAIL_GENERATOR_ENABLED':
raise Rejected('approved_flag_invalid')
require(len(values) == 1, 'approved_flag_ambiguous')
return values[0] == 'true'
def environment_values(lines, key):
return [line.partition('=')[2] for line in lines if line.partition('=')[0] == key]
def config_target(text):
names = set(re.findall(r'^\s*add_header X-Material-Proxy-Target ([^;\s]+) always;\s*$', text, re.M))
ips = set(re.findall(r'^\s*server (\d+\.\d+\.\d+\.\d+):3000\s+[^;]*;\s*$', text, re.M))
require(len(names) == 1 and names <= NAMES and len(ips) == 1, 'proxy_target_ambiguous')
return next(iter(names)), next(iter(ips))
def inspect_target(name, expected_enabled, private_identity):
require(name in NAMES, 'container_scope_rejected')
try:
data = json.loads(command(['docker', 'inspect', name]))
except (ValueError, TypeError):
raise Rejected('runtime_inspection_invalid') from None
require(isinstance(data, list) and len(data) == 1, 'runtime_inspection_invalid')
d = data[0]
require(d.get('Name') == '/' + name and d.get('State', {}).get('Running') is True,
'container_not_running')
container_id, image_id = d.get('Id', ''), d.get('Image', '')
require(re.fullmatch(r'[a-f0-9]{64}', container_id), 'container_identity_invalid')
require(re.fullmatch(r'sha256:[a-f0-9]{64}', image_id), 'image_identity_invalid')
cfg = d.get('Config') or {}
env = cfg.get('Env') or []
expected = 'true' if expected_enabled else 'false'
require(environment_values(env, 'DETAIL_GENERATOR_ENABLED') == [expected], 'runtime_flag_mismatch')
if expected_enabled:
require(environment_values(env, 'DETAIL_GENERATOR_STORAGE_DIR') == ['/app/detail-generator-private'],
'runtime_storage_config_mismatch')
mounts = [m for m in d.get('Mounts', []) if m.get('Destination') == '/app/detail-generator-private']
require(len(mounts) == 1, 'private_mount_missing_or_ambiguous')
mount = mounts[0]
require(mount.get('Source') == str(PRIVATE) and mount.get('Type') == 'bind' and mount.get('RW') is True,
'private_mount_mismatch')
# Access check only. Never create/delete material files in a gate.
access = "require('fs').accessSync('/app/detail-generator-private',require('fs').constants.R_OK|require('fs').constants.W_OK)"
command(['docker', 'exec', '--user', '1001:1001', name, 'node', '-e', access])
revision = (cfg.get('Labels') or {}).get('org.opencontainers.image.revision', '')
require(re.fullmatch(r'[a-f0-9]{40}', revision), 'revision_invalid')
require(command(['docker', 'exec', name, 'cat', '/app/DEPLOYMENT_COMMIT']).strip() == revision,
'baked_revision_mismatch')
network = (d.get('NetworkSettings', {}).get('Networks') or {}).get(NETWORK) or {}
ip = network.get('IPAddress', '')
require(re.fullmatch(r'\d+\.\d+\.\d+\.\d+', ip), 'network_identity_invalid')
return {'container': name, 'containerId': container_id, 'imageId': image_id,
'revision': revision, 'enabled': expected_enabled, 'privateIdentity': private_identity,
'ip': ip}
def http_json(url):
try:
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
request = urllib.request.Request(url, headers={'User-Agent': 'gongxiangp2-desktop-admission/1', 'Connection': 'close'})
with opener.open(request, timeout=5) as response:
require(response.status == 200, 'candidate_config_unavailable')
return json.loads(response.read(16385))
except Rejected:
raise
except Exception:
raise Rejected('candidate_config_unavailable') from None
def current_snapshot():
physical(ENV_FILE)
enabled = enabled_from_env(ENV_FILE.read_text(encoding='utf-8-sig'))
identity = None
if enabled:
value = physical(PRIVATE, directory=True, owner=(1001, 1001), mode=0o700)
identity = [value.st_dev, value.st_ino, value.st_uid, value.st_gid]
physical(PROXY)
name, ip = config_target(PROXY.read_text(encoding='utf-8'))
value = inspect_target(name, enabled, identity)
require(value['ip'] == ip, 'proxy_network_mismatch')
config = http_json('http://' + ip + ':3000/api/desktop/config')
require(isinstance(config, dict) and config.get('enabled') is enabled
and config.get('clientId') == 'jiansun-detail-desktop' and config.get('pkce') == 'S256',
'candidate_config_mismatch')
value.pop('ip')
return value
def policy_digest():
return hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
def receipt_path(snapshot):
return RECEIPTS / (snapshot['containerId'] + '.json')
def attest(snapshot, entry_sha):
require(re.fullmatch(r'[a-f0-9]{64}', entry_sha), 'entry_digest_invalid')
physical(POLICY, directory=True, owner=(0, 0), mode=0o700)
physical(RECEIPTS, directory=True, owner=(0, 0), mode=0o700)
receipt = {'schema': VERSION, 'policySha256': policy_digest(), 'entrySha256': entry_sha,
'verifiedAt': int(time.time()), 'snapshot': snapshot}
target = receipt_path(snapshot)
if target.exists():
physical(target, owner=(0, 0), mode=0o600)
descriptor, temp = tempfile.mkstemp(prefix='.attest-', dir=RECEIPTS)
try:
with os.fdopen(descriptor, 'w', encoding='utf-8') as stream:
os.fchown(stream.fileno(), 0, 0)
os.fchmod(stream.fileno(), 0o600)
json.dump(receipt, stream, sort_keys=True)
stream.flush()
os.fsync(stream.fileno())
# Recheck identity after HTTP/filesystem observation, before publishing.
require(current_snapshot() == snapshot, 'candidate_changed_during_attestation')
os.replace(temp, target)
finally:
if os.path.exists(temp):
os.unlink(temp)
def validate_receipt(snapshot):
physical(POLICY, directory=True, owner=(0, 0), mode=0o700)
physical(RECEIPTS, directory=True, owner=(0, 0), mode=0o700)
target = receipt_path(snapshot)
physical(target, owner=(0, 0), mode=0o600)
try:
receipt = json.loads(target.read_text(encoding='utf-8'))
except (OSError, ValueError):
raise Rejected('receipt_invalid') from None
require(receipt.get('schema') == VERSION and receipt.get('policySha256') == policy_digest()
and re.fullmatch(r'[a-f0-9]{64}', receipt.get('entrySha256', ''))
and receipt.get('snapshot') == snapshot, 'candidate_not_attested')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--attest-config', action='store_true')
parser.add_argument('--entry-sha256', default='')
args = parser.parse_args()
try:
require(os.geteuid() == 0, 'root_required')
snapshot = current_snapshot()
if args.attest_config:
attest(snapshot, args.entry_sha256)
else:
require(not args.entry_sha256, 'unexpected_entry_digest')
validate_receipt(snapshot)
print(json.dumps({'desktopAdmission': 'accepted', 'mode': 'attest' if args.attest_config else 'check',
'containerId': snapshot['containerId'], 'revision': snapshot['revision'],
'enabled': snapshot['enabled'], 'privateMountVerified': snapshot['enabled']}))
return 0
except Rejected as error:
print(json.dumps({'desktopAdmission': 'rejected', 'reason': str(error)}), file=sys.stderr)
return 1
except Exception:
# Docker output, env values, HTTP bodies and exception details can carry
# credentials. Never forward them to deployment logs or journald.
print(json.dumps({'desktopAdmission': 'rejected', 'reason': 'inspection_failed'}), file=sys.stderr)
return 1
if __name__ == '__main__':
sys.exit(main())