Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
b908a39
test: define sealed release evidence API
seonghobae Aug 5, 2026
50e0351
test: specify sealed evidence trust boundary
seonghobae Aug 5, 2026
e81c2b8
release: implement fail-closed evidence verifier
seonghobae Aug 5, 2026
252c08a
docs: define sealed evidence handoff contract
seonghobae Aug 5, 2026
9b0e780
docs: record sealed release evidence verifier
seonghobae Aug 5, 2026
270848d
security: preserve evidence-directory symlink boundary
seonghobae Aug 5, 2026
beceb43
test: reject public evidence-directory symlink traversal
seonghobae Aug 5, 2026
ee93da2
style: use modern callable import
seonghobae Aug 5, 2026
adedffe
test: expose sealed evidence filesystem races
seonghobae Aug 5, 2026
83370a0
fix: bind sealed evidence to stable regular-file snapshots
seonghobae Aug 5, 2026
c11a615
test: cover sealed snapshot failure boundaries
seonghobae Aug 5, 2026
f1ae25a
docs: define immutable evidence snapshot boundary
seonghobae Aug 5, 2026
12233c6
style: combine snapshot test contexts
seonghobae Aug 5, 2026
e637b3c
chore: record sealed evidence snapshot hardening
seonghobae Aug 5, 2026
06f0866
test: expose sealed evidence resource-limit gaps
seonghobae Aug 5, 2026
1331710
fix: bound sealed evidence snapshot reads
seonghobae Aug 5, 2026
f12c22e
test: align filesystem fault injection with bounded reads
seonghobae Aug 5, 2026
b2f7974
test: cover bounded snapshot filesystem failure
seonghobae Aug 5, 2026
c9bd881
test: reject post-verification checksum mutation
seonghobae Aug 5, 2026
749231d
fix: bind checksum file through manifest issuance
seonghobae Aug 5, 2026
1224890
docs: record checksum snapshot lifetime
seonghobae Aug 5, 2026
975a166
chore: record checksum lifetime hardening
seonghobae Aug 5, 2026
a28cfcd
test: require exclusive strict manifest output
seonghobae Aug 5, 2026
dc96722
test: cover durable exclusive manifest output
seonghobae Aug 5, 2026
ede4ba8
fix: seal manifest output boundary
seonghobae Aug 5, 2026
4207522
docs: define fail-closed manifest output
seonghobae Aug 5, 2026
471c225
test: expose CLI output symlink resolution gap
seonghobae Aug 5, 2026
4b2f5b8
security: preserve final manifest path for no-follow creation
seonghobae Aug 5, 2026
b17e8a0
docs: record sealed manifest output
seonghobae Aug 5, 2026
8adb4e2
test: require sealed repository and source identity
seonghobae Aug 5, 2026
9453465
release: seal exact repository and source identity
seonghobae Aug 5, 2026
185d6b3
test: fix sealed evidence import ordering
seonghobae Aug 5, 2026
53a3f88
test: expose SBOM semantic ABA substitution
seonghobae Aug 5, 2026
a3bc152
test: normalize sealed evidence imports
seonghobae Aug 5, 2026
b9b859e
test: normalize SBOM digest regression imports
seonghobae Aug 5, 2026
97f53da
fix: bind parsed SBOMs to sealed digests
seonghobae Aug 5, 2026
552cf01
test: forward sealed SBOM digest in checksum snapshot hook
seonghobae Aug 5, 2026
00b9a41
test: forward sealed SBOM digest in snapshot hook
seonghobae Aug 5, 2026
10d9828
docs: explain checksum-bound SBOM parsing
seonghobae Aug 5, 2026
8489c52
test: reject output-parent redirection into evidence
seonghobae Aug 5, 2026
1f3e333
fix: recheck manifest parent outside sealed evidence
seonghobae Aug 5, 2026
93505f2
docs: document output-parent redirection boundary
seonghobae Aug 5, 2026
c05fd48
chore: record manifest parent redirection hardening
seonghobae Aug 5, 2026
eb2cba6
test: cover output-parent resolution failure
seonghobae Aug 5, 2026
034aed2
merge: integrate exact protected main into sealed evidence verifier
seonghobae Aug 6, 2026
d420c36
docs: preserve integrated release evidence changelog
seonghobae Aug 6, 2026
492b558
merge: integrate sealed source identity with current verifier base
seonghobae Aug 6, 2026
26ab0e1
docs: preserve integrated source identity changelog
seonghobae Aug 6, 2026
c86f031
merge: reconcile exact source-identity tree with protected main
seonghobae Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]

### Added
- Add canonical `SOURCE_IDENTITY.json` evidence that seals the exact repository
and 40-character protected-main source commit inside the checksummed release
set. Handoff manifests now use format version 2 and include both source-identity
and checksum-file digests for independent credential-bound revalidation.
- Add a shipped, credential-free sealed release-evidence verifier that accepts
only the exact wheel, source distribution, paired CycloneDX 1.7 SBOMs, and
canonical `SHA256SUMS`; independently recomputes content-bound UUIDv5 and
Expand Down Expand Up @@ -56,6 +60,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
without changing the centrally managed review-agent credential contract.

### Security
- Recheck the manifest output parent against the verified evidence directory
immediately before exclusive creation, after descriptor binding, and after
durable synchronization. Redirecting a previously safe parent through a
directory symlink during evidence verification now fails closed before a
trusted handoff can be issued inside the sealed set.
- Reject legacy five-file release evidence whose repository and source commit
exist only as caller assertions. The canonical source-identity payload is
strict, bounded, descriptor-bound, checksum-covered, and rehashed through final
manifest issuance; malformed, noncanonical, stale, mixed, or relabeled source
identity now fails closed without claiming build provenance.
- Bind each selected release-evidence payload to an opened regular-file
descriptor and its current path identity, bracket parsed checksum and SBOM
bytes with bounded digests, retain the accepted `SHA256SUMS` snapshot through
Expand Down
190 changes: 121 additions & 69 deletions docs/sealed-release-evidence.md

Large diffs are not rendered by default.

191 changes: 170 additions & 21 deletions src/egressweave/release_evidence.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""Verify and summarize a credential-free sealed EgressWeave release evidence set.

The verifier treats distributions and CycloneDX documents as inert bytes. It
accepts only the exact canonical wheel, source distribution, one SBOM for each
artifact, and a sorted ``SHA256SUMS`` file. It then emits a deterministic manifest
that a separately reviewed credentialed workflow can bind to repository and
source identity without rebuilding or executing caller-controlled code.
The verifier treats distributions, CycloneDX documents, and source identity as
inert bytes. It accepts only the exact canonical wheel, source distribution, one
SBOM for each artifact, one canonical source-identity document, and a sorted
``SHA256SUMS`` file. It then emits a deterministic manifest for a separately
reviewed credentialed workflow without rebuilding or executing caller-controlled
code.
"""

from __future__ import annotations
Expand All @@ -27,7 +28,10 @@
CYCLONEDX_DOCUMENT_VERSION = 1
ATTESTATION_PREDICATE_TYPE = "https://cyclonedx.org/bom"
EVIDENCE_MANIFEST_FORMAT = "egressweave.release-evidence"
EVIDENCE_MANIFEST_VERSION = 1
EVIDENCE_MANIFEST_VERSION = 2
SOURCE_IDENTITY_FILENAME = "SOURCE_IDENTITY.json"
SOURCE_IDENTITY_FORMAT = "egressweave.release-source-identity"
SOURCE_IDENTITY_VERSION = 1
DOCUMENT_IDENTITY_URL_PREFIX = (
"https://github.com/ContextualWisdomLab/EgressWeave/sbom/sha256/"
)
Expand All @@ -42,6 +46,7 @@
r"^(?P<digest>[0-9a-f]{64}) (?P<filename>[A-Za-z0-9][A-Za-z0-9._+-]*)$"
)
MAX_CHECKSUM_BYTES = 65_536
MAX_SOURCE_IDENTITY_BYTES = 4_096
MAX_SBOM_BYTES = 8 * 1024 * 1024
MAX_ARTIFACT_BYTES = 256 * 1024 * 1024

Expand Down Expand Up @@ -134,8 +139,10 @@ def _require_stable_read(
raise SystemExit(f"{label} changed during verification")


def _select_evidence_paths(evidence_dir: Path) -> tuple[Path, Path, Path, Path, Path]:
"""Return the exact wheel, sdist, SBOMs, and checksum file or fail closed."""
def _select_evidence_paths(
evidence_dir: Path,
) -> tuple[Path, Path, Path, Path, Path, Path]:
"""Return the exact artifacts, SBOMs, source identity, and checksum file."""
if not evidence_dir.is_dir() or evidence_dir.is_symlink():
raise SystemExit("release evidence directory is missing or unsafe")
try:
Expand All @@ -158,15 +165,32 @@ def _select_evidence_paths(evidence_dir: Path) -> tuple[Path, Path, Path, Path,

wheel_sbom = evidence_dir / f"{wheel_path.name}.cdx.json"
sdist_sbom = evidence_dir / f"{sdist_path.name}.cdx.json"
source_identity_path = evidence_dir / SOURCE_IDENTITY_FILENAME
checksum_path = evidence_dir / "SHA256SUMS"
expected = {wheel_path, sdist_path, wheel_sbom, sdist_sbom, checksum_path}
legacy_expected = {
wheel_path,
sdist_path,
wheel_sbom,
sdist_sbom,
checksum_path,
}
if set(entries) == legacy_expected:
raise SystemExit("release evidence lacks sealed source identity")
expected = legacy_expected | {source_identity_path}
if set(entries) != expected:
observed = [path.name for path in entries]
required = sorted(path.name for path in expected)
raise SystemExit(
f"release evidence cardinality mismatch; expected {required}, observed {observed}"
)
return wheel_path, sdist_path, wheel_sbom, sdist_sbom, checksum_path
return (
wheel_path,
sdist_path,
wheel_sbom,
sdist_sbom,
source_identity_path,
checksum_path,
)


def _load_checksums(
Expand Down Expand Up @@ -233,8 +257,79 @@ def _reject_json_constant(value: str) -> None:
raise ValueError(f"non-standard JSON number {value}")


def _load_strict_json(path: Path) -> dict[str, Any]:
"""Load one stable bounded RFC 8259 object without duplicate names."""
def _load_source_identity(
path: Path,
*,
expected_digest: str,
) -> tuple[str, str]:
"""Return exact repository and source values from one sealed identity file."""
label = "sealed source identity"
digest_before = _sha256_file(
path,
maximum_bytes=MAX_SOURCE_IDENTITY_BYTES,
label=label,
)
try:
raw_content = _read_bounded_file(
path,
maximum_bytes=MAX_SOURCE_IDENTITY_BYTES,
label=label,
)
document = json.loads(
raw_content.decode("utf-8"),
object_pairs_hook=_reject_duplicate_keys,
parse_constant=_reject_json_constant,
)
except (UnicodeError, json.JSONDecodeError, RecursionError, ValueError) as error:
raise SystemExit("sealed source identity is not strict JSON") from error
digest_after = _sha256_file(
path,
maximum_bytes=MAX_SOURCE_IDENTITY_BYTES,
label=label,
)
_require_stable_read(
raw_content,
digest_before=digest_before,
digest_after=digest_after,
label=label,
)
if digest_after != expected_digest:
raise SystemExit("sealed source identity changed during verification")
if type(document) is not dict:
raise SystemExit("sealed source identity must be a JSON object")
required_names = {"format", "formatVersion", "repository", "sourceSha"}
if (
set(document) != required_names
or document.get("format") != SOURCE_IDENTITY_FORMAT
or type(document.get("formatVersion")) is not int
or document.get("formatVersion") != SOURCE_IDENTITY_VERSION
or type(document.get("repository")) is not str
or document.get("repository") != EXPECTED_REPOSITORY
or type(document.get("sourceSha")) is not str
or SOURCE_SHA_PATTERN.fullmatch(document.get("sourceSha")) is None
):
raise SystemExit("sealed source identity has an invalid exact profile")
canonical = (
json.dumps(
document,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
allow_nan=False,
)
+ "\n"
).encode("utf-8")
if raw_content != canonical:
raise SystemExit("sealed source identity is not canonical JSON")
return document["repository"], document["sourceSha"]


def _load_strict_json(
path: Path,
*,
expected_digest: str | None = None,
) -> dict[str, Any]:
"""Load strict bounded JSON and optionally bind it to a sealed digest."""
label = f"SBOM {path.name}"
digest_before = _sha256_file(path, maximum_bytes=MAX_SBOM_BYTES, label=label)
try:
Expand All @@ -258,6 +353,8 @@ def _load_strict_json(path: Path) -> dict[str, Any]:
digest_after=digest_after,
label=label,
)
if expected_digest is not None and digest_after != expected_digest:
raise SystemExit(f"{label} does not match the sealed digest")
if type(document) is not dict:
raise SystemExit(f"{label} must be a JSON object")
return document
Expand Down Expand Up @@ -300,9 +397,10 @@ def _verify_sbom(
artifact_name: str,
artifact_digest: str,
version: str,
expected_digest: str,
) -> str:
"""Verify exact CycloneDX identity and root-artifact binding for one SBOM."""
document = _load_strict_json(sbom_path)
document = _load_strict_json(sbom_path, expected_digest=expected_digest)
required_envelope = {
"$schema": CYCLONEDX_SCHEMA,
"bomFormat": CYCLONEDX_FORMAT,
Expand Down Expand Up @@ -380,14 +478,24 @@ def build_evidence_manifest(
if SOURCE_SHA_PATTERN.fullmatch(source_sha) is None:
raise SystemExit("source SHA must be exactly 40 lowercase hexadecimal characters")

wheel_path, sdist_path, wheel_sbom, sdist_sbom, checksum_path = (
_select_evidence_paths(evidence_dir)
)
(
wheel_path,
sdist_path,
wheel_sbom,
sdist_sbom,
source_identity_path,
checksum_path,
) = _select_evidence_paths(evidence_dir)
payload_specs = (
(wheel_path, MAX_ARTIFACT_BYTES, "wheel"),
(sdist_path, MAX_ARTIFACT_BYTES, "source distribution"),
(wheel_sbom, MAX_SBOM_BYTES, "wheel SBOM"),
(sdist_sbom, MAX_SBOM_BYTES, "source-distribution SBOM"),
(
source_identity_path,
MAX_SOURCE_IDENTITY_BYTES,
"sealed source identity",
),
)
payload_paths = tuple(path for path, _, _ in payload_specs)
checksums, checksum_digest = _load_checksums(
Expand All @@ -398,6 +506,13 @@ def build_evidence_manifest(
if checksums != observed_digests:
raise SystemExit("release evidence digest mismatch")

sealed_repository, sealed_source_sha = _load_source_identity(
source_identity_path,
expected_digest=observed_digests[source_identity_path.name],
)
if sealed_repository != repository or sealed_source_sha != source_sha:
raise SystemExit("sealed source identity does not match caller expectations")

version = WHEEL_PATTERN.fullmatch(wheel_path.name).group("version")
artifacts: list[dict[str, str]] = []
for kind, artifact_path, sbom_path in (
Expand All @@ -410,6 +525,7 @@ def build_evidence_manifest(
artifact_name=artifact_path.name,
artifact_digest=artifact_digest,
version=version,
expected_digest=observed_digests[sbom_path.name],
)
artifacts.append(
{
Expand All @@ -435,12 +551,16 @@ def build_evidence_manifest(
artifacts.sort(key=lambda item: item["artifactFilename"])
return {
"artifacts": artifacts,
"checksumFilename": checksum_path.name,
"checksumSha256": checksum_digest,
"format": EVIDENCE_MANIFEST_FORMAT,
"formatVersion": EVIDENCE_MANIFEST_VERSION,
"cycloneDxSpecVersion": CYCLONEDX_SPEC_VERSION,
"predicateType": ATTESTATION_PREDICATE_TYPE,
"repository": repository,
"sourceSha": source_sha,
"repository": sealed_repository,
"sourceIdentityFilename": source_identity_path.name,
"sourceIdentitySha256": observed_digests[source_identity_path.name],
"sourceSha": sealed_source_sha,
}


Expand Down Expand Up @@ -473,13 +593,34 @@ def _open_exclusive_manifest(path: str, flags: int) -> int:
return os.open(path, flags | getattr(os, "O_NOFOLLOW", 0), 0o600)


def write_evidence_manifest(manifest: dict[str, Any], output_path: Path) -> None:
"""Create one private descriptor-bound manifest without replacing a path."""
def _require_output_outside_verified_set(
output_path: Path,
verified_root: Path,
) -> None:
"""Require the current output location to remain outside verified evidence."""
try:
resolved_parent = output_path.parent.resolve(strict=True)
except (OSError, RuntimeError) as error:
raise SystemExit("evidence manifest parent directory is unavailable") from error
resolved_output = resolved_parent / output_path.name
if resolved_output == verified_root or resolved_output.is_relative_to(verified_root):
raise SystemExit("evidence manifest output must remain outside the verified set")


def write_evidence_manifest(
manifest: dict[str, Any],
output_path: Path,
*,
forbidden_root: Path | None = None,
) -> None:
"""Create one private manifest and optionally exclude one verified directory."""
payload = _encode_evidence_manifest(manifest)
try:
output_path.parent.mkdir(parents=True, exist_ok=True)
except OSError as error:
raise SystemExit("evidence manifest parent directory is unavailable") from error
if forbidden_root is not None:
_require_output_outside_verified_set(output_path, forbidden_root)

try:
with open(output_path, "xb", opener=_open_exclusive_manifest) as stream:
Expand All @@ -488,6 +629,8 @@ def write_evidence_manifest(manifest: dict[str, Any], output_path: Path) -> None
stream,
label="evidence manifest output",
)
if forbidden_root is not None:
_require_output_outside_verified_set(output_path, forbidden_root)
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
Expand All @@ -496,6 +639,8 @@ def write_evidence_manifest(manifest: dict[str, Any], output_path: Path) -> None
stream,
label="evidence manifest output",
)
if forbidden_root is not None:
_require_output_outside_verified_set(output_path, forbidden_root)
except FileExistsError:
raise SystemExit("evidence manifest output already exists") from None
except OSError as error:
Expand All @@ -517,7 +662,11 @@ def main() -> int:
repository=arguments.repository,
source_sha=arguments.source_sha,
)
write_evidence_manifest(manifest, output_path)
write_evidence_manifest(
manifest,
output_path,
forbidden_root=resolved_evidence_dir,
)
print(f"verified sealed release evidence: {output_path}")
return 0

Expand Down
Loading
Loading