diff --git a/.github/workflows/qualify-released-linux-sdk.yml b/.github/workflows/qualify-released-linux-sdk.yml index 85e1943..a4a1e02 100644 --- a/.github/workflows/qualify-released-linux-sdk.yml +++ b/.github/workflows/qualify-released-linux-sdk.yml @@ -15,6 +15,7 @@ on: - tools/tests/test_released_sdk_inventory.py - tools/tests/test_released_sdk_filesystem.py - tools/tests/test_released_sdk_diagnostics.py + - tools/tests/test_released_sdk_scanner_diagnostics.py - tools/RELEASED_SDK_INVENTORY.md workflow_dispatch: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index be82ac3..b2199a8 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -2,6 +2,21 @@ name: Validate build images on: pull_request: + # GHA caches from PR merge refs and release tags cannot seed another PR. + # A trusted default-branch producer makes this shared stage reusable there. + push: + branches: + - main + paths: + - .dockerignore + - .github/workflows/validate.yml + - linux/Dockerfile + - windows/Dockerfile + - portable/Dockerfile + - llvm-toolchain.json + - llvm-bootstrap-packages.lock + - tools/install_llvm_toolchain.py + - tools/validate_llvm_toolchain.py permissions: contents: read @@ -13,6 +28,7 @@ concurrency: jobs: changes: name: Select changed images + if: github.event_name == 'pull_request' runs-on: ubuntu-26.04 outputs: portable: ${{ steps.changes.outputs.portable }} @@ -144,6 +160,30 @@ jobs: - name: Test required-check aggregation run: tools/test-require-image-checks.sh + llvm-cache: + name: Prime shared LLVM cache on main + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-26.04 + timeout-minutes: 360 + steps: + - name: Check out image sources + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4 + + - name: Build shared LLVM source toolchain + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7 + with: + context: . + file: linux/Dockerfile + target: llvm-toolchain + build-args: LLVM_BUILD_JOBS=2 + platforms: linux/amd64 + outputs: type=cacheonly + cache-from: type=gha,scope=llvm-toolchain-23.1.3 + cache-to: type=gha,mode=max,scope=llvm-toolchain-23.1.3,ignore-error=true + llvm: name: Shared LLVM source toolchain needs: changes @@ -587,7 +627,7 @@ jobs: - linux - windows - windows-native - if: always() + if: always() && github.event_name == 'pull_request' permissions: contents: read runs-on: ubuntu-26.04 diff --git a/AGENTS.md b/AGENTS.md index 66e028d..b7097dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,7 +107,11 @@ compiler limits separate from model-worker concurrency. Build the shared `llvm-toolchain` stage once before parallel PR target validation; selected image jobs and the required aggregate retain that dependency. Cold source builds - may use the supported six-hour hosted-job timeout. + may use the supported six-hour hosted-job timeout. Input-filtered trusted + `main` pushes seed the same shared LLVM Actions cache for later PRs and + measurements; PR merge-ref and release-tag caches cannot seed another PR. + Keep that cache-only job separate from required PR image/native checks and + private-package access. Cache eviction or changed inputs can require rebuilding. - Every semantic release publishes the broad Linux, slim and portable Linux Classic, general Windows, and task-focused Windows Classic images with their supported tags. The Linux publisher owns `linux-build`, `classic-build`, and @@ -137,6 +141,15 @@ root fields and bounded UID/mode ownership headers before and after protected reads. Keep full stat equality for every nonroot path; see `tools/RELEASED_SDK_INVENTORY.md` for the root fence contract. + Fence the scanner's one-file bounded local log with compression disabled; + Moby rejects compression with a single retained file. The schema-2 filesystem + receipt records execution as true after an accepted start or complete fenced + started-state proof, false before any attempt, and null for an unresolved + attempt. Reconcile after cleanup; a created snapshot cannot exclude a pending + start. Acquisition remains schema 1 and never starts its container. Recovery + observations cannot grant successful qualification. Retain only fixed failure stages, + codes, exit status and errno names; keep arbitrary logs, paths and exception + text out of published diagnostics. - Validate Dockerfiles with `docker build --check`. Build and smoke-test each affected image, including `classic-validation` before `classic-final` and the Windows `classic-check` target plus native bundle; note that a cold diff --git a/README.md b/README.md index 82a24eb..88856cb 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,11 @@ Producer CI gives cold source builds up to the supported six-hour hosted-job limit. It keeps LLVM and Windows build concurrency at two jobs each on hosted runners. A shared stage cache compiles the source once before parallel PR image validation; each target also retains its own LLVM-versioned cache scope. +Trusted `main` pushes affecting the LLVM stage seed its shared Actions cache, +which later PRs and measurements can restore. GitHub isolates caches created +on PR merge refs and release tags, so those caches alone cannot seed a new PR. +The first build, changed inputs, or cache eviction may still require a cold +build; required image and native checks run independently of the cache seed. Increase build parallelism only for runners with corresponding CPU and memory capacity; model-worker concurrency does not set compiler concurrency. @@ -484,6 +489,14 @@ It binds the installed executables used by the later consumer; earlier library builds remain part of trusted SDK production and are not historical execution attestations. +Released SDK inventory adoption also requires the separate hosted filesystem +qualification in [`tools/RELEASED_SDK_INVENTORY.md`](tools/RELEASED_SDK_INVENTORY.md). +Its schema-2 receipt reconciles lost Docker start responses during fenced cleanup: +execution is `true` when proven, `false` before an attempt, and `null` when the +attempt remains unresolved. Successful qualification still requires the accepted +start response, complete filesystem equality and owned cleanup. The separate +schema-1 acquisition container remains unstarted. + Shared application libraries live under `/usr/local`; Debian packages provide the baseline transitive libraries. Compiler flags use `-march=x86-64 -mtune=generic`. Verification checks actual ELF dependency diff --git a/tools/RELEASED_SDK_INVENTORY.md b/tools/RELEASED_SDK_INVENTORY.md index 574b4f2..56a3542 100644 --- a/tools/RELEASED_SDK_INVENTORY.md +++ b/tools/RELEASED_SDK_INVENTORY.md @@ -162,10 +162,37 @@ privileges or credentials, fixed `/usr/bin/python3 -I -B` entrypoint and bounded wait/log output. Host-side image, command, source, mount and ownership fences surround execution and cleanup. Only this owned executed scanner may be stopped on failure; the acquisition container remains unstarted. +The local log driver uses exactly one 128 MiB file with `compress=false`, and +that configuration is checked with the other container fences. Moby's +[local driver configuration](https://github.com/moby/moby/blob/docker-v29.4.2/daemon/logger/local/config.go) +rejects its default compression setting when `max-file=1`; explicit disabling +allows startup while preserving the log bound. -Its separate `filesystem-qualification.json` receipt explicitly records -`image_code_executed: true`, the exact index/platform/config/producer/consumer, -scanner/helper hashes, run identity, its own container and cleanup outcome. +Its separate schema-2 `filesystem-qualification.json` receipt records +`image_code_executed` as `true`, `false` or `null`, along with the exact +index/platform/config/producer/consumer, scanner/helper hashes, run identity, +its own container and cleanup outcome. `true` requires an accepted Docker start +response or a complete owned-container fence with coherent running/exited state, +true-integer PID, nonzero valid UTC RFC3339Nano `StartedAt`, and false paused, +restarting and dead flags. This records Docker's execution boundary, not proof +of particular CPU instructions. Moby's [start implementation](https://github.com/moby/moby/blob/docker-v29.4.2/daemon/start.go) +starts the task before setting the running state; its +[state transitions](https://github.com/moby/moby/blob/docker-v29.4.2/daemon/container/state.go) +preserve `StartedAt` after exit, and its +[inspection](https://github.com/moby/moby/blob/docker-v29.4.2/daemon/inspect.go) +formats that timestamp as RFC3339Nano. + +A start response can be lost after execution begins. Cleanup's complete fences +before mutation and after stop also reconcile this evidence; proven execution +survives later cleanup failure. `false` means no start was attempted and no +fenced started state was observed. After any attempted start, missing inspection, +fence drift, malformed state or only a created/zero-start snapshot leaves `null` +unless a prior accepted response or fenced observation proved `true`. A created +snapshot cannot exclude a pending start request, even when removal succeeds. +Only fixed booleans and null enter this evidence; raw state/timestamps are not +published. Acquisition receipts and scanner reports retain their separate +schema 1. Reconciliation never grants qualification: success still requires the +accepted start response, completed scan, full comparison and successful cleanup. Success requires exact equality of all three maps, every metadata/tool field and canonical digests. An omitted unrelated producer file, missing notice, changed resolved path, malformed report, source drift or cleanup failure stops the final @@ -173,6 +200,26 @@ collection. This is trusted-producer filesystem qualification; image Python and OS remain trusted. It does not establish runtime, export, gameplay or audible acceptance, and does not claim independence from a malicious image OS. +Failed filesystem qualification retains a fixed host phase and, after a valid +Docker wait response, an integer exit status in `0..255`. Unexpected Docker HTTP +statuses retain the fixed `docker-request-denied` code without response bodies +or request paths. The original fixed qualification error also remains in +`failure.reason` if cleanup fails independently. The top-level reason then +reports `owned-cleanup-failed` with `owned_container_removed: false`; a failed +lookup, including 404 after a rejected or ambiguous create, never certifies +removal. Resolving the generated name still requires the full ownership fence +before any mutation. The collector fences the exited container before reading logs +even when the process failed. Nonzero +exit output is limited to 4 KiB and must use the same strict stdout-only Docker +framing as successful reports. The only retained scanner output is an exact +stopped-report schema containing a fixed stage and allowlisted failure code, +plus an allowlisted errno name for OS errors. Unknown codes, extra fields, +stderr, raw tracebacks, paths, exception strings and malformed output never +enter the receipt. A nonzero exit always fails qualification, including when +its output resembles a successful inventory. Diagnostics preserve owned +cleanup and every source, image, mount, isolation and inventory comparison gate; +they provide failure evidence rather than SDK acceptance. + The fixed `portable-final` row admits only `atrinik/classic-portable-build:1.22.0` with this authenticated release identity: diff --git a/tools/collect-released-sdk-metadata.py b/tools/collect-released-sdk-metadata.py index 9acf228..d73ab5d 100644 --- a/tools/collect-released-sdk-metadata.py +++ b/tools/collect-released-sdk-metadata.py @@ -1,11 +1,12 @@ #!/usr/bin/python3 -I # Copyright (c) 2026 Atrinik contributors # SPDX-License-Identifier: MIT -"""Collect bounded installed identity from three fixed released Linux SDKs. +"""Collect bounded identity from fixed historical and final released Linux SDKs. Only trusted same-repository push/dispatch on GitHub-hosted Linux may acquire. -No image code runs: a task-owned container stays in created state until removal. -Artifacts prove installed identity, not runtime health or a final portable cohort. +Acquisition never starts its container. Final filesystem qualification executes +a separate isolated scanner and retains its distinct execution receipt. +Artifacts do not establish runtime health or a final portable cohort. RegistryToken semantics: moby/moby v28.5.1 api/types/registry/authconfig.go and distribution/registry.go. Archive HEAD resolves LinkTarget in daemon/containerfs_linux.go; @@ -13,6 +14,7 @@ """ import argparse import base64 +import datetime import hashlib import http.client import importlib.util @@ -995,7 +997,29 @@ def identity(value): return result -def scanner_logs(response): +SCANNER_HOST_FAILURE_CODES = frozenset({ + 'final-image-coordinate-mismatch', 'unsafe-scanner-source', 'scanner-source-changed', + 'scanner-source-set', 'image-volumes-not-allowed', 'invalid-created-container', + 'container-not-created', 'scanner-ownership-mismatch', 'scanner-command-mismatch', + 'scanner-state-mismatch', 'scanner-isolation-mismatch', 'scanner-mount-mismatch', + 'scanner-log-config-mismatch', + 'scanner-execution-not-started', + 'container-image-mismatch', 'container-manifest-mismatch', 'scanner-process-failed', + 'scanner-logs-unframed', 'scanner-logs-invalid', 'scanner-logs-limit', + 'scanner-logs-incomplete', 'invalid-json', 'docker-request-denied', +}) + + +def scanner_host_failure(error): + if (type(error) is Stop and len(error.args) == 1 and type(error.args[0]) is str + and error.args[0] in SCANNER_HOST_FAILURE_CODES): + return error.args[0] + if type(error) is INVENTORY.InventoryError: + return INVENTORY.failure_code(error) + return 'filesystem-qualification-failed' + + +def scanner_logs(response, *, limit=SCANNER_LIMIT): require(response.chunked or response.length is not None, 'scanner-logs-unframed') result = bytearray() while True: @@ -1004,12 +1028,41 @@ def scanner_logs(response): break require(len(header) == 8 and header[:4] == b'\x01\0\0\0', 'scanner-logs-invalid') size = struct.unpack('>I', header[4:])[0] - require(0 < size <= 1024 * 1024 and len(result) + size <= SCANNER_LIMIT, 'scanner-logs-limit') + require(0 < size <= 1024 * 1024 and len(result) + size <= min(limit, SCANNER_LIMIT), 'scanner-logs-limit') result.extend(exact_read(response, size)) require(response.length in (None, 0) and result, 'scanner-logs-incomplete') return decode(bytes(result)) +def scanner_started_state(state): + """Recognize Moby's started state; never infer it from status alone. + + Inspect serializes StartedAt as RFC3339Nano; SetStopped preserves it. + Only call this after the complete owned-container fence succeeds. + """ + if type(state) is not dict: + return False + status, pid, started = state.get('Status'), state.get('Pid'), state.get('StartedAt') + if not (status in ('running', 'exited') and type(pid) is int + and (0 < pid <= 2**31 - 1 if status == 'running' else pid == 0) + and state.get('Running') is (status == 'running') + and all(state.get(key) is False for key in ('Paused', 'Restarting', 'Dead')) + and type(started) is str and len(started) <= 30): + return False + match = re.fullmatch(r'[0-9]{4}-[0-9]{2}-[0-9]{2}T(?:[01][0-9]|2[0-3]):' + r'[0-5][0-9]:[0-5][0-9](?:\.([0-9]{1,9}))?Z', started) + if match is None: + return False + try: + timestamp = datetime.datetime.fromisoformat(started) + except ValueError: + return False + # Python truncates sub-microsecond fractions; check Go's zero time without + # accidentally treating a nonzero nanosecond as zero. + return (timestamp != datetime.datetime.min.replace(tzinfo=datetime.timezone.utc) + or int(match[1] or '0') != 0) + + class ScannerImage(StoppedImage): """Separate executed qualification; never weakens StoppedImage's fence.""" def __init__(self, docker, target, context, verified, sources): @@ -1020,8 +1073,13 @@ def __init__(self, docker, target, context, verified, sources): require(set(sources) == set(SCANNER_FILES), 'scanner-source-set') super().__init__(docker, target, context, verified) self.name = 'atrinik-sdk-filesystem-' + self.owner + self.started_observed = False self.sources = sources self.entrypoint = ['/usr/bin/python3', '-I', '-B', '/scan/scan-released-sdk-filesystem.py'] + # Moby's local driver defaults to compression, which is incompatible + # with max-file=1. Disable it explicitly while retaining the bound. + self.log_config = {'Type': 'local', 'Config': { + 'max-size': '128m', 'max-file': '1', 'compress': 'false'}} self.mounts = [{'Type': 'bind', 'Source': item['path'], 'Target': '/scan/' + name, 'ReadOnly': True} for name, item in sources.items()] @@ -1032,7 +1090,7 @@ def create(self): 'NetworkDisabled': True, 'Healthcheck': {'Test': ['NONE']}, 'HostConfig': {'NetworkMode': 'none', 'ReadonlyRootfs': True, 'CapDrop': ['ALL'], 'SecurityOpt': ['no-new-privileges'], 'RestartPolicy': {'Name': 'no'}, - 'Mounts': self.mounts, 'LogConfig': {'Type': 'local', 'Config': {'max-size': '128m', 'max-file': '1'}}}} + 'Mounts': self.mounts, 'LogConfig': self.log_config}} self.attempted = True conn, response = self.docker.request('POST', '/containers/create?' + urllib.parse.urlencode({ 'name': self.name, 'platform': 'linux/amd64'}), {'Content-Type': 'application/json'}, @@ -1063,6 +1121,7 @@ def fence(self, states=('created',)): and not host.get('Privileged') and not host.get('Binds') and host.get('CapDrop') == ['ALL'] and host.get('SecurityOpt') == ['no-new-privileges'] and host.get('RestartPolicy', {}).get('Name') == 'no', 'scanner-isolation-mismatch') + require(host.get('LogConfig') == self.log_config, 'scanner-log-config-mismatch') mounts = value.get('Mounts') require(isinstance(mounts, list) and len(mounts) == len(self.mounts), 'scanner-mount-mismatch') actual = {(item.get('Source'), item.get('Destination')) for item in mounts @@ -1074,26 +1133,55 @@ def fence(self, states=('created',)): selected = value.get('ImageManifestDescriptor') if selected is not None or image != self.verified['config_digest']: require(selected_manifest_matches(selected, self.verified), 'container-manifest-mismatch') + # Retain only a bounded fact, after every identity/isolation fence. + # Cleanup also reaches this site before mutation and after stopping. + if scanner_started_state(state): + self.started_observed = True return value - def execute(self): + def execute(self, *, progress=None): + progress = progress if progress is not None else {} + progress['phase'] = 'container-start' self.fence() + # The daemon may execute even if the response is lost. From this point + # a failed call means unknown until a fully fenced start is observed. + progress['start_attempted'] = True conn, _ = self.docker.request('POST', '/containers/' + self.identifier + '/start', expected=204) + progress['start_accepted'] = True conn.close() + progress['phase'] = 'container-wait' conn, response = self.docker.request('POST', '/containers/' + self.identifier + '/wait?condition=not-running', timeout=INVENTORY.MAX_SECONDS + 30) try: result = decode(bounded_read(response)) - require(type(result.get('StatusCode')) is int and result['StatusCode'] == 0 and - not result.get('Error'), 'scanner-process-failed') + status = result.get('StatusCode') + require(type(status) is int and 0 <= status <= 255, 'scanner-process-failed') + progress['exit_code'] = status + require(not result.get('Error'), 'scanner-process-failed') finally: conn.close() + progress['phase'] = 'container-exit-fence' self.fence(('exited',)) + progress['phase'] = 'container-logs' conn, response = self.docker.request('GET', '/containers/' + self.identifier + '/logs?stdout=1&stderr=1') try: - return scanner_logs(response) + try: + observed = scanner_logs(response, limit=4096 if status else SCANNER_LIMIT) + except Stop as error: + if status: + progress['log_reason'] = scanner_host_failure(error) + raise Stop('scanner-process-failed') from None + raise finally: conn.close() + if status: + failure = INVENTORY.scanner_failure(observed) + if failure is None: + progress['log_reason'] = 'scanner-failure-report-invalid' + else: + progress['scan_failure'] = failure + raise Stop('scanner-process-failed') + return observed def cleanup(self): if not self.attempted: @@ -1118,27 +1206,36 @@ def cleanup(self): def qualify_filesystem(docker, target, context, verified, expected): policy = target_policy(target) require(target in FINAL_TARGETS, 'final-target-required') - receipt = {**context, 'schema_version': 1, 'claim': 'trusted-producer-filesystem-equality', - 'image_code_executed': True, 'final_portable_cohort': False, 'target': target, + receipt = {**context, 'schema_version': 2, 'claim': 'trusted-producer-filesystem-equality', + 'image_code_executed': False, 'final_portable_cohort': False, 'target': target, 'index_digest': TARGETS[target][2], 'manifest_digest': verified['manifest_digest'], 'config_digest': verified['config_digest'], **policy, 'status': 'started'} scanner = None + progress = {'phase': 'identity'} try: require(verified['manifest_digest'] == policy['manifest_digest'] and verified['config_digest'] == policy['config_digest'], 'final-image-coordinate-mismatch') + progress['phase'] = 'scanner-sources' sources = scanner_sources() receipt['scanner_sources'] = {name: {key: item[key] for key in ('sha256', 'size')} for name, item in sources.items()} scanner = ScannerImage(docker, target, context, verified, sources) + progress['phase'] = 'container-create' scanner.create() receipt['container_id'] = scanner.identifier - observed = scanner.execute() + observed = scanner.execute(progress=progress) + require(progress.get('start_accepted') is True, 'scanner-execution-not-started') + progress['phase'] = 'source-recheck' require(scanner_sources() == sources, 'scanner-source-changed') + progress['phase'] = 'inventory-comparison' INVENTORY.compare_scan(observed, expected) receipt['scan'] = observed receipt['status'] = 'verified-filesystem-equality' - except BaseException: - receipt.update(status='stopped', reason='filesystem-qualification-failed') + except BaseException as error: + reason = scanner_host_failure(error) + # Cleanup may fail independently; retain the original fixed diagnostic + # even when the top-level reason must report unresolved owned cleanup. + receipt.update(status='stopped', reason=reason, failure={**progress, 'reason': reason}) finally: if scanner is not None: try: @@ -1147,6 +1244,15 @@ def qualify_filesystem(docker, target, context, verified, expected): receipt['owned_container_removed'] = True except BaseException: receipt.update(status='stopped', reason='owned-cleanup-failed', owned_container_removed=False) + # Cleanup may supply the first complete observation after a lost 204. + # No observation can promote a failed qualification to success. A + # created snapshot cannot exclude a still-pending daemon start request. + if progress.get('start_accepted') is True or (scanner is not None and scanner.started_observed is True): + receipt['image_code_executed'] = True + elif progress.get('start_attempted') is True: + receipt['image_code_executed'] = None + else: + receipt['image_code_executed'] = False return receipt def collect(target, context, original, folder): diff --git a/tools/released_sdk_inventory.py b/tools/released_sdk_inventory.py index 524092c..6a42781 100644 --- a/tools/released_sdk_inventory.py +++ b/tools/released_sdk_inventory.py @@ -154,6 +154,41 @@ def failure_code(error): return 'inventory-operation-failed' +SCANNER_STAGES = frozenset({'startup', 'metadata', 'producer-tree', 'metadata-binding', + 'source-archives-and-providers', 'debian-notices', 'common-licenses', 'build-tools', + 'llvm-tools', 'final-fence', 'report'}) +SCANNER_ERRNOS = frozenset({'EACCES', 'EPERM', 'ENOENT', 'ELOOP', 'ENOTDIR', 'EROFS', + 'EIO', 'EMFILE', 'ENFILE', 'ENOSPC', 'other'}) +SCANNER_FAILURE_CODES = frozenset({ + 'scanner-alias-boundary', 'scanner-alias-cycle-or-limit', 'scanner-alias-drift', + 'scanner-alias-path', 'scanner-alias-type', 'scanner-deadline', 'scanner-depth-limit', + 'scanner-duplicate-json-key', 'scanner-empty-tree', 'scanner-execution-boundary', + 'scanner-file-drift', 'scanner-file-grew', 'scanner-file-limit', 'scanner-interrupted', + 'scanner-invalid-json', 'scanner-llvm-alias', 'scanner-manifest-bytes-mismatch', + 'scanner-member-limit', 'scanner-path', 'scanner-path-drift', 'scanner-provider-owner', + 'scanner-provider-path', 'scanner-report-limit', 'scanner-tool-not-executable', + 'scanner-total-limit', 'scanner-tree-drift', 'scanner-tree-special-or-link', + 'scanner-unsafe-directory', 'scanner-unsafe-file', +}) + + +def scanner_failure(value): + """Admit only bounded source-defined diagnostics from executed image stdout.""" + if type(value) is not dict or set(value) not in ( + {'status', 'reason', 'stage'}, {'status', 'reason', 'stage', 'errno_name'}): + return None + if (value.get('status') != 'stopped' or type(value.get('reason')) is not str + or type(value.get('stage')) is not str or value['stage'] not in SCANNER_STAGES): + return None + if value['reason'] == 'scanner-os-error': + if type(value.get('errno_name')) is not str or value['errno_name'] not in SCANNER_ERRNOS: + return None + elif ('errno_name' in value or value['reason'] not in + FAILURE_CODES | SCANNER_FAILURE_CODES | {'filesystem-scan-failed'}): + return None + return dict(value) + + class ProviderReader(ABC): """Authenticated byte reader supplied by the source-inventory caller. diff --git a/tools/scan-released-sdk-filesystem.py b/tools/scan-released-sdk-filesystem.py index b4c9145..8f1dc7f 100644 --- a/tools/scan-released-sdk-filesystem.py +++ b/tools/scan-released-sdk-filesystem.py @@ -10,6 +10,7 @@ See RELEASED_SDK_INVENTORY.md for provenance and execution-claim boundaries. """ import hashlib +import errno import importlib.util import json import os @@ -262,30 +263,41 @@ def fence(self): I.require(identity(actual) == expected, 'scanner-path-drift') -def scan(root_fd): +def scan(root_fd, *, progress=None): + progress = progress if progress is not None else {} fs = Filesystem(root_fd) + progress['stage'] = 'metadata' raw, metadata_records = {}, {} for name in I.METADATA_FILES: metadata_records[name], raw[name] = fs.record(I.ROOT + '/' + name, retain=True) documents = {name: decode(value) for name, value in raw.items()} + progress['stage'] = 'producer-tree' maps = {'producer': fs.tree()} + progress['stage'] = 'metadata-binding' I.bind_metadata(maps['producer'], raw, metadata_records) + progress['stage'] = 'source-archives-and-providers' I.crosscheck_archives(maps['producer'], documents['runtime-sources.json'], documents['contract.json'], reader=fs) + progress['stage'] = 'debian-notices' maps['debian_notices'] = {} for requested in I.notice_requests(documents['runtime-sources.json'], documents['debian-sources.json']): resolved = fs.resolve(requested, I.DOC) record, _ = fs.record(resolved) maps['debian_notices'][requested] = {**record, 'resolved_path': resolved} + progress['stage'] = 'common-licenses' maps['common_licenses'] = {name: fs.record(I.COMMON + '/' + name)[0] for name in I.COMMON_LICENSE_NAMES} + progress['stage'] = 'build-tools' build_tools = I.build_tool_records(documents['installed.json'], lambda path: fs.record(path, executable=True)[0]) + progress['stage'] = 'llvm-tools' llvm_tools = {} for role, (declared, canonical) in I.LLVM_TOOL_FILES.items(): path, resolved = I.LLVM_PREFIX + '/' + declared, I.LLVM_PREFIX + '/' + canonical I.require(fs.resolve(path, I.LLVM_PREFIX) == resolved, 'scanner-llvm-alias') record, _ = fs.record(resolved, executable=role not in ('runtime', 'unwinder')) llvm_tools[role] = {'path': path, 'canonical_path': resolved, **record} + progress['stage'] = 'final-fence' fs.fence() + progress['stage'] = 'report' return {'schema_version': 1, 'claim': 'trusted-producer-filesystem-inventory', 'image_code_executed': True, 'final_portable_cohort': False, 'inventories': maps, 'inventory_sha256': {key: I.inventory_digest(value) for key, value in maps.items()}, @@ -295,6 +307,7 @@ def scan(root_fd): def main(): def stop(_number, _frame): raise I.InventoryError('scanner-interrupted') + progress = {'stage': 'startup'} try: I.require(sys.flags.isolated == 1 and len(sys.argv) == 1 and os.geteuid() != 0, 'scanner-execution-boundary') @@ -303,15 +316,24 @@ def stop(_number, _frame): signal.alarm(I.MAX_SECONDS) fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) try: - result = scan(fd) + result = scan(fd, progress=progress) finally: os.close(fd) encoded = (json.dumps(result, sort_keys=True, separators=(',', ':')) + '\n').encode() I.require(len(encoded) <= MAX_REPORT, 'scanner-report-limit') sys.stdout.buffer.write(encoded) return 0 - except BaseException: - print('{"status":"stopped","reason":"filesystem-scan-failed"}') + except BaseException as error: + failure = {'status': 'stopped', 'reason': 'filesystem-scan-failed', 'stage': progress['stage']} + if (type(error) is I.InventoryError and len(error.args) == 1 + and type(error.args[0]) is str + and error.args[0] in I.FAILURE_CODES | I.SCANNER_FAILURE_CODES): + failure['reason'] = error.args[0] + elif isinstance(error, OSError): + names = {getattr(errno, name): name for name in I.SCANNER_ERRNOS if name != 'other'} + failure.update(reason='scanner-os-error', + errno_name=names.get(error.errno, 'other') if type(error.errno) is int else 'other') + print(json.dumps(failure, sort_keys=True, separators=(',', ':'))) return 1 finally: signal.alarm(0) diff --git a/tools/tests/test_released_sdk_filesystem.py b/tools/tests/test_released_sdk_filesystem.py index 45c8078..c41de41 100644 --- a/tools/tests/test_released_sdk_filesystem.py +++ b/tools/tests/test_released_sdk_filesystem.py @@ -534,6 +534,7 @@ def scanner(self): 'Config': {'Labels': {C.OWNER_LABEL: scanner.owner}, 'Image': scanner.reference, 'Entrypoint': scanner.entrypoint, 'Cmd': [], 'User': '65534:65534', 'WorkingDir': '/'}, 'HostConfig': {'ReadonlyRootfs': True, 'NetworkMode': 'none', 'Privileged': False, + 'LogConfig': copy.deepcopy(scanner.log_config), 'CapDrop': ['ALL'], 'SecurityOpt': ['no-new-privileges'], 'RestartPolicy': {'Name': 'no'}}, 'Mounts': [{'Source': row['Source'], 'Destination': row['Target'], 'Type': 'bind', 'RW': False} for row in scanner.mounts], @@ -591,6 +592,8 @@ def test_scanner_create_exact_readonly_command_mounts_and_no_credentials(self): self.assertEqual(body['Entrypoint'], ['/usr/bin/python3', '-I', '-B', '/scan/scan-released-sdk-filesystem.py']) self.assertEqual(body['Env'], []) self.assertEqual(body['User'], '65534:65534') + self.assertEqual(body['HostConfig']['LogConfig'], {'Type': 'local', 'Config': { + 'max-size': '128m', 'max-file': '1', 'compress': 'false'}}) self.assertTrue(body['HostConfig']['ReadonlyRootfs']) self.assertEqual({row['Target'] for row in body['HostConfig']['Mounts']}, {'/scan/' + name for name in C.SCANNER_FILES}) @@ -657,7 +660,10 @@ def test_qualification_identity_comparison_and_cleanup_failure(self): for failure in ('none', 'compare', 'cleanup', 'source'): scanner = Mock() scanner.identifier = 'f' * 64 - scanner.execute.return_value = expected if failure != 'compare' else {'fixture': False} + def execute(*, progress): + progress['start_accepted'] = True + return expected if failure != 'compare' else {'fixture': False} + scanner.execute.side_effect = execute if failure == 'cleanup': scanner.cleanup.side_effect = RuntimeError('hostile secret') sources = C.scanner_sources() diff --git a/tools/tests/test_released_sdk_scanner_diagnostics.py b/tools/tests/test_released_sdk_scanner_diagnostics.py new file mode 100644 index 0000000..9471c0f --- /dev/null +++ b/tools/tests/test_released_sdk_scanner_diagnostics.py @@ -0,0 +1,525 @@ +# Copyright (c) 2026 Atrinik contributors +# SPDX-License-Identifier: MIT +"""Offline scanner failure transport and secret-free receipt regressions.""" +import ast +import contextlib +import errno +import importlib.util +import io +import json +from pathlib import Path +import struct +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location('scanner_diagnostic_fixtures', + ROOT / 'tools/tests/test_released_sdk_filesystem.py') +F = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(F) +C, S, I = F.C, F.S, F.I +SECRET = 'PRIVATE_TOKEN https://private.invalid/?signature=PRIVATE_SIGNATURE' + + +def frame(value, stream=1): + raw = json.dumps(value).encode() + return bytes((stream, 0, 0, 0)) + struct.pack('>I', len(raw)) + raw + + +class ScannerDiagnosticTests(unittest.TestCase): + def test_scanner_code_allowlist_covers_literal_failures(self): + codes = set() + for node in ast.walk(ast.parse((ROOT / 'tools/scan-released-sdk-filesystem.py').read_text())): + if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) and node.func.value.id == 'I' + and node.func.attr in ('require', 'InventoryError')): + code = node.args[1 if node.func.attr == 'require' else 0] + self.assertIsInstance(code, ast.Constant) + codes.add(code.value) + self.assertEqual(codes, I.SCANNER_FAILURE_CODES) + + def test_scanner_main_emits_only_fixed_stage_code_and_errno(self): + for error, reason, extra in ( + (I.InventoryError('scanner-path-drift'), 'scanner-path-drift', {}), + (I.InventoryError('inventory-provider-bytes-mismatch'), 'inventory-provider-bytes-mismatch', {}), + (PermissionError(errno.EACCES, SECRET, SECRET), 'scanner-os-error', {'errno_name': 'EACCES'}), + (OSError(987654321, SECRET, SECRET), 'scanner-os-error', {'errno_name': 'other'}), + (I.InventoryError(SECRET), 'filesystem-scan-failed', {}), + (RuntimeError(SECRET), 'filesystem-scan-failed', {}), + ): + def fail(_fd, *, progress): + progress['stage'] = 'producer-tree' + raise error + out = io.StringIO() + with self.subTest(reason=reason), patch.object(S.sys, 'argv', ['scanner']), \ + patch.object(S.sys, 'flags', SimpleNamespace(isolated=1)), \ + patch.object(S.os, 'geteuid', return_value=65534), \ + patch.object(S.os, 'open', return_value=55), patch.object(S.os, 'close') as close, \ + patch.object(S.signal, 'signal'), patch.object(S.signal, 'alarm'), \ + patch.object(S, 'scan', side_effect=fail), contextlib.redirect_stdout(out): + self.assertEqual(S.main(), 1) + close.assert_called_once_with(55) + self.assertEqual(json.loads(out.getvalue()), { + 'status': 'stopped', 'reason': reason, 'stage': 'producer-tree', **extra}) + self.assertNotIn(SECRET, out.getvalue()) + + def test_scanner_main_rejects_execution_boundary_before_open(self): + for isolated, uid, argv in ((0, 65534, ['scanner']), + (1, 0, ['scanner']), + (1, 65534, ['scanner', SECRET])): + out = io.StringIO() + with self.subTest(isolated=isolated, uid=uid, argv_count=len(argv)), \ + patch.object(S.sys, 'argv', argv), \ + patch.object(S.sys, 'flags', SimpleNamespace(isolated=isolated)), \ + patch.object(S.os, 'geteuid', return_value=uid), \ + patch.object(S.os, 'open') as open_root, \ + patch.object(S, 'scan') as scan, \ + patch.object(S.signal, 'alarm'), contextlib.redirect_stdout(out): + self.assertEqual(S.main(), 1) + open_root.assert_not_called() + scan.assert_not_called() + self.assertEqual(json.loads(out.getvalue()), { + 'status': 'stopped', 'reason': 'scanner-execution-boundary', + 'stage': 'startup'}) + self.assertNotIn(SECRET, out.getvalue()) + + def test_real_scan_retains_failed_stage(self): + fixture = F.FilesystemTests() + fixture.setUp() + self.addCleanup(fixture.doCleanups) + fixture.fixture() + progress = {} + with patch.object(S.Filesystem, 'tree', side_effect=I.InventoryError('scanner-tree-drift')): + with self.assertRaisesRegex(I.InventoryError, 'scanner-tree-drift'): + S.scan(fixture.root_fd, progress=progress) + self.assertEqual(progress, {'stage': 'producer-tree'}) + + def test_untrusted_failure_report_requires_exact_allowlisted_shape(self): + good = {'status': 'stopped', 'reason': 'scanner-path-drift', 'stage': 'final-fence'} + self.assertEqual(I.scanner_failure(good), good) + for value in (None, [], SECRET, {}, {**good, 'extra': SECRET}, {**good, 'reason': SECRET}, + {**good, 'stage': SECRET}, {**good, 'status': 'success'}, + {**good, 'errno_name': 'EACCES'}, {**good, 'reason': 'scanner-os-error'}, + {**good, 'reason': 'scanner-os-error', 'errno_name': SECRET}): + with self.subTest(value=value): + self.assertIsNone(I.scanner_failure(value)) + + def qualification(self, payload, *, status=1, stream=1, drift=False, raw=None): + fixture = F.QualificationTests() + fixture.setUp() + self.addCleanup(fixture.doCleanups) + scanner, state = fixture.scanner() + scanner.create, scanner.cleanup = Mock(), Mock() + connections = [] + def request(method, route, **kwargs): + conn = Mock() + connections.append(conn) + if route.endswith('/start'): + return conn, F.Response() + if '/wait?' in route: + state['State'] = {'Status': 'exited', 'Running': False, 'Pid': 0} + if drift: + state['Config']['User'] = '0' + return conn, F.Response(F.encoded({'StatusCode': status})) + if '/logs?' in route: + return conn, F.Response(raw if raw is not None else frame(payload, stream)) + self.fail('unexpected request') + scanner.docker.request.side_effect = request + with patch.object(C, 'ScannerImage', return_value=scanner): + receipt = C.qualify_filesystem(scanner.docker, F.TARGET, fixture.context, + fixture.verified, {'expected': 'inventory'}) + scanner.cleanup.assert_called_once() + for conn in connections: + conn.close.assert_called_once() + self.assertTrue(receipt['owned_container_removed']) + self.assertEqual(receipt['status'], 'stopped') + self.assertNotIn('scan', receipt) + self.assertNotIn(SECRET, json.dumps(receipt)) + return receipt, scanner + + def test_nonzero_process_retains_sanitized_failure_and_cleans_owned_container(self): + failure = {'status': 'stopped', 'reason': 'scanner-provider-owner', + 'stage': 'source-archives-and-providers'} + receipt, scanner = self.qualification(failure) + self.assertEqual(receipt['reason'], 'scanner-process-failed') + self.assertEqual(receipt['failure'], {'reason': 'scanner-process-failed', + 'phase': 'container-logs', 'exit_code': 1, + 'start_attempted': True, 'start_accepted': True, 'scan_failure': failure}) + self.assertTrue(receipt['image_code_executed']) + self.assertEqual(scanner.docker.request.call_count, 3) + + def test_bad_failure_output_and_stderr_never_enter_receipt(self): + for payload, stream in (({'status': 'stopped', 'reason': SECRET, 'stage': SECRET}, 1), + (SECRET, 2), ({'expected': 'inventory'}, 1)): + with self.subTest(stream=stream, payload=payload): + receipt, _ = self.qualification(payload, stream=stream) + self.assertEqual(receipt['reason'], 'scanner-process-failed') + self.assertNotIn('scan_failure', receipt['failure']) + self.assertIn(receipt['failure']['log_reason'], + ('scanner-logs-invalid', 'scanner-failure-report-invalid')) + receipt, _ = self.qualification(None, raw=b'\x01\0\0\0' + struct.pack('>I', 4097)) + self.assertEqual(receipt['failure']['log_reason'], 'scanner-logs-limit') + + def test_exit_status_and_postexit_identity_cannot_be_bypassed_by_failure_report(self): + failure = {'status': 'stopped', 'reason': 'scanner-path-drift', 'stage': 'final-fence'} + for status in (True, -1, 256, SECRET): + with self.subTest(status=status): + receipt, scanner = self.qualification(failure, status=status) + self.assertNotIn('exit_code', receipt['failure']) + self.assertEqual(scanner.docker.request.call_count, 2) + receipt, scanner = self.qualification(failure, drift=True) + self.assertEqual(receipt['reason'], 'scanner-command-mismatch') + self.assertEqual(scanner.docker.request.call_count, 2) + receipt, _ = self.qualification(failure, status=0) + self.assertEqual(receipt['failure']['phase'], 'inventory-comparison') + self.assertNotIn('scan_failure', receipt['failure']) + + def test_host_errors_are_exact_type_and_code_allowlisted(self): + class ForeignStop(C.Stop): + pass + for error in (C.Stop(SECRET), C.Stop('unknown-code'), C.Stop('scanner-process-failed', SECRET), + ForeignStop('scanner-process-failed'), RuntimeError(SECRET)): + self.assertEqual(C.scanner_host_failure(error), 'filesystem-qualification-failed') + + def test_single_log_file_disables_compression_and_fences_config_drift(self): + fixture = F.QualificationTests() + fixture.setUp() + self.addCleanup(fixture.doCleanups) + scanner, state = fixture.scanner() + scanner.docker.request.return_value = (Mock(), F.Response(F.encoded({'Id': scanner.identifier}))) + scanner.create() + body = json.loads(scanner.docker.request.call_args.args[3]) + log = body['HostConfig']['LogConfig'] + # Source-derived local-driver validation: compression defaults to true + # and cannot be combined with one file. The explicit option is needed. + self.assertEqual(log['Type'], 'local') + self.assertEqual(log['Config']['max-file'], '1') + self.assertEqual(log['Config']['max-size'], '128m') + self.assertEqual(log['Config'].get('compress', 'true'), 'false') + for config in ({'Type': 'json-file', 'Config': log['Config']}, + {'Type': 'local', 'Config': {'max-size': '128m', 'max-file': '1'}}, + {'Type': 'local', 'Config': {**log['Config'], 'compress': 'true'}}, + {'Type': 'local', 'Config': {**log['Config'], 'max-file': '2'}}, + {'Type': 'local', 'Config': {**log['Config'], 'max-size': '256m'}}): + state['HostConfig']['LogConfig'] = config + with self.subTest(config=config), self.assertRaisesRegex(C.Stop, 'scanner-log-config-mismatch'): + scanner.fence() + + def test_actual_http_rejections_retain_fixed_code_without_response_details(self): + for rejected, phase, executed in ( + ('create', 'container-create', False), + ('start', 'container-start', None), + ('wait', 'container-wait', True), + ('logs', 'container-logs', True), + ): + for http_status in (403, 500): + with self.subTest(rejected=rejected, http_status=http_status): + fixture = F.QualificationTests() + fixture.setUp() + self.addCleanup(fixture.doCleanups) + scanner, value = fixture.scanner() + scanner.identifier, scanner.attempted = None, False + scanner.docker = C.Docker('/fixture-only', (1, 2, 3)) + connections, denied, calls = [], [], [] + + def connection(*args, **kwargs): + conn = Mock() + connections.append(conn) + current = {} + def request(method, path, **kwargs): + current.update(method=method, path=path) + calls.append((method, path)) + def getresponse(): + path = current['path'] + operation = path.rsplit('/', 1)[-1].split('?')[0] + response_status, raw = 200, b'' + if operation == rejected: + response_status, raw = http_status, SECRET.encode() + elif operation == 'create': + response_status, raw = 201, F.encoded({'Id': value['Id']}) + elif operation == 'start': + value['State'].update(Status='running', Running=True, Pid=123) + response_status = 204 + elif operation == 'wait': + value['State'].update(Status='exited', Running=False, Pid=0) + raw = F.encoded({'StatusCode': 0}) + elif operation == 'json': + raw = F.encoded(value) + elif operation == 'stop': + value['State'].update(Status='exited', Running=False, Pid=0) + response_status = 204 + elif current['method'] == 'DELETE': + response_status = 204 + else: + self.fail('unexpected request') + response = F.Response(raw) + response.status = response_status + if operation == rejected: + response.read = Mock(wraps=response.read) + denied.append(response) + return response + conn.request.side_effect = request + conn.getresponse.side_effect = getresponse + return conn + + with patch.object(C, 'ScannerImage', return_value=scanner), \ + patch.object(C, 'LocalConnection', side_effect=connection): + receipt = C.qualify_filesystem(scanner.docker, F.TARGET, fixture.context, + fixture.verified, {'expected': 'inventory'}) + self.assertEqual(receipt['status'], 'stopped') + self.assertEqual(receipt['reason'], 'docker-request-denied') + self.assertEqual(receipt['failure']['phase'], phase) + self.assertEqual(receipt['failure']['reason'], 'docker-request-denied') + self.assertIs(receipt['image_code_executed'], executed) + self.assertTrue(receipt['owned_container_removed']) + self.assertEqual(calls[-1], ('DELETE', '/containers/' + value['Id'])) + self.assertNotIn('scan', receipt) + self.assertNotIn(SECRET, json.dumps(receipt)) + self.assertNotIn('/containers/', json.dumps(receipt)) + self.assertEqual(len(denied), 1) + denied[0].read.assert_not_called() + for conn in connections: + conn.close.assert_called_once() + # The old spelling has no production emitter and is not a public code. + self.assertEqual(C.scanner_host_failure(C.Stop('docker-http-status')), + 'filesystem-qualification-failed') + + def rejected_create_receipt(self, create_status, lookup): + fixture = F.QualificationTests() + fixture.setUp() + self.addCleanup(fixture.doCleanups) + scanner, value = fixture.scanner() + scanner.identifier, scanner.attempted = None, False + scanner.docker = C.Docker('/fixture-only', (1, 2, 3)) + connections, denied, calls = [], [], [] + if lookup == 'foreign': + value['Config']['Labels'][C.OWNER_LABEL] = SECRET + + def connection(*args, **kwargs): + conn = Mock() + connections.append(conn) + current = {} + def request(method, path, **kwargs): + current.update(method=method, path=path) + calls.append((method, path)) + def getresponse(): + response_status, raw = 200, b'' + if current['method'] == 'POST': + self.assertIn('/create?', current['path']) + if create_status is None: + raise ConnectionResetError(SECRET) + response_status, raw = create_status, SECRET.encode() + elif current['method'] == 'GET': + if type(lookup) is int: + response_status, raw = lookup, SECRET.encode() + elif lookup == 'malformed': + raw = F.encoded({'Id': SECRET}) + else: + raw = F.encoded(value) + elif current['method'] == 'DELETE': + self.assertEqual(lookup, 'owned') + self.assertEqual(current['path'], '/containers/' + value['Id']) + response_status = 204 + else: + self.fail('unexpected request') + response = F.Response(raw) + response.status = response_status + if response_status not in (200, 204): + response.read = Mock(wraps=response.read) + denied.append(response) + return response + conn.request.side_effect = request + conn.getresponse.side_effect = getresponse + return conn + + with patch.object(C, 'ScannerImage', return_value=scanner), \ + patch.object(C, 'LocalConnection', side_effect=connection): + receipt = C.qualify_filesystem(scanner.docker, F.TARGET, fixture.context, + fixture.verified, {'expected': 'inventory'}) + self.assertEqual(receipt['status'], 'stopped') + self.assertIs(receipt['image_code_executed'], False) + self.assertEqual(receipt['failure']['phase'], 'container-create') + self.assertNotIn('start_attempted', receipt['failure']) + self.assertNotIn('scan', receipt) + self.assertNotIn(SECRET, json.dumps(receipt)) + self.assertNotIn('/containers/', json.dumps(receipt)) + for response in denied: + response.read.assert_not_called() + for conn in connections: + conn.close.assert_called_once() + return receipt, calls + + def test_create_failure_preserved_when_named_cleanup_is_unresolved(self): + for create_status in (403, 500, None): + for lookup in (404, 403, 500, 'malformed', 'foreign'): + with self.subTest(create_status=create_status, lookup=lookup): + receipt, calls = self.rejected_create_receipt(create_status, lookup) + self.assertEqual(receipt['reason'], 'owned-cleanup-failed') + self.assertIs(receipt['owned_container_removed'], False) + self.assertEqual(receipt['failure']['reason'], + 'filesystem-qualification-failed' if create_status is None else 'docker-request-denied') + self.assertEqual([method for method, _ in calls], + ['POST', 'GET', 'GET'] if lookup == 'foreign' else ['POST', 'GET']) + + def test_create_failure_preserved_after_fenced_owned_cleanup(self): + for create_status in (403, 500, None): + with self.subTest(create_status=create_status): + receipt, calls = self.rejected_create_receipt(create_status, 'owned') + reason = 'filesystem-qualification-failed' if create_status is None else 'docker-request-denied' + self.assertEqual(receipt['reason'], reason) + self.assertEqual(receipt['failure']['reason'], reason) + self.assertIs(receipt['owned_container_removed'], True) + self.assertEqual([method for method, _ in calls], ['POST', 'GET', 'GET', 'DELETE']) + + def lost_start_receipt(self, status='running', *, drift=None, inspect_failure=False, + stop_failure=False, prestart_failure=False): + fixture = F.QualificationTests() + fixture.setUp() + self.addCleanup(fixture.doCleanups) + scanner, value = fixture.scanner() + scanner.create = Mock() + zero = '0001-01-01T00:00:00Z' + started = '2026-10-13T12:34:56.123456789Z' + value['State'].update(StartedAt=zero, Paused=False, Restarting=False, Dead=False) + scanner.docker = C.Docker('/fixture-only', (1, 2, 3)) + calls, connections = [], [] + dispatched = False + + def connection(*args, **kwargs): + conn = Mock() + connections.append(conn) + current = {} + def request(method, path, **kwargs): + current.update(method=method, path=path) + calls.append((method, path)) + def getresponse(): + nonlocal dispatched + response_status, raw = 200, b'' + if current['method'] == 'GET': + if (dispatched and inspect_failure) or prestart_failure: + raise ConnectionResetError(SECRET) + raw = json.dumps(value).encode() + elif current['path'].endswith('/start'): + dispatched = True + value['State'].update(Status=status, Running=status == 'running', + Pid=123 if status == 'running' else 0, + StartedAt=zero if status == 'created' else started) + if drift: + drift(value) + # The daemon started the exact container, but no 204 reaches + # the collector's real Docker.request/LocalConnection path. + raise ConnectionResetError(SECRET) + elif '/stop?' in current['path']: + if stop_failure: + raise ConnectionResetError(SECRET) + value['State'].update(Status='exited', Running=False, Pid=0) + response_status = 204 + elif current['method'] == 'DELETE': + response_status = 204 + else: + self.fail('unexpected request') + response = F.Response(raw) + response.status = response_status + return response + conn.request.side_effect = request + conn.getresponse.side_effect = getresponse + return conn + + with patch.object(C, 'ScannerImage', return_value=scanner), \ + patch.object(C, 'LocalConnection', side_effect=connection): + receipt = C.qualify_filesystem(scanner.docker, F.TARGET, fixture.context, + fixture.verified, {'expected': 'inventory'}) + self.assertEqual(receipt['status'], 'stopped') + self.assertNotIn('scan', receipt) + self.assertNotIn(SECRET, json.dumps(receipt)) + for conn in connections: + conn.close.assert_called_once() + return receipt, calls + + def test_lost_start_response_reconciles_owned_running_and_fast_exit(self): + for status in ('running', 'exited'): + with self.subTest(status=status): + receipt, calls = self.lost_start_receipt(status) + self.assertIs(receipt['image_code_executed'], True) + self.assertEqual(receipt['schema_version'], 2) + self.assertTrue(receipt['owned_container_removed']) + self.assertEqual([m for m, _ in calls], + ['GET', 'POST', 'GET', 'POST', 'GET', 'DELETE'] + if status == 'running' else ['GET', 'POST', 'GET', 'DELETE']) + + def test_lost_start_created_snapshot_is_unknown_even_after_removal(self): + receipt, calls = self.lost_start_receipt('created') + self.assertIsNone(receipt['image_code_executed']) + self.assertTrue(receipt['owned_container_removed']) + self.assertEqual([m for m, _ in calls], ['GET', 'POST', 'GET', 'DELETE']) + + def test_no_dispatched_start_is_definitively_unexecuted(self): + receipt, calls = self.lost_start_receipt(prestart_failure=True) + self.assertIs(receipt['image_code_executed'], False) + self.assertFalse(receipt['owned_container_removed']) + self.assertTrue(all(m == 'GET' for m, _ in calls)) + + def test_lost_start_inspection_failure_or_fence_drift_stays_unknown(self): + edits = ( + lambda v: v.update(Id='a' * 64), + lambda v: v.update(Name='/foreign'), + lambda v: v['Config']['Labels'].update({C.OWNER_LABEL: 'foreign'}), + lambda v: v.update(Image='sha256:' + '0' * 64), + lambda v: v.update(ImageManifestDescriptor={'digest': 'sha256:' + '0' * 64}), + lambda v: v['Config'].update(User='0'), + lambda v: v['HostConfig'].update(NetworkMode='host'), + lambda v: v['Mounts'][0].update(RW=True), + lambda v: v['HostConfig']['LogConfig']['Config'].update(compress='true'), + ) + for drift in edits: + receipt, calls = self.lost_start_receipt(drift=drift) + self.assertIsNone(receipt['image_code_executed']) + self.assertFalse(receipt['owned_container_removed']) + self.assertEqual([m for m, _ in calls], ['GET', 'POST', 'GET']) + receipt, _ = self.lost_start_receipt(inspect_failure=True) + self.assertIsNone(receipt['image_code_executed']) + self.assertFalse(receipt['owned_container_removed']) + + def test_malformed_or_inconsistent_start_evidence_cannot_prove_execution(self): + for status, field, invalid in ( + ('running', 'StartedAt', '0001-01-01T00:00:00Z'), + ('exited', 'StartedAt', '0001-01-01T00:00:00Z'), + ('exited', 'StartedAt', '0001-01-01T00:00:00.000000000Z'), + ('exited', 'StartedAt', SECRET), + ('exited', 'StartedAt', '2026-02-30T00:00:00Z'), + ('exited', 'StartedAt', '2026-01-01T00:00:00.1234567890Z'), + ('exited', 'StartedAt', None), + ('exited', 'StartedAt', ''), + ('exited', 'StartedAt', '0000-01-01T00:00:00Z'), + ('exited', 'StartedAt', '2026-10-13T12:34:56+00:00'), + ('running', 'Pid', True), + ('running', 'Pid', 0), + ('running', 'Pid', -1), + ('running', 'Pid', 2**31), + ('running', 'Pid', 123.0), + ('running', 'Running', 1), + ('exited', 'Pid', False), + ('exited', 'Pid', 1), + ('exited', 'Paused', True), + ('exited', 'Restarting', True), + ('exited', 'Dead', True), + ('exited', 'Dead', 0), + ('exited', 'Paused', None), + ): + with self.subTest(status=status, field=field, invalid=invalid): + receipt, _ = self.lost_start_receipt(status, + drift=lambda v: v['State'].update({field: invalid}), + stop_failure=True) + self.assertIsNone(receipt['image_code_executed']) + + def test_proven_start_is_retained_when_owned_cleanup_later_fails(self): + receipt, calls = self.lost_start_receipt(stop_failure=True) + self.assertIs(receipt['image_code_executed'], True) + self.assertFalse(receipt['owned_container_removed']) + self.assertEqual(receipt['reason'], 'owned-cleanup-failed') + self.assertEqual([m for m, _ in calls], ['GET', 'POST', 'GET', 'POST']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/tests/test_workflow_contracts.py b/tools/tests/test_workflow_contracts.py index ff5fce6..66a5c88 100644 --- a/tools/tests/test_workflow_contracts.py +++ b/tools/tests/test_workflow_contracts.py @@ -312,6 +312,61 @@ def git(*args): {f'portable={selected}', f'classic={selected}', f'linux={selected}', f'windows={windows}'}) + def test_main_cache_trigger_covers_shared_stage_inputs(self): + text = workflow('validate.yml') + trigger = text.split(' push:\n', 1)[1].split('\npermissions:', 1)[0] + self.assertIn(' branches:\n - main\n', trigger) + paths = set(re.findall(r'^ - (.+)$', trigger.split(' paths:\n', 1)[1], re.M)) + required = {'.dockerignore', '.github/workflows/validate.yml'} + for dockerfile, stage in (('linux/Dockerfile', 'llvm-toolchain'), + ('windows/Dockerfile', 'llvm-toolchain'), + ('portable/Dockerfile', 'portable-llvm')): + required.add(dockerfile) + source = (ROOT / dockerfile).read_text() + block = source.split(' AS ' + stage + '\n', 1)[1].split('\nFROM ', 1)[0] + # Each local COPY source in the shared producer must warm main + # when changed, even if that input is outside the Dockerfile tree. + for sources in re.findall(r'^COPY (.+)$', block, re.M): + required.update(sources.split()[:-1]) + self.assertEqual(paths, required) + for unrelated in ('README.md', 'tools/collect-released-sdk-metadata.py', + 'tools/verify_published_identity.py'): + self.assertNotIn(unrelated, paths) + + def test_cache_seed_is_main_only_and_keeps_pr_acceptance(self): + text = workflow('validate.yml') + seed = text.split(' llvm-cache:\n', 1)[1].split('\n llvm:\n', 1)[0] + condition = re.search(r'^ if: (.+)$', seed, re.M).group(1) + # Exercise the workflow's actual inline event/ref expression. A fork + # PR or another branch/tag must never execute the trusted main writer. + for event in ('push', 'pull_request', 'pull_request_target', 'workflow_dispatch'): + for ref in ('refs/heads/main', 'refs/heads/review', 'refs/tags/v9.8.7', 'refs/pull/1/merge'): + values = {'github.event_name': event, 'github.ref': ref} + matches = [re.fullmatch(r"(github\.event_name|github\.ref) == '([^']+)'", + clause.strip()) for clause in condition.split('&&')] + self.assertEqual(len(matches), 2) + self.assertTrue(all(matches), condition) + selected = all(values[match[1]] == match[2] for match in matches) + self.assertEqual(selected, event == 'push' and ref == 'refs/heads/main') + self.assertNotIn(' needs:', seed) + self.assertNotIn('packages:', seed) + self.assertNotIn('docker/login-action', seed) + self.assertIn('outputs: type=cacheonly', seed) + self.assertIn('target: llvm-toolchain', seed) + self.assertIn('build-args: LLVM_BUILD_JOBS=2', seed) + self.assertIn('cache-to: type=gha,mode=max,scope=llvm-toolchain-23.1.3,ignore-error=true', seed) + # PRs retain their own required producer; a main seed is never proof + # that a changed PR toolchain is valid and never replaces that job. + llvm = text.split(' llvm:\n', 1)[1].split('\n linux:\n', 1)[0] + self.assertIn(' needs: changes', llvm) + self.assertNotIn('llvm-cache', llvm) + self.assertEqual(text.count('needs: [changes, llvm]'), 4) + changes = text.split(' changes:\n', 1)[1].split('\n llvm-cache:\n', 1)[0] + required = text.split(' required:\n', 1)[1] + self.assertIn(" if: github.event_name == 'pull_request'", changes) + self.assertIn(" if: always() && github.event_name == 'pull_request'", required) + self.assertNotIn('llvm-cache', required) + def test_pr_workflow_remains_private_package_free_and_strict(self): text = workflow('validate.yml') self.assertNotIn('packages:', text)