Files
truf-server/app/worker_package_builder.py
2026-09-30 20:30:56 +03:00

538 lines
24 KiB
Python

"""Build reproducible remote-worker package trees from pinned public inputs."""
import argparse
import base64
import csv
import hashlib
import json
import os
import shutil
import stat
import struct
import subprocess
import sys
import tarfile
import tempfile
import urllib.request
import zipfile
sys.dont_write_bytecode = True
APP_DIR = os.path.abspath(os.path.dirname(__file__))
PROJECT_DIR = os.path.dirname(APP_DIR)
DEPENDENCIES_DIR = os.path.join(APP_DIR, 'dependencies')
if os.path.isdir(DEPENDENCIES_DIR) and DEPENDENCIES_DIR not in sys.path:
sys.path.insert(0, DEPENDENCIES_DIR)
if APP_DIR not in sys.path:
sys.path.insert(0, APP_DIR)
from lifecycle_authority import REMOTE_WORKER_CODE_AUTHORITY_FILES
from runtime_security import harden_private_tree, reject_reparse_components, sha256_file
from worker_package import (
DEFAULT_WORKER_PACKAGE_CAPABILITIES,
build_worker_package_manifest,
verify_worker_package,
worker_package_manifest_sha256,
write_worker_package_manifest,
)
class WorkerPackageBuildError(RuntimeError):
pass
def _regular_file(path, label):
path = os.path.abspath(os.fspath(path))
reject_reparse_components(path)
details = os.stat(path, follow_symlinks=False)
if not stat.S_ISREG(details.st_mode) or os.path.islink(path):
raise WorkerPackageBuildError(f'{label} is not a regular file')
return path
def _copy_file(source, destination, label):
source = _regular_file(source, label)
os.makedirs(os.path.dirname(destination), exist_ok=True)
shutil.copyfile(source, destination)
shutil.copymode(source, destination, follow_symlinks=False)
return destination
def _copy_tree(source, destination, label):
source = os.path.abspath(os.fspath(source))
reject_reparse_components(source)
if not os.path.isdir(source) or os.path.islink(source):
raise WorkerPackageBuildError(f'{label} is not a directory')
os.makedirs(destination, exist_ok=False)
copied = 0
for current, directories, names in os.walk(source, followlinks=False):
relative = os.path.relpath(current, source)
target = destination if relative == '.' else os.path.join(destination, relative)
for name in list(directories):
path = os.path.join(current, name)
reject_reparse_components(path)
if os.path.islink(path) or name.lower() == '__pycache__':
raise WorkerPackageBuildError(f'{label} contains an unsupported directory')
os.makedirs(os.path.join(target, name), exist_ok=False)
for name in names:
if name.lower().endswith(('.pyc', '.pyo')):
raise WorkerPackageBuildError(f'{label} contains cached bytecode')
_copy_file(
os.path.join(current, name), os.path.join(target, name),
f'{label} file',
)
copied += 1
if copied == 0:
raise WorkerPackageBuildError(f'{label} is empty')
return copied
def _windows_support_files(root):
launcher = (
'@echo off\n'
'"%~dp0runtime\\python\\python.exe" -u -I -S -B '
'"%~dp0app\\remote_worker_bootstrap.py" -- %*\n'
)
with open(os.path.join(root, 'truf-worker.cmd'), 'w', encoding='ascii', newline='\r\n') as handle:
handle.write(launcher)
with open(os.path.join(root, 'run-worker.cmd'), 'w', encoding='ascii', newline='\r\n') as handle:
handle.write(
'@echo off\n'
'"%~dp0runtime\\python\\python.exe" -u -I -S -B '
'"%~dp0app\\remote_worker_bootstrap.py" -- run %*\n'
)
with open(os.path.join(root, 'prepare-worker.ps1'), 'w', encoding='ascii', newline='\r\n') as handle:
handle.write(
"$ErrorActionPreference = 'Stop'\n"
"$root = (Resolve-Path -LiteralPath $PSScriptRoot).Path\n"
"$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value\n"
"& icacls.exe $root /inheritance:r /grant:r "
"\"*$sid`:(OI)(CI)F\" \"*S-1-5-18`:(OI)(CI)F\" "
"\"*S-1-5-32-544`:(OI)(CI)F\" | Out-Null\n"
"if ($LASTEXITCODE -ne 0) { throw 'worker package ACL preparation failed' }\n"
"& icacls.exe (Join-Path $root '*') /inheritance:d /T /C | Out-Null\n"
"if ($LASTEXITCODE -ne 0) { throw 'worker package child ACL preparation failed' }\n"
)
def _linux_support_files(root):
launcher = (
'#!/bin/sh\n'
'set -eu\n'
'root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\n'
'exec python3 -u -I -S -B "$root/app/remote_worker_bootstrap.py" -- "$@"\n'
)
for name, arguments in (('truf-worker', ''), ('run-worker', 'run ')):
path = os.path.join(root, name)
with open(path, 'w', encoding='ascii', newline='\n') as handle:
handle.write(launcher.replace('-- "$@"', f'-- {arguments}"$@"'))
os.chmod(path, 0o755)
prepare = (
'#!/bin/sh\n'
'set -eu\n'
'test "$(id -u)" -eq 0 || { echo "prepare-worker.sh requires sudo" >&2; exit 1; }\n'
'test -n "${SUDO_UID:-}" && test -n "${SUDO_GID:-}" && test "$SUDO_UID" -ne 0 || '
'{ echo "run prepare-worker.sh through sudo as the worker user" >&2; exit 1; }\n'
'root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\n'
'test -z "$(find "$root" -type l -print -quit)" || '
'{ echo "worker package contains a symbolic link" >&2; exit 1; }\n'
'chown 0:0 "$root"\n'
'chmod 0755 "$root"\n'
'chown -R "$SUDO_UID:$SUDO_GID" "$root/app"\n'
'find "$root/app" -type d -exec chmod 0700 {} +\n'
'find "$root/app" -type f -exec chmod 0600 {} +\n'
'chown "$SUDO_UID:$SUDO_GID" "$root/worker-package.json"\n'
'chmod 0600 "$root/worker-package.json"\n'
'chown -R 0:0 "$root/bin" "$root/runtime"\n'
'find "$root/bin" "$root/runtime" -type d -exec chmod 0755 {} +\n'
'find "$root/bin" "$root/runtime" -type f -exec chmod go-w {} +\n'
'chown 0:0 "$root/truf-worker" "$root/run-worker" "$root/prepare-worker.sh"\n'
'chmod 0755 "$root/truf-worker" "$root/run-worker" "$root/prepare-worker.sh"\n'
)
prepare_path = os.path.join(root, 'prepare-worker.sh')
with open(prepare_path, 'w', encoding='ascii', newline='\n') as handle:
handle.write(prepare)
os.chmod(prepare_path, 0o755)
def assemble_worker_package(
package_root, *, source_app, dependencies_root, detector_policy_source,
trufflehog_source, git_source_root, git_executable, platform_tag,
operator_readme_source, python_source_root=None, build_inputs_path=None,
operator_cheatsheet_sources=(),
capabilities=DEFAULT_WORKER_PACKAGE_CAPABILITIES,
):
package_root = os.path.abspath(os.fspath(package_root))
parent = os.path.dirname(package_root)
if os.path.lexists(package_root):
raise WorkerPackageBuildError('worker package destination already exists')
if not os.path.isdir(parent):
raise WorkerPackageBuildError('worker package destination parent is absent')
staging = tempfile.mkdtemp(prefix='.truf-worker-build-', dir=parent)
try:
app_root = os.path.join(staging, 'app')
os.makedirs(app_root)
source_app = os.path.abspath(os.fspath(source_app))
for name in REMOTE_WORKER_CODE_AUTHORITY_FILES:
_copy_file(
os.path.join(source_app, *name.split('/')),
os.path.join(app_root, *name.split('/')),
f'worker authority {name}',
)
_copy_tree(dependencies_root, os.path.join(app_root, 'dependencies'), 'worker dependencies')
policy_relative = 'app/trufflehog-custom-detectors.yaml'
_copy_file(
detector_policy_source,
os.path.join(staging, *policy_relative.split('/')),
'detector policy',
)
executable_name = 'trufflehog.exe' if platform_tag.startswith('windows-') else 'trufflehog'
trufflehog_relative = f'bin/{executable_name}'
_copy_file(
trufflehog_source, os.path.join(staging, *trufflehog_relative.split('/')),
'TruffleHog executable',
)
if not platform_tag.startswith('windows-'):
os.chmod(os.path.join(staging, *trufflehog_relative.split('/')), 0o755)
git_root_relative = 'runtime/git'
_copy_tree(git_source_root, os.path.join(staging, 'runtime', 'git'), 'Git runtime')
git_executable = str(git_executable).replace('\\', '/').strip('/')
git_relative = f'{git_root_relative}/{git_executable}'
_regular_file(os.path.join(staging, *git_relative.split('/')), 'Git executable')
python_root_relative = None
if platform_tag.startswith('windows-'):
if not python_source_root:
raise WorkerPackageBuildError('Windows package requires bundled Python')
python_root_relative = 'runtime/python'
_copy_tree(
python_source_root, os.path.join(staging, 'runtime', 'python'),
'Python runtime',
)
_regular_file(
os.path.join(staging, 'runtime', 'python', 'python.exe'),
'Python executable',
)
_windows_support_files(staging)
elif python_source_root:
raise WorkerPackageBuildError('Linux package must use image Python')
else:
_linux_support_files(staging)
if build_inputs_path:
_copy_file(
build_inputs_path, os.path.join(staging, 'worker-build-inputs.json'),
'worker build inputs',
)
_copy_file(
operator_readme_source, os.path.join(staging, 'README_RU.md'),
'Russian worker operator guide',
)
for source in operator_cheatsheet_sources:
name = os.path.basename(os.fspath(source))
if not name.startswith('remote-worker-cheatsheet-') or not name.endswith('-ru.md'):
raise WorkerPackageBuildError('worker operator cheatsheet name is invalid')
_copy_file(source, os.path.join(staging, name), 'worker operator cheatsheet')
manifest = build_worker_package_manifest(
staging,
trufflehog_path=trufflehog_relative,
git_path=git_relative,
detector_policy_path=policy_relative,
git_root=git_root_relative,
python_root=python_root_relative,
capabilities=[
{
'source': source,
'platform': platform,
'planning_kind': planning_kind,
}
for source, platform, planning_kind in capabilities
],
platform_tag=platform_tag,
)
manifest_path = write_worker_package_manifest(
os.path.join(staging, 'worker-package.json'), manifest,
)
if platform_tag.startswith('windows-'):
harden_private_tree(staging)
verify_worker_package(manifest_path)
os.replace(staging, package_root)
staging = None
return manifest
finally:
if staging is not None:
shutil.rmtree(staging, ignore_errors=True)
def _download(url, destination, expected_sha256, expected_bytes):
if os.path.exists(destination):
if os.path.getsize(destination) != expected_bytes or sha256_file(destination) != expected_sha256:
raise WorkerPackageBuildError('cached public build input does not match its pin')
return destination
partial = destination + '.partial'
digest = hashlib.sha256()
size = 0
try:
with urllib.request.urlopen(url, timeout=120) as response, open(partial, 'xb') as output:
while block := response.read(1024 * 1024):
digest.update(block)
size += len(block)
output.write(block)
if size != expected_bytes or digest.hexdigest() != expected_sha256:
raise WorkerPackageBuildError('downloaded public build input does not match its pin')
os.replace(partial, destination)
finally:
if os.path.exists(partial):
os.unlink(partial)
return destination
def _safe_unzip(archive_path, destination):
os.makedirs(destination, exist_ok=False)
root = os.path.abspath(destination)
with zipfile.ZipFile(archive_path) as archive:
for item in archive.infolist():
name = item.filename.replace('\\', '/')
parts = [part for part in name.split('/') if part]
if not parts or name.startswith('/') or any(part in ('.', '..') for part in parts):
raise WorkerPackageBuildError('ZIP build input contains an unsafe path')
mode = item.external_attr >> 16
if stat.S_ISLNK(mode):
raise WorkerPackageBuildError('ZIP build input contains a link')
target = os.path.abspath(os.path.join(root, *parts))
if os.path.commonpath((root, target)) != root:
raise WorkerPackageBuildError('ZIP build input escapes its destination')
if item.is_dir():
os.makedirs(target, exist_ok=True)
continue
os.makedirs(os.path.dirname(target), exist_ok=True)
with archive.open(item) as source, open(target, 'xb') as output:
shutil.copyfileobj(source, output)
def _extract_trufflehog(archive_path, destination):
with tarfile.open(archive_path, 'r:gz') as archive:
matches = [item for item in archive.getmembers() if item.name == 'trufflehog.exe']
if len(matches) != 1 or not matches[0].isfile():
raise WorkerPackageBuildError('TruffleHog archive executable is unavailable')
with archive.extractfile(matches[0]) as source, open(destination, 'xb') as output:
shutil.copyfileobj(source, output)
def _deterministic_zip(root, destination):
if os.path.lexists(destination):
raise WorkerPackageBuildError('worker archive destination already exists')
root = os.path.abspath(root)
with zipfile.ZipFile(destination, 'x', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
for current, directories, names in os.walk(root, followlinks=False):
directories.sort()
names.sort()
for name in names:
path = _regular_file(os.path.join(current, name), 'worker archive file')
relative = os.path.relpath(path, root).replace(os.sep, '/')
item = zipfile.ZipInfo(relative, (1980, 1, 1, 0, 0, 0))
item.compress_type = zipfile.ZIP_DEFLATED
item.external_attr = 0o100600 << 16
with open(path, 'rb') as source:
archive.writestr(item, source.read())
return destination
def _normalize_windows_dependency_artifacts(dependencies):
bin_root = os.path.join(dependencies, 'bin')
normalized = set()
if os.path.isdir(bin_root):
for name in sorted(os.listdir(bin_root)):
if not name.lower().endswith('.exe'):
continue
path = _regular_file(
os.path.join(bin_root, name), 'Windows dependency launcher',
)
with zipfile.ZipFile(path) as archive:
entries = archive.infolist()
central_offset = archive.start_dir
if not entries:
raise WorkerPackageBuildError(
'Windows dependency launcher has no embedded ZIP entries'
)
payload = bytearray(open(path, 'rb').read())
for entry in entries:
offset = entry.header_offset
if payload[offset:offset + 4] != b'PK\x03\x04':
raise WorkerPackageBuildError(
'Windows dependency launcher local header is invalid'
)
payload[offset + 10:offset + 14] = b'\x00\x00\x21\x00'
offset = central_offset
for _entry in entries:
if payload[offset:offset + 4] != b'PK\x01\x02':
raise WorkerPackageBuildError(
'Windows dependency launcher central header is invalid'
)
payload[offset + 12:offset + 16] = b'\x00\x00\x21\x00'
name_bytes, extra_bytes, comment_bytes = struct.unpack_from(
'<HHH', payload, offset + 28,
)
offset += 46 + name_bytes + extra_bytes + comment_bytes
with open(path, 'wb') as handle:
handle.write(payload)
normalized.add(name)
referenced = set()
for current, _directories, names in os.walk(dependencies):
if os.path.basename(current).lower().endswith('.dist-info') and 'RECORD' in names:
record = os.path.join(current, 'RECORD')
with open(record, 'r', encoding='utf-8', newline='') as handle:
rows = list(csv.reader(handle))
changed = False
for row in rows:
if len(row) != 3:
raise WorkerPackageBuildError('Windows dependency RECORD is invalid')
relative = row[0].replace('\\', '/')
if not relative.startswith('../../bin/'):
continue
name = relative.rsplit('/', 1)[-1]
path = _regular_file(
os.path.join(bin_root, name), 'Windows dependency RECORD launcher',
)
digest = base64.urlsafe_b64encode(
bytes.fromhex(sha256_file(path))
).decode('ascii').rstrip('=')
row[1] = 'sha256=' + digest
row[2] = str(os.path.getsize(path))
referenced.add(name)
changed = True
if changed:
with open(record, 'w', encoding='utf-8', newline='') as handle:
csv.writer(handle, lineterminator='\r\n').writerows(rows)
if referenced != normalized:
raise WorkerPackageBuildError(
'Windows dependency launchers and RECORD entries do not match'
)
def build_windows_portable(project_root, output_root, archive_path, cache_root):
project_root = os.path.abspath(project_root)
pins_path = os.path.join(project_root, 'docker', 'worker-package-pins.json')
with open(pins_path, 'r', encoding='utf-8') as handle:
pins = json.load(handle)['windows']['x86_64']
os.makedirs(cache_root, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='truf-worker-windows-') as temporary:
extracted = {}
for name in ('python', 'git', 'trufflehog'):
pin = pins[name]
suffix = '.tar.gz' if name == 'trufflehog' else '.zip'
archive = _download(
pin['url'], os.path.join(cache_root, name + suffix),
pin['archive_sha256'], int(pin['archive_bytes']),
)
if name == 'trufflehog':
extracted[name] = os.path.join(temporary, 'trufflehog.exe')
_extract_trufflehog(archive, extracted[name])
else:
extracted[name] = os.path.join(temporary, name)
_safe_unzip(archive, extracted[name])
dependencies = os.path.join(temporary, 'dependencies')
subprocess.run([
sys.executable, '-m', 'pip', '--isolated', 'install',
'--require-hashes', '--only-binary=:all:', '--no-compile', '--no-deps',
'--disable-pip-version-check', '--platform=win_amd64',
'--python-version=3.12', '--implementation=cp', '--abi=cp312',
'--target', dependencies,
'-r', os.path.join(project_root, 'docker', 'requirements-worker.lock'),
], check=True)
_normalize_windows_dependency_artifacts(dependencies)
manifest = assemble_worker_package(
output_root,
source_app=os.path.join(project_root, 'app'),
dependencies_root=dependencies,
detector_policy_source=os.path.join(project_root, 'app', 'trufflehog-custom-detectors.yaml'),
trufflehog_source=extracted['trufflehog'],
git_source_root=extracted['git'],
git_executable='cmd/git.exe',
python_source_root=extracted['python'],
build_inputs_path=pins_path,
operator_readme_source=os.path.join(
project_root, 'docs', 'remote-worker-quickstart-ru.md',
),
operator_cheatsheet_sources=[
os.path.join(project_root, 'docs', f'remote-worker-cheatsheet-{name}-ru.md')
for name in ('windows', 'linux', 'docker')
],
platform_tag='windows-x86_64',
)
_deterministic_zip(output_root, archive_path)
release = {
'schema': 1,
'platform_tag': 'windows-x86_64',
'archive': os.path.basename(archive_path),
'archive_bytes': os.path.getsize(archive_path),
'archive_sha256': sha256_file(archive_path),
'package_manifest_sha256': worker_package_manifest_sha256(manifest),
'build_inputs_sha256': sha256_file(pins_path),
}
release_path = archive_path + '.json'
if os.path.lexists(release_path):
raise WorkerPackageBuildError('worker release metadata destination already exists')
with open(release_path, 'x', encoding='ascii', newline='\n') as handle:
json.dump(release, handle, ensure_ascii=True, sort_keys=True, separators=(',', ':'))
handle.write('\n')
harden_private_tree(release_path)
return release
def parse_args(argv=None):
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
subparsers = parser.add_subparsers(dest='command', required=True)
windows = subparsers.add_parser('windows', allow_abbrev=False)
windows.add_argument('--project-root', default=PROJECT_DIR)
windows.add_argument('--output', required=True)
windows.add_argument('--archive', required=True)
windows.add_argument('--cache', required=True)
assemble = subparsers.add_parser('assemble', allow_abbrev=False)
assemble.add_argument('--output', required=True)
assemble.add_argument('--source-app', required=True)
assemble.add_argument('--dependencies', required=True)
assemble.add_argument('--detector-policy', required=True)
assemble.add_argument('--trufflehog', required=True)
assemble.add_argument('--git-root', required=True)
assemble.add_argument('--git-executable', required=True)
assemble.add_argument('--platform-tag', required=True)
assemble.add_argument('--python-root')
assemble.add_argument('--build-inputs')
assemble.add_argument('--operator-readme', required=True)
assemble.add_argument('--operator-cheatsheet', action='append', default=[])
return parser.parse_args(argv)
def main(argv=None):
args = parse_args(argv)
if args.command == 'windows':
release = build_windows_portable(
args.project_root, args.output, args.archive, args.cache,
)
print(json.dumps(release, ensure_ascii=True, sort_keys=True))
elif args.command == 'assemble':
manifest = assemble_worker_package(
args.output,
source_app=args.source_app,
dependencies_root=args.dependencies,
detector_policy_source=args.detector_policy,
trufflehog_source=args.trufflehog,
git_source_root=args.git_root,
git_executable=args.git_executable,
platform_tag=args.platform_tag,
python_source_root=args.python_root,
build_inputs_path=args.build_inputs,
operator_readme_source=args.operator_readme,
operator_cheatsheet_sources=args.operator_cheatsheet,
)
print(worker_package_manifest_sha256(manifest))
if __name__ == '__main__':
main()