From 60deba9c5a05b7a884a180bca94889099b6c4bb6 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 27 Jul 2026 20:43:53 -0700 Subject: [PATCH 1/5] fix: reconcile live GitHub security coverage --- CHANGELOG.md | 6 + src/app/portfolio_truth.py | 4 + src/github_security_coverage.py | 528 +++++++++++++++++- src/portfolio_repository_state.py | 19 +- src/portfolio_truth_reconcile.py | 61 +- src/portfolio_truth_status.py | 2 + src/portfolio_truth_types.py | 51 ++ .../github_security_coverage/outcomes.json | 32 ++ tests/test_github_security_coverage.py | 207 ++++++- tests/test_portfolio_truth.py | 38 ++ 10 files changed, 901 insertions(+), 47 deletions(-) create mode 100644 tests/fixtures/github_security_coverage/outcomes.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d06abf..880451e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] ### Changed +- Kept PortfolioTruth `0.11.0` and + `GitHubSecurityCoverageReceiptV1` read-compatible while adding normalized + provider reason codes, explicit completed-zero semantics, producer-commit + binding, and a same-pass GitHub default-branch/head observation for every + repository in the security cohort. Missing credentials and stale or partial + observations now stay fail-closed instead of presenting zero as clean. - Migrated `config/portfolio-catalog.yaml` and the example catalog from `intended_disposition` to `operating_path` (179 + 3 entries; identity value mapping — `resolve_declared_operating_path` already treated disposition diff --git a/src/app/portfolio_truth.py b/src/app/portfolio_truth.py index 68f4125..1730821 100644 --- a/src/app/portfolio_truth.py +++ b/src/app/portfolio_truth.py @@ -68,6 +68,9 @@ def run_portfolio_truth_mode(args: Any) -> None: "portfolio_truth_security_cohort_count", DEFAULT_EXPECTED_GITHUB_COHORT_COUNT, ), + expected_producer_commit=( + producer_evidence.commit if producer_evidence is not None else None + ), ) if loaded_security is not None: security_alerts_by_name = loaded_security.entries_by_full_name @@ -77,6 +80,7 @@ def run_portfolio_truth_mode(args: Any) -> None: "produced_at": loaded_security.produced_at, "state": loaded_security.receipt_state, "age_hours": loaded_security.age_hours, + "producer_commit": loaded_security.producer_commit, "cohort_policy": loaded_security.cohort_policy, "cohort_repository_count": len( loaded_security.cohort_repositories diff --git a/src/github_security_coverage.py b/src/github_security_coverage.py index 9bb31dc..1defb59 100644 --- a/src/github_security_coverage.py +++ b/src/github_security_coverage.py @@ -23,6 +23,7 @@ GITHUB_SECURITY_RECEIPT_FILENAME = "github-security-coverage-latest.json" GITHUB_API_VERSION = "2026-03-10" DEFAULT_COHORT_POLICY = "portfolio-default-attention-v1" +REMOTE_REPOSITORY_SOURCE = "github-graphql-default-branch-head-v1" DEFAULT_ATTENTION_STATES = frozenset( {"active-product", "active-infra", "decision-needed"} ) @@ -34,6 +35,7 @@ { "not_requested", "observed", + "credential_unavailable", "forbidden", "rate_limited", "transient_error", @@ -55,6 +57,20 @@ "stale", } ) +REMOTE_REPOSITORY_STATES = frozenset( + { + "observed", + "partial", + "not_requested", + "credential_unavailable", + "forbidden", + "not_found", + "rate_limited", + "transient_error", + "malformed", + "stale", + } +) DEFAULT_BASE_REQUEST_LIMIT = 48 DEFAULT_TOTAL_REQUEST_LIMIT = 75 DEFAULT_QUOTA_RESERVE = 100 @@ -90,6 +106,7 @@ class SecurityCoverageError(ValueError): class LoadedSecurityCoverage: entries_by_full_name: dict[str, dict[str, Any]] produced_at: str + producer_commit: str schema_version: str cohort_policy: str cohort_repositories: tuple[str, ...] @@ -188,6 +205,37 @@ def _empty_counts(provider: str) -> dict[str, int]: return {key: 0 for key in _COUNT_KEYS[provider]} +def _provider_reason_code(state: str) -> str: + return { + "observed": "observed", + "stale": "stale_observation", + "feature_unavailable": "provider_unavailable", + "forbidden": "forbidden", + "credential_unavailable": "authentication_missing", + "not_found": "endpoint_unsupported", + "gone": "endpoint_unsupported", + "rate_limited": "rate_limited", + "malformed": "malformed_response", + "transient_error": "transient_error", + "not_requested": "not_requested", + }[state] + + +def _remote_reason_code(state: str) -> str: + return { + "observed": "observed", + "partial": "default_branch_head_unavailable", + "stale": "stale_observation", + "forbidden": "forbidden", + "credential_unavailable": "authentication_missing", + "not_found": "repository_not_found", + "rate_limited": "rate_limited", + "malformed": "malformed_response", + "transient_error": "transient_error", + "not_requested": "not_requested", + }[state] + + def _provider_result( provider: str, *, @@ -205,8 +253,14 @@ def _provider_result( ) -> dict[str, Any]: if state not in PROVIDER_STATES: raise SecurityCoverageError(f"invalid provider state: {state}") + completed = ( + state == "observed" + and pagination_complete + and isinstance(counts, dict) + ) return { "state": state, + "reason_code": _provider_reason_code(state), "observed_at": observed_at, "http_status": http_status, "http_classification": http_classification @@ -227,6 +281,10 @@ def _provider_result( "result": conditional_result, }, "pagination_complete": pagination_complete, + "completed": completed, + "zero_findings": ( + sum(counts.values()) == 0 if completed and counts is not None else None + ), "counts": counts if state == "observed" else None, } @@ -245,6 +303,8 @@ def _classify_failure(provider: str, response: requests.Response) -> tuple[str, status = response.status_code message = _response_message(response) remaining = response.headers.get("X-RateLimit-Remaining") + if status == 401: + return "credential_unavailable", "github_authentication_missing" if status == 429 or ( status == 403 and (remaining == "0" or "rate limit" in message or "secondary rate" in message) @@ -392,13 +452,15 @@ def _preflight_json( state, reason = _preflight_failure(response) if state == "rate_limited": budget.stop_reason = "rate_limited" + elif state == "credential_unavailable": + budget.stop_reason = "authentication_missing" elif reserve_reached: budget.stop_reason = "quota_reserve" return ( None, state, reason, - state == "rate_limited" or reserve_reached, + state in {"credential_unavailable", "rate_limited"} or reserve_reached, ) try: payload = response.json() @@ -664,6 +726,8 @@ def _fetch_provider( state, reason = _classify_failure(provider, response) if state == "rate_limited": budget.stop_reason = "rate_limited" + elif state == "credential_unavailable": + budget.stop_reason = "authentication_missing" elif reserve_reached: budget.stop_reason = "quota_reserve" return ( @@ -678,7 +742,8 @@ def _fetch_provider( conditional_request=bool(prior_etag), conditional_result="failed", ), - state == "rate_limited" or reserve_reached, + state in {"credential_unavailable", "rate_limited"} + or reserve_reached, ) try: page = response.json() @@ -733,6 +798,225 @@ def _fetch_provider( return result, budget.stop_reason == "quota_reserve" +def _remote_repository_result( + *, + state: str, + observed_at: str | None = None, + reason: str | None = None, + default_branch: str | None = None, + head_sha: str | None = None, + archived: bool | None = None, +) -> dict[str, Any]: + if state not in REMOTE_REPOSITORY_STATES: + raise SecurityCoverageError(f"invalid remote repository state: {state}") + return { + "source": REMOTE_REPOSITORY_SOURCE, + "state": state, + "reason_code": _remote_reason_code(state), + "reason": reason, + "observed_at": observed_at, + "default_branch": default_branch if state == "observed" else None, + "head_sha": head_sha if state == "observed" else None, + "archived": archived if state in {"observed", "partial"} else None, + } + + +def _remote_failure_results( + cohort: tuple[str, ...], + *, + state: str, + observed_at: str | None, + reason: str | None, +) -> dict[str, dict[str, Any]]: + return { + repo_full_name: _remote_repository_result( + state=state, + observed_at=observed_at, + reason=reason, + ) + for repo_full_name in cohort + } + + +def _graphql_error_outcome( + errors: Any, + *, + alias: str | None = None, +) -> tuple[str, str] | None: + if not isinstance(errors, list): + return None + matching: list[dict[str, Any]] = [] + for value in errors: + error = _mapping(value) + path = error.get("path") + path_alias = path[0] if isinstance(path, list) and path else None + if alias is None or path_alias in {None, alias}: + matching.append(error) + if not matching: + return None + messages = " ".join(_text(error.get("message")).lower() for error in matching) + if "rate limit" in messages or "secondary rate" in messages: + return "rate_limited", "github_graphql_rate_limited" + if any( + phrase in messages + for phrase in ( + "bad credentials", + "requires authentication", + "could not authenticate", + ) + ): + return "credential_unavailable", "github_graphql_authentication_missing" + if "resource not accessible" in messages or "forbidden" in messages: + return "forbidden", "github_graphql_forbidden" + if "could not resolve to a repository" in messages or "not found" in messages: + return "not_found", "github_graphql_repository_not_found" + return "malformed", "github_graphql_error" + + +def _collect_remote_repository_observations( + session: requests.Session, + *, + api_base_url: str, + cohort: tuple[str, ...], + now_iso: str, + budget: _Budget, +) -> dict[str, dict[str, Any]]: + """Observe each cohort repo's default branch and head in one bounded query.""" + if not budget.consume(pagination=False): + return _remote_failure_results( + cohort, + state="not_requested", + observed_at=None, + reason=budget.stop_reason, + ) + + variables: dict[str, str] = {} + fields: list[str] = [] + for index, repo_full_name in enumerate(cohort): + owner_name, name = repo_full_name.split("/", 1) + variables[f"owner{index}"] = owner_name + variables[f"name{index}"] = name + fields.append( + f"repo{index}: repository(owner: $owner{index}, name: $name{index}) " + "{ nameWithOwner isArchived defaultBranchRef { name target { oid } } }" + ) + declarations = ", ".join(f"${key}: String!" for key in variables) + query = f"query({declarations}) {{ {' '.join(fields)} }}" + try: + response = session.post( + f"{api_base_url}/graphql", + json={"query": query, "variables": variables}, + timeout=30, + ) + except requests.RequestException: + return _remote_failure_results( + cohort, + state="transient_error", + observed_at=now_iso, + reason="network_error", + ) + + reserve_reached = _reserve_reached(response, budget) + if response.status_code != 200: + state, reason = _preflight_failure(response) + if state == "rate_limited": + budget.stop_reason = "rate_limited" + elif state == "credential_unavailable": + budget.stop_reason = "authentication_missing" + elif reserve_reached: + budget.stop_reason = "quota_reserve" + remote_state = ( + state if state in REMOTE_REPOSITORY_STATES else "malformed" + ) + return _remote_failure_results( + cohort, + state=remote_state, + observed_at=now_iso, + reason=reason, + ) + + try: + payload = response.json() + except ValueError: + payload = None + if not isinstance(payload, dict): + return _remote_failure_results( + cohort, + state="malformed", + observed_at=now_iso, + reason="non_object_payload", + ) + errors = payload.get("errors") + data = _mapping(payload.get("data")) + if not data: + global_error = _graphql_error_outcome(errors) + if global_error is not None: + state, reason = global_error + if state == "rate_limited": + budget.stop_reason = "rate_limited" + elif state == "credential_unavailable": + budget.stop_reason = "authentication_missing" + return _remote_failure_results( + cohort, + state=state, + observed_at=now_iso, + reason=reason, + ) + results: dict[str, dict[str, Any]] = {} + for index, expected_repo in enumerate(cohort): + alias = f"repo{index}" + repository = _mapping(data.get(alias)) + if not repository: + error_outcome = _graphql_error_outcome(errors, alias=alias) + state, reason = error_outcome or ( + "not_found", + "repository_not_returned", + ) + results[expected_repo] = _remote_repository_result( + state=state, + observed_at=now_iso, + reason=reason, + ) + continue + full_name = _text(repository.get("nameWithOwner")) + if full_name.lower() != expected_repo.lower(): + results[expected_repo] = _remote_repository_result( + state="malformed", + observed_at=now_iso, + reason="repository_identity_mismatch", + ) + continue + archived = repository.get("isArchived") + default_ref = _mapping(repository.get("defaultBranchRef")) + default_branch = _text(default_ref.get("name")) + head_sha = _text(_mapping(default_ref.get("target")).get("oid")) + if not isinstance(archived, bool): + results[expected_repo] = _remote_repository_result( + state="malformed", + observed_at=now_iso, + reason="repository_archived_state_invalid", + ) + elif not default_branch or not re.fullmatch(r"[0-9a-f]{40,64}", head_sha): + results[expected_repo] = _remote_repository_result( + state="partial", + observed_at=now_iso, + reason="default_branch_head_unavailable", + archived=archived, + ) + else: + results[expected_repo] = _remote_repository_result( + state="observed", + observed_at=now_iso, + reason=None, + default_branch=default_branch, + head_sha=head_sha, + archived=archived, + ) + if reserve_reached: + budget.stop_reason = "quota_reserve" + return results + + def collect_security_coverage( portfolio_truth: dict[str, Any], *, @@ -748,10 +1032,6 @@ def collect_security_coverage( api_base_url: str | None = None, ) -> dict[str, Any]: """Collect the bounded default-attention cohort into a provenance receipt.""" - if not token: - raise SecurityCoverageError( - "authorized GitHub read credential unavailable; no receipt was written" - ) if ( not 0 < base_request_limit <= DEFAULT_BASE_REQUEST_LIMIT or not base_request_limit <= total_request_limit <= DEFAULT_TOTAL_REQUEST_LIMIT @@ -801,14 +1081,14 @@ def collect_security_coverage( collected_at = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) now_iso = collected_at.isoformat() client = session or requests.Session() - client.headers.update( - { - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {token}", - "User-Agent": "github-repo-auditor/security-coverage", - "X-GitHub-Api-Version": GITHUB_API_VERSION, - } - ) + client_headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "github-repo-auditor/security-coverage", + "X-GitHub-Api-Version": GITHUB_API_VERSION, + } + if token: + client_headers["Authorization"] = f"Bearer {token}" + client.headers.update(client_headers) budget = _Budget( base_limit=base_request_limit, total_limit=total_request_limit, @@ -817,22 +1097,50 @@ def collect_security_coverage( resolved_api_base_url = api_base_url or os.environ.get( "GITHUB_API_BASE_URL", "https://api.github.com" ) - eligibility, unavailable_providers, halted = _collect_eligibility_preflight( - client, - api_base_url=resolved_api_base_url, - candidates=_eligibility_candidates(prior_receipt, cohort), - now_iso=now_iso, - budget=budget, - ) + if token: + eligibility, unavailable_providers, halted = _collect_eligibility_preflight( + client, + api_base_url=resolved_api_base_url, + candidates=_eligibility_candidates(prior_receipt, cohort), + now_iso=now_iso, + budget=budget, + ) + else: + eligibility = _eligibility_metadata( + state="credential_unavailable", + observed_at=now_iso, + reason="github_authentication_missing", + candidates=(), + request_count=0, + ) + unavailable_providers = {} + halted = True repositories: dict[str, dict[str, Any]] = {} for repo_full_name in cohort: providers: dict[str, dict[str, Any]] = {} for provider in PROVIDER_NAMES: if halted: + halted_state = ( + "credential_unavailable" + if not token + or _mapping(eligibility).get("state") + == "credential_unavailable" + or budget.stop_reason == "authentication_missing" + else "not_requested" + ) providers[provider] = _provider_result( provider, - state="not_requested", - reason=budget.stop_reason or "collection_halted", + state=halted_state, + observed_at=( + now_iso + if halted_state == "credential_unavailable" + else None + ), + reason=( + "github_authentication_missing" + if halted_state == "credential_unavailable" + else budget.stop_reason or "collection_halted" + ), ) continue if provider in unavailable_providers.get(repo_full_name, frozenset()): @@ -856,6 +1164,30 @@ def collect_security_coverage( ) repositories[repo_full_name] = {"providers": providers} + eligibility_state = _text(_mapping(eligibility).get("state")) + authentication_missing = ( + not token + or eligibility_state == "credential_unavailable" + or budget.stop_reason == "authentication_missing" + ) + if not authentication_missing: + remote_observations = _collect_remote_repository_observations( + client, + api_base_url=resolved_api_base_url, + cohort=cohort, + now_iso=now_iso, + budget=budget, + ) + else: + remote_observations = _remote_failure_results( + cohort, + state="credential_unavailable", + observed_at=now_iso, + reason="github_authentication_missing", + ) + for repo_full_name, observation in remote_observations.items(): + repositories[repo_full_name]["repository"] = observation + produced_at = ( collected_at if now is not None else datetime.now(timezone.utc) ).astimezone(timezone.utc) @@ -1027,7 +1359,14 @@ def _validate_provider( state = _text(data.get("state")) if state not in PROVIDER_STATES: raise SecurityCoverageError(f"{provider}.state is invalid: {state!r}") + raw_state = state + declared_reason_code = data.get("reason_code") + expected_raw_reason_code = _provider_reason_code(raw_state) + if declared_reason_code is not None and declared_reason_code != expected_raw_reason_code: + raise SecurityCoverageError(f"{provider}.reason_code does not match state") counts = data.get("counts") + raw_completed = False + raw_zero_findings: bool | None = None http_status = data.get("http_status") http_classification = data.get("http_classification") conditional = _mapping(data.get("conditional")) @@ -1094,6 +1433,8 @@ def _validate_provider( ) normalized_counts[key] = raw counts = normalized_counts + raw_completed = True + raw_zero_findings = sum(normalized_counts.values()) == 0 if provider_age_hours > max_age_hours: state = "stale" counts = None @@ -1103,6 +1444,10 @@ def _validate_provider( raise SecurityCoverageError( f"{provider} not_requested must not claim HTTP status" ) + if state == "credential_unavailable" and http_status not in {None, 401}: + raise SecurityCoverageError( + f"{provider} credential_unavailable requires HTTP 401 or no request" + ) if state == "forbidden" and http_status != 403: raise SecurityCoverageError(f"{provider} forbidden requires HTTP 403") if state == "feature_unavailable": @@ -1131,11 +1476,24 @@ def _validate_provider( raise SecurityCoverageError(f"{provider} gone requires HTTP 410") if state == "rate_limited" and http_status not in {403, 429}: raise SecurityCoverageError(f"{provider} rate_limited requires HTTP 403 or 429") + declared_completed = data.get("completed") + if declared_completed is not None and declared_completed is not raw_completed: + raise SecurityCoverageError(f"{provider}.completed does not match observation") + declared_zero_findings = data.get("zero_findings") + if ( + declared_zero_findings is not None + and declared_zero_findings is not raw_zero_findings + ): + raise SecurityCoverageError( + f"{provider}.zero_findings requires a completed observation" + ) if receipt_is_stale and state == "observed": state = "stale" counts = None + normalized_completed = state == "observed" and raw_completed return { "state": state, + "reason_code": _provider_reason_code(state), "observed_at": data.get("observed_at"), "http_status": http_status, "http_classification": http_classification, @@ -1144,15 +1502,118 @@ def _validate_provider( "last_modified": data.get("last_modified"), "conditional": conditional, "pagination_complete": bool(data.get("pagination_complete")), + "completed": normalized_completed, + "zero_findings": raw_zero_findings if normalized_completed else None, "counts": counts, } +def _validate_remote_repository( + value: Any, + *, + receipt_is_stale: bool, + produced_at: datetime, + current: datetime, + max_age_hours: int, +) -> dict[str, Any]: + if value is None: + return _remote_repository_result( + state="not_requested", + reason="remote_observation_not_in_receipt", + ) + data = _mapping(value) + if data.get("source") != REMOTE_REPOSITORY_SOURCE: + raise SecurityCoverageError("repository observation source is invalid") + state = _text(data.get("state")) + if state not in REMOTE_REPOSITORY_STATES: + raise SecurityCoverageError( + f"repository observation state is invalid: {state!r}" + ) + declared_reason_code = data.get("reason_code") + if ( + declared_reason_code is not None + and declared_reason_code != _remote_reason_code(state) + ): + raise SecurityCoverageError( + "repository observation reason_code does not match state" + ) + observed_at_value = data.get("observed_at") + observed_at = ( + _parse_datetime( + observed_at_value, + field_name="repository.observed_at", + ) + if observed_at_value is not None + else None + ) + if observed_at is not None: + if observed_at > produced_at: + raise SecurityCoverageError( + "repository.observed_at is later than receipt produced_at" + ) + if (current - observed_at).total_seconds() / 3600 < -0.05: + raise SecurityCoverageError("repository.observed_at is future-dated") + default_branch = data.get("default_branch") + head_sha = data.get("head_sha") + archived = data.get("archived") + if state == "observed": + if observed_at is None: + raise SecurityCoverageError( + "repository.observed_at is required when observed" + ) + if not isinstance(default_branch, str) or not default_branch.strip(): + raise SecurityCoverageError( + "repository.default_branch is required when observed" + ) + if not isinstance(head_sha, str) or not re.fullmatch( + r"[0-9a-f]{40,64}", head_sha + ): + raise SecurityCoverageError( + "repository.head_sha is invalid when observed" + ) + if not isinstance(archived, bool): + raise SecurityCoverageError( + "repository.archived must be boolean when observed" + ) + elif state == "partial": + if observed_at is None or not isinstance(archived, bool): + raise SecurityCoverageError( + "partial repository observation requires timestamp and archived state" + ) + if default_branch is not None or head_sha is not None: + raise SecurityCoverageError( + "partial repository observation cannot claim branch or head" + ) + elif any(value is not None for value in (default_branch, head_sha, archived)): + raise SecurityCoverageError( + "unobserved repository state cannot claim remote values" + ) + + if state in {"observed", "partial"} and observed_at is not None: + observation_age_hours = (current - observed_at).total_seconds() / 3600 + if receipt_is_stale or observation_age_hours > max_age_hours: + state = "stale" + default_branch = None + head_sha = None + archived = None + return { + "source": REMOTE_REPOSITORY_SOURCE, + "state": state, + "reason_code": _remote_reason_code(state), + "reason": "receipt_stale" if state == "stale" else data.get("reason"), + "observed_at": data.get("observed_at"), + "default_branch": default_branch, + "head_sha": head_sha, + "archived": archived, + } + + def validate_security_coverage_receipt( payload: Any, *, max_age_hours: int = 24, expected_cohort_count: int = DEFAULT_EXPECTED_GITHUB_COHORT_COUNT, + expected_producer_commit: str | None = None, now: datetime | None = None, source_path: str = "", ) -> LoadedSecurityCoverage: @@ -1183,6 +1644,11 @@ def validate_security_coverage_receipt( raise SecurityCoverageError( "security coverage receipt producer commit is invalid" ) + if expected_producer_commit is not None and commit != expected_producer_commit: + raise SecurityCoverageError( + "security coverage receipt producer commit mismatch: " + f"receipt={commit}; expected={expected_producer_commit}" + ) if payload.get("github_api_version") != GITHUB_API_VERSION: raise SecurityCoverageError("security coverage receipt API version is invalid") @@ -1255,6 +1721,13 @@ def validate_security_coverage_receipt( "receipt_schema_version": GITHUB_SECURITY_RECEIPT_SCHEMA_VERSION, "source_produced_at": produced_at.isoformat(), "receipt_state": "stale" if receipt_is_stale else "fresh", + "repository": _validate_remote_repository( + repo_data.get("repository"), + receipt_is_stale=receipt_is_stale, + produced_at=produced_at, + current=current, + max_age_hours=max_age_hours, + ), "providers": { provider: _validate_provider( provider, @@ -1272,6 +1745,7 @@ def validate_security_coverage_receipt( return LoadedSecurityCoverage( entries_by_full_name=entries, produced_at=produced_at.isoformat(), + producer_commit=commit, schema_version=GITHUB_SECURITY_RECEIPT_SCHEMA_VERSION, cohort_policy=policy, cohort_repositories=canonical_cohort, @@ -1286,6 +1760,7 @@ def load_security_coverage_receipt( *, max_age_hours: int = 24, expected_cohort_count: int = DEFAULT_EXPECTED_GITHUB_COHORT_COUNT, + expected_producer_commit: str | None = None, now: datetime | None = None, ) -> LoadedSecurityCoverage: try: @@ -1302,6 +1777,7 @@ def load_security_coverage_receipt( payload, max_age_hours=max_age_hours, expected_cohort_count=expected_cohort_count, + expected_producer_commit=expected_producer_commit, now=now, source_path=str(path), ) @@ -1367,6 +1843,11 @@ def main() -> None: type=int, default=DEFAULT_EXPECTED_GITHUB_COHORT_COUNT, ) + parser.add_argument( + "--expected-producer-commit", + default=None, + help="Require the receipt producer commit to match this exact SHA", + ) parser.add_argument( "--base-request-limit", type=int, default=DEFAULT_BASE_REQUEST_LIMIT ) @@ -1382,6 +1863,7 @@ def main() -> None: args.output, max_age_hours=args.max_age_hours, expected_cohort_count=args.expected_cohort_count, + expected_producer_commit=args.expected_producer_commit, ) print( json.dumps( diff --git a/src/portfolio_repository_state.py b/src/portfolio_repository_state.py index d8a13ab..3e10808 100644 --- a/src/portfolio_repository_state.py +++ b/src/portfolio_repository_state.py @@ -6,12 +6,23 @@ from typing import Any -def observe_repository_state(path: Path, *, observed_at: datetime) -> dict[str, Any]: +def observe_repository_state( + path: Path, + *, + observed_at: datetime, + remote_default_branch: dict[str, Any] | None = None, +) -> dict[str, Any]: """Read local Git/worktree state without changing refs or exposing file names.""" + remote = dict(remote_default_branch) if remote_default_branch else { + "state": "unknown", + "reason_code": "not_requested", + "reason": "no independent live remote read was performed by portfolio generation", + } if not (path / ".git").exists(): return { "state": "not_a_repository", "observed_at": observed_at.astimezone(UTC).isoformat(), + "remote_default_branch": remote, } try: head = _git(path, "rev-parse", "HEAD") @@ -53,10 +64,7 @@ def observe_repository_state(path: Path, *, observed_at: datetime) -> dict[str, "ahead": ahead, "behind": behind, }, - "remote_default_branch": { - "state": "unknown", - "reason": "no independent live remote read was performed by portfolio generation", - }, + "remote_default_branch": remote, "worktrees": worktrees, } except (OSError, subprocess.CalledProcessError, ValueError) as exc: @@ -64,6 +72,7 @@ def observe_repository_state(path: Path, *, observed_at: datetime) -> dict[str, "state": "unknown", "observed_at": observed_at.astimezone(UTC).isoformat(), "reason": str(exc), + "remote_default_branch": remote, } diff --git a/src/portfolio_truth_reconcile.py b/src/portfolio_truth_reconcile.py index 5dc916f..5510f9f 100644 --- a/src/portfolio_truth_reconcile.py +++ b/src/portfolio_truth_reconcile.py @@ -514,6 +514,33 @@ def _build_coverage_envelope( ) for provider in ("dependabot", "code_scanning", "secret_scanning") } + provider_zero_finding_counts = { + provider: sum( + (project.security.providers.get(provider) or {}).get("zero_findings") + is True + for project in workspace_projects + ) + for provider in ("dependabot", "code_scanning", "secret_scanning") + } + remote_default_branch_counts = { + state: sum( + (project.repository_state.get("remote_default_branch") or {}).get("state") + == state + for project in workspace_projects + ) + for state in ( + "observed", + "partial", + "stale", + "credential_unavailable", + "forbidden", + "not_found", + "rate_limited", + "malformed", + "not_requested", + "unknown", + ) + } git_observed = sum( project.repository_state.get("state") == "observed" for project in workspace_projects @@ -550,6 +577,8 @@ def _build_coverage_envelope( "cohort_stale_count": cohort_stale, "cohort_unknown_count": cohort_unknown, "provider_observed_counts": provider_counts, + "provider_zero_finding_counts": provider_zero_finding_counts, + "remote_default_branch_counts": remote_default_branch_counts, "project_count": workspace_project_count, }, { @@ -1166,6 +1195,32 @@ def _build_truth_project( "source": "derived", "detail": str(risk_entry["doctor_gap"]).lower(), } + remote_default_branch = ( + dict(security_entry.get("repository") or {}) + if security_entry is not None + else None + ) + repository_state = ( + observe_repository_state( + project_path, + observed_at=now, + remote_default_branch=remote_default_branch, + ) + if project_path is not None and has_git + else { + "state": "not_a_repository", + "observed_at": now.isoformat(), + "remote_default_branch": remote_default_branch + or { + "state": "unknown", + "reason_code": "not_requested", + "reason": ( + "no independent live remote read was performed by " + "portfolio generation" + ), + }, + } + ) return PortfolioTruthProject( identity=identity, declared=declared, @@ -1173,11 +1228,7 @@ def _build_truth_project( risk=risk, security=security, advisory=advisory, - repository_state=( - observe_repository_state(project_path, observed_at=now) - if project_path is not None and has_git - else {"state": "not_a_repository", "observed_at": now.isoformat()} - ), + repository_state=repository_state, provenance=provenance, warnings=warnings, ) diff --git a/src/portfolio_truth_status.py b/src/portfolio_truth_status.py index 8e18aef..bdaf0ab 100644 --- a/src/portfolio_truth_status.py +++ b/src/portfolio_truth_status.py @@ -161,6 +161,7 @@ def load_security_coverage_by_full_name( receipt_path: Path | None = None, max_age_hours: int = 24, expected_cohort_count: int = DEFAULT_EXPECTED_GITHUB_COHORT_COUNT, + expected_producer_commit: str | None = None, now: datetime | None = None, ) -> LoadedSecurityCoverage | None: """Load the canonical provenance-bearing security receipt. @@ -176,6 +177,7 @@ def load_security_coverage_by_full_name( selected, max_age_hours=max_age_hours, expected_cohort_count=expected_cohort_count, + expected_producer_commit=expected_producer_commit, now=now, ) except SecurityCoverageError as exc: diff --git a/src/portfolio_truth_types.py b/src/portfolio_truth_types.py index 9a33c19..3a2d744 100644 --- a/src/portfolio_truth_types.py +++ b/src/portfolio_truth_types.py @@ -9,6 +9,8 @@ SCHEMA_VERSION = "0.11.0" # 0.11.0: provenance-bearing GitHub security receipts preserve per-provider # states and expose complete/partial/stale/unknown coverage denominators. +# Additive 0.11.0 fields bind normalized provider reason codes, completed-zero +# observations, and live remote default-branch/head evidence to the same receipt. # 0.10.0: canonical producer receipts bind the exact checkout; coverage and # repository/worktree observation envelopes fail closed on unavailable evidence. # 0.8.0: derived.registry_status removed (was a stale->parked synonym table over @@ -302,6 +304,13 @@ def from_projects( dependabot_observed_count = 0 code_scanning_observed_count = 0 secret_scanning_observed_count = 0 + dependabot_zero_finding_count = 0 + code_scanning_zero_finding_count = 0 + secret_scanning_zero_finding_count = 0 + remote_default_branch_observed_count = 0 + remote_default_branch_partial_count = 0 + remote_default_branch_stale_count = 0 + remote_default_branch_unavailable_count = 0 decision_needed_count = 0 default_attention_count = 0 for project in projects: @@ -327,6 +336,33 @@ def from_projects( code_scanning_observed_count += 1 if provider_states["secret_scanning"] == "observed": secret_scanning_observed_count += 1 + dependabot_zero_finding_count += int( + (security.providers.get("dependabot") or {}).get("zero_findings") + is True + ) + code_scanning_zero_finding_count += int( + (security.providers.get("code_scanning") or {}).get( + "zero_findings" + ) + is True + ) + secret_scanning_zero_finding_count += int( + (security.providers.get("secret_scanning") or {}).get( + "zero_findings" + ) + is True + ) + remote_state = ( + project.repository_state.get("remote_default_branch") or {} + ).get("state") + if remote_state == "observed": + remote_default_branch_observed_count += 1 + elif remote_state == "partial": + remote_default_branch_partial_count += 1 + elif remote_state == "stale": + remote_default_branch_stale_count += 1 + else: + remote_default_branch_unavailable_count += 1 if security.coverage_state == "complete": complete_repo_count += 1 scanned_count += 1 @@ -369,6 +405,21 @@ def from_projects( "dependabot_observed_count": dependabot_observed_count, "code_scanning_observed_count": code_scanning_observed_count, "secret_scanning_observed_count": secret_scanning_observed_count, + "dependabot_zero_finding_count": dependabot_zero_finding_count, + "code_scanning_zero_finding_count": code_scanning_zero_finding_count, + "secret_scanning_zero_finding_count": secret_scanning_zero_finding_count, + "remote_default_branch_observed_count": ( + remote_default_branch_observed_count + ), + "remote_default_branch_partial_count": ( + remote_default_branch_partial_count + ), + "remote_default_branch_stale_count": ( + remote_default_branch_stale_count + ), + "remote_default_branch_unavailable_count": ( + remote_default_branch_unavailable_count + ), "coverage_state": ( "known" if unavailable_count == 0 diff --git a/tests/fixtures/github_security_coverage/outcomes.json b/tests/fixtures/github_security_coverage/outcomes.json new file mode 100644 index 0000000..63c539d --- /dev/null +++ b/tests/fixtures/github_security_coverage/outcomes.json @@ -0,0 +1,32 @@ +{ + "authentication_missing": { + "reason_code": "authentication_missing", + "state": "credential_unavailable" + }, + "partial_coverage": { + "message": "Resource not accessible by integration", + "reason_code": "forbidden", + "status_code": 403 + }, + "stale_observation": { + "age_hours": 25, + "reason_code": "stale_observation" + }, + "successful_zero_findings": { + "payload": [], + "reason_code": "observed", + "zero_findings": true + }, + "malformed_provider_data": { + "payload": { + "unexpected": "object" + }, + "reason_code": "malformed_response" + }, + "rate_limited": { + "message": "API rate limit exceeded", + "reason_code": "rate_limited", + "remaining": "0", + "status_code": 403 + } +} diff --git a/tests/test_github_security_coverage.py b/tests/test_github_security_coverage.py index f413e55..df6b17b 100644 --- a/tests/test_github_security_coverage.py +++ b/tests/test_github_security_coverage.py @@ -23,6 +23,14 @@ from src.portfolio_truth_status import load_security_coverage_by_full_name NOW = datetime(2026, 7, 16, 12, tzinfo=timezone.utc) +OUTCOME_FIXTURES = json.loads( + ( + Path(__file__).parent + / "fixtures" + / "github_security_coverage" + / "outcomes.json" + ).read_text() +) def _truth(count: int = 16) -> dict[str, Any]: @@ -144,6 +152,21 @@ def _eligibility_responses(count: int = 6) -> list[_Response]: ] +def _remote_graphql_response(count: int) -> _Response: + repositories = { + f"repo{index}": { + "nameWithOwner": f"owner/repo-{index:02d}", + "isArchived": False, + "defaultBranchRef": { + "name": "main", + "target": {"oid": f"{index:040x}"}, + }, + } + for index in range(count) + } + return _Response(200, {"data": repositories}) + + def test_default_attention_cohort_is_exact_and_fail_closed() -> None: truth = _truth(DEFAULT_EXPECTED_GITHUB_COHORT_COUNT) truth["projects"].append( @@ -178,13 +201,44 @@ def test_repo_less_non_supplementary_attention_identity_fails_closed() -> None: derive_default_attention_cohort(truth) -def test_no_token_never_attempts_collection() -> None: +def test_no_token_writes_exact_fail_closed_outcomes_without_network() -> None: session = _Session() - with pytest.raises(SecurityCoverageError, match="credential unavailable"): - collect_security_coverage(_truth(), token=None, session=session) + receipt = collect_security_coverage( + _truth(DEFAULT_EXPECTED_GITHUB_COHORT_COUNT), + token=None, + session=session, + now=NOW, + producer_commit="a" * 40, + ) assert session.calls == [] + expected = OUTCOME_FIXTURES["authentication_missing"] + for repository in receipt["repositories"].values(): + assert repository["repository"]["state"] == expected["state"] + assert repository["repository"]["reason_code"] == expected["reason_code"] + for provider in repository["providers"].values(): + assert provider["state"] == expected["state"] + assert provider["reason_code"] == expected["reason_code"] + assert provider["completed"] is False + assert provider["zero_findings"] is None + + +def test_rejected_token_stops_after_first_request_and_marks_whole_cut_unknown() -> None: + session = _Session([_Response(401, {"message": "Bad credentials"})]) + + receipt = _collect(session=session) + + assert len(session.calls) == 1 + assert receipt["request_budget"]["stop_reason"] == "authentication_missing" + assert all( + repository["repository"]["reason_code"] == "authentication_missing" + and all( + provider["reason_code"] == "authentication_missing" + for provider in repository["providers"].values() + ) + for repository in receipt["repositories"].values() + ) def test_valid_prior_for_old_cohort_is_ignored_during_contraction() -> None: @@ -198,11 +252,12 @@ def test_valid_prior_for_old_cohort_is_ignored_during_contraction() -> None: ) assert receipt["cohort"]["repository_count"] == 9 - assert len(session.calls) == 27 + assert len(session.calls) == 28 assert all( kwargs.get("headers") == {} - for _, kwargs in session.calls + for _, kwargs in session.calls[:27] ) + assert "headers" not in session.calls[27][1] def test_invalid_prior_for_old_cohort_still_fails_closed() -> None: @@ -255,13 +310,71 @@ def test_collector_is_serial_count_only_and_bounded_to_48_base_requests() -> Non assert len(session.calls) == DEFAULT_BASE_REQUEST_LIMIT == 48 assert receipt["request_budget"]["base_requests"] == 48 assert receipt["request_budget"]["total_requests"] == 48 - assert receipt["request_budget"]["stop_reason"] is None + assert receipt["request_budget"]["stop_reason"] == "base_request_limit" assert all("/alerts/" not in url for url, _ in session.calls) for repository in receipt["repositories"].values(): for provider in repository["providers"].values(): assert provider["state"] == "observed" assert provider["counts"] is not None assert provider["pagination_complete"] is True + assert provider["completed"] is True + assert provider["zero_findings"] is True + assert all( + repository["repository"]["state"] == "not_requested" + for repository in receipt["repositories"].values() + ) + + +def test_current_nine_repository_cut_binds_remote_branch_and_head() -> None: + session = _Session( + [ + *[_Response() for _ in range(27)], + _remote_graphql_response(DEFAULT_EXPECTED_GITHUB_COHORT_COUNT), + ] + ) + + receipt = _collect( + session=session, + cohort_count=DEFAULT_EXPECTED_GITHUB_COHORT_COUNT, + ) + + assert len(session.calls) == 28 + assert receipt["request_budget"]["stop_reason"] is None + assert all( + repository["repository"]["state"] == "observed" + and repository["repository"]["reason_code"] == "observed" + and repository["repository"]["default_branch"] == "main" + and len(repository["repository"]["head_sha"]) == 40 + for repository in receipt["repositories"].values() + ) + + +def test_graphql_rate_limit_marks_remote_cut_with_exact_reason_code() -> None: + outcome = OUTCOME_FIXTURES["rate_limited"] + session = _Session( + [ + *[_Response() for _ in range(27)], + _Response( + 200, + { + "data": None, + "errors": [{"message": outcome["message"]}], + }, + ), + ] + ) + + receipt = _collect( + session=session, + cohort_count=DEFAULT_EXPECTED_GITHUB_COHORT_COUNT, + ) + + assert receipt["request_budget"]["stop_reason"] == "rate_limited" + assert all( + repository["repository"]["state"] == "rate_limited" + and repository["repository"]["reason_code"] == outcome["reason_code"] + for repository in receipt["repositories"].values() + ) def test_incremental_eligibility_preflight_skips_plan_blocked_providers() -> None: @@ -270,9 +383,9 @@ def test_incremental_eligibility_preflight_skips_plan_blocked_providers() -> Non receipt = _collect(session=session, prior=prior) - assert len(session.calls) == 38 - assert receipt["request_budget"]["base_requests"] == 38 - assert receipt["request_budget"]["total_requests"] == 38 + assert len(session.calls) == 39 + assert receipt["request_budget"]["base_requests"] == 39 + assert receipt["request_budget"]["total_requests"] == 39 assert receipt["request_budget"]["stop_reason"] is None assert receipt["eligibility"]["state"] == "observed" assert receipt["eligibility"]["request_count"] == 2 @@ -397,12 +510,13 @@ def test_collector_rejects_relaxed_request_bounds_before_network( def test_rate_limit_stops_immediately_and_leaves_remainder_not_requested() -> None: + outcome = OUTCOME_FIXTURES["rate_limited"] session = _Session( [ _Response( - 403, - {"message": "API rate limit exceeded"}, - headers={"X-RateLimit-Remaining": "0"}, + outcome["status_code"], + {"message": outcome["message"]}, + headers={"X-RateLimit-Remaining": outcome["remaining"]}, ) ] ) @@ -418,6 +532,10 @@ def test_rate_limit_stops_immediately_and_leaves_remainder_not_requested() -> No assert states.count("rate_limited") == 1 assert states.count("not_requested") == 47 assert receipt["request_budget"]["stop_reason"] == "rate_limited" + first = receipt["repositories"]["owner/repo-00"]["providers"]["dependabot"] + assert first["reason_code"] == outcome["reason_code"] + assert first["completed"] is False + assert first["zero_findings"] is None def test_quota_reserve_stops_before_following_request() -> None: @@ -455,9 +573,10 @@ def test_total_request_ceiling_halts_incomplete_pagination() -> None: def test_forbidden_and_feature_unavailable_are_distinct() -> None: + outcome = OUTCOME_FIXTURES["partial_coverage"] session = _Session( [ - _Response(403, {"message": "Resource not accessible by integration"}), + _Response(outcome["status_code"], {"message": outcome["message"]}), _Response(403, {"message": "Advanced Security must be enabled"}), ] ) @@ -466,11 +585,28 @@ def test_forbidden_and_feature_unavailable_are_distinct() -> None: first = receipt["repositories"]["owner/repo-00"]["providers"] assert first["dependabot"]["state"] == "forbidden" + assert first["dependabot"]["reason_code"] == outcome["reason_code"] + assert first["dependabot"]["completed"] is False + assert first["dependabot"]["zero_findings"] is None assert first["dependabot"]["counts"] is None assert first["code_scanning"]["state"] == "feature_unavailable" + assert first["code_scanning"]["reason_code"] == "provider_unavailable" assert first["code_scanning"]["counts"] is None +def test_malformed_provider_payload_has_exact_fail_closed_reason_code() -> None: + outcome = OUTCOME_FIXTURES["malformed_provider_data"] + session = _Session([_Response(200, outcome["payload"])]) + + receipt = _collect(session=session) + provider = receipt["repositories"]["owner/repo-00"]["providers"]["dependabot"] + + assert provider["state"] == "malformed" + assert provider["reason_code"] == outcome["reason_code"] + assert provider["completed"] is False + assert provider["zero_findings"] is None + + def test_conditional_304_reuses_only_valid_prior_counts() -> None: prior = _collect() for repository in prior["repositories"].values(): @@ -495,10 +631,13 @@ def test_conditional_304_reuses_only_valid_prior_counts() -> None: def test_stale_provider_observation_becomes_unknown_count() -> None: + outcome = OUTCOME_FIXTURES["stale_observation"] receipt = _collect() receipt["produced_at"] = NOW.isoformat() provider = receipt["repositories"]["owner/repo-00"]["providers"]["dependabot"] - provider["observed_at"] = (NOW - timedelta(hours=25)).isoformat() + provider["observed_at"] = ( + NOW - timedelta(hours=outcome["age_hours"]) + ).isoformat() loaded = validate_security_coverage_receipt( receipt, expected_cohort_count=16, now=NOW @@ -506,9 +645,29 @@ def test_stale_provider_observation_becomes_unknown_count() -> None: normalized = loaded.entries_by_full_name["owner/repo-00"]["providers"]["dependabot"] assert normalized["state"] == "stale" + assert normalized["reason_code"] == outcome["reason_code"] + assert normalized["completed"] is False + assert normalized["zero_findings"] is None assert normalized["counts"] is None +def test_successful_empty_provider_response_is_explicit_completed_zero() -> None: + outcome = OUTCOME_FIXTURES["successful_zero_findings"] + receipt = _collect(session=_Session([_Response(200, outcome["payload"])])) + provider = receipt["repositories"]["owner/repo-00"]["providers"]["dependabot"] + + assert provider["state"] == "observed" + assert provider["reason_code"] == outcome["reason_code"] + assert provider["completed"] is True + assert provider["zero_findings"] is outcome["zero_findings"] + assert provider["counts"] == { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + } + + def test_receipt_loader_uses_embedded_provenance_not_newer_mtime( tmp_path: Path, ) -> None: @@ -565,6 +724,26 @@ def test_receipt_provenance_and_provider_timestamps_fail_closed(tmp_path: Path) ) +def test_receipt_producer_must_match_expected_canonical_commit() -> None: + receipt = _collect() + + loaded = validate_security_coverage_receipt( + receipt, + expected_cohort_count=16, + expected_producer_commit="a" * 40, + now=NOW, + ) + assert loaded.producer_commit == "a" * 40 + + with pytest.raises(SecurityCoverageError, match="producer commit mismatch"): + validate_security_coverage_receipt( + receipt, + expected_cohort_count=16, + expected_producer_commit="b" * 40, + now=NOW, + ) + + def test_canonical_receipt_join_does_not_fall_back_to_repo_basename() -> None: entry = { "receipt_schema_version": "GitHubSecurityCoverageReceiptV1", diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index c4aa4c4..e5e087e 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -527,6 +527,13 @@ def test_truth_snapshot_respects_declared_and_derived_fields( "dependabot_observed_count", "code_scanning_observed_count", "secret_scanning_observed_count", + "dependabot_zero_finding_count", + "code_scanning_zero_finding_count", + "secret_scanning_zero_finding_count", + "remote_default_branch_observed_count", + "remote_default_branch_partial_count", + "remote_default_branch_stale_count", + "remote_default_branch_unavailable_count", "coverage_state", "repos_with_open_high_critical", "total_open_high", @@ -1195,6 +1202,16 @@ def test_receipt_partial_provider_coverage_emits_explicit_denominators( "receipt_schema_version": "GitHubSecurityCoverageReceiptV1", "receipt_state": "fresh", "source_produced_at": now.isoformat(), + "repository": { + "source": "github-graphql-default-branch-head-v1", + "state": "observed", + "reason_code": "observed", + "reason": None, + "observed_at": now.isoformat(), + "default_branch": "main", + "head_sha": "b" * 40, + "archived": False, + }, "providers": { "dependabot": observed, "code_scanning": { @@ -1218,6 +1235,15 @@ def test_receipt_partial_provider_coverage_emits_explicit_denominators( now=now, security_alerts_by_name=security, ) + repeated = build_portfolio_truth_snapshot( + workspace_root=portfolio_workspace, + catalog_path=portfolio_catalog, + legacy_registry_path=legacy_registry, + include_notion=False, + now=now, + security_alerts_by_name=security, + ) + assert repeated.snapshot.to_dict() == result.snapshot.to_dict() alpha = next( project for project in result.snapshot.projects @@ -1237,6 +1263,17 @@ def test_receipt_partial_provider_coverage_emits_explicit_denominators( assert rollup["dependabot_observed_count"] == 1 assert rollup["code_scanning_observed_count"] == 0 assert rollup["secret_scanning_observed_count"] == 0 + assert rollup["remote_default_branch_observed_count"] == 1 + assert alpha.repository_state["remote_default_branch"] == { + "source": "github-graphql-default-branch-head-v1", + "state": "observed", + "reason_code": "observed", + "reason": None, + "observed_at": now.isoformat(), + "default_branch": "main", + "head_sha": "b" * 40, + "archived": False, + } def test_security_overlay_populates_and_force_elevates( @@ -2588,6 +2625,7 @@ def fake_publish(**_kwargs): assert captured["expected_cohort_count"] == 3 assert captured["max_age_hours"] == 12 + assert captured["expected_producer_commit"] is None def test_portfolio_truth_app_passes_validated_producer_receipt_to_publisher( From 5406549747c173c3c73ddce6f28b164af37ccf10 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 27 Jul 2026 20:52:00 -0700 Subject: [PATCH 2/5] ci: bound Ruff to reviewed lint contract --- CHANGELOG.md | 4 +++- pyproject.toml | 5 ++++- uv.lock | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 880451e..d93533f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,9 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) provider reason codes, explicit completed-zero semantics, producer-commit binding, and a same-pass GitHub default-branch/head observation for every repository in the security cohort. Missing credentials and stale or partial - observations now stay fail-closed instead of presenting zero as clean. + observations now stay fail-closed instead of presenting zero as clean. The + development dependency also bounds Ruff below `0.16` so CI retains its + reviewed lint contract until the expanded ruleset is migrated separately. - Migrated `config/portfolio-catalog.yaml` and the example catalog from `intended_disposition` to `operating_path` (179 + 3 entries; identity value mapping — `resolve_declared_operating_path` already treated disposition diff --git a/pyproject.toml b/pyproject.toml index 9dbe09a..e61682f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,10 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest>=8.0", - "ruff>=0.4", + # Ruff 0.16 enables a materially wider ruleset under the repository's + # implicit selection; keep CI on the reviewed 0.15 line until that migration + # is handled as its own cleanup change. + "ruff>=0.4,<0.16", "mypy>=1.10", "mutmut>=2.5", "responses>=0.25", diff --git a/uv.lock b/uv.lock index 8868808..523d6e1 100644 --- a/uv.lock +++ b/uv.lock @@ -712,7 +712,7 @@ requires-dist = [ { name = "requests", specifier = ">=2.31.0" }, { name = "responses", marker = "extra == 'dev'", specifier = ">=0.25" }, { name = "rich", specifier = ">=13.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4,<0.16" }, { name = "sentence-transformers", marker = "extra == 'semantic'", specifier = ">=3.0" }, { name = "shiv", marker = "extra == 'build'", specifier = ">=1.0" }, { name = "sqlite-vec", marker = "extra == 'semantic'", specifier = ">=0.1.0" }, From 01dab51ccd72f6801c0b76a7eef25f4c272dfc52 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 27 Jul 2026 20:54:56 -0700 Subject: [PATCH 3/5] fix: reconcile archived receipt fallback --- src/portfolio_truth_reconcile.py | 41 ++++++++++++--- tests/test_portfolio_truth.py | 88 ++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 8 deletions(-) diff --git a/src/portfolio_truth_reconcile.py b/src/portfolio_truth_reconcile.py index 5510f9f..019a6a8 100644 --- a/src/portfolio_truth_reconcile.py +++ b/src/portfolio_truth_reconcile.py @@ -950,17 +950,47 @@ def _build_truth_project( ), } + security_entry = _select_security_entry( + security_alerts_by_name or {}, + raw_project.get("repo_full_name"), + raw_project["name"], + ) + remote_repository = ( + dict(security_entry.get("repository") or {}) + if security_entry is not None + else {} + ) status_entry = _select_repo_status_entry( repo_status_by_name or {}, raw_project.get("repo_full_name"), raw_project["name"], ) - github_archived = bool(status_entry and status_entry.get("archived") is True) - if status_entry is not None: + live_status_available = bool( + status_entry and status_entry.get("source") == "github_api" + ) + remote_status_available = ( + remote_repository.get("state") == "observed" + and isinstance(remote_repository.get("archived"), bool) + ) + if live_status_available: + github_archived = status_entry.get("archived") is True provenance["github.archived"] = { - "source": str(status_entry.get("source") or "audit_report"), + "source": "github_api", "detail": str(github_archived).lower(), } + elif remote_status_available: + github_archived = remote_repository["archived"] is True + provenance["github.archived"] = { + "source": str(remote_repository.get("source") or "github_security"), + "detail": str(github_archived).lower(), + } + else: + github_archived = bool(status_entry and status_entry.get("archived") is True) + if status_entry is not None: + provenance["github.archived"] = { + "source": str(status_entry.get("source") or "audit_report"), + "detail": str(github_archived).lower(), + } last_activity = raw_project["last_meaningful_activity_at"] activity_status = _activity_status_for(last_activity, now=now) @@ -1001,11 +1031,6 @@ def _build_truth_project( "detail": "derived", } - security_entry = _select_security_entry( - security_alerts_by_name or {}, - raw_project.get("repo_full_name"), - raw_project["name"], - ) security = _build_security_fields(security_entry) # Only Dependabot high/critical counts drive the risk tier today. Code-scanning diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index e5e087e..f9a0508 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -1109,6 +1109,94 @@ def test_github_archived_status_reconciles_to_archived_attention( ) +def test_receipt_archived_state_is_fallback_when_live_status_is_unavailable( + portfolio_workspace: Path, + portfolio_catalog: Path, + legacy_registry: Path, +) -> None: + now = datetime.fromtimestamp(1_700_200_000, tz=timezone.utc) + alpha_path = portfolio_workspace / "Alpha" + subprocess.run(["git", "init"], cwd=alpha_path, capture_output=True, check=True) + subprocess.run( + ["git", "remote", "add", "origin", "https://github.com/d/Alpha.git"], + cwd=alpha_path, + capture_output=True, + check=True, + ) + security = { + "d/Alpha": { + "receipt_schema_version": "GitHubSecurityCoverageReceiptV1", + "receipt_state": "fresh", + "repository": { + "source": "github-graphql-default-branch-head-v1", + "state": "observed", + "reason_code": "observed", + "reason": None, + "observed_at": now.isoformat(), + "default_branch": "main", + "head_sha": "b" * 40, + "archived": True, + }, + "providers": {}, + } + } + stale_status = { + "Alpha": { + "full_name": "d/Alpha", + "archived": False, + "source": "audit_report", + } + } + + result = build_portfolio_truth_snapshot( + workspace_root=portfolio_workspace, + catalog_path=portfolio_catalog, + legacy_registry_path=legacy_registry, + include_notion=False, + now=now, + security_alerts_by_name=security, + repo_status_by_name=stale_status, + ) + alpha = next( + project + for project in result.snapshot.projects + if project.identity.display_name == "Alpha" + ) + + assert alpha.derived.archived is True + assert alpha.derived.attention_state == "archived" + assert alpha.provenance["github.archived"] == { + "source": "github-graphql-default-branch-head-v1", + "detail": "true", + } + + live_result = build_portfolio_truth_snapshot( + workspace_root=portfolio_workspace, + catalog_path=portfolio_catalog, + legacy_registry_path=legacy_registry, + include_notion=False, + now=now, + security_alerts_by_name=security, + repo_status_by_name={ + "Alpha": { + "full_name": "d/Alpha", + "archived": False, + "source": "github_api", + } + }, + ) + live_alpha = next( + project + for project in live_result.snapshot.projects + if project.identity.display_name == "Alpha" + ) + assert live_alpha.derived.archived is False + assert live_alpha.provenance["github.archived"] == { + "source": "github_api", + "detail": "false", + } + + def test_build_security_fields_maps_ghas_entry() -> None: from src.portfolio_truth_reconcile import _build_security_fields From 524ea4a7904c542ac055e2e7c04392cf88843abd Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 27 Jul 2026 21:00:20 -0700 Subject: [PATCH 4/5] fix: preserve lifecycle freshness precedence --- src/app/portfolio_truth.py | 6 ++++-- src/portfolio_truth_reconcile.py | 2 +- tests/test_portfolio_truth.py | 25 +++++++++++++++++++------ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/app/portfolio_truth.py b/src/app/portfolio_truth.py index 1730821..90ef2be 100644 --- a/src/app/portfolio_truth.py +++ b/src/app/portfolio_truth.py @@ -4,7 +4,6 @@ from pathlib import Path from typing import Any -from src.cache import ResponseCache from src.cli_output import print_info from src.github_security_coverage import DEFAULT_EXPECTED_GITHUB_COHORT_COUNT from src.portfolio_context_recovery import ( @@ -90,7 +89,10 @@ def run_portfolio_truth_mode(args: Any) -> None: repo_status_by_name = load_live_repo_status_by_name( username=args.username, token=getattr(args, "token", None), - cache=None if getattr(args, "no_cache", False) else ResponseCache(), + # Lifecycle state is reconciled against the fresh security receipt. + # Do not let the general API response cache masquerade as a newer + # repository-status observation. + cache=None, ) if repo_status_by_name is None: repo_status_by_name = load_repo_status_from_audit_by_name( diff --git a/src/portfolio_truth_reconcile.py b/src/portfolio_truth_reconcile.py index 019a6a8..eda84f1 100644 --- a/src/portfolio_truth_reconcile.py +++ b/src/portfolio_truth_reconcile.py @@ -969,7 +969,7 @@ def _build_truth_project( status_entry and status_entry.get("source") == "github_api" ) remote_status_available = ( - remote_repository.get("state") == "observed" + remote_repository.get("state") in {"observed", "partial"} and isinstance(remote_repository.get("archived"), bool) ) if live_status_available: diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index f9a0508..3d34978 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -1109,10 +1109,12 @@ def test_github_archived_status_reconciles_to_archived_attention( ) +@pytest.mark.parametrize("remote_state", ["observed", "partial"]) def test_receipt_archived_state_is_fallback_when_live_status_is_unavailable( portfolio_workspace: Path, portfolio_catalog: Path, legacy_registry: Path, + remote_state: str, ) -> None: now = datetime.fromtimestamp(1_700_200_000, tz=timezone.utc) alpha_path = portfolio_workspace / "Alpha" @@ -1129,12 +1131,20 @@ def test_receipt_archived_state_is_fallback_when_live_status_is_unavailable( "receipt_state": "fresh", "repository": { "source": "github-graphql-default-branch-head-v1", - "state": "observed", - "reason_code": "observed", + "state": remote_state, + "reason_code": ( + "observed" + if remote_state == "observed" + else "default_branch_head_unavailable" + ), "reason": None, "observed_at": now.isoformat(), - "default_branch": "main", - "head_sha": "b" * 40, + "default_branch": ( + "main" if remote_state == "observed" else None + ), + "head_sha": ( + "b" * 40 if remote_state == "observed" else None + ), "archived": True, }, "providers": {}, @@ -2688,7 +2698,9 @@ def fake_publish(**_kwargs): ) monkeypatch.setattr("src.app.portfolio_truth.publish_portfolio_truth", fake_publish) monkeypatch.setattr( - "src.app.portfolio_truth.load_live_repo_status_by_name", lambda **_kwargs: {} + "src.app.portfolio_truth.load_live_repo_status_by_name", + lambda **kwargs: captured.setdefault("repo_status_cache", kwargs["cache"]) + or {}, ) monkeypatch.setenv("GHRA_REQUIRE_PRODUCER_EVIDENCE", "0") args = SimpleNamespace( @@ -2700,7 +2712,7 @@ def fake_publish(**_kwargs): catalog=None, username="testuser", token=None, - no_cache=True, + no_cache=False, portfolio_truth_include_release_count=False, portfolio_truth_include_security=True, portfolio_truth_security_receipt=None, @@ -2714,6 +2726,7 @@ def fake_publish(**_kwargs): assert captured["expected_cohort_count"] == 3 assert captured["max_age_hours"] == 12 assert captured["expected_producer_commit"] is None + assert captured["repo_status_cache"] is None def test_portfolio_truth_app_passes_validated_producer_receipt_to_publisher( From 4387067a4dc36e234ec34c11a678ba12c9fd71f3 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 27 Jul 2026 21:07:08 -0700 Subject: [PATCH 5/5] fix: count transient remote failures --- src/portfolio_truth_reconcile.py | 1 + tests/test_portfolio_truth.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/portfolio_truth_reconcile.py b/src/portfolio_truth_reconcile.py index eda84f1..45e2b3d 100644 --- a/src/portfolio_truth_reconcile.py +++ b/src/portfolio_truth_reconcile.py @@ -536,6 +536,7 @@ def _build_coverage_envelope( "forbidden", "not_found", "rate_limited", + "transient_error", "malformed", "not_requested", "unknown", diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index 3d34978..c567976 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -1373,6 +1373,34 @@ def test_receipt_partial_provider_coverage_emits_explicit_denominators( "archived": False, } + security["d/Alpha"]["repository"] = { + "source": "github-graphql-default-branch-head-v1", + "state": "transient_error", + "reason_code": "transient_error", + "reason": "GitHub GraphQL request failed", + "observed_at": now.isoformat(), + "default_branch": None, + "head_sha": None, + "archived": None, + } + transient_result = build_portfolio_truth_snapshot( + workspace_root=portfolio_workspace, + catalog_path=portfolio_catalog, + legacy_registry_path=legacy_registry, + include_notion=False, + now=now, + security_alerts_by_name=security, + ) + github_coverage = next( + item + for item in transient_result.snapshot.to_dict()["coverage"] + if item["source"] == "github_security" + ) + assert github_coverage["remote_default_branch_counts"]["transient_error"] == 1 + assert sum(github_coverage["remote_default_branch_counts"].values()) == ( + github_coverage["project_count"] + ) + def test_security_overlay_populates_and_force_elevates( portfolio_workspace: Path,