diff --git a/README.md b/README.md index 3c7c2a7..9696591 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ tools/capture_pack_report.sh python3 -W error -m unittest discover -s tests -v ``` -Current verified baseline: **89 tests**, **9/9 graph invariants**, **7/7 pack/index checks**, **57 isolated Git invocations** in the DAG evidence run, two independently replayed evidence packages, and two attested offline browser captures. +Current verified baseline: **90 tests**, **9/9 graph invariants**, **7/7 pack/index checks**, **57 isolated Git invocations** in the DAG evidence run, two independently replayed evidence packages, and two attested offline browser captures. | Artifact | What it proves | |---|---| diff --git a/SECURITY.md b/SECURITY.md index 37c677e..c7474f8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,6 +13,10 @@ The command boundary is deliberately narrow: - temporary roots with symlinked path components are rejected. - stdout and stderr are captured in private temporary files and rejected before loading into memory when either exceeds 1 MiB. +## Evidence file permissions + +Evidence refreshes reject symlinked targets and multi-link files, stage each generated artifact as owner-read/write only (`0600`), fsync it, and atomically replace the managed target. Committed evidence is intentionally public synthetic data and may be checked out with ordinary repository permissions; this pipeline must not be used for secrets or private inputs. + ## Pack/index closed subset The pack experiment passes only three fixed synthetic blob IDs to `git pack-objects`; it does not accept a repository path, revision, ref, or caller-provided object list. Generated `.pack` and `.idx` files must be regular, single-link files no larger than 1 MiB and must remain the same inode and size across the bounded read. diff --git a/docs/assets/evidence-pipeline.svg b/docs/assets/evidence-pipeline.svg index 248cfda..227df9f 100644 --- a/docs/assets/evidence-pipeline.svg +++ b/docs/assets/evidence-pipeline.svg @@ -1,7 +1,7 @@ Reproducible evidence pipeline The actual path from fixed fixture bytes through isolated Git plumbing and independent checks into the checked-in evidence artifacts. - {"capture_attestation":"docs/demo/git-dag-v1/capture-attestation.json","capture_attestation_receipt_sha256":"7c9b58040ada09b40dc57e21c36767d044ec08e8e6a1c928e7dd5da0941ff238","capture_status":"attested","container_image":"mcr.microsoft.com/playwright@sha256:2f29369043d81d6d69a815ceb80760f55e85f5020371ad06a4d996f18503ad1c","network":"none","report_receipt_sha256":"2da1ccd8799699ade939f3901566e448baeb9691973624452231b35594469c84","screenshot_sha256":"e539db1152e8b1d66ee543d045739851991deb2e1a45174eb4358621efd4e17e","source":"evidence/git-dag-v1.json"} + {"capture_attestation":"docs/demo/git-dag-v1/capture-attestation.json","capture_attestation_receipt_sha256":"6e1199b957e5e7cdaf0af582d1547ac5a63137b3a0a3b9eadcb85673e09576b5","capture_status":"attested","container_image":"mcr.microsoft.com/playwright@sha256:2f29369043d81d6d69a815ceb80760f55e85f5020371ad06a4d996f18503ad1c","network":"none","report_receipt_sha256":"2da1ccd8799699ade939f3901566e448baeb9691973624452231b35594469c84","screenshot_sha256":"e539db1152e8b1d66ee543d045739851991deb2e1a45174eb4358621efd4e17e","source":"evidence/git-dag-v1.json"} diff --git a/docs/demo/git-dag-v1/capture-attestation.json b/docs/demo/git-dag-v1/capture-attestation.json index 3a46836..2594024 100644 --- a/docs/demo/git-dag-v1/capture-attestation.json +++ b/docs/demo/git-dag-v1/capture-attestation.json @@ -43,7 +43,7 @@ "schema_version": "git-dag-browser-capture-attestation/v1", "script": { "path": "tools/capture_report.sh", - "sha256": "74dcf776035069299067544d3bd7b8a1443724f1bd7b842876ea53831b0d8441", + "sha256": "124a296ccf38d36a5220c62ea4730fbba2b21414e26b668ded42d855a691d6d5", "size": 10937 }, "viewport": { @@ -55,6 +55,6 @@ "receipt": { "algorithm": "sha256", "canonicalization": "UTF-8 JSON; sorted keys; compact separators", - "sha256": "7c9b58040ada09b40dc57e21c36767d044ec08e8e6a1c928e7dd5da0941ff238" + "sha256": "6e1199b957e5e7cdaf0af582d1547ac5a63137b3a0a3b9eadcb85673e09576b5" } } diff --git a/docs/demo/git-dag-v1/manifest.json b/docs/demo/git-dag-v1/manifest.json index f9504a6..d8492cf 100644 --- a/docs/demo/git-dag-v1/manifest.json +++ b/docs/demo/git-dag-v1/manifest.json @@ -3,7 +3,7 @@ { "path": "docs/assets/evidence-pipeline.svg", "role": "evidence pipeline derived from execution summary", - "sha256": "55504a79c5025689c167020db503ddaf64adb1542776254d5dedb2fa11f8b157", + "sha256": "c20efa2ff42ce32a205d9c83343abae9b43c94826c5a19042b2d2a188b707fe3", "size": 4600 }, { @@ -33,7 +33,7 @@ { "path": "docs/demo/git-dag-v1/capture-attestation.json", "role": "capture provenance binding browser, isolation, input, and outputs", - "sha256": "ea18a02969796afd0900dc5883ab29490bb517bf0f8d74f7f5f6043cb36a7f08", + "sha256": "2df440c8071e0b4ab839527176660ce832acb97b0634915bcf2d795c074a737f", "size": 1990 }, { @@ -69,7 +69,7 @@ ], "capture": { "attestation": "docs/demo/git-dag-v1/capture-attestation.json", - "attestation_receipt_sha256": "7c9b58040ada09b40dc57e21c36767d044ec08e8e6a1c928e7dd5da0941ff238", + "attestation_receipt_sha256": "6e1199b957e5e7cdaf0af582d1547ac5a63137b3a0a3b9eadcb85673e09576b5", "browser_binary": "/ms-playwright/chromium_headless_shell-1193/chrome-linux/headless_shell", "browser_sha256": "003728e0b77eb9d52e4d258594bd55ce22ecd245eb6d3b6858fbd844c901ad7d", "browser_version": "Chromium 140.0.7339.186", @@ -140,12 +140,12 @@ }, { "path": "tools/generate_evidence.py", - "sha256": "442346818e239a4ab9b4fa9804929e61bf266dc7145a543fb40076298d47ce6b", + "sha256": "9276b529ec13eaa6ceda1cab1282b6679a689697e5c6930ca17b5e0bc8ba3a92", "size": 49590 }, { "path": "tools/capture_report.sh", - "sha256": "74dcf776035069299067544d3bd7b8a1443724f1bd7b842876ea53831b0d8441", + "sha256": "124a296ccf38d36a5220c62ea4730fbba2b21414e26b668ded42d855a691d6d5", "size": 10937 } ] diff --git a/docs/demo/git-pack-index-v1/capture-attestation.json b/docs/demo/git-pack-index-v1/capture-attestation.json index cbbb156..cdc139a 100644 --- a/docs/demo/git-pack-index-v1/capture-attestation.json +++ b/docs/demo/git-pack-index-v1/capture-attestation.json @@ -43,7 +43,7 @@ "schema_version": "git-pack-browser-capture-attestation/v1", "script": { "path": "tools/capture_pack_report.sh", - "sha256": "3477eeec4a10026fe95b87803f68657069c77f2b491bd69d3cddd56777924f0b", + "sha256": "7cddc4ec1ba66e203195d9a9eb3c454fe39b3790472106bad410cfc9d313ce8f", "size": 11083 }, "viewport": { @@ -55,6 +55,6 @@ "receipt": { "algorithm": "sha256", "canonicalization": "UTF-8 JSON; sorted keys; compact separators", - "sha256": "9709eafc99c848f825d083b795a9ba2802109d76b21289e2a4f72c445519c35b" + "sha256": "6495ad5876921dcc8f1c6ad01573507c5107512ca3d1256d3e42f6357ac4f622" } } diff --git a/docs/demo/git-pack-index-v1/manifest.json b/docs/demo/git-pack-index-v1/manifest.json index 4ecdcdc..60ec3f1 100644 --- a/docs/demo/git-pack-index-v1/manifest.json +++ b/docs/demo/git-pack-index-v1/manifest.json @@ -33,7 +33,7 @@ { "path": "docs/demo/git-pack-index-v1/capture-attestation.json", "role": "pack capture provenance and isolation attestation", - "sha256": "18e5fea29af65a0fd15b2959486b282f75425f1dabb1df20a556b57bf3c22757", + "sha256": "724b0c25f4298756385182cbb48a5c260c59051e4f06e21e3495b16c47655b3a", "size": 2011 }, { @@ -69,7 +69,7 @@ ], "capture": { "attestation": "docs/demo/git-pack-index-v1/capture-attestation.json", - "attestation_receipt_sha256": "9709eafc99c848f825d083b795a9ba2802109d76b21289e2a4f72c445519c35b", + "attestation_receipt_sha256": "6495ad5876921dcc8f1c6ad01573507c5107512ca3d1256d3e42f6357ac4f622", "browser_binary": "/ms-playwright/chromium_headless_shell-1193/chrome-linux/headless_shell", "browser_sha256": "003728e0b77eb9d52e4d258594bd55ce22ecd245eb6d3b6858fbd844c901ad7d", "browser_version": "Chromium 140.0.7339.186", @@ -138,14 +138,19 @@ "sha256": "03648331670cdc350fd486b2939e4914e90ee7ca7c2ea2b5cabd4eced21fe766", "size": 2335 }, + { + "path": "tools/generate_evidence.py", + "sha256": "9276b529ec13eaa6ceda1cab1282b6679a689697e5c6930ca17b5e0bc8ba3a92", + "size": 49590 + }, { "path": "tools/generate_pack_evidence.py", - "sha256": "2a85d4e5d3a98cca6e562eae7fad28e5f64d61bd02f973dcffdade274b89514d", - "size": 41315 + "sha256": "234af1ac72189cec9034d3b7f396f6950b01564495208a8891620bb7721ec3a9", + "size": 41376 }, { "path": "tools/capture_pack_report.sh", - "sha256": "3477eeec4a10026fe95b87803f68657069c77f2b491bd69d3cddd56777924f0b", + "sha256": "7cddc4ec1ba66e203195d9a9eb3c454fe39b3790472106bad410cfc9d313ce8f", "size": 11083 } ] diff --git a/tests/test_evidence.py b/tests/test_evidence.py index 7b1c48e..ba092f6 100644 --- a/tests/test_evidence.py +++ b/tests/test_evidence.py @@ -5,8 +5,11 @@ import json from pathlib import Path import re +import stat import struct +import tempfile import unittest +from unittest.mock import patch import zlib from tools import generate_evidence @@ -160,6 +163,14 @@ def test_capture_toolchain_is_digest_pinned(self) -> None: self.assertRegex(capture["browser_sha256"], r"^[0-9a-f]{64}$") self.assertEqual(capture["network"], "none") self.assertEqual(capture["viewport"], {"height": 1800, "width": 1440}) + script = (ROOT / "tools/capture_report.sh").read_text() + self.assertIn( + 'chmod 0600 "$temporary_root/output/git-dag-report.png"', + script, + ) + self.assertIn('chmod 0600 "$dom_path"', script) + self.assertIn("os.chmod(temporary_path, 0o600)", script) + self.assertNotIn("chmod 0644", script) def test_sources_are_hashed_and_path_relative(self) -> None: for source in self.manifest["sources"]: @@ -247,5 +258,17 @@ def test_readme_baseline_matches_current_evidence(self) -> None: self.assertIn("11 local subcommands are allow-listed", readme) + def test_writer_keeps_generated_files_owner_only(self) -> None: + with tempfile.TemporaryDirectory() as temporary_root: + root = Path(temporary_root) + with patch.object(generate_evidence, "ROOT", root): + generate_evidence._write_artifacts( + {Path("nested/evidence.txt"): b"public synthetic evidence\n"} + ) + target = root / "nested/evidence.txt" + self.assertEqual(target.read_bytes(), b"public synthetic evidence\n") + self.assertEqual(stat.S_IMODE(target.stat().st_mode), 0o600) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_pack_evidence.py b/tests/test_pack_evidence.py index ccb03e7..719b42a 100644 --- a/tests/test_pack_evidence.py +++ b/tests/test_pack_evidence.py @@ -87,6 +87,7 @@ def test_manifest_binds_artifacts_capture_and_sources(self) -> None: { "git_dag_lab/pack.py", "git_dag_lab/cli.py", + "tools/generate_evidence.py", "tools/generate_pack_evidence.py", "tools/capture_pack_report.sh", }, @@ -229,6 +230,13 @@ def test_capture_toolchain_is_digest_pinned_and_network_disabled(self) -> None: self.assertIn("--network none", script) self.assertIn("--pull=never", script) self.assertIn("--read-only", script) + self.assertIn( + 'chmod 0600 "$temporary_root/output/git-pack-report.png"', + script, + ) + self.assertIn('chmod 0600 "$dom_path"', script) + self.assertIn("os.chmod(temporary_path, 0o600)", script) + self.assertNotIn("chmod 0644", script) self.assertNotRegex(script, re.compile(r"(?m)^\s*curl\b|\bwget\b")) diff --git a/tools/capture_pack_report.sh b/tools/capture_pack_report.sh index 9b09f7c..4fca50d 100755 --- a/tools/capture_pack_report.sh +++ b/tools/capture_pack_report.sh @@ -221,8 +221,8 @@ if [[ $(sha256sum "$repo_root/tools/capture_pack_report.sh" | awk '{print $1}') exit 1 fi -chmod 0644 "$temporary_root/output/git-pack-report.png" -chmod 0644 "$dom_path" +chmod 0600 "$temporary_root/output/git-pack-report.png" +chmod 0600 "$dom_path" mv -f -- "$temporary_root/output/git-pack-report.png" "$output_path" mv -f -- "$dom_path" "$rendered_dom_path" @@ -312,7 +312,7 @@ try: temporary.flush() os.fsync(temporary.fileno()) temporary_path = Path(temporary.name) - os.chmod(temporary_path, 0o644) + os.chmod(temporary_path, 0o600) os.replace(temporary_path, attestation_path) temporary_path = None finally: diff --git a/tools/capture_report.sh b/tools/capture_report.sh index 21357d9..a61b542 100755 --- a/tools/capture_report.sh +++ b/tools/capture_report.sh @@ -221,8 +221,8 @@ if [[ $(sha256sum "$repo_root/tools/capture_report.sh" | awk '{print $1}') != "$ exit 1 fi -chmod 0644 "$temporary_root/output/git-dag-report.png" -chmod 0644 "$dom_path" +chmod 0600 "$temporary_root/output/git-dag-report.png" +chmod 0600 "$dom_path" mv -f -- "$temporary_root/output/git-dag-report.png" "$output_path" mv -f -- "$dom_path" "$rendered_dom_path" @@ -312,7 +312,7 @@ try: temporary.flush() os.fsync(temporary.fileno()) temporary_path = Path(temporary.name) - os.chmod(temporary_path, 0o644) + os.chmod(temporary_path, 0o600) os.replace(temporary_path, attestation_path) temporary_path = None finally: diff --git a/tools/generate_evidence.py b/tools/generate_evidence.py index a550731..fdd4775 100755 --- a/tools/generate_evidence.py +++ b/tools/generate_evidence.py @@ -1011,7 +1011,7 @@ def _write_artifacts(generated: Mapping[Path, bytes]) -> None: temporary.flush() os.fsync(temporary.fileno()) temporary_path = Path(temporary.name) - os.chmod(temporary_path, 0o644) + os.chmod(temporary_path, 0o600) os.replace(temporary_path, target) temporary_path = None finally: diff --git a/tools/generate_pack_evidence.py b/tools/generate_pack_evidence.py index ac669e2..ff5cb60 100755 --- a/tools/generate_pack_evidence.py +++ b/tools/generate_pack_evidence.py @@ -809,6 +809,7 @@ def build_artifacts(*, allow_missing_screenshot: bool) -> dict[Path, bytes]: "sources": [ _source_row(Path("git_dag_lab/pack.py")), _source_row(Path("git_dag_lab/cli.py")), + _source_row(Path("tools/generate_evidence.py")), _source_row(Path("tools/generate_pack_evidence.py")), _source_row(Path("tools/capture_pack_report.sh")), ],