Skip to content

Commit d3536eb

Browse files
committed
fix: bind live normalized security evidence
1 parent ec69fbe commit d3536eb

2 files changed

Lines changed: 149 additions & 1 deletion

File tree

src/portfolio_truth_publish.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,14 @@ def publish_portfolio_truth(
222222
else nullcontext()
223223
)
224224
try:
225-
with publication_guard:
225+
with publication_guard as live_security:
226+
if (
227+
live_security is not None
228+
and live_security.entries_by_full_name != security_alerts_by_name
229+
):
230+
raise PortfolioTruthPublishError(
231+
"Security receipt normalized evidence changed after it was loaded."
232+
)
226233
if producer_evidence is not None and producer_repo_root is not None:
227234
verify_evidence_still_current(producer_repo_root, producer_evidence)
228235
for path, staged in temp_files.items():

tests/test_portfolio_truth.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2831,6 +2831,147 @@ def stage_then_replace_receipt(target: Path, content: str) -> Path:
28312831
assert not list(output_dir.glob("portfolio-truth-*.json"))
28322832

28332833

2834+
def test_publish_refuses_nested_evidence_that_expires_after_snapshot(
2835+
portfolio_workspace: Path,
2836+
portfolio_catalog: Path,
2837+
legacy_registry: Path,
2838+
tmp_path: Path,
2839+
monkeypatch: pytest.MonkeyPatch,
2840+
) -> None:
2841+
from contextlib import contextmanager
2842+
2843+
now = datetime.now(timezone.utc).replace(microsecond=0)
2844+
nested_observed_at = now - timedelta(hours=24) + timedelta(milliseconds=500)
2845+
output_dir = tmp_path / "output"
2846+
output_dir.mkdir()
2847+
receipt_path = output_dir / "github-security-coverage-latest.json"
2848+
receipt_truth = {
2849+
"projects": [
2850+
{
2851+
"identity": {"repo_full_name": "d/Alpha"},
2852+
"derived": {"attention_state": "active-product"},
2853+
}
2854+
]
2855+
}
2856+
receipt = collect_security_coverage(
2857+
receipt_truth,
2858+
token=None,
2859+
expected_cohort_count=1,
2860+
now=now,
2861+
producer_commit="a" * 40,
2862+
)
2863+
alpha_receipt = receipt["repositories"]["d/Alpha"]
2864+
alpha_receipt["providers"]["dependabot"] = _provider_result(
2865+
"dependabot",
2866+
state="observed",
2867+
observed_at=nested_observed_at.isoformat(),
2868+
http_status=200,
2869+
pagination_complete=True,
2870+
counts={"critical": 0, "high": 0, "medium": 0, "low": 0},
2871+
)
2872+
alpha_receipt["repository"] = _remote_repository_result(
2873+
state="observed",
2874+
observed_at=nested_observed_at.isoformat(),
2875+
default_branch="main",
2876+
head_sha="b" * 40,
2877+
archived=False,
2878+
)
2879+
write_security_coverage_receipt(
2880+
receipt,
2881+
receipt_path,
2882+
expected_cohort_count=1,
2883+
)
2884+
loaded = load_security_coverage_receipt(
2885+
receipt_path,
2886+
expected_cohort_count=1,
2887+
expected_producer_commit="a" * 40,
2888+
now=now,
2889+
)
2890+
at_boundary = load_security_coverage_receipt(
2891+
receipt_path,
2892+
expected_cohort_count=1,
2893+
expected_producer_commit="a" * 40,
2894+
now=now + timedelta(milliseconds=500),
2895+
)
2896+
reloaded = load_security_coverage_receipt(
2897+
receipt_path,
2898+
expected_cohort_count=1,
2899+
expected_producer_commit="a" * 40,
2900+
now=now + timedelta(seconds=1),
2901+
)
2902+
assert loaded.receipt_state == at_boundary.receipt_state == "fresh"
2903+
assert reloaded.receipt_state == "fresh"
2904+
assert loaded.entries_by_full_name["d/Alpha"]["providers"]["dependabot"][
2905+
"state"
2906+
] == "observed"
2907+
assert reloaded.entries_by_full_name["d/Alpha"]["providers"]["dependabot"][
2908+
"state"
2909+
] == "stale"
2910+
assert at_boundary.entries_by_full_name["d/Alpha"]["providers"]["dependabot"][
2911+
"state"
2912+
] == "observed"
2913+
assert loaded.entries_by_full_name["d/Alpha"]["repository"]["state"] == (
2914+
"observed"
2915+
)
2916+
assert reloaded.entries_by_full_name["d/Alpha"]["repository"]["state"] == (
2917+
"stale"
2918+
)
2919+
assert at_boundary.entries_by_full_name["d/Alpha"]["repository"]["state"] == (
2920+
"observed"
2921+
)
2922+
2923+
binding = loaded.binding()
2924+
metadata = {
2925+
"source_id": "github-security-coverage-receipt",
2926+
"schema_version": loaded.schema_version,
2927+
"produced_at": loaded.produced_at,
2928+
"state": loaded.receipt_state,
2929+
"age_hours": loaded.age_hours,
2930+
"producer_commit": loaded.producer_commit,
2931+
"cohort_policy": loaded.cohort_policy,
2932+
"cohort_repository_count": len(loaded.cohort_repositories),
2933+
"path": loaded.source_path,
2934+
"receipt_id": loaded.receipt_id,
2935+
"content_sha256": loaded.content_sha256,
2936+
}
2937+
registry_output = portfolio_workspace / "project-registry.md"
2938+
report_output = portfolio_workspace / "PORTFOLIO-AUDIT-REPORT.md"
2939+
registry_output.write_text("sentinel-registry\n")
2940+
report_output.write_text("sentinel-report\n")
2941+
2942+
@contextmanager
2943+
def reloaded_guard(_binding: SecurityCoverageReceiptBinding):
2944+
yield reloaded
2945+
2946+
monkeypatch.setattr(
2947+
"src.portfolio_truth_publish.verified_security_coverage_receipt_binding",
2948+
reloaded_guard,
2949+
)
2950+
2951+
with pytest.raises(
2952+
PortfolioTruthPublishError,
2953+
match="normalized evidence changed after it was loaded",
2954+
):
2955+
publish_portfolio_truth(
2956+
workspace_root=portfolio_workspace,
2957+
output_dir=output_dir,
2958+
registry_output=registry_output,
2959+
portfolio_report_output=report_output,
2960+
catalog_path=portfolio_catalog,
2961+
legacy_registry_path=legacy_registry,
2962+
include_notion=False,
2963+
security_alerts_by_name=loaded.entries_by_full_name,
2964+
security_coverage_metadata=metadata,
2965+
security_receipt_binding=binding,
2966+
now=now,
2967+
)
2968+
2969+
assert registry_output.read_text() == "sentinel-registry\n"
2970+
assert report_output.read_text() == "sentinel-report\n"
2971+
assert not (output_dir / "portfolio-truth-latest.json").exists()
2972+
assert not list(output_dir.glob("portfolio-truth-*.json"))
2973+
2974+
28342975
def test_publish_requires_producer_evidence_before_touching_outputs(
28352976
portfolio_workspace: Path,
28362977
portfolio_catalog: Path,

0 commit comments

Comments
 (0)