This runbook is the concrete image-rotation-on-CVE policy for basecrawl.
It implements the TCB residual described in docs/tcb-inventory.md and must be
followed whenever a Chromium or OS vulnerability requires a measured-image change.
Goal: produce a new digest-pinned, reproducibly measurable image, then atomically rotate the validator allowlist so the new measurement is pinned and the vulnerable measurement is removed (never default-accepted both indefinitely).
- CVE details and a patched Chromium (or OS/runtime) pin are available as digest-addressable artifacts.
dstack-mrand the pinned dstack / meta-dstack revisions from the mission environment are available for offline measurement reproduction.- Validators can reload the measurement allowlist used by L1
(
CHALLENGE_MEASUREMENT_ALLOWLIST_FILE/CHALLENGE_KEY_RELEASE_ALLOWLIST_FILE). - Registry publish authorization (if pushing a new immutable image digest) is explicit and limited to the authorized repository.
Capture the currently allowlisted six-field TDX tuple from image/allowlist.json
(and the relay platform entry that includes key_provider when used for L1):
mrtd, rtmr0, rtmr1, rtmr2, compose_hash, os_image_hash
Tag this set as retired in your operator log. Do not leave it active after rotation completes.
Update only pinnable surfaces:
- Bump
CHROMIUM_VERSION/ Chrome path and, when required, the digest-pinned Puppeteer/runtimeFROMline inimage/Dockerfile. - If OS cookies change, they must arrive only through the new digest-pinned
runtime image (no floating
apt installat build). - Keep
SOURCE_DATE_EPOCHand locked toolchains. Do not introduce unpinned tags orplaywright install --with-deps.
From the repository root:
python3 image/reproducibility.py build --count 2
# or the equivalent BuildKit command pair used by image/reproducibility.pyPASS criteria:
- Two independent builds of the same patched source + pins yield the
same image digest / digest-pinned identity and the same offline
dstack-mrmeasurement (MRTD/RTMR0-2 for the fixed VM shape). - Publish only an immutable
@sha256:…digests (never floating tags as the persistence path). - Re-pin
image/docker-compose.ymland Phala app-compose to the new digest; recomputecompose_hash.
Programmatic helper for measurement identity (offline / CI, no live CVM required for the gate itself):
python3 image/cve_rotation.py measure --materials <json-or-flags>
python3 image/cve_rotation.py rebuild-check --materials-a A.json --materials-b B.jsonPolicy: rotation is atomic. The post-rotation allowlist for the TDX platform contains the new measurement and does not contain the retired vulnerable measurement. Dual-pin windows are never the default and must not become an indefinite state.
# basecrawl image allowlist (six-field)
python3 image/cve_rotation.py rotate \
--allowlist image/allowlist.json \
--new-entry new-measurement.json \
--retire-entry retired-measurement.json
# relay L1 / key-release allowlist (platform-namespaced)
# Write only the new pin; retired entry is stripped in the same write.
cd relay && PYTHONPATH=src python -m relay.keyrelease.allowlist_rotate rotate \
--allowlist path/to/validator-allowlist.json \
--new-entry new-l1-entry.json \
--retire-entry retired-l1-entry.jsonImplementation requirement: write via a temporary file in the same directory and
os.replace onto the destination so readers never observe a half-written file.
There is no long-lived "accept either" mode unless an operator explicitly
invokes a time-boxed dual-pin tool (not this default path).
After validators reload the allowlist:
- A quote / proof whose measurement equals the retired tuple must fail L1
with reason
measurement_not_allowlisted. - A quote / proof whose measurement equals the new tuple must L1
pass(crypto-valid + UpToDate TCB assumed). - Empty allowlist still fails closed (
allowlist_empty); never stay dual-open.
Executable simulation (no CVM required for the policy gate):
python3 image/cve_rotation.py verify-rotation \
--before retired.json --after new.json --allowlist image/allowlist.json
# relay side:
cd relay && PYTHONPATH=src uv run pytest tests/test_cve_image_rotation.py -qEven after rotation, a future 0-day inside the new pin is still a residual —
the enclave may still be measured-but-exploited while L1 attests cleanly.
Replay-audit sampling remains the continuous backstop (relay.scoring.replay_audit).
Document the CVE ID, new digest, new measurement, retirement time, and operator
in the evidence log.
| Property | Rule |
|---|---|
| Determinism | Same patched sources + pins → same digest + same measurement |
| Rotation atomicity | New pin written together with retired pin removed |
| Dual-accept | Not the default; never leave both indefinitely |
| L1 outcome | Retired → measurement_not_allowlisted; new → pass |
| Residual | Measured-but-exploited 0-day acknowledged; replay-audit backstop |