241 lines
10 KiB
Python
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())
|