From db2bdd7d4acf61de00b3dbf1caeea3be4019a449 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 20 Sep 2026 11:47:05 +0900 Subject: [PATCH] feat(ops): verify release provenance evidence pairs --- .proofline/criteria/ac-0035.md | 16 + .proofline/criteria/ac-0036.md | 16 + .proofline/criteria/ac-0037.md | 16 + .proofline/identities.json | 4 +- .proofline/lines/line-0010/dcy-0010.md | 91 ++++ .../lines/line-0010/evidence/activity-log.md | 35 ++ .proofline/lines/line-0010/line-0010.md | 4 + .proofline/lines/line-0010/req-0010.md | 85 ++++ docs/operations.md | 51 ++- scripts/release_provenance_gate.py | 28 +- scripts/verify_release_provenance_pair.py | 50 +++ tests/adapters/persistence/test_ops_checks.py | 80 +++- .../test_release_provenance_contract.py | 188 ++++++++ tests/scripts/test_release_provenance_gate.py | 49 ++- trex_core/adapters/persistence/ops_checks.py | 408 ++++++++++++++++-- 15 files changed, 1054 insertions(+), 67 deletions(-) create mode 100644 .proofline/criteria/ac-0035.md create mode 100644 .proofline/criteria/ac-0036.md create mode 100644 .proofline/criteria/ac-0037.md create mode 100644 .proofline/lines/line-0010/dcy-0010.md create mode 100644 .proofline/lines/line-0010/evidence/activity-log.md create mode 100644 .proofline/lines/line-0010/line-0010.md create mode 100644 .proofline/lines/line-0010/req-0010.md create mode 100755 scripts/verify_release_provenance_pair.py create mode 100644 tests/adapters/persistence/test_release_provenance_contract.py diff --git a/.proofline/criteria/ac-0035.md b/.proofline/criteria/ac-0035.md new file mode 100644 index 0000000..8c75d04 --- /dev/null +++ b/.proofline/criteria/ac-0035.md @@ -0,0 +1,16 @@ +--- +id: "ac-0035" +status: active +--- + +# Observed provenance evidence와 개별 gate + +## Criterion + +`release_provenance_gate.py`는 운영 계층이 제공한 versioned observed evidence JSON을 strict하게 검증하고 그 source를 `operator_observed`로 표시해야 한다. Schema version 1은 exact field `schema_version`, `target_id`, `target_kind`, `image_id`, `image_digest`, `oci_revision`, `observed_at`만 허용한다. `schema_version`은 integer `1`, target field는 non-empty string, image 값은 lowercase `sha256:<64 hex>`, revision은 lowercase 40-hex, 관측 시각은 UTC RFC 3339여야 한다. Schema·duplicate-key·secret validation 실패는 입력을 record에 복사하지 않고 record/receipt 없이 non-zero로 종료해야 한다. 유효 evidence 이후 gate가 직접 관측한 audit checkout HEAD/detached/dirty 상태, revision별 runtime-file blob 일치와 read-only state capture는 caller evidence와 분리하고 REQ-0010의 gate record v1 exact schema로 기록한다. Observed revision과 checkout HEAD가 다르거나 checkout, runtime file 또는 필수 capture가 실패하면 `gate.passed=false`의 immutable failure record를 남기고 non-zero로 종료해야 한다. Record write 뒤 CLI는 canonical path와 raw bytes SHA-256을 REQ-0010 gate receipt v1 JSON으로 출력한다. Core는 target/runtime을 inspect하거나 caller evidence를 직접 관측값으로 주장하지 않는다. + +## Verification + +- 성공 조건: 유효한 evidence와 clean detached checkout, 일치 runtime files, 성공 capture를 사용하면 REQ-0010 gate record v1에 `source=operator_observed`, caller evidence와 gate-observed evidence가 분리되고 `gate.passed=true`이며 receipt의 SHA-256이 저장된 raw bytes와 같고 CLI가 0으로 종료한다. +- 실패·경계 조건: 필수 field 각각의 누락, unknown/duplicate key, bool을 포함한 잘못된 type, empty target, uppercase·길이 오류 digest/revision, non-UTC 또는 timezone 없는 timestamp와 secret-like key/value는 record/receipt 없이 non-zero로 종료한다. 유효 evidence 이후 revision mismatch, dirty/attached checkout, runtime-file mismatch와 capture 실패는 immutable failure record·receipt와 non-zero를 만들며 success로 승격되지 않는다. +- 검사 방법: evidence parser와 `collect_release_provenance()` 단위 테스트, CLI subprocess/fixture 테스트로 record schema·source 표기·verdict·exit code를 확인하고 legacy unversioned evidence가 거부되는지 검증한다. diff --git a/.proofline/criteria/ac-0036.md b/.proofline/criteria/ac-0036.md new file mode 100644 index 0000000..8e23498 --- /dev/null +++ b/.proofline/criteria/ac-0036.md @@ -0,0 +1,16 @@ +--- +id: "ac-0036" +status: active +--- + +# PRE/POST pair 검증과 immutable manifest + +## Criterion + +Pair validator는 운영 계층이 gate receipt에서 보존해 명시적으로 전달한 PRE/POST canonical record path와 expected raw-bytes SHA-256만 읽고 filesystem scan이나 latest-file 추정을 하지 않아야 한다. Current record bytes의 SHA-256은 expected 값과 일치해야 한다. PRE는 `phase=pre`, POST는 `phase=post`이고 두 record는 REQ-0010 gate record v1 exact schema와 success 불변식—동일한 schema version·operation ID·instance·target kind/ID, clean detached checkout, checkout HEAD와 observed revision 일치, non-empty runtime verification 전부 성공, 세 command output return code 0, broker capture 성공, gate summary `true,false,true,true,true`—을 충족해야 한다. 성공하면 REQ-0010 pair manifest v1 exact schema로 manifest를 checkout 밖에 exclusive-create하고 read-only로 전환한다. Image 동일성·변경 허용과 PRE→POST command-class transition 정책은 판정하지 않는다. Expected digest 불일치·불완전·record 불일치·failed gate 또는 output collision에서는 manifest를 생성·overwrite하지 않고 non-zero로 종료해야 한다. + +## Verification + +- 성공 조건: 동일 operation·instance·target의 유효한 성공 PRE/POST canonical path와 gate receipt의 expected SHA-256을 전달하면 current raw bytes와 digest를 재검증하고 REQ-0010 pair manifest v1 read-only file 하나를 생성하며 CLI가 0으로 종료한다. +- 실패·경계 조건: PRE 없는 POST, path 또는 expected digest 누락, 동일 path 두 번, phase 역전, unsupported/unknown/duplicate schema key, operation/instance/target mismatch, failed/incomplete success invariant, expected digest 발행 뒤 record byte 변경과 기존 output path 충돌은 새 manifest를 남기지 않고 기존 file을 변경하지 않으며 non-zero로 종료한다. +- 검사 방법: parser/validator 단위 테스트와 신규 pair CLI subprocess 테스트로 success manifest, exact digest binding, explicit path 사용, tamper detection, collision 불변성을 검증한다. diff --git a/.proofline/criteria/ac-0037.md b/.proofline/criteria/ac-0037.md new file mode 100644 index 0000000..23df98c --- /dev/null +++ b/.proofline/criteria/ac-0037.md @@ -0,0 +1,16 @@ +--- +id: "ac-0037" +status: active +--- + +# Dirty checkout isolation 무결성 + +## Criterion + +Dirty audit checkout은 기존과 같이 state capture 전에 fail-closed해야 하며, isolation은 tracked modified file, untracked regular file과 symlink를 checkout 밖의 operation-specific directory에 보존하되 source를 삭제·reset·checkout하지 않아야 한다. REQ-0010 isolation manifest v1 exact schema는 각 dirty relative path의 source type, size, SHA-256 또는 symlink target-text SHA-256, copied relative path, copied type/size/hash와 `matches`를 기록해야 한다. File content와 symlink 대상 content는 manifest에 넣지 않는다. Directory·FIFO/socket/device 같은 unsupported type, checkout 또는 symlink escape, copy/hash mismatch와 output collision은 성공으로 표시하지 않고 gate를 non-zero로 유지해야 한다. Manifest는 exclusive-create 후 read-only mode여야 한다. + +## Verification + +- 성공 조건: modified/untracked regular file, empty file, nested path와 checkout 내부 symlink를 격리하면 source와 copied metadata/hash가 같고 `matches=true`이며 manifest가 versioned/read-only이고 source bytes·type이 그대로 남는다. +- 실패·경계 조건: directory·special file, `../`/absolute path, checkout 밖을 가리키는 symlink, injected copy/hash mismatch와 기존 manifest collision은 성공으로 기록되지 않고 source를 변경·삭제하지 않으며 non-zero 결과를 만든다. +- 검사 방법: 임시 Git repository에서 regular/untracked/empty/nested/symlink 및 adversarial path/type fixtures를 사용해 isolation helper와 gate CLI를 검증하고, source before/after hash·mode·symlink target과 manifest content를 비교한다. diff --git a/.proofline/identities.json b/.proofline/identities.json index 7ca9e3f..b0c0c04 100644 --- a/.proofline/identities.json +++ b/.proofline/identities.json @@ -1,5 +1,5 @@ { "schema_version": 1, - "next_line_number": 10, - "next_ac_number": 35 + "next_line_number": 11, + "next_ac_number": 38 } diff --git a/.proofline/lines/line-0010/dcy-0010.md b/.proofline/lines/line-0010/dcy-0010.md new file mode 100644 index 0000000..3d8f594 --- /dev/null +++ b/.proofline/lines/line-0010/dcy-0010.md @@ -0,0 +1,91 @@ +--- +id: "dcy-0010" +status: confirmed +--- + +# 운영 provenance 검증 계약과 audit checkout 증거 무결성 + +## Problem + +PR #93은 audit checkout의 clean/detached/expected revision, runtime file hash, image digest 형식과 PRE/POST 상태 capture를 검사하는 `release_provenance_gate.py`를 추가했다. 현재 gate는 개별 record를 생성하지만, 실행 환경이 관측한 target·image identity를 명시적인 expected/observed 구조로 보존하지 않고 같은 operation의 PRE/POST record가 한 쌍인지 검증하거나 최종 manifest로 묶지 않는다. Dirty checkout 격리는 파일을 checkout 밖에 복사하지만 각 파일의 hash·type·복사 성공 여부를 manifest에 남기지 않아 보존본이 원본과 같은 bytes인지 사후 검증하기 어렵다. + +실제 Docker Compose/container 또는 host process 선택, runtime inspect, stop/start/recreate/deploy 실행, 전역 operation lock, audit checkout 교체와 maintenance window 승인은 배포 환경을 소유한 운영 계층의 책임이다. `trex-core`가 특정 runtime을 선택하거나 lifecycle command를 실행하면 application repository가 production orchestration과 권한 경계를 소유하게 된다. 이 저장소가 제공해야 하는 것은 운영 계층이 수집한 evidence를 엄격한 schema로 검증하고, 실패를 machine-readable non-zero 결과로 반환하며, 개별 PRE/POST record와 dirty isolation manifest의 무결성을 증명할 수 있게 하는 source-owned verification contract다. + +기대 상태는 `trex-core` provenance gate가 운영 계층이 제공한 observed target/image evidence와 audit checkout evidence를 명시적으로 구분해 기록하고, 누락·형식 오류·checkout mismatch·capture 실패를 fail-closed하는 것이다. 별도 pair validator는 명시적으로 전달된 PRE/POST record 두 개의 operation identity와 gate 성공 여부를 검증하고 immutable manifest를 생성해야 한다. Dirty isolation은 source를 삭제하거나 정리하지 않으면서 regular file과 symlink의 보존 결과를 hash/type 기준으로 검증 가능하게 남겨야 한다. + +## Evidence + +- GitHub Issue [#87](https://github.com/fourblocks2020/trex-core/issues/87)은 running artifact와 audit checkout의 provenance 불일치, PRE/POST 증거 연결, dirty checkout 무손실 보존 문제를 추적한다. Issue 본문의 lifecycle wrapper·runtime inspect·global lock 항목은 현재 책임 재검토 결과 운영 계층 소유로 분리해야 한다. +- PR [#93](https://github.com/fourblocks2020/trex-core/pull/93)은 `main`에 병합되었고 조사 기준 revision은 `cd2342f2c69267c471b5e81658c58332e00857de`다. 관련 변경은 `f3a6b2c`, `8d9d627`, `619feff`, `8378e03`, `7dc2f65`에 걸쳐 있다. +- `scripts/release_provenance_gate.py`는 command class와 read-only capture command 형태를 제한하고 dirty checkout에서 state capture를 실행하지 않은 채 exit 2로 차단한다. 하지만 `--expected-oci-revision`과 `--image-digest`는 caller가 제공한 평면 문자열이며 target identity, evidence source와 expected/observed 구분을 기록하지 않는다. +- `trex_core/adapters/persistence/ops_checks.py`의 `collect_release_provenance()`는 audit checkout HEAD와 caller가 제공한 revision을 비교하고 image digest의 `sha256:<64 hex>` 형식을 검사한다. Runtime 파일은 working-tree bytes와 지정 revision의 Git blob hash를 비교한다. +- `scripts/release_provenance_gate.py`는 PRE와 POST record를 각각 exclusive-create하고 read-only mode로 전환하지만, 동일 operation ID·instance·target identity를 검증하는 pair validator와 pair manifest는 없다. +- `isolate_dirty_checkout_files()`의 현재 `manifest.json`은 `dirty_paths`와 `copied_paths`만 기록한다. 각 source/copied path의 file type, SHA-256, size와 복사 일치 여부는 기록하지 않는다. 따라서 path-only manifest가 현재 baseline이며 hash manifest는 이번 Line의 신규 범위다. +- `docs/operations.md`의 현재 실행 절차는 host `trex-core-daemon`과 `pkill`을 설명하지만 Issue #87은 container identity를 예로 든다. 저장소에는 production Compose/service definition이나 canonical lifecycle executor가 없으므로 이 Line에서 특정 runtime을 선택하거나 lifecycle command를 추가할 직접 근거가 없다. +- 현재 `main`에서 `tests/adapters/persistence/test_ops_checks.py`와 `tests/scripts/test_release_provenance_gate.py`를 실행한 결과 `19 passed, 3 skipped`다. 이는 기존 개별 gate baseline을 확인하지만 observed evidence schema, pair integrity와 hash isolation manifest를 검증하지 않는다. +- Duplicate sweep 결과 Issue #87 관련 open PR과 `release provenance`·`lifecycle wrapper` open PR은 없고 PR #93만 merged 상태다. +- 현재 active ProofLine AC 전체와 `.proofline/lines/**`를 `release_provenance`, `audit checkout`, `재기동`, `lifecycle wrapper`로 검색한 결과 이 검증 계약을 소유하는 AC는 없다. Image publication을 소유하는 `req-0002`·`req-0004`는 production restart와 deployment를 비범위로 두므로 update/satisfy 대상이 아니다. + +## Scope + +- 이 Line은 기존 PR #93 gate를 기반으로 version-independent 동작을 소유하는 신규 AC 세 건을 `create`한다. 기존 active AC의 `update`, `retire`, `satisfy`, release evidence 대상은 없다. + +### 신규 AC-A: observed provenance evidence와 개별 gate + +- 운영 계층이 수집해 전달하는 evidence는 최소 `target_id`, `target_kind`, `image_id`, `image_digest`, `oci_revision`, `observed_at`을 가진 versioned JSON object다. `target_kind`는 extensibility를 위한 식별값이며 `trex-core`가 Docker·Compose·host runtime을 선택하거나 inspect하지 않는다. +- Gate는 evidence source를 `operator_observed`로 명시하고, caller가 제공한 observed 값과 gate가 직접 확인한 checkout HEAD/detached/dirty 상태 및 runtime-file Git blob 비교를 별도 field로 기록한다. Caller evidence를 gate 직접 관측값으로 표시하지 않는다. +- `target_id`와 `target_kind`는 non-empty string, `oci_revision`은 40-hex commit, `image_id`와 `image_digest`는 `sha256:<64 hex>`, `observed_at`은 timezone을 가진 UTC timestamp여야 한다. 누락·중복 key, 잘못된 type/형식, secret-like key/value, checkout revision mismatch, dirty/attached checkout, runtime-file mismatch와 필수 state capture 실패는 record에 원인을 남기고 non-zero로 종료한다. +- 정상 검증은 gate record에 `schema_version`, evidence의 expected/observed provenance, checkout/runtime/capture 결과와 `gate.passed=true`를 남긴다. 실패 record도 exclusive-create read-only로 남기되 lifecycle 허용을 주장하지 않는다. +- Verification은 정상 evidence, 각 필수 field 누락·잘못된 type/형식, unknown key, secret-like 값, revision mismatch, dirty/attached checkout, runtime-file mismatch와 capture 실패를 독립적으로 포함한다. + +### 신규 AC-B: PRE/POST pair validator와 immutable manifest + +- Pair validator는 filesystem scan으로 record를 추정하지 않고 운영 계층이 명시적으로 전달한 정확한 PRE path와 POST path만 읽는다. +- PRE는 `phase=pre`, POST는 `phase=post`이며 두 record는 동일한 `schema_version`, `operation_id`, `instance`, `target_kind`, `target_id`를 가져야 한다. 각 command class는 기존 allowlist에 속해야 하지만 실제 PRE→POST lifecycle transition policy와 image 변경 허용 여부는 운영 계층이 소유한다. +- 두 record 모두 `gate.passed=true`이고 필수 checkout/runtime/state capture evidence가 완전해야 한다. PRE/POST의 image identity는 각각 manifest에 보존하되 deploy 여부를 모르는 core validator가 임의로 동일성을 강제하지 않는다. +- Pair 검증 성공 시 두 input record의 canonical path, SHA-256, phase, command class, observed image identity와 검증 시각을 포함한 versioned manifest를 checkout 밖에 exclusive-create하고 read-only mode로 전환한다. 동일 output path 재사용은 overwrite하지 않고 실패한다. +- PRE 없는 POST, phase 역전, identity mismatch, failed gate, 변조된/비정상 record, 필수 evidence 누락에서는 manifest를 생성하지 않고 non-zero로 종료한다. +- Verification은 성공 pair, PRE 없는 POST, phase mismatch, operation/instance/target mismatch, failed gate, input record byte 변경, output collision을 독립적으로 포함한다. + +### 신규 AC-C: dirty isolation 무결성과 비밀 안전성 + +- Dirty checkout이면 기존과 같이 state capture 전에 fail-closed하고 tracked modified file, untracked regular file과 symlink를 checkout 밖의 operation-specific isolation directory에 복사한다. Source를 삭제·reset·checkout하지 않는다. +- Isolation manifest는 각 dirty path에 대해 relative path, source type, source SHA-256 또는 symlink target SHA-256, size, copied relative path, copied type/hash/size와 `matches`를 기록한다. Directory·unsupported special file·checkout escape·symlink escape는 성공으로 추정하지 않고 오류 상태를 기록하며 gate를 실패시킨다. +- Manifest 자체는 versioned JSON이며 exclusive-create 후 read-only mode로 전환한다. Secret-like file content는 manifest에 넣지 않고 hash·size·type만 저장한다. +- Verification은 modified/untracked regular file, symlink, empty file, nested path, checkout escape, unsupported type, copy/hash mismatch와 output collision을 포함하고 source bytes가 변경·삭제되지 않았음을 확인한다. + +### Source 및 문서 변경 경계 + +- 예상 source/test owners는 `scripts/release_provenance_gate.py`, 신규 pair verification CLI, `trex_core/adapters/persistence/ops_checks.py`, provenance 관련 tests와 `docs/operations.md`다. +- Public trading/domain API, daemon composition root, persistence schema와 runtime service lifecycle에는 변경이 없다. +- `docs/operations.md`는 `trex-core` gate가 lifecycle을 실행하거나 runtime truth를 직접 관측하지 않는다는 경계를 명시하고, 운영 계층이 exact observed evidence를 제공하고 non-zero 결과를 lifecycle 차단 조건으로 사용해야 한다고 설명한다. +- Production runtime validation은 운영 계층 구현과 별도 사용자 승인을 받은 maintenance window가 있어야 하며 이번 source delivery의 완료 증거가 아니다. + +## Out of Scope + +- Docker Compose/container 또는 host process 중 canonical production runtime 선택 +- Runtime/container/process inspect, 실제 stop/start/recreate/deploy/recovery command 실행 +- Lifecycle wrapper, global operation lock, stale-lock 해제와 maintenance window orchestration +- Audit checkout의 branch/worktree 생성·교체·reset, dirty source 삭제 또는 recovery branch commit +- Trading/domain contract, 주문 lifecycle, database schema 또는 migration 변경 +- 자동 주문 제출·취소, 자동 DB recovery, 자동 `sync --live` 또는 `verify --live` +- 여러 instance의 일괄·병렬 재기동 또는 실패 뒤 다음 instance 자동 진행 +- Production credential·raw broker payload를 evidence에 저장하거나 Issue/PR에 게시 +- 사용자 승인 없는 production 변경, 배포 완료 또는 runtime 검증 완료 주장 +- Container registry publication, image signing, SBOM, package visibility 또는 release workflow 변경 + +## Risks and Unknowns + +### Risks + +- 운영 계층이 잘못된 observed evidence를 제공하면 `trex-core`는 실제 runtime을 독립적으로 재관측하지 않으므로 그 진실성을 증명할 수 없다. Record는 evidence source를 명시하고 운영 계층은 runtime-specific inspect와 lifecycle 차단을 별도 계약으로 구현해야 한다. +- Deploy operation은 PRE와 POST image identity가 달라질 수 있으므로 core pair validator가 동일 image를 강제하면 정상 deploy를 막는다. Core는 두 identity와 record hash를 보존하고 transition policy는 운영 계층에 맡긴다. +- Hash manifest는 dirty bytes의 보존 동일성을 증명하지만 그 내용의 안전성이나 배포 적합성을 승인하지 않는다. Recovery와 clean checkout 준비는 운영자가 별도로 수행한다. +- 기존 record consumer가 새 versioned schema를 전제로 하지 않을 수 있다. CLI와 parser는 기존 record를 pair success로 암묵 승격하지 않고 unsupported schema로 fail-closed해야 한다. + +### Assumptions + +- 운영 계층은 실제 runtime-specific inspect로 evidence JSON을 생성하고 gate의 non-zero 종료를 lifecycle 차단 신호로 사용한다. +- `target_id`는 운영 계층이 한 operation의 PRE/POST에 안정적으로 유지하는 logical target identity이며 runtime-specific 의미는 core가 해석하지 않는다. +- Existing `trex-core slots ... --json`, `rabbitmqctl list_consumers`와 broker log capture는 PR #93의 approved read-only capture boundary를 유지한다. +- Issue #87 구현 지시는 source/test/docs/PR delivery를 승인하지만 production restart와 checkout mutation 승인을 포함하지 않는다. diff --git a/.proofline/lines/line-0010/evidence/activity-log.md b/.proofline/lines/line-0010/evidence/activity-log.md new file mode 100644 index 0000000..51eba70 --- /dev/null +++ b/.proofline/lines/line-0010/evidence/activity-log.md @@ -0,0 +1,35 @@ +# Activity Log — line-0010 + +## 2026-09-20 + +- 사용자 확인으로 `dcy-0010`을 confirmed 처리했다. +- 사용자 승인으로 `req-0010`을 approved, `ac-0035`~`ac-0037`을 active 처리했다. +- `proofline validate` 통과. 기존 line-0001~0009 status 누락 warning만 존재한다. +- 구현 계획: + 1. observed evidence strict parser, versioned gate record와 raw-byte digest receipt + 2. explicit PRE/POST path와 trusted digest 기반 pair validator/manifest + 3. regular file/symlink isolation type·size·hash manifest + 4. operations-owned lifecycle 경계 문서화 + 5. focused/full/ProofLine 검증과 독립 review +- 실제 production lifecycle command, runtime inspect, global lock과 checkout 복구는 범위 밖이며 실행하지 않는다. + +### 구현 및 검증 + +- `release_provenance_gate.py`를 versioned `--observed-evidence-file` 입력과 machine-readable raw-byte digest receipt로 전환했다. +- Strict observed evidence parser, v1 gate record, trusted-digest PRE/POST pair validator와 `scripts/verify_release_provenance_pair.py`를 추가했다. +- Dirty checkout isolation을 regular file/symlink type·size·SHA-256 copy integrity manifest로 강화했다. +- `docs/operations.md`에 source/operations 책임 경계와 새 gate/pair 절차를 반영했다. +- Focused 검증: `32 passed, 3 skipped`; 변경 파일 Ruff 통과. +- 전체 회귀: `1247 passed, 96 skipped`. +- 저장소 전체 `ruff check .`는 변경 전부터 존재하는 광범위한 baseline 위반 719건으로 실패했다. 이번 변경 파일에 한정한 Ruff 결과는 PASS다. +- 다음 작업: canonical ProofLine/diff 재검증, 독립 runtime/impact review, PR delivery. + +### 독립 리뷰 차단사항 수정 + +- AC/security와 runtime/downstream 독립 리뷰가 동일하게 checkout 밖 symlink target 허용을 차단 결함으로 재현했다. +- 원인은 symlink target text의 복사 무결성만 검사하고 target 해석 경계를 검사하지 않은 것이었다. +- 절대 symlink target과 상대 `..` escape를 복사 전에 거부하고, 상대 target resolve 결과가 checkout 내부인지 검증하도록 수정했다. +- 외부 absolute/relative escape fail-closed 및 source 불변 회귀와 내부 상대 symlink 성공 회귀를 추가했다. +- 수정 후 focused 검증: `35 passed, 3 skipped`; 전체 회귀 `1250 passed, 96 skipped`. +- 변경 파일 Ruff·compile, `proofline validate`, `git diff --check` 통과. ProofLine에는 기존 line-0001~0009 status warning만 남아 있다. +- 차단사항 독립 재검토 PASS: focused isolation `4 passed`, 관련 persistence/CLI `22 passed, 3 skipped`, chained symlink escape 수동 probe도 fail-closed. 잔여 blocker 없음. diff --git a/.proofline/lines/line-0010/line-0010.md b/.proofline/lines/line-0010/line-0010.md new file mode 100644 index 0000000..6112f13 --- /dev/null +++ b/.proofline/lines/line-0010/line-0010.md @@ -0,0 +1,4 @@ +--- +id: "line-0010" +status: discovery +--- diff --git a/.proofline/lines/line-0010/req-0010.md b/.proofline/lines/line-0010/req-0010.md new file mode 100644 index 0000000..b6db895 --- /dev/null +++ b/.proofline/lines/line-0010/req-0010.md @@ -0,0 +1,85 @@ +--- +id: "req-0010" +status: approved +discovery: "dcy-0010" +criteria: + create: + - "ac-0035" + - "ac-0036" + - "ac-0037" + update: [] + retire: [] + satisfy: [] +--- + +# 운영 provenance 검증 계약과 audit checkout 증거 무결성 + +## Objective + +`trex-core`가 운영 lifecycle을 실행하거나 특정 runtime을 선택하지 않으면서도 운영 계층이 제공한 observed target/image evidence, audit checkout 검증, PRE/POST 상태 capture와 dirty isolation 결과를 재현 가능한 형식으로 검증하게 한다. 개별 gate record는 caller evidence와 gate 직접 관측값을 구분하고 fail-closed하며, 명시적인 PRE/POST pair validator는 같은 operation의 성공 record 두 개만 immutable manifest로 결합한다. Dirty checkout 보존본은 path뿐 아니라 type·size·SHA-256 일치 여부로 무결성을 증명한다. + +## Scope + +- `release_provenance_gate.py`는 versioned observed evidence JSON을 입력받아 strict schema·secret 검사를 수행하고, target/image evidence를 `operator_observed` source로 기록한다. +- Gate가 직접 관측하는 audit checkout HEAD/detached/dirty 상태, revision blob과 working-tree runtime file hash, read-only capture 결과는 caller evidence와 별도 namespace에 기록한다. +- 개별 record는 versioned schema, operation metadata, observed evidence, checkout/runtime/capture 결과와 gate verdict를 포함하고 checkout 밖에 exclusive-create read-only file로 저장한다. +- Gate CLI는 record를 생성한 직후 raw file bytes의 SHA-256과 canonical path를 machine-readable receipt로 출력한다. 신규 pair verification CLI는 운영 계층이 보존한 PRE/POST canonical path와 expected SHA-256을 명시적으로 받아 검증한다. +- Pair validator는 두 record가 같은 schema version, operation ID, instance, target kind/ID를 가지며 각각 성공한 pre/post record이고 current raw bytes가 expected SHA-256과 같을 때만 versioned immutable manifest를 생성한다. +- Pair manifest는 두 input의 canonical path·verified SHA-256·phase·command class·observed image identity와 검증 시각을 저장한다. Image 변경과 command-class transition의 운영 정책은 판정하지 않는다. +- Dirty isolation은 modified/untracked regular file과 symlink를 checkout 밖에 보존하고 source/copied type·size·hash와 `matches`를 versioned manifest에 기록한다. Source bytes를 삭제·reset·checkout하지 않는다. +- Existing read-only capture allowlist, broker log 원문 비저장, actor/reason secret-like material 거부와 audit checkout 밖 output 원칙을 유지한다. +- `docs/operations.md`는 trex-core gate가 runtime inspect나 lifecycle command를 수행하지 않으며 운영 계층이 observed evidence 생성과 non-zero 결과의 enforcement를 소유한다고 명시한다. +- Repository-local tests는 정상·실패·collision·tamper 경계를 검증한다. + +## Constraints + +모든 schema object는 아래에 명시된 key만 허용하고 unknown/duplicate key를 거부한다. `sha256`은 prefix 없는 lowercase 64-hex, `image_id`와 `image_digest`는 `sha256:<64 lowercase hex>`, revision은 lowercase 40-hex, 모든 timestamp는 UTC RFC 3339다. Record·manifest hash는 JSON 재직렬화 값이 아니라 저장된 raw file bytes를 대상으로 한다. + +**Observed evidence v1** + +- Exact keys: `schema_version`(integer `1`), `target_id`(non-empty string), `target_kind`(non-empty string), `image_id`, `image_digest`, `oci_revision`, `observed_at`. + +**Gate record v1** + +- Exact top-level keys: `schema_version`(integer `1`), `timestamp_utc`, `actor`, `reason`, `instance`, `command_class`, `operation_id`, `phase`(`pre|post`), `observed_evidence`, `checkout`, `runtime_file_verification`, `state_capture`, `gate`; dirty checkout이면 `dirty_checkout_isolation`을 추가로 허용한다. +- `observed_evidence`: observed evidence v1 exact object에 `source`=`operator_observed`만 추가한다. +- `checkout`: exact keys `path`, `head_revision`, `detached_head`, `git_status_porcelain`, `git_clean`, `dirty_paths`. Pair success에는 `head_revision == observed_evidence.oci_revision`, `detached_head=true`, `git_clean=true`, empty `dirty_paths`가 필요하다. +- `runtime_file_verification`: non-empty list이며 각 item exact keys는 `path`, `path_within_checkout`, `exists`, `current_sha256`, `expected_revision`, `expected_sha256`, `matches_expected_revision`이다. Pair success에는 모든 boolean이 true이고 `expected_revision == observed_evidence.oci_revision`, 두 SHA-256이 같아야 한다. +- `state_capture.command_outputs`: exact keys `pending_slots`, `reconciliation_slots`, `queue_consumers`; 각 output의 exact keys는 `command`, `returncode`, `stdout`, `stdout_format`, `stderr`이며 pair success에는 `returncode=0`이 필요하다. `stdout` payload의 application-owned nested key는 schema 통제 대상이 아니지만 secret sanitization 대상이다. +- `state_capture.broker_side_effect_logs`: `capture_status=captured|captured_empty`이면 exact keys `path`, `exists`, `capture_status`, `file_sha256`, `scanned_lines`, `matches`; `unavailable`이면 exact keys `path`, `exists`, `capture_status`, `matches`; `error`이면 exact keys `path`, `exists`, `capture_status`, `error_type`, `matches`다. Pair success에는 success variant가 필요하고 `matches` item exact keys는 `event`, `line_number`, `line_sha256`이다. +- `gate`: exact keys `passed`, `deployment_blocked`, `runtime_files_verified`, `state_captures_succeeded`, `broker_log_captured`. Pair success에는 각각 `true,false,true,true,true`가 필요하다. +- Gate receipt stdout: exact JSON object `schema_version`(integer `1`), `record_path`(canonical absolute path), `record_sha256`(raw record bytes SHA-256), `gate_passed`(boolean). Failure record를 정상적으로 쓴 경우에도 receipt를 출력하되 CLI는 non-zero다. + +**Pair manifest v1** + +- Exact top-level keys: `schema_version`(integer `1`), `verified_at`, `operation_id`, `instance`, `target_kind`, `target_id`, `pre`, `post`. +- `pre`/`post` exact keys: `record_path`, `record_sha256`, `phase`, `command_class`, `image_id`, `image_digest`, `oci_revision`, `observed_at`; `phase`는 각 object 이름과 일치해야 한다. + +**Isolation manifest v1** + +- Exact top-level keys: `schema_version`(integer `1`), `timestamp_utc`, `checkout_dir`, `isolate_dir`, `entries`. +- 각 entry exact keys: `path`, `source_type`, `source_size`, `source_sha256`, `copied_path`, `copied_type`, `copied_size`, `copied_sha256`, `matches`. Type은 `regular|symlink`; symlink size/hash는 target text의 UTF-8 bytes 기준이다. + +- Observed evidence schema의 필수 field는 `schema_version`, `target_id`, `target_kind`, `image_id`, `image_digest`, `oci_revision`, `observed_at`이다. Unknown/duplicate key, 누락, 잘못된 type/형식과 secret-like material은 fail-closed한다. +- `schema_version`은 integer `1`, target field는 non-empty string, `image_id`와 `image_digest`는 lowercase `sha256:<64 hex>`, `oci_revision`은 lowercase 40-hex Git commit, `observed_at`은 timezone을 가진 UTC RFC 3339 timestamp다. +- `target_kind`의 의미와 target/runtime 실재 여부는 운영 계층이 소유한다. Core는 Docker·Compose·host runtime을 선택하거나 inspect하지 않고 caller evidence를 직접 관측했다고 표시하지 않는다. +- Gate는 observed `oci_revision`과 audit checkout HEAD가 같아야 하고 runtime file working bytes가 해당 revision Git blob과 같아야 성공한다. +- Observed evidence JSON 자체의 schema·duplicate key·secret validation이 실패하면 해당 입력을 record에 복사하지 않고 record/receipt 없이 non-zero로 종료한다. Evidence validation을 통과한 뒤 checkout/runtime/capture gate가 실패하면 immutable failure record와 receipt를 남긴다. +- Pair validator는 filesystem scan이나 latest-file 선택을 하지 않는다. PRE/POST canonical path와 gate receipt에서 보존한 expected SHA-256은 필수 명시 입력이며, input raw bytes를 다시 hash해 일치해야 한다. +- Pair validator는 실제 deploy/restart가 수행됐는지, image 변경이 허용되는지 또는 command-class transition이 운영 정책에 맞는지 주장하지 않는다. +- Legacy unversioned record를 새 pair success로 암묵 승격하지 않는다. Unsupported schema는 fail-closed한다. +- Record와 manifest는 overwrite하지 않고 exclusive-create하며 성공한 write 뒤 read-only mode로 전환한다. +- Dirty isolation의 hash manifest는 file content를 포함하지 않는다. Unsupported special file, checkout/symlink escape, copy/hash mismatch와 output collision은 성공으로 기록하지 않는다. +- Normative Design Documents: 없음. 이 REQ와 AC-0035~AC-0037이 이번 specification의 normative behavior를 소유한다. + +## Non-Goals + +- Docker Compose/container 또는 host process 중 canonical production runtime 선택 +- Runtime/container/process inspect, 실제 stop/start/recreate/deploy/recovery command 실행 +- Lifecycle wrapper, global operation lock, stale-lock 처리와 maintenance orchestration +- Audit checkout worktree 생성·교체·reset, dirty source 삭제 또는 recovery branch commit +- Image transition과 command-class transition의 운영 정책 판정 +- Trading/domain contract, 주문 lifecycle, database schema·migration 또는 daemon composition 변경 +- 자동 주문·취소, DB recovery, `sync --live`, `verify --live` +- Production deployment, restart 또는 runtime validation 완료 주장 +- Registry publication, image signing, SBOM, package visibility 또는 release workflow 변경 diff --git a/docs/operations.md b/docs/operations.md index 0687dd4..8e20b2b 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -47,12 +47,26 @@ Release tag의 clean checkout에서 `./scripts/build_release_image.sh trex-core: non-root user 및 container `--version`을 검증한다. 전체 명령과 registry 사용법은 [`docs/ops/release-image-build.md`](ops/release-image-build.md)를 따른다. -### 배포/복구/수동 재기동 provenance gate (issue #87) +### 배포/복구/수동 재기동 provenance 검증 (issue #87) -배포, 복구, 수동 stop/start/recreate 전에 아래 gate를 실행해 **검증 가능한 release record**를 남긴다. +이 저장소는 provenance **검증 contract**만 제공한다. 실행 중 target의 inspect, observed evidence 생성, 대상 선택, 승인, operation lock, stop/start/recreate/deploy, checkout 복구와 non-zero exit enforcement는 운영 계층이 소유한다. Core gate는 runtime을 직접 inspect하거나 lifecycle command를 실행하지 않으며, caller evidence를 `operator_observed`로 명시한다. + +운영 계층은 각 PRE/POST 시점에 exact schema의 JSON을 생성한다. Unknown/duplicate key, 형식 오류, timezone 없는 시각과 secret-like material은 record를 만들기 전에 거부된다. + +```json +{ + "schema_version": 1, + "target_id": "", + "target_kind": "", + "image_id": "sha256:<64-lowercase-hex>", + "image_digest": "sha256:<64-lowercase-hex>", + "oci_revision": "<40-lowercase-hex>", + "observed_at": "2026-09-20T02:00:00Z" +} +``` ```bash -# PRE (대상 인스턴스 1개만 지정; POST도 같은 operation-id로 별도 실행) +# PRE: 대상 인스턴스 1개만 지정한다. POST도 같은 operation-id로 별도 실행한다. python scripts/release_provenance_gate.py \ --actor "" \ --reason "" \ @@ -60,8 +74,7 @@ python scripts/release_provenance_gate.py \ --operation-id "" \ --phase "pre" \ --command-class "manual-stop" \ - --expected-oci-revision "<40-character-running-image-oci-revision>" \ - --image-digest "sha256:<64-hex-running-image-digest>" \ + --observed-evidence-file "/absolute/path/pre-observed-evidence.json" \ --runtime-file "trex_core/runtime/daemon.py" \ --runtime-file "trex_core/runtime/cli.py" \ --capture-command "pending_slots=.venv/bin/trex-core slots --status pending --instance --json" \ @@ -69,16 +82,26 @@ python scripts/release_provenance_gate.py \ --capture-command "queue_consumers=rabbitmqctl list_consumers" ``` -- gate는 audit checkout이 detached HEAD인지 확인하고 NUL 구분 `git status --porcelain=v1`로 clean 여부를 검사한다. branch checkout 또는 dirty checkout이면 운영 상태 조회 명령도 실행하지 않고 종료 코드 `2`로 중단하며, dirty 변경 파일은 audit checkout 밖의 `-release-provenance-isolation/`에 작업별 디렉터리로 복사한다. 우회 옵션은 없다. -- `checkout.head_revision`과 필수 `--expected-oci-revision` 일치 여부를 검사해 audit checkout HEAD가 실행 이미지 OCI revision과 같은지 검증한다. -- 필수 `--image-digest` 형식과 하나 이상의 `--runtime-file` 검증 결과(sha256 + revision 일치 여부)가 JSON record에 저장된다. -- actor/timestamp/instance/operation-id/phase/command-class/reason이 JSON record에 함께 저장되어 PRE/POST 및 stop/start/recreate 감사 추적이 가능하다. actor/reason에 credential 형태의 key/value가 포함되면 기록 전에 거부한다. -- PRE/POST 모두 실행해 재기동 전후의 `pending_slots`, `reconciliation_slots`, `queue_consumers`, broker-side-effect 로그(`order_submit/fill/reject/cancel`)를 자동 캡처한다. 필수 조회 또는 로그 캡처가 누락·실패하면 gate는 실패한다. -- capture command는 위의 인스턴스 지정 `trex-core slots` 두 형식과 `rabbitmqctl list_consumers`만 허용한다. 주문 제출·취소, DB 복구, 자동 sync/verify live 실행은 거부한다. -- broker-side-effect 로그 입력은 기본 `/tmp/trex-core.log`를 사용하며, 로그 파일 위치가 다르면 `--broker-log-path `로 명시한다. record에는 원문 대신 파일 sha256·스캔 행 수와 event 종류·행 번호·행 sha256만 남긴다. 파일을 정상 스캔했지만 일치 이벤트가 없으면 `captured_empty`, 파일 누락·읽기 실패는 각각 `unavailable`·`error`로 기록하며 후자는 gate를 차단한다. -- record와 격리 디렉터리는 audit checkout 밖에 둔다. 기본 record 위치는 `-release-provenance/`이며, 충돌 없는 파일명을 exclusive-create하고 read-only mode로 전환해 기존 record를 덮어쓰지 않는다. +- Gate stdout은 `record_path`, raw record bytes의 `record_sha256`, `gate_passed`를 포함한 v1 JSON receipt다. 운영 계층은 PRE/POST receipt를 신뢰 경계 밖에 보존하고 non-zero이면 lifecycle을 실행하지 않는다. +- Gate는 audit checkout이 detached HEAD인지 확인하고 NUL 구분 `git status --porcelain=v1`로 clean 여부를 검사한다. branch checkout 또는 dirty checkout이면 운영 상태 조회 명령도 실행하지 않고 종료 코드 `2`로 중단한다. +- Observed `oci_revision`, audit checkout HEAD와 revision별 runtime-file bytes가 모두 일치해야 한다. Target/image 값은 caller 관측값이며 core가 직접 관측했다고 주장하지 않는다. +- PRE/POST에서 `pending_slots`, `reconciliation_slots`, `queue_consumers`, broker-side-effect 로그를 read-only로 캡처한다. 필수 조회 또는 로그 캡처가 누락·실패하면 gate는 실패한다. +- Capture command는 위의 인스턴스 지정 `trex-core slots` 두 형식과 `rabbitmqctl list_consumers`만 허용한다. 주문 제출·취소, DB 복구 또는 lifecycle 명령은 실행하지 않는다. +- Dirty checkout의 regular file과 symlink는 checkout 밖 `-release-provenance-isolation/`에 원본을 변경하지 않고 복사한다. v1 isolation manifest는 path, type, size, source/copy SHA-256과 일치 verdict를 기록하며 unsupported type·escape·copy mismatch는 실패한다. +- Record와 manifest는 audit checkout 밖에 exclusive-create하고 read-only mode로 전환한다. 기존 파일 충돌은 덮어쓰지 않고 실패한다. -재기동 후에는 같은 `--operation-id`, `--phase "post"`, 해당 command class(예: `manual-start`)로 POST record를 추가 저장한다. +재기동 후 운영 계층은 새 observed evidence로 POST gate를 실행한다. 두 receipt의 exact path와 trusted digest를 명시해 pair manifest를 생성한다. Validator는 디렉터리를 scan하거나 latest file을 추정하지 않는다. PRE/POST의 image identity와 command class는 서로 달라도 되지만 operation ID, instance, target kind/ID와 각 record의 success evidence는 일치해야 한다. + +```bash +python scripts/verify_release_provenance_pair.py \ + --pre-record "" \ + --pre-sha256 "" \ + --post-record "" \ + --post-sha256 "" \ + --output "/absolute/path/-pair-manifest.json" +``` + +Pair manifest는 input raw bytes를 trusted digest와 다시 비교한 뒤 exclusive-create/read-only로 저장한다. Digest mismatch, malformed/legacy/failure record, identity mismatch와 output collision은 manifest 없이 non-zero로 종료한다. 이 성공은 provenance record pair의 일관성만 증명하며 실제 lifecycle 실행 여부나 운영 정책 승인을 증명하지 않는다. --- diff --git a/scripts/release_provenance_gate.py b/scripts/release_provenance_gate.py index 6dcf805..c9aef0f 100755 --- a/scripts/release_provenance_gate.py +++ b/scripts/release_provenance_gate.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import hashlib import json import re import shlex @@ -18,6 +19,7 @@ if __package__ in {None, ""}: from trex_core.adapters.persistence.ops_checks import ( collect_release_provenance, isolate_dirty_checkout_files, + load_observed_evidence, ) _COMMAND_CLASSES = ("deploy", "recovery", "manual-stop", "manual-start", "recreate") @@ -92,8 +94,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--phase", required=True, choices=("pre", "post")) parser.add_argument("--command-class", required=True, choices=_COMMAND_CLASSES) parser.add_argument("--checkout-dir", default=".") - parser.add_argument("--expected-oci-revision", required=True) - parser.add_argument("--image-digest", required=True) + parser.add_argument("--observed-evidence-file", required=True) parser.add_argument("--runtime-file", action="append", required=True) parser.add_argument("--broker-log-path", default="/tmp/trex-core.log") parser.add_argument("--capture-command", action="append", required=True) @@ -102,6 +103,7 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) try: + observed_evidence = load_observed_evidence(args.observed_evidence_file) capture_commands: dict[str, list[str]] = {} for raw in args.capture_command: key, command = _parse_capture_command(raw) @@ -136,13 +138,12 @@ def main(argv: list[str] | None = None) -> int: operation_id=args.operation_id, phase=args.phase, checkout_dir=checkout, - expected_oci_revision=args.expected_oci_revision, - image_digest=args.image_digest, + observed_evidence=observed_evidence, runtime_files=args.runtime_file, capture_commands=capture_commands, broker_log_path=args.broker_log_path, ) - except (RuntimeError, ValueError) as exc: + except (RuntimeError, TypeError, ValueError) as exc: parser.error(str(exc)) output_dir.mkdir(parents=True, exist_ok=True) @@ -162,10 +163,21 @@ def main(argv: list[str] | None = None) -> int: ) # Exclusive create prevents a provenance record from being overwritten. - with output_path.open("x", encoding="utf-8") as fp: - fp.write(json.dumps(report, ensure_ascii=False, indent=2) + "\n") + record_bytes = (json.dumps(report, ensure_ascii=False, indent=2) + "\n").encode("utf-8") + with output_path.open("xb") as fp: + fp.write(record_bytes) output_path.chmod(0o444) - print(str(output_path)) + print( + json.dumps( + { + "schema_version": 1, + "record_path": str(output_path.resolve()), + "record_sha256": hashlib.sha256(record_bytes).hexdigest(), + "gate_passed": bool(report["gate"]["passed"]), + }, + separators=(",", ":"), + ) + ) if dirty_checkout: return 2 return 0 if report["gate"]["passed"] else 1 diff --git a/scripts/verify_release_provenance_pair.py b/scripts/verify_release_provenance_pair.py new file mode 100755 index 0000000..3722573 --- /dev/null +++ b/scripts/verify_release_provenance_pair.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Verify one explicit release-provenance PRE/POST pair.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +if __package__ in {None, ""}: + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from trex_core.adapters.persistence.ops_checks import verify_release_provenance_pair + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pre-record", required=True) + parser.add_argument("--pre-sha256", required=True) + parser.add_argument("--post-record", required=True) + parser.add_argument("--post-sha256", required=True) + parser.add_argument("--output", required=True) + args = parser.parse_args(argv) + + try: + manifest = verify_release_provenance_pair( + pre_path=args.pre_record, + pre_sha256=args.pre_sha256, + post_path=args.post_record, + post_sha256=args.post_sha256, + output_path=args.output, + ) + except (OSError, TypeError, ValueError) as exc: + parser.error(str(exc)) + print( + json.dumps( + { + "schema_version": 1, + "manifest_path": str(Path(args.output).resolve()), + "operation_id": manifest["operation_id"], + }, + separators=(",", ":"), + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/adapters/persistence/test_ops_checks.py b/tests/adapters/persistence/test_ops_checks.py index 9290f88..056c5d0 100644 --- a/tests/adapters/persistence/test_ops_checks.py +++ b/tests/adapters/persistence/test_ops_checks.py @@ -130,8 +130,15 @@ def _provenance_kwargs(repo: Path, head: str) -> dict: "operation_id": "op-1", "phase": "pre", "checkout_dir": repo, - "expected_oci_revision": head, - "image_digest": "sha256:" + ("a" * 64), + "observed_evidence": { + "schema_version": 1, + "target_id": "service-a", + "target_kind": "compose-service", + "image_id": "sha256:" + ("b" * 64), + "image_digest": "sha256:" + ("a" * 64), + "oci_revision": head, + "observed_at": "2026-09-20T02:00:00Z", + }, "runtime_files": ["tracked.txt"], "capture_commands": { "pending_slots": [ @@ -151,7 +158,7 @@ def test_collect_release_provenance_passes_on_clean_checkout(tmp_path): report = collect_release_provenance(**_provenance_kwargs(repo, head)) assert report["checkout"]["git_clean"] is True assert report["checkout"]["detached_head"] is True - assert report["artifact_identity"]["oci_revision_matches_head"] is True + assert report["observed_evidence"]["source"] == "operator_observed" assert report["runtime_file_verification"][0]["matches_expected_revision"] is True assert report["gate"]["passed"] is True assert report["operation_id"] == "op-1" @@ -181,7 +188,7 @@ def test_collect_release_provenance_blocks_on_revision_or_runtime_mismatch(tmp_p kwargs = _provenance_kwargs(repo, old_head) report = collect_release_provenance(**kwargs) assert report["checkout"]["git_clean"] is True - assert report["artifact_identity"]["oci_revision_matches_head"] is False + assert report["checkout"]["head_revision"] != report["observed_evidence"]["oci_revision"] assert report["runtime_file_verification"][0]["matches_expected_revision"] is False assert report["gate"]["passed"] is False @@ -192,13 +199,58 @@ def test_isolate_dirty_checkout_files_copies_dirty_and_untracked(tmp_path): (repo / "new.txt").write_text("new\n", encoding="utf-8") isolated = tmp_path / "isolated" manifest = isolate_dirty_checkout_files(checkout_dir=repo, isolate_dir=isolated) - assert set(manifest["dirty_paths"]) == {"tracked.txt", "new.txt"} - assert set(manifest["copied_paths"]) == {"tracked.txt", "new.txt"} + entries = {entry["path"]: entry for entry in manifest["entries"]} + assert set(entries) == {"tracked.txt", "new.txt"} + assert all(entry["source_type"] == "regular" for entry in entries.values()) + assert all(entry["matches"] is True for entry in entries.values()) snapshot = Path(manifest["isolate_dir"]) assert snapshot.parent == isolated assert (snapshot / "tracked.txt").read_text(encoding="utf-8") == "dirty\n" assert (snapshot / "new.txt").read_text(encoding="utf-8") == "new\n" - assert (snapshot / "manifest.json").is_file() + assert (snapshot / "manifest.json").stat().st_mode & 0o222 == 0 + + +@pytest.mark.parametrize("target_kind", ["absolute", "relative_escape"]) +def test_isolate_dirty_checkout_files_rejects_symlink_escape_without_mutating_source( + tmp_path, target_kind +): + repo, _ = _init_git_repo(tmp_path) + outside = tmp_path / "outside.txt" + outside.write_text("outside\n", encoding="utf-8") + link = repo / "escape-link" + target = str(outside) if target_kind == "absolute" else "../outside.txt" + link.symlink_to(target) + isolated = tmp_path / "isolated" + + with pytest.raises(ValueError, match="symlink target escapes checkout"): + isolate_dirty_checkout_files(checkout_dir=repo, isolate_dir=isolated) + + assert link.is_symlink() + assert link.readlink() == Path(target) + assert outside.read_text(encoding="utf-8") == "outside\n" + if isolated.exists(): + assert not list(isolated.glob("*/manifest.json")) + + +def test_isolate_dirty_checkout_files_preserves_internal_relative_symlink(tmp_path): + repo, _ = _init_git_repo(tmp_path) + nested = repo / "nested" + nested.mkdir() + (nested / "target.txt").write_text("target\n", encoding="utf-8") + link = repo / "internal-link" + link.symlink_to("nested/target.txt") + + manifest = isolate_dirty_checkout_files( + checkout_dir=repo, isolate_dir=tmp_path / "isolated" + ) + + entry = next(item for item in manifest["entries"] if item["path"] == "internal-link") + copied = Path(manifest["isolate_dir"]) / "internal-link" + assert entry["source_type"] == "symlink" + assert entry["copied_type"] == "symlink" + assert entry["matches"] is True + assert copied.readlink() == Path("nested/target.txt") + assert link.readlink() == Path("nested/target.txt") def test_collect_release_provenance_rejects_runtime_path_escape(tmp_path): @@ -226,8 +278,15 @@ def test_collect_release_provenance_reports_git_failure_context(tmp_path): operation_id="op-1", phase="pre", checkout_dir=not_repo, - expected_oci_revision="0" * 40, - image_digest="sha256:" + ("a" * 64), + observed_evidence={ + "schema_version": 1, + "target_id": "service-a", + "target_kind": "compose-service", + "image_id": "sha256:" + ("b" * 64), + "image_digest": "sha256:" + ("a" * 64), + "oci_revision": "0" * 40, + "observed_at": "2026-09-20T02:00:00Z", + }, runtime_files=["tracked.txt"], capture_commands={ "pending_slots": [ @@ -291,8 +350,7 @@ def test_empty_broker_log_is_explicitly_captured_as_no_matching_events(tmp_path) @pytest.mark.parametrize( ("field", "value"), [ - ("expected_oci_revision", None), - ("image_digest", "not-a-digest"), + ("observed_evidence", {}), ("runtime_files", []), ("capture_commands", {}), ("instance", "../escape"), diff --git a/tests/adapters/persistence/test_release_provenance_contract.py b/tests/adapters/persistence/test_release_provenance_contract.py new file mode 100644 index 0000000..f607f12 --- /dev/null +++ b/tests/adapters/persistence/test_release_provenance_contract.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from trex_core.adapters.persistence.ops_checks import ( + load_observed_evidence, + verify_release_provenance_pair, +) + +DIGEST_A = "sha256:" + "a" * 64 +DIGEST_B = "sha256:" + "b" * 64 +REVISION = "c" * 40 + + +def _evidence() -> dict: + return { + "schema_version": 1, + "target_id": "service-a", + "target_kind": "compose-service", + "image_id": DIGEST_A, + "image_digest": DIGEST_B, + "oci_revision": REVISION, + "observed_at": "2026-09-20T02:00:00Z", + } + + +def test_load_observed_evidence_accepts_exact_v1(tmp_path: Path): + path = tmp_path / "evidence.json" + path.write_text(json.dumps(_evidence()), encoding="utf-8") + + assert load_observed_evidence(path) == _evidence() + + +@pytest.mark.parametrize( + "raw", + [ + '{"schema_version":1,"schema_version":1}', + json.dumps({**_evidence(), "unknown": True}), + json.dumps({**_evidence(), "target_id": ""}), + json.dumps({**_evidence(), "schema_version": True}), + json.dumps({**_evidence(), "image_id": "SHA256:" + "A" * 64}), + json.dumps({**_evidence(), "observed_at": "2026-09-20T02:00:00+09:00"}), + json.dumps({**_evidence(), "target_id": "token=must-not-leak"}), + ], +) +def test_load_observed_evidence_rejects_invalid_or_sensitive_input(tmp_path: Path, raw: str): + path = tmp_path / "evidence.json" + path.write_text(raw, encoding="utf-8") + + with pytest.raises(ValueError): + load_observed_evidence(path) + + +def _record(*, phase: str, operation_id: str = "op-1", gate_passed: bool = True) -> dict: + return { + "schema_version": 1, + "timestamp_utc": "2026-09-20T02:00:00Z", + "actor": "operator-a", + "reason": "release", + "instance": "instance-a", + "command_class": "deploy" if phase == "pre" else "recreate", + "operation_id": operation_id, + "phase": phase, + "observed_evidence": {**_evidence(), "source": "operator_observed"}, + "checkout": { + "path": "/audit/checkout", + "head_revision": REVISION, + "detached_head": True, + "git_status_porcelain": [], + "git_clean": True, + "dirty_paths": [], + }, + "runtime_file_verification": [ + { + "path": "runtime.py", + "path_within_checkout": True, + "exists": True, + "current_sha256": "d" * 64, + "expected_revision": REVISION, + "expected_sha256": "d" * 64, + "matches_expected_revision": True, + } + ], + "state_capture": { + "command_outputs": { + key: { + "command": ["read-only"], + "returncode": 0, + "stdout": {}, + "stdout_format": "json", + "stderr": "", + } + for key in ("pending_slots", "reconciliation_slots", "queue_consumers") + }, + "broker_side_effect_logs": { + "path": "/tmp/broker.log", + "exists": True, + "capture_status": "captured_empty", + "file_sha256": "e" * 64, + "scanned_lines": 0, + "matches": [], + }, + }, + "gate": { + "passed": gate_passed, + "deployment_blocked": not gate_passed, + "runtime_files_verified": True, + "state_captures_succeeded": True, + "broker_log_captured": True, + }, + } + + +def _write_record(path: Path, record: dict) -> str: + path.write_text(json.dumps(record, sort_keys=True) + "\n", encoding="utf-8") + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def test_pair_validator_binds_explicit_paths_and_expected_digests(tmp_path: Path): + pre = tmp_path / "pre.json" + post = tmp_path / "post.json" + pre_sha = _write_record(pre, _record(phase="pre")) + post_sha = _write_record(post, _record(phase="post")) + output = tmp_path / "manifests" / "pair.json" + + manifest = verify_release_provenance_pair( + pre_path=pre, + pre_sha256=pre_sha, + post_path=post, + post_sha256=post_sha, + output_path=output, + ) + + assert manifest["pre"]["record_path"] == str(pre.resolve()) + assert manifest["post"]["record_sha256"] == post_sha + assert output.stat().st_mode & 0o222 == 0 + + +@pytest.mark.parametrize("mutation", ["tamper", "failed", "mismatch"]) +def test_pair_validator_fails_without_manifest(tmp_path: Path, mutation: str): + pre = tmp_path / "pre.json" + post = tmp_path / "post.json" + pre_sha = _write_record(pre, _record(phase="pre")) + post_record = _record(phase="post") + if mutation == "failed": + post_record["gate"]["passed"] = False + post_record["gate"]["deployment_blocked"] = True + if mutation == "mismatch": + post_record["operation_id"] = "op-2" + post_sha = _write_record(post, post_record) + if mutation == "tamper": + post.write_text(post.read_text() + " ", encoding="utf-8") + output = tmp_path / "pair.json" + + with pytest.raises(ValueError): + verify_release_provenance_pair( + pre_path=pre, + pre_sha256=pre_sha, + post_path=post, + post_sha256=post_sha, + output_path=output, + ) + + assert not output.exists() + + +def test_pair_validator_does_not_overwrite_existing_output(tmp_path: Path): + pre = tmp_path / "pre.json" + post = tmp_path / "post.json" + pre_sha = _write_record(pre, _record(phase="pre")) + post_sha = _write_record(post, _record(phase="post")) + output = tmp_path / "pair.json" + output.write_text("keep", encoding="utf-8") + + with pytest.raises(FileExistsError): + verify_release_provenance_pair( + pre_path=pre, + pre_sha256=pre_sha, + post_path=post, + post_sha256=post_sha, + output_path=output, + ) + + assert output.read_text(encoding="utf-8") == "keep" diff --git a/tests/scripts/test_release_provenance_gate.py b/tests/scripts/test_release_provenance_gate.py index 72a7942..7bfc5e9 100644 --- a/tests/scripts/test_release_provenance_gate.py +++ b/tests/scripts/test_release_provenance_gate.py @@ -26,7 +26,7 @@ def test_documented_python_invocation_loads_project_without_pythonpath(): ) assert completed.returncode == 0, completed.stderr - assert "--expected-oci-revision" in completed.stdout + assert "--observed-evidence-file" in completed.stdout assert script.stat().st_mode & 0o111 != 0 @@ -62,6 +62,21 @@ def _argv(tmp_path: Path, repo: Path, head: str) -> list[str]: trex, rabbit = _fake_read_only_commands(tmp_path) broker_log = tmp_path / "broker.log" broker_log.write_text("event=order_submit account=must-not-leak\n", encoding="utf-8") + evidence = tmp_path / "observed-evidence.json" + evidence.write_text( + json.dumps( + { + "schema_version": 1, + "target_id": "service-a", + "target_kind": "compose-service", + "image_id": "sha256:" + ("b" * 64), + "image_digest": "sha256:" + ("a" * 64), + "oci_revision": head, + "observed_at": "2026-09-20T02:00:00Z", + } + ), + encoding="utf-8", + ) return [ "--actor", "operator-a", "--reason", "deploy release", @@ -70,8 +85,7 @@ def _argv(tmp_path: Path, repo: Path, head: str) -> list[str]: "--phase", "pre", "--command-class", "deploy", "--checkout-dir", str(repo), - "--expected-oci-revision", head, - "--image-digest", "sha256:" + ("a" * 64), + "--observed-evidence-file", str(evidence), "--runtime-file", "runtime.py", "--broker-log-path", str(broker_log), "--capture-command", f"pending_slots={trex} slots --status pending --instance inst-a --json", @@ -85,15 +99,20 @@ def test_cli_writes_unique_record_outside_checkout_and_keeps_checkout_clean(tmp_ argv = _argv(tmp_path, repo, head) assert release_provenance_gate.main(argv) == 0 - first = Path(capsys.readouterr().out.strip()) + first_receipt = json.loads(capsys.readouterr().out) + first = Path(first_receipt["record_path"]) assert release_provenance_gate.main(argv) == 0 - second = Path(capsys.readouterr().out.strip()) + second_receipt = json.loads(capsys.readouterr().out) + second = Path(second_receipt["record_path"]) assert first != second assert first.parent == tmp_path / "repo-release-provenance" assert first.stat().st_mode & 0o222 == 0 assert second.stat().st_mode & 0o222 == 0 payload = json.loads(first.read_text(encoding="utf-8")) + assert payload["schema_version"] == 1 + assert payload["observed_evidence"]["source"] == "operator_observed" + assert first_receipt["record_sha256"] == __import__("hashlib").sha256(first.read_bytes()).hexdigest() assert payload["gate"]["passed"] is True assert "must-not-leak" not in first.read_text(encoding="utf-8") status = subprocess.run( @@ -108,7 +127,8 @@ def test_cli_dirty_checkout_is_fail_closed_and_isolated(tmp_path, capsys): (repo / "runtime.py").write_text("dirty\n", encoding="utf-8") assert release_provenance_gate.main(argv) == 2 - output = Path(capsys.readouterr().out.strip()) + receipt = json.loads(capsys.readouterr().out) + output = Path(receipt["record_path"]) payload = json.loads(output.read_text(encoding="utf-8")) assert output.stat().st_mode & 0o222 == 0 assert payload["gate"]["passed"] is False @@ -131,3 +151,20 @@ def test_cli_rejects_unsafe_command_and_checkout_local_output(tmp_path): argv.extend(["--output-dir", str(repo / "artifacts")]) with pytest.raises(SystemExit): release_provenance_gate.main(argv) + + +def test_invalid_observed_evidence_creates_no_record_or_receipt(tmp_path, capsys): + repo, head = _init_repo(tmp_path) + argv = _argv(tmp_path, repo, head) + evidence_index = argv.index("--observed-evidence-file") + 1 + evidence = Path(argv[evidence_index]) + evidence.write_text( + '{"schema_version":1,"schema_version":1,"target_id":"service-a"}', + encoding="utf-8", + ) + + with pytest.raises(SystemExit): + release_provenance_gate.main(argv) + + assert capsys.readouterr().out == "" + assert not (tmp_path / "repo-release-provenance").exists() diff --git a/trex_core/adapters/persistence/ops_checks.py b/trex_core/adapters/persistence/ops_checks.py index d9edfc7..1e95501 100644 --- a/trex_core/adapters/persistence/ops_checks.py +++ b/trex_core/adapters/persistence/ops_checks.py @@ -13,6 +13,7 @@ from __future__ import annotations import hashlib import json +import os import re import shutil import subprocess @@ -68,10 +69,39 @@ _SENSITIVE_VALUE = re.compile( ) _FULL_GIT_REVISION = re.compile(r"[0-9a-f]{40}") _IMAGE_DIGEST = re.compile(r"sha256:[0-9a-f]{64}") +_SHA256 = re.compile(r"[0-9a-f]{64}") _SAFE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}") _REQUIRED_CAPTURE_KEYS = frozenset( {"pending_slots", "reconciliation_slots", "queue_consumers"} ) +_OBSERVED_EVIDENCE_KEYS = frozenset( + { + "schema_version", + "target_id", + "target_kind", + "image_id", + "image_digest", + "oci_revision", + "observed_at", + } +) +_RECORD_KEYS = frozenset( + { + "schema_version", + "timestamp_utc", + "actor", + "reason", + "instance", + "command_class", + "operation_id", + "phase", + "observed_evidence", + "checkout", + "runtime_file_verification", + "state_capture", + "gate", + } +) def _run_git(checkout_dir: Path, *args: str) -> str: @@ -153,6 +183,80 @@ def _sanitize_json(value: object) -> object: return value +def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def _load_strict_json(path: str | Path) -> dict[str, object]: + try: + value = json.loads( + Path(path).read_text(encoding="utf-8"), + object_pairs_hook=_reject_duplicate_keys, + ) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ValueError("invalid JSON input") from exc + if not isinstance(value, dict): + raise TypeError("JSON input must be an object") + return value + + +def _is_utc_timestamp(value: object) -> bool: + if not isinstance(value, str): + return False + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return False + return parsed.tzinfo is not None and parsed.utcoffset() == datetime.now(tz=UTC).utcoffset() + + +def _contains_sensitive_material(value: object) -> bool: + if isinstance(value, dict): + return any( + _SENSITIVE_KEY.search(str(key)) or _contains_sensitive_material(item) + for key, item in value.items() + ) + if isinstance(value, list): + return any(_contains_sensitive_material(item) for item in value) + return isinstance(value, str) and _sanitize_text(value) != value + + +def _validate_observed_evidence(value: object) -> dict[str, object]: + if not isinstance(value, dict) or set(value) != _OBSERVED_EVIDENCE_KEYS: + raise ValueError("observed evidence keys must match schema version 1") + if type(value["schema_version"]) is not int or value["schema_version"] != 1: + raise ValueError("observed evidence schema_version must be integer 1") + if not all( + isinstance(value[key], str) and bool(value[key]) + for key in ("target_id", "target_kind") + ): + raise ValueError("observed target fields must be non-empty strings") + if not all( + isinstance(value[key], str) and _IMAGE_DIGEST.fullmatch(value[key]) + for key in ("image_id", "image_digest") + ): + raise ValueError("observed image identity must use lowercase sha256 digests") + if not isinstance(value["oci_revision"], str) or not _FULL_GIT_REVISION.fullmatch( + value["oci_revision"] + ): + raise ValueError("observed oci_revision must be a full lowercase git SHA") + if not _is_utc_timestamp(value["observed_at"]): + raise ValueError("observed_at must be a UTC RFC 3339 timestamp") + if _contains_sensitive_material(value): + raise ValueError("observed evidence must not contain sensitive material") + return dict(value) + + +def load_observed_evidence(path: str | Path) -> dict[str, object]: + """Load strict caller-observed provenance without claiming runtime observation.""" + return _validate_observed_evidence(_load_strict_json(path)) + + def _validate_capture_commands( capture_commands: dict[str, list[str]], *, instance: str ) -> None: @@ -297,8 +401,7 @@ def collect_release_provenance( command_class: str, operation_id: str, phase: str, - expected_oci_revision: str, - image_digest: str, + observed_evidence: dict[str, object], runtime_files: list[str], capture_commands: dict[str, list[str]], checkout_dir: str | Path = ".", @@ -313,10 +416,8 @@ def collect_release_provenance( raise ValueError("instance and operation_id must be safe identifiers") if phase not in {"pre", "post"}: raise ValueError("phase must be pre or post") - if not _FULL_GIT_REVISION.fullmatch(expected_oci_revision): - raise ValueError("expected_oci_revision must be a full lowercase git SHA") - if not _IMAGE_DIGEST.fullmatch(image_digest): - raise ValueError("image_digest must be sha256:<64 lowercase hex>") + observed = _validate_observed_evidence(observed_evidence) + expected_oci_revision = str(observed["oci_revision"]) if not runtime_files: raise ValueError("at least one runtime file is required") _validate_capture_commands(capture_commands, instance=instance) @@ -378,6 +479,7 @@ def collect_release_provenance( and broker_logs_ok ) return { + "schema_version": 1, "timestamp_utc": datetime.now(tz=UTC).isoformat(), "actor": actor, "reason": reason, @@ -393,11 +495,7 @@ def collect_release_provenance( "git_clean": git_clean, "dirty_paths": dirty_paths, }, - "artifact_identity": { - "expected_oci_revision": expected_oci_revision, - "oci_revision_matches_head": oci_revision_matches_head, - "image_digest": image_digest, - }, + "observed_evidence": {**observed, "source": "operator_observed"}, "runtime_file_verification": runtime_reports, "state_capture": { "command_outputs": command_outputs, @@ -413,38 +511,296 @@ def collect_release_provenance( } +def _exact_object(value: object, keys: set[str] | frozenset[str], label: str) -> dict: + if not isinstance(value, dict) or set(value) != set(keys): + raise ValueError(f"{label} keys do not match schema version 1") + return value + + +def _validate_success_record(record: dict[str, object], *, phase: str) -> dict[str, object]: + _exact_object(record, _RECORD_KEYS, "gate record") + if type(record["schema_version"]) is not int or record["schema_version"] != 1: + raise ValueError("unsupported gate record schema") + if record["phase"] != phase: + raise ValueError(f"record phase must be {phase}") + if not _is_utc_timestamp(record["timestamp_utc"]): + raise ValueError("record timestamp must be UTC RFC 3339") + for key in ("actor", "reason", "instance", "command_class", "operation_id"): + if not isinstance(record[key], str) or not record[key]: + raise ValueError(f"record {key} must be a non-empty string") + + observed = _exact_object( + record["observed_evidence"], _OBSERVED_EVIDENCE_KEYS | {"source"}, "observed evidence" + ) + if observed["source"] != "operator_observed": + raise ValueError("observed evidence source must be operator_observed") + _validate_observed_evidence({key: observed[key] for key in _OBSERVED_EVIDENCE_KEYS}) + + checkout = _exact_object( + record["checkout"], + { + "path", + "head_revision", + "detached_head", + "git_status_porcelain", + "git_clean", + "dirty_paths", + }, + "checkout", + ) + if not ( + checkout["detached_head"] is True + and checkout["git_clean"] is True + and checkout["dirty_paths"] == [] + and checkout["head_revision"] == observed["oci_revision"] + ): + raise ValueError("checkout evidence does not satisfy pair success invariants") + + runtime_reports = record["runtime_file_verification"] + runtime_keys = { + "path", + "path_within_checkout", + "exists", + "current_sha256", + "expected_revision", + "expected_sha256", + "matches_expected_revision", + } + if not isinstance(runtime_reports, list) or not runtime_reports: + raise ValueError("runtime verification must be a non-empty list") + for item in runtime_reports: + report = _exact_object(item, runtime_keys, "runtime verification") + if not ( + isinstance(report["path"], str) + and report["path"] + and report["path_within_checkout"] is True + and report["exists"] is True + and report["matches_expected_revision"] is True + and report["expected_revision"] == observed["oci_revision"] + and isinstance(report["current_sha256"], str) + and _SHA256.fullmatch(report["current_sha256"]) + and report["current_sha256"] == report["expected_sha256"] + ): + raise ValueError("runtime verification does not satisfy pair success invariants") + + state_capture = _exact_object( + record["state_capture"], {"command_outputs", "broker_side_effect_logs"}, "state capture" + ) + command_outputs = _exact_object( + state_capture["command_outputs"], _REQUIRED_CAPTURE_KEYS, "command outputs" + ) + output_keys = {"command", "returncode", "stdout", "stdout_format", "stderr"} + for output in command_outputs.values(): + captured = _exact_object(output, output_keys, "command output") + if captured["returncode"] != 0 or type(captured["returncode"]) is not int: + raise ValueError("state capture command did not succeed") + + broker = state_capture["broker_side_effect_logs"] + broker_success_keys = { + "path", + "exists", + "capture_status", + "file_sha256", + "scanned_lines", + "matches", + } + broker = _exact_object(broker, broker_success_keys, "broker capture") + if broker["capture_status"] not in {"captured", "captured_empty"}: + raise ValueError("broker capture did not succeed") + + gate = _exact_object( + record["gate"], + { + "passed", + "deployment_blocked", + "runtime_files_verified", + "state_captures_succeeded", + "broker_log_captured", + }, + "gate", + ) + if [ + gate[key] + for key in ( + "passed", + "deployment_blocked", + "runtime_files_verified", + "state_captures_succeeded", + "broker_log_captured", + ) + ] != [True, False, True, True, True]: + raise ValueError("gate summary does not satisfy pair success invariants") + return record + + +def _record_digest(path: Path, expected: str) -> str: + if not _SHA256.fullmatch(expected): + raise ValueError("expected record digest must be lowercase SHA-256") + try: + payload = path.read_bytes() + except OSError as exc: + raise ValueError("record path is not readable") from exc + actual = _sha256_bytes(payload) + if actual != expected: + raise ValueError("record bytes do not match expected SHA-256") + return actual + + +def verify_release_provenance_pair( + *, + pre_path: str | Path, + pre_sha256: str, + post_path: str | Path, + post_sha256: str, + output_path: str | Path, +) -> dict[str, object]: + """Verify one explicit PRE/POST pair and write an immutable manifest.""" + pre = Path(pre_path) + post = Path(post_path) + if not pre.is_absolute() or not post.is_absolute(): + raise ValueError("record paths must be canonical absolute paths") + pre = pre.resolve() + post = post.resolve() + if pre == post: + raise ValueError("PRE and POST record paths must differ") + pre_digest = _record_digest(pre, pre_sha256) + post_digest = _record_digest(post, post_sha256) + pre_record = _validate_success_record(_load_strict_json(pre), phase="pre") + post_record = _validate_success_record(_load_strict_json(post), phase="post") + + for key in ("schema_version", "operation_id", "instance"): + if pre_record[key] != post_record[key]: + raise ValueError(f"PRE/POST {key} mismatch") + pre_observed = pre_record["observed_evidence"] + post_observed = post_record["observed_evidence"] + for key in ("target_kind", "target_id"): + if pre_observed[key] != post_observed[key]: + raise ValueError(f"PRE/POST {key} mismatch") + + destination = Path(output_path).resolve() + for checkout_path in (pre_record["checkout"]["path"], post_record["checkout"]["path"]): + checkout = Path(checkout_path).resolve() + try: + destination.relative_to(checkout) + except ValueError: + continue + raise ValueError("pair manifest must be outside the audit checkout") + + def manifest_record( + path: Path, digest: str, record: dict[str, object], observed: dict[str, object] + ) -> dict[str, object]: + return { + "record_path": str(path), + "record_sha256": digest, + "phase": record["phase"], + "command_class": record["command_class"], + "image_id": observed["image_id"], + "image_digest": observed["image_digest"], + "oci_revision": observed["oci_revision"], + "observed_at": observed["observed_at"], + } + + manifest = { + "schema_version": 1, + "verified_at": datetime.now(tz=UTC).isoformat(), + "operation_id": pre_record["operation_id"], + "instance": pre_record["instance"], + "target_kind": pre_observed["target_kind"], + "target_id": pre_observed["target_id"], + "pre": manifest_record(pre, pre_digest, pre_record, pre_observed), + "post": manifest_record(post, post_digest, post_record, post_observed), + } + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open("x", encoding="utf-8") as fp: + fp.write(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n") + destination.chmod(0o444) + return manifest + + def isolate_dirty_checkout_files( *, checkout_dir: str | Path, isolate_dir: str | Path, ) -> dict: - """Copy dirty/untracked checkout files to an isolation directory.""" + """Copy dirty files without mutating the checkout and verify copy integrity.""" checkout = Path(checkout_dir).resolve() dst_root = Path(isolate_dir).resolve() stamp = datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%S.%fZ") dst = dst_root / f"{stamp}--{uuid.uuid4().hex[:12]}" _, dirty_paths = _git_status(checkout) - copied: list[str] = [] + entries: list[dict[str, object]] = [] + for rel in dirty_paths: - src = _resolve_under(checkout, rel) - if src is None or not src.exists() or src.is_dir(): - continue - target = (dst / rel).resolve() + rel_path = Path(rel) + if rel_path.is_absolute() or ".." in rel_path.parts: + raise ValueError(f"unsafe dirty path: {rel}") + src = checkout / rel_path try: - target.relative_to(dst) - except ValueError: - continue - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(src, target) - copied.append(rel) + src.parent.resolve().relative_to(checkout) + except ValueError as exc: + raise ValueError(f"dirty path parent escapes checkout: {rel}") from exc + + if src.is_symlink(): + source_type = "symlink" + source_target = os.readlink(src) + source_target_path = Path(source_target) + if source_target_path.is_absolute(): + raise ValueError(f"symlink target escapes checkout: {rel}") + resolved_source_target = (src.parent / source_target_path).resolve(strict=False) + try: + resolved_source_target.relative_to(checkout) + except ValueError as exc: + raise ValueError(f"symlink target escapes checkout: {rel}") from exc + source_bytes = source_target.encode("utf-8") + target = dst / rel_path + target.parent.mkdir(parents=True, exist_ok=True) + os.symlink(source_target, target) + copied_type = "symlink" if target.is_symlink() else "unsupported" + copied_bytes = os.readlink(target).encode("utf-8") if target.is_symlink() else b"" + elif src.is_file(): + source_type = "regular" + source_bytes = src.read_bytes() + target = dst / rel_path + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, target, follow_symlinks=False) + copied_type = "regular" if target.is_file() and not target.is_symlink() else "unsupported" + copied_bytes = target.read_bytes() if copied_type == "regular" else b"" + else: + raise ValueError(f"unsupported dirty path type: {rel}") + + source_sha = _sha256_bytes(source_bytes) + copied_sha = _sha256_bytes(copied_bytes) + matches = ( + copied_type == source_type + and len(copied_bytes) == len(source_bytes) + and copied_sha == source_sha + ) + if not matches: + raise ValueError(f"isolated copy integrity mismatch: {rel}") + entries.append( + { + "path": rel, + "source_type": source_type, + "source_size": len(source_bytes), + "source_sha256": source_sha, + "copied_path": str(target.resolve(strict=False)) if source_type == "regular" else str(target), + "copied_type": copied_type, + "copied_size": len(copied_bytes), + "copied_sha256": copied_sha, + "matches": True, + } + ) + manifest = { + "schema_version": 1, "timestamp_utc": datetime.now(tz=UTC).isoformat(), "checkout_dir": str(checkout), "isolate_dir": str(dst), - "dirty_paths": dirty_paths, - "copied_paths": copied, + "entries": entries, } dst.mkdir(parents=True, exist_ok=True) - with (dst / "manifest.json").open("x", encoding="utf-8") as fp: + manifest_path = dst / "manifest.json" + with manifest_path.open("x", encoding="utf-8") as fp: fp.write(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n") + manifest_path.chmod(0o444) return manifest