Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 21 additions & 1 deletion src/portfolio_truth_publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,20 @@ def _parse_bound_datetime(value: object, *, field: str) -> datetime:
return parsed


def _is_same_bound_security_receipt(
prior_metadata: dict[str, object],
current_metadata: dict[str, object],
) -> bool:
"""Return true only when both payloads identify the same immutable receipt."""
binding_fields = ("receipt_id", "content_sha256", "producer_commit")
return all(
isinstance(prior_metadata.get(field), str)
and bool(str(prior_metadata[field]).strip())
and prior_metadata[field] == current_metadata.get(field)
for field in binding_fields
)


def _load_prior_security_alerts(
latest_path: Path,
*,
Expand Down Expand Up @@ -145,6 +159,9 @@ def _load_prior_security_alerts(
"Prior PortfolioTruth security evidence is not immutably bound."
)

prior_security_metadata = (canonical.get("inputs") or {}).get(
"github_security"
) or {}
prior_generated_at = _parse_bound_datetime(
canonical.get("generated_at"),
field="Prior PortfolioTruth generated_at",
Expand All @@ -153,7 +170,10 @@ def _load_prior_security_alerts(
current_security_metadata.get("produced_at"),
field="Current security receipt produced_at",
)
if prior_generated_at > current_produced_at:
if prior_generated_at > current_produced_at and not _is_same_bound_security_receipt(
prior_security_metadata,
current_security_metadata,
):
raise PortfolioTruthPublishError(
"Prior PortfolioTruth was generated after the current security receipt."
)
Expand Down
69 changes: 68 additions & 1 deletion tests/test_portfolio_truth.py
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,7 @@
portfolio_workspace: Path,
portfolio_catalog: Path,
legacy_registry: Path,
snapshot_generated_at: datetime | None = None,
) -> tuple[dict, dict, dict]:
now = datetime(2026, 8, 4, 12, tzinfo=timezone.utc)
alpha = portfolio_workspace / "Alpha"
Expand Down Expand Up @@ -659,7 +660,7 @@
catalog_path=portfolio_catalog,
legacy_registry_path=legacy_registry,
include_notion=False,
now=now,
now=snapshot_generated_at or now,
security_alerts_by_name=security,
security_coverage_metadata=metadata,
)
Expand Down Expand Up @@ -706,6 +707,72 @@
assert evidence.content_sha256 == hashlib.sha256(latest.read_bytes()).hexdigest()


def test_prior_security_loader_allows_same_receipt_truth_generated_after_receipt(
tmp_path: Path,
portfolio_workspace: Path,
portfolio_catalog: Path,
legacy_registry: Path,
) -> None:
import src.portfolio_truth_publish as publish_mod

_, metadata, payload = _legacy_prior_security_payload(
portfolio_workspace=portfolio_workspace,
portfolio_catalog=portfolio_catalog,
legacy_registry=legacy_registry,
snapshot_generated_at=datetime(2026, 8, 4, 12, 0, 1, tzinfo=timezone.utc),
)
latest = tmp_path / "portfolio-truth-latest.json"
latest.write_text(json.dumps(payload), encoding="utf-8")

evidence = publish_mod._load_prior_security_alerts(
latest,
current_security_metadata=metadata,
security_max_age_hours=24,
)

assert evidence.final_cohort_repositories == ("d/Alpha",)
assert evidence.alerts_by_full_name["d/Alpha"]["dependabot_high"] == 1


@pytest.mark.parametrize(
("binding_field", "replacement"),
(
("receipt_id", "sha256:" + "e" * 64),
("content_sha256", "f" * 64),
("producer_commit", "b" * 40),
),
)
def test_prior_security_loader_refuses_future_truth_from_different_receipt(
binding_field: str,
replacement: str,
tmp_path: Path,
portfolio_workspace: Path,
portfolio_catalog: Path,
legacy_registry: Path,
) -> None:
import src.portfolio_truth_publish as publish_mod

_, metadata, payload = _legacy_prior_security_payload(
portfolio_workspace=portfolio_workspace,
portfolio_catalog=portfolio_catalog,
legacy_registry=legacy_registry,
snapshot_generated_at=datetime(2026, 8, 4, 12, 0, 1, tzinfo=timezone.utc),
)
latest = tmp_path / "portfolio-truth-latest.json"
latest.write_text(json.dumps(payload), encoding="utf-8")
current_metadata = {**metadata, binding_field: replacement}

with pytest.raises(
PortfolioTruthPublishError,
match="generated after the current security receipt",
):
publish_mod._load_prior_security_alerts(
latest,
current_security_metadata=current_metadata,
security_max_age_hours=24,
)


@pytest.mark.parametrize(
("mutation", "expected_message"),
(
Expand Down