From 59f94c9f1d44e2479bc306b2277181484a5ea952 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Wed, 5 Aug 2026 00:12:53 -0700 Subject: [PATCH 1/5] feat(security): converge portfolio admission semantics --- CHANGELOG.md | 8 + docs/portfolio-decision-contract.md | 17 +- docs/security-model.md | 19 +- .../portfolio-command-center-v1/manifest.json | 2 +- .../portfolio-truth.json | 4 +- output/demo/pending-proposals.json | 16 +- output/demo/portfolio-truth-history-01.json | 38 +- output/demo/portfolio-truth-history-02.json | 38 +- output/demo/portfolio-truth-history-03.json | 38 +- output/demo/portfolio-truth-history-04.json | 38 +- output/demo/portfolio-truth-history-05.json | 38 +- output/demo/portfolio-truth-history-06.json | 38 +- output/demo/portfolio-truth-history-07.json | 36 +- output/demo/portfolio-truth-history-08.json | 32 +- output/demo/portfolio-truth-history-09.json | 26 +- output/demo/portfolio-truth-latest.json | 26 +- output/demo/security-burndown-demo.json | 10 +- output/demo/weekly-command-center-demo.json | 149 +++++- src/demo_portfolio.py | 92 +++- src/portfolio_decision_queue.py | 87 ++-- src/portfolio_risk.py | 12 +- src/portfolio_security_gate.py | 93 ++-- src/portfolio_truth_reconcile.py | 36 +- src/portfolio_truth_render.py | 94 ++-- src/portfolio_truth_types.py | 26 +- src/portfolio_truth_validate.py | 77 +-- src/security_admission.py | 320 ++++++++++++ src/weekly_command_center.py | 71 ++- ...ortfolio_decision_current_security_v2.json | 48 +- tests/test_demo_portfolio.py | 68 ++- tests/test_portfolio_decision_queue.py | 74 ++- tests/test_portfolio_risk.py | 2 +- tests/test_portfolio_security_gate.py | 67 ++- tests/test_portfolio_truth.py | 483 ++++++++++-------- tests/test_security_admission.py | 246 +++++++++ tests/test_weekly_command_center.py | 118 ++++- 36 files changed, 1877 insertions(+), 710 deletions(-) create mode 100644 src/security_admission.py create mode 100644 tests/test_security_admission.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b6493789..75ac5480 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,14 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) files, alter portfolio scoring, or regenerate portfolio truth. ### Changed +- Converged the strict security gate, risk/attention derivation, portfolio + rollups, registry/report rendering, decision digest, and weekly command-center + posture on one + fail-closed `SecurityAdmissionV1` interpretation of fresh Dependabot, CodeQL, + and secret-scanning evidence. Missing or contradictory evidence now carries + explicit reason codes; non-Dependabot blocking findings can no longer vanish + from risk or decision surfaces. PortfolioTruth remains schema `0.11.0` and + retains its legacy compatibility fields for existing consumers. - Kept PortfolioTruth `0.11.0` and `GitHubSecurityCoverageReceiptV1` read-compatible while adding normalized provider reason codes, explicit completed-zero semantics, producer-commit diff --git a/docs/portfolio-decision-contract.md b/docs/portfolio-decision-contract.md index 82414b11..9b80f44d 100644 --- a/docs/portfolio-decision-contract.md +++ b/docs/portfolio-decision-contract.md @@ -11,17 +11,24 @@ generation `decision_fingerprint`, the exact question and accountable owner, the allowed `accept` / `defer` / `reject` outcomes, an operator approval boundary, an immutable evidence reference, `evaluated_at`, `evidence_observed_at`, `valid_until`, source generations, and a readback -contract. The current security policy expires 36 hours after the underlying -Dependabot observation, reflecting the verified daily producer cadence plus -one bounded grace window. Regenerating PortfolioTruth never moves that clock. +contract. The current security policy expires 36 hours after the oldest +admitted Dependabot, CodeQL, or secret-scanning observation, reflecting the +verified daily producer cadence plus one bounded grace window. Regenerating +PortfolioTruth never moves that clock. + +Security decisions carry the shared `SecurityAdmissionV1` identity and the +provider-specific blocking counts. A CodeQL high/critical finding or an open +secret-scanning finding enters the same repository-scoped decision contract as +a Dependabot high/critical finding. Incomplete or contradictory admission is +retained under `withheld_decisions` with the canonical `SECURITY_*` reason code. Incomplete owner decisions are emitted under `withheld_decisions` with stable reason codes. They are not actionable and consumers must not invent a question or options. A prior digest may be supplied with `--previous-digest`; the next generation then records the superseding generation and fingerprint. A missing decision closes only when a newer complete GitHub security receipt establishes -authoritative absence. Bridge `SHIPPED` is supporting evidence only and is -explicitly barred from satisfying the readback contract. +authoritative absence across all three providers. Bridge `SHIPPED` is supporting +evidence only and is explicitly barred from satisfying the readback contract. The machine-readable shape is `config/portfolio-decision-digest-v2.schema.json`. Generate JSON and Markdown diff --git a/docs/security-model.md b/docs/security-model.md index 5ac59de4..b01212de 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -64,7 +64,7 @@ Output lands in `output/ghas-alerts--.json`. Excel and control-cente Run `audit security-gate --output-dir output` after generating portfolio truth with `--portfolio-truth-include-security`. The gate reads `output/portfolio-truth-latest.json` and exits nonzero when any scanned repo has open -high/critical Dependabot alerts. +high/critical Dependabot or CodeQL alerts, or an open secret-scanning finding. Use `--max-age-hours N` when the caller needs freshness enforcement. For example, `audit security-gate --output-dir output --max-age-hours 168` fails as `STALE` when @@ -74,6 +74,23 @@ The gate is deliberately strict: a snapshot with no scanned security overlay is reported as `UNKNOWN`, not clear. This prevents a missing GHAS overlay from looking like a clean portfolio. +All PortfolioTruth security consumers now use the same internal +`SecurityAdmissionV1` derivation. It admits a repo as clear only when the receipt is +fresh, coverage is complete, all three provider observations are complete, clocks +are monotonic, compatibility counts match provider counts, and no blocking finding +is present. Partial, stale, missing, malformed, or contradictory evidence carries +explicit `SECURITY_*` reason codes and cannot render green. Known findings remain +visible even when another provider is unavailable, but the evidence stays +unadmitted and the decision queue withholds actionability until the complete +evidence contract is restored. + +This is a consumer-semantics convergence, not a PortfolioTruth schema migration: +the producer remains at `0.11.0`, existing compatibility count fields remain, and +the legacy Dependabot-only `open_high_critical` project field is retained for old +readers. Risk/attention, the strict gate, portfolio rollups, registry/report +rendering, the decision digest, and the weekly command-center security posture use +the combined admission result. + ## OSSF Scorecard Pass `--ossf-scorecard` to enrich each repo with pre-computed OSSF Scorecard data. diff --git a/fixtures/contracts/portfolio-command-center-v1/manifest.json b/fixtures/contracts/portfolio-command-center-v1/manifest.json index 938b7c4a..e35fd44f 100644 --- a/fixtures/contracts/portfolio-command-center-v1/manifest.json +++ b/fixtures/contracts/portfolio-command-center-v1/manifest.json @@ -24,7 +24,7 @@ "portfolio_truth_schema_version": "0.11.0", "producer": { "artifact_path": "fixtures/contracts/portfolio-command-center-v1/portfolio-truth.json", - "artifact_sha256": "5b12c9851e43f82bcaf872f900539d5a5b67da9d405a7e73f7d3a20bfcdb9eae", + "artifact_sha256": "3838ee7bb814170691bebe5506a9f55fefa1315b9436f4f24c4c041409f0b258", "generator": "src.portfolio_truth_contract_fixture:build_contract_fixture", "manifest_path": "fixtures/contracts/portfolio-command-center-v1/manifest.json", "repository": "saagpatel/GithubRepoAuditor" diff --git a/fixtures/contracts/portfolio-command-center-v1/portfolio-truth.json b/fixtures/contracts/portfolio-command-center-v1/portfolio-truth.json index 269829d7..bcb1ded6 100644 --- a/fixtures/contracts/portfolio-command-center-v1/portfolio-truth.json +++ b/fixtures/contracts/portfolio-command-center-v1/portfolio-truth.json @@ -1492,7 +1492,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "risk_tier": "moderate", "security_risk": true }, @@ -1609,9 +1609,11 @@ "scanned_count": 1, "secret_scanning_observed_count": 1, "secret_scanning_zero_finding_count": 1, + "security_admission_schema_version": "SecurityAdmissionV1", "stale_count": 1, "total_open_critical": 0, "total_open_high": 3, + "total_open_secrets": 0, "unavailable_count": 3, "unknown_count": 1 } diff --git a/output/demo/pending-proposals.json b/output/demo/pending-proposals.json index 86684b62..051bd1c8 100644 --- a/output/demo/pending-proposals.json +++ b/output/demo/pending-proposals.json @@ -8,7 +8,7 @@ "repo_full_name": "demo-org/solstice-cairn", "description": "Open an auto-PR improving the managed context block for Solstice Cairn.", "status": "pending", - "created_at": "2026-07-31T22:50:13.825998+00:00", + "created_at": "2026-08-03T20:46:39.243078+00:00", "approved_at": "", "approved_by": "", "rejected_at": "", @@ -22,8 +22,8 @@ "repo_full_name": "demo-org/tabard-anvil", "description": "Apply catalog seed updates for Tabard Anvil.", "status": "approved", - "created_at": "2026-07-31T22:50:13.825998+00:00", - "approved_at": "2026-08-01T19:50:13.825998+00:00", + "created_at": "2026-08-03T20:46:39.243078+00:00", + "approved_at": "2026-08-04T17:46:39.243078+00:00", "approved_by": "demo-operator", "rejected_at": "", "executed_at": "", @@ -36,10 +36,10 @@ "repo_full_name": "demo-org/umbra-trellis", "description": "Open an auto-PR improving the managed context block for Umbra Trellis.", "status": "rejected", - "created_at": "2026-07-31T22:50:13.825998+00:00", + "created_at": "2026-08-03T20:46:39.243078+00:00", "approved_at": "", "approved_by": "", - "rejected_at": "2026-08-01T19:50:13.825998+00:00", + "rejected_at": "2026-08-04T17:46:39.243078+00:00", "executed_at": "", "execution_ref": "" }, @@ -50,11 +50,11 @@ "repo_full_name": "demo-org/vellum-compass", "description": "Apply catalog seed updates for Vellum Compass.", "status": "executed", - "created_at": "2026-07-31T22:50:13.825998+00:00", - "approved_at": "2026-08-01T19:50:13.825998+00:00", + "created_at": "2026-08-03T20:46:39.243078+00:00", + "approved_at": "2026-08-04T17:46:39.243078+00:00", "approved_by": "demo-operator", "rejected_at": "", - "executed_at": "2026-08-01T20:50:13.825998+00:00", + "executed_at": "2026-08-04T18:46:39.243078+00:00", "execution_ref": "demo-run-0004" } ] diff --git a/output/demo/portfolio-truth-history-01.json b/output/demo/portfolio-truth-history-01.json index 7ac14a9d..2959f4a0 100644 --- a/output/demo/portfolio-truth-history-01.json +++ b/output/demo/portfolio-truth-history-01.json @@ -2607,7 +2607,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -2971,7 +2971,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -3334,7 +3334,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -3699,7 +3699,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -4914,7 +4914,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -5272,7 +5272,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -7412,7 +7412,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -7776,7 +7776,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8140,7 +8140,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8504,7 +8504,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8869,7 +8869,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -10083,7 +10083,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -10449,7 +10449,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -10812,7 +10812,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -11174,7 +11174,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -11530,7 +11530,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -13057,8 +13057,10 @@ "remote_default_branch_unavailable_count": 22, "coverage_state": "partial", "repos_with_open_high_critical": 16, - "total_open_high": 133, - "total_open_critical": 5 + "total_open_high": 137, + "total_open_critical": 6, + "total_open_secrets": 1, + "security_admission_schema_version": "SecurityAdmissionV1" }, "decision": { "decision_needed_count": 13, diff --git a/output/demo/portfolio-truth-history-02.json b/output/demo/portfolio-truth-history-02.json index 503c0ebc..50f4277f 100644 --- a/output/demo/portfolio-truth-history-02.json +++ b/output/demo/portfolio-truth-history-02.json @@ -2607,7 +2607,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -2971,7 +2971,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -3334,7 +3334,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -3699,7 +3699,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -4914,7 +4914,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -5272,7 +5272,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -7412,7 +7412,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -7776,7 +7776,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8140,7 +8140,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8504,7 +8504,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8869,7 +8869,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -10083,7 +10083,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -10449,7 +10449,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -10812,7 +10812,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -11174,7 +11174,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -11530,7 +11530,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -13057,8 +13057,10 @@ "remote_default_branch_unavailable_count": 22, "coverage_state": "partial", "repos_with_open_high_critical": 16, - "total_open_high": 117, - "total_open_critical": 5 + "total_open_high": 121, + "total_open_critical": 6, + "total_open_secrets": 1, + "security_admission_schema_version": "SecurityAdmissionV1" }, "decision": { "decision_needed_count": 13, diff --git a/output/demo/portfolio-truth-history-03.json b/output/demo/portfolio-truth-history-03.json index d4bb10e0..ae3a92f1 100644 --- a/output/demo/portfolio-truth-history-03.json +++ b/output/demo/portfolio-truth-history-03.json @@ -2607,7 +2607,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -2971,7 +2971,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -3334,7 +3334,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -3699,7 +3699,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -4914,7 +4914,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -5272,7 +5272,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -7412,7 +7412,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -7776,7 +7776,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8140,7 +8140,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8504,7 +8504,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8869,7 +8869,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -10083,7 +10083,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -10449,7 +10449,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -10812,7 +10812,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -11174,7 +11174,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -11530,7 +11530,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -13057,8 +13057,10 @@ "remote_default_branch_unavailable_count": 22, "coverage_state": "partial", "repos_with_open_high_critical": 16, - "total_open_high": 101, - "total_open_critical": 5 + "total_open_high": 105, + "total_open_critical": 6, + "total_open_secrets": 1, + "security_admission_schema_version": "SecurityAdmissionV1" }, "decision": { "decision_needed_count": 13, diff --git a/output/demo/portfolio-truth-history-04.json b/output/demo/portfolio-truth-history-04.json index e3b7b943..018c8ef2 100644 --- a/output/demo/portfolio-truth-history-04.json +++ b/output/demo/portfolio-truth-history-04.json @@ -2607,7 +2607,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -2971,7 +2971,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -3334,7 +3334,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -3699,7 +3699,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -4914,7 +4914,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -5272,7 +5272,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -7412,7 +7412,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -7776,7 +7776,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8140,7 +8140,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8504,7 +8504,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8869,7 +8869,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -10083,7 +10083,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -10449,7 +10449,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -10812,7 +10812,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -11174,7 +11174,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -11530,7 +11530,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -13057,8 +13057,10 @@ "remote_default_branch_unavailable_count": 22, "coverage_state": "partial", "repos_with_open_high_critical": 16, - "total_open_high": 85, - "total_open_critical": 5 + "total_open_high": 89, + "total_open_critical": 6, + "total_open_secrets": 1, + "security_admission_schema_version": "SecurityAdmissionV1" }, "decision": { "decision_needed_count": 13, diff --git a/output/demo/portfolio-truth-history-05.json b/output/demo/portfolio-truth-history-05.json index 73da5053..450bcba9 100644 --- a/output/demo/portfolio-truth-history-05.json +++ b/output/demo/portfolio-truth-history-05.json @@ -2607,7 +2607,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -2971,7 +2971,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -3334,7 +3334,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -3699,7 +3699,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -4914,7 +4914,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -5272,7 +5272,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -7412,7 +7412,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -7776,7 +7776,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8140,7 +8140,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8504,7 +8504,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8869,7 +8869,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -10083,7 +10083,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -10449,7 +10449,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -10812,7 +10812,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -11174,7 +11174,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -11530,7 +11530,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -13057,8 +13057,10 @@ "remote_default_branch_unavailable_count": 22, "coverage_state": "partial", "repos_with_open_high_critical": 16, - "total_open_high": 69, - "total_open_critical": 5 + "total_open_high": 73, + "total_open_critical": 6, + "total_open_secrets": 1, + "security_admission_schema_version": "SecurityAdmissionV1" }, "decision": { "decision_needed_count": 13, diff --git a/output/demo/portfolio-truth-history-06.json b/output/demo/portfolio-truth-history-06.json index a36893c4..f42cfa05 100644 --- a/output/demo/portfolio-truth-history-06.json +++ b/output/demo/portfolio-truth-history-06.json @@ -2607,7 +2607,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -2971,7 +2971,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -3334,7 +3334,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -3699,7 +3699,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -4914,7 +4914,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -5272,7 +5272,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -7412,7 +7412,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -7776,7 +7776,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8140,7 +8140,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8504,7 +8504,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8869,7 +8869,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -10083,7 +10083,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -10449,7 +10449,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -10812,7 +10812,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -11174,7 +11174,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -11530,7 +11530,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -13057,8 +13057,10 @@ "remote_default_branch_unavailable_count": 22, "coverage_state": "partial", "repos_with_open_high_critical": 16, - "total_open_high": 53, - "total_open_critical": 5 + "total_open_high": 57, + "total_open_critical": 6, + "total_open_secrets": 1, + "security_admission_schema_version": "SecurityAdmissionV1" }, "decision": { "decision_needed_count": 13, diff --git a/output/demo/portfolio-truth-history-07.json b/output/demo/portfolio-truth-history-07.json index 6b091d06..97b803f1 100644 --- a/output/demo/portfolio-truth-history-07.json +++ b/output/demo/portfolio-truth-history-07.json @@ -2608,7 +2608,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -2972,7 +2972,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -3335,7 +3335,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -3700,7 +3700,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -4915,7 +4915,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -5273,7 +5273,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -7413,7 +7413,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -7777,7 +7777,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8503,7 +8503,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8868,7 +8868,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -10082,7 +10082,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -10448,7 +10448,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -10811,7 +10811,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -11173,7 +11173,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -11529,7 +11529,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -13056,8 +13056,10 @@ "remote_default_branch_unavailable_count": 22, "coverage_state": "partial", "repos_with_open_high_critical": 15, - "total_open_high": 37, - "total_open_critical": 5 + "total_open_high": 41, + "total_open_critical": 6, + "total_open_secrets": 1, + "security_admission_schema_version": "SecurityAdmissionV1" }, "decision": { "decision_needed_count": 12, diff --git a/output/demo/portfolio-truth-history-08.json b/output/demo/portfolio-truth-history-08.json index bbfc069d..fcd2123d 100644 --- a/output/demo/portfolio-truth-history-08.json +++ b/output/demo/portfolio-truth-history-08.json @@ -2609,7 +2609,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -2973,7 +2973,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -3336,7 +3336,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -3701,7 +3701,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -4916,7 +4916,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -5274,7 +5274,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -7414,7 +7414,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -7778,7 +7778,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8504,7 +8504,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8869,7 +8869,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -10083,7 +10083,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -10811,7 +10811,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -11173,7 +11173,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -13054,8 +13054,10 @@ "remote_default_branch_unavailable_count": 22, "coverage_state": "partial", "repos_with_open_high_critical": 13, - "total_open_high": 26, - "total_open_critical": 5 + "total_open_high": 30, + "total_open_critical": 6, + "total_open_secrets": 1, + "security_admission_schema_version": "SecurityAdmissionV1" }, "decision": { "decision_needed_count": 10, diff --git a/output/demo/portfolio-truth-history-09.json b/output/demo/portfolio-truth-history-09.json index 95e74c75..46b5de52 100644 --- a/output/demo/portfolio-truth-history-09.json +++ b/output/demo/portfolio-truth-history-09.json @@ -2609,7 +2609,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -2973,7 +2973,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -3336,7 +3336,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -3701,7 +3701,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -4916,7 +4916,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -7776,7 +7776,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8502,7 +8502,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8867,7 +8867,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -10081,7 +10081,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -11169,7 +11169,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -13050,8 +13050,10 @@ "remote_default_branch_unavailable_count": 22, "coverage_state": "partial", "repos_with_open_high_critical": 10, - "total_open_high": 20, - "total_open_critical": 5 + "total_open_high": 24, + "total_open_critical": 6, + "total_open_secrets": 1, + "security_admission_schema_version": "SecurityAdmissionV1" }, "decision": { "decision_needed_count": 9, diff --git a/output/demo/portfolio-truth-latest.json b/output/demo/portfolio-truth-latest.json index 95e74c75..46b5de52 100644 --- a/output/demo/portfolio-truth-latest.json +++ b/output/demo/portfolio-truth-latest.json @@ -2609,7 +2609,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -2973,7 +2973,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -3336,7 +3336,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -3701,7 +3701,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -4916,7 +4916,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -7776,7 +7776,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8502,7 +8502,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -8867,7 +8867,7 @@ "no-run-instructions", "active-high-severity-alerts" ], - "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, open high/critical security alerts.", + "risk_summary": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", "doctor_gap": false, "context_risk": true, "path_risk": true, @@ -10081,7 +10081,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -11169,7 +11169,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "doctor_gap": false, "context_risk": false, "path_risk": false, @@ -13050,8 +13050,10 @@ "remote_default_branch_unavailable_count": 22, "coverage_state": "partial", "repos_with_open_high_critical": 10, - "total_open_high": 20, - "total_open_critical": 5 + "total_open_high": 24, + "total_open_critical": 6, + "total_open_secrets": 1, + "security_admission_schema_version": "SecurityAdmissionV1" }, "decision": { "decision_needed_count": 9, diff --git a/output/demo/security-burndown-demo.json b/output/demo/security-burndown-demo.json index 12f73249..91d51904 100644 --- a/output/demo/security-burndown-demo.json +++ b/output/demo/security-burndown-demo.json @@ -26,8 +26,8 @@ "Aurora Ledger", "Basalt Relay", "Cinder Atlas", - "Ember Conduit", - "Glacier Quill" + "Meridian Vault", + "Onyx Placard" ], "affected_repo_count": 5 }, @@ -39,9 +39,9 @@ "first_patched_version": "0.9.2", "affected_repos": [ "Cinder Atlas", - "Ember Conduit", - "Glacier Quill", - "Ivory Sextant" + "Meridian Vault", + "Onyx Placard", + "Ember Conduit" ], "affected_repo_count": 4 } diff --git a/output/demo/weekly-command-center-demo.json b/output/demo/weekly-command-center-demo.json index 1400f3b0..a7993ab8 100644 --- a/output/demo/weekly-command-center-demo.json +++ b/output/demo/weekly-command-center-demo.json @@ -1,7 +1,7 @@ { "username": "demo-operator", - "generated_at": "2026-08-01T22:50:13.825998+00:00", - "headline": "10 projects carry open high or critical alerts.", + "generated_at": "2026-08-04T20:46:39.243078+00:00", + "headline": "10 projects carry blocking GitHub security findings.", "decision": "Clear Aurora Ledger before starting lower-pressure cleanup.", "why_this_week": "Aurora Ledger holds the largest observed alert backlog and is the only flagship blocked on a security decision.", "next_step": "Open the burndown view, confirm the grouped fix, then record the decision.", @@ -9,92 +9,189 @@ "elevated_count": 10, "risk_tier_counts": { "elevated": 10, - "moderate": 12, - "baseline": 10, - "deferred": 8 + "moderate": 6, + "baseline": 13, + "deferred": 11 }, "top_elevated": [ { "repo": "flagship/aurora-ledger", "risk_tier": "elevated", - "risk_summary": "Open high or critical alerts need an operator decision." + "risk_summary": "1 risk factor(s): blocking GitHub security findings." }, { - "repo": "flagship/basalt-relay", + "repo": "flagship/cinder-atlas", "risk_tier": "elevated", - "risk_summary": "Open high or critical alerts need an operator decision." + "risk_summary": "1 risk factor(s): blocking GitHub security findings." }, { - "repo": "flagship/cinder-atlas", + "repo": "lab/foxglove-mast", "risk_tier": "elevated", - "risk_summary": "Open high or critical alerts need an operator decision." + "risk_summary": "3 risk factor(s): weak context quality, investigate override active, run instructions missing." }, { - "repo": "platform/ember-conduit", + "repo": "lab/pallas-runner", "risk_tier": "elevated", - "risk_summary": "Open high or critical alerts need an operator decision." + "risk_summary": "3 risk factor(s): weak context quality, investigate override active, run instructions missing." }, { - "repo": "studio/glacier-quill", + "repo": "lab/tabard-anvil", "risk_tier": "elevated", - "risk_summary": "Open high or critical alerts need an operator decision." + "risk_summary": "3 risk factor(s): weak context quality, investigate override active, run instructions missing." } ] }, "security_posture": { "scanned_count": 10, "repos_with_open_high_critical": 10, - "total_open_critical": 5, - "total_open_high": 20, + "repos_with_blocking_findings": 10, + "unadmitted_count": 8, + "admission_status_counts": { + "fail": 10, + "unknown": 4, + "pass": 4 + }, + "total_open_critical": 6, + "total_open_high": 24, + "total_open_secrets": 1, "top_alerts": [ { "repo": "flagship/aurora-ledger", "risk_tier": "elevated", "dependabot_critical": 1, - "dependabot_high": 4 + "dependabot_high": 4, + "code_scanning_critical": 0, + "code_scanning_high": 2, + "secret_scanning_open": 0, + "total_open_critical": 1, + "total_open_high": 6, + "security_admission_status": "fail", + "security_admission_evidence_complete": true, + "security_admission_reason_codes": [ + "SECURITY_ADMISSION_FINDINGS" + ] }, { "repo": "flagship/cinder-atlas", "risk_tier": "elevated", "dependabot_critical": 2, - "dependabot_high": 3 + "dependabot_high": 3, + "code_scanning_critical": 1, + "code_scanning_high": 0, + "secret_scanning_open": 1, + "total_open_critical": 3, + "total_open_high": 3, + "security_admission_status": "fail", + "security_admission_evidence_complete": true, + "security_admission_reason_codes": [ + "SECURITY_ADMISSION_FINDINGS" + ] }, { "repo": "studio/glacier-quill", "risk_tier": "elevated", "dependabot_critical": 1, - "dependabot_high": 2 + "dependabot_high": 2, + "code_scanning_critical": 0, + "code_scanning_high": 1, + "secret_scanning_open": 0, + "total_open_critical": 1, + "total_open_high": 3, + "security_admission_status": "fail", + "security_admission_evidence_complete": true, + "security_admission_reason_codes": [ + "SECURITY_ADMISSION_FINDINGS" + ] }, { "repo": "flagship/basalt-relay", - "risk_tier": "elevated", + "risk_tier": "moderate", "dependabot_critical": 0, - "dependabot_high": 2 + "dependabot_high": 2, + "code_scanning_critical": 0, + "code_scanning_high": 1, + "secret_scanning_open": 0, + "total_open_critical": 0, + "total_open_high": 3, + "security_admission_status": "fail", + "security_admission_evidence_complete": true, + "security_admission_reason_codes": [ + "SECURITY_ADMISSION_FINDINGS" + ] }, { "repo": "studio/kestrel-loom", - "risk_tier": "elevated", + "risk_tier": "moderate", "dependabot_critical": 0, - "dependabot_high": 3 + "dependabot_high": 3, + "code_scanning_critical": 0, + "code_scanning_high": 0, + "secret_scanning_open": 0, + "total_open_critical": 0, + "total_open_high": 3, + "security_admission_status": "fail", + "security_admission_evidence_complete": false, + "security_admission_reason_codes": [ + "SECURITY_ADMISSION_FINDINGS", + "SECURITY_COVERAGE_PARTIAL", + "SECURITY_PROVIDER_CODE_SCANNING_NOT_OBSERVED", + "SECURITY_PROVIDER_SECRET_SCANNING_NOT_OBSERVED" + ] } ] }, "path_attention": [ + { + "repo": "flagship/aurora-ledger", + "headline": "1 risk factor(s): blocking GitHub security findings.", + "activity_status": "active", + "context_quality": "full" + }, + { + "repo": "flagship/basalt-relay", + "headline": "1 risk factor(s): blocking GitHub security findings.", + "activity_status": "active", + "context_quality": "standard" + }, { "repo": "flagship/cinder-atlas", - "headline": "Open high or critical alerts need an operator decision.", + "headline": "1 risk factor(s): blocking GitHub security findings.", "activity_status": "active", "context_quality": "minimum-viable" }, + { + "repo": "flagship/meridian-vault", + "headline": "1 risk factor(s): blocking GitHub security findings.", + "activity_status": "active", + "context_quality": "full" + }, + { + "repo": "platform/ember-conduit", + "headline": "1 risk factor(s): blocking GitHub security findings.", + "activity_status": "active", + "context_quality": "full" + }, + { + "repo": "platform/ivory-sextant", + "headline": "1 risk factor(s): blocking GitHub security findings.", + "activity_status": "recent", + "context_quality": "full" + }, + { + "repo": "platform/lumen-ferry", + "headline": "4 risk factor(s): weak context quality, investigate override active, run instructions missing, blocking GitHub security findings.", + "activity_status": "recent", + "context_quality": "boilerplate" + }, { "repo": "studio/glacier-quill", - "headline": "Open high or critical alerts need an operator decision.", + "headline": "1 risk factor(s): blocking GitHub security findings.", "activity_status": "active", "context_quality": "minimum-viable" }, { "repo": "studio/kestrel-loom", - "headline": "Open high or critical alerts need an operator decision.", + "headline": "1 risk factor(s): blocking GitHub security findings.", "activity_status": "recent", "context_quality": "minimum-viable" } diff --git a/src/demo_portfolio.py b/src/demo_portfolio.py index f3898679..99560cc9 100644 --- a/src/demo_portfolio.py +++ b/src/demo_portfolio.py @@ -35,6 +35,7 @@ from src.portfolio_truth_precedence import build_precedence_matrix from src.portfolio_truth_provenance import REQUIRED_PROJECT_PROVENANCE_KEYS from src.portfolio_truth_types import DERIVATION_POLICY_VERSION, SCHEMA_VERSION +from src.security_admission import derive_security_admission # The demo workspace is deliberately not a real filesystem path. DEMO_WORKSPACE_ROOT = "/demo-workspace" @@ -448,6 +449,7 @@ def _category_for(spec: DemoProject) -> str: return "commercial" return _GROUP_CATEGORIES[spec.group] + _PURPOSES = { "flagship": "Operator-facing product surface with an active release lane.", "platform": "Shared platform service other demo projects depend on.", @@ -843,8 +845,7 @@ def build_projects( "target_maturity": "operating", "notes": "", "doctor_standard": "", - "automation_eligible": context_quality - in {"minimum-viable", "boilerplate"}, + "automation_eligible": context_quality in {"minimum-viable", "boilerplate"}, } path_entry = build_operating_path_entry( {**declared, "has_explicit_entry": True}, @@ -880,6 +881,7 @@ def build_projects( "path_confidence": path_entry["path_confidence"], "path_rationale": path_entry["path_rationale"], } + security_admission = derive_security_admission(security) risk, attention_state = build_project_decision( display_name=spec.codename, operating_path=operating_path, @@ -893,9 +895,12 @@ def build_projects( doctor_standard=declared["doctor_standard"], known_risks_present=derived["known_risks_present"], run_instructions_present=derived["run_instructions_present"], - security_coverage_state=security["coverage_state"], - security_high_alerts=security.get("dependabot_high") or 0, - security_critical_alerts=security.get("dependabot_critical") or 0, + security_coverage_state=security_admission.effective_coverage_state, + security_high_alerts=security_admission.total_open_high, + security_critical_alerts=( + security_admission.total_open_critical + + security_admission.total_open_secrets + ), ) derived["attention_state"] = attention_state provenance_values = { @@ -980,7 +985,6 @@ def build_projects( return projects - def build_snapshot( generated_at: datetime, *, @@ -1093,18 +1097,38 @@ def build_weekly_digest(snapshot: dict[str, Any]) -> dict[str, Any]: projects = snapshot["projects"] rollups = snapshot["rollups"] elevated = [p for p in projects if p["risk"]["risk_tier"] == "elevated"] - open_alerts = sorted( - (p for p in projects if (p["security"]["open_high_critical"] or 0) > 0), - key=lambda p: p["security"]["open_high_critical"], - reverse=True, - ) - lead = ( - open_alerts[0]["identity"]["display_name"] if open_alerts else "the portfolio" + admitted_security = [ + (p, derive_security_admission(p["security"])) + for p in projects + if p["security"].get("cohort_member") + ] + blocking = sorted( + ( + (p, admission) + for p, admission in admitted_security + if admission.has_findings + ), + key=lambda item: ( + -( + item[1].total_open_critical + + item[1].total_open_secrets + + item[1].total_open_high + ), + item[0]["identity"]["display_name"], + ), ) + lead = blocking[0][0]["identity"]["display_name"] if blocking else "the portfolio" + admission_status_counts: dict[str, int] = {} + for _, admission in admitted_security: + admission_status_counts[admission.status] = ( + admission_status_counts.get(admission.status, 0) + 1 + ) return { "username": "demo-operator", "generated_at": snapshot["generated_at"], - "headline": f"{len(elevated)} projects carry open high or critical alerts.", + "headline": ( + f"{len(blocking)} projects carry blocking GitHub security findings." + ), "decision": f"Clear {lead} before starting lower-pressure cleanup.", "why_this_week": ( f"{lead} holds the largest observed alert backlog and is the only " @@ -1124,20 +1148,42 @@ def build_weekly_digest(snapshot: dict[str, Any]) -> dict[str, Any]: ], }, "security_posture": { - "scanned_count": rollups["security"]["scanned_count"], - "repos_with_open_high_critical": rollups["security"][ - "repos_with_open_high_critical" - ], - "total_open_critical": rollups["security"]["total_open_critical"], - "total_open_high": rollups["security"]["total_open_high"], + "scanned_count": sum( + admission.evidence_complete for _, admission in admitted_security + ), + "repos_with_open_high_critical": len(blocking), + "repos_with_blocking_findings": len(blocking), + "unadmitted_count": sum( + not admission.evidence_complete for _, admission in admitted_security + ), + "admission_status_counts": admission_status_counts, + "total_open_critical": sum( + admission.total_open_critical for _, admission in admitted_security + ), + "total_open_high": sum( + admission.total_open_high for _, admission in admitted_security + ), + "total_open_secrets": sum( + admission.total_open_secrets for _, admission in admitted_security + ), "top_alerts": [ { "repo": p["identity"]["project_key"], "risk_tier": p["risk"]["risk_tier"], - "dependabot_critical": p["security"]["dependabot_critical"], - "dependabot_high": p["security"]["dependabot_high"], + "dependabot_critical": admission.dependabot_critical, + "dependabot_high": admission.dependabot_high, + "code_scanning_critical": admission.code_scanning_critical, + "code_scanning_high": admission.code_scanning_high, + "secret_scanning_open": admission.secret_scanning_open, + "total_open_critical": admission.total_open_critical, + "total_open_high": admission.total_open_high, + "security_admission_status": admission.status, + "security_admission_evidence_complete": ( + admission.evidence_complete + ), + "security_admission_reason_codes": list(admission.reason_codes), } - for p in open_alerts[:5] + for p, admission in blocking[:5] ], }, "path_attention": [ diff --git a/src/portfolio_decision_queue.py b/src/portfolio_decision_queue.py index 2741c195..68a5d745 100644 --- a/src/portfolio_decision_queue.py +++ b/src/portfolio_decision_queue.py @@ -17,6 +17,8 @@ from pathlib import Path from typing import Any +from src.security_admission import derive_security_admission + CONTRACT_VERSION = "decision_queue_v2" DIGEST_CONTRACT_VERSION = "portfolio_decision_digest_v2" ITEM_SCHEMA_VERSION = "portfolio_decision_item_v2" @@ -188,6 +190,9 @@ def _readback_contract(project_identity: str) -> dict[str, Any]: "success_condition": { "dependabot_critical": 0, "dependabot_high": 0, + "code_scanning_critical": 0, + "code_scanning_high": 0, + "secret_scanning_open": 0, }, "preserve_if": "matching newer receipt still reports open high or critical alerts", "reopen_if": "a newer receipt yields a different nonzero decision fingerprint", @@ -242,8 +247,7 @@ def _security_decision( basics = _project_basics(project) declared = _mapping(project.get("declared")) security = _mapping(project.get("security")) - providers = _mapping(security.get("providers")) - dependabot = _mapping(providers.get("dependabot")) + admission = derive_security_admission(security) github_source = _mapping( _mapping(portfolio_truth.get("inputs")).get("github_security") ) @@ -295,16 +299,6 @@ def refuse(code: str, detail: str) -> tuple[None, dict[str, Any]]: "SECURITY_SOURCE_PRODUCER_MISMATCH", "PortfolioTruth and the GitHub security receipt name different producer commits.", ) - if _text(security.get("coverage_state")) != "complete": - return refuse( - "SECURITY_COVERAGE_INCOMPLETE", - "Repository security coverage is not complete.", - ) - if _text(security.get("receipt_state")) != "fresh": - return refuse( - "SECURITY_PROJECT_RECEIPT_NOT_FRESH", - "The repository security envelope is not fresh.", - ) if _text(security.get("source_produced_at")) != _text( github_source.get("produced_at") ): @@ -312,22 +306,27 @@ def refuse(code: str, detail: str) -> tuple[None, dict[str, Any]]: "SECURITY_SOURCE_MISMATCH", "The project security envelope does not match the admitted receipt generation.", ) - if _text(dependabot.get("state")) not in {"observed", "not_modified"}: - return refuse( - "SECURITY_PROVIDER_NOT_OBSERVED", - "Dependabot evidence is not in an observed state.", + if not admission.evidence_complete: + reason_code = next( + ( + code + for code in admission.reason_codes + if code + not in { + "SECURITY_ADMISSION_FINDINGS", + "SECURITY_ADMISSION_UNKNOWN", + } + ), + "SECURITY_ADMISSION_UNKNOWN", ) - if ( - dependabot.get("pagination_complete") is not True - or dependabot.get("completed") is not True - ): return refuse( - "SECURITY_PROVIDER_INCOMPLETE", - "Dependabot evidence is incomplete or pagination is not closed.", + reason_code, + "Canonical security admission failed closed: " + + ", ".join(admission.reason_codes), ) evaluated = _parse_datetime(portfolio_truth.get("generated_at")) - evidence_observed = _parse_datetime(dependabot.get("observed_at")) + evidence_observed = _parse_datetime(admission.evidence_observed_at) receipt_produced = _parse_datetime(github_source.get("produced_at")) if not evaluated: return refuse( @@ -337,7 +336,7 @@ def refuse(code: str, detail: str) -> tuple[None, dict[str, Any]]: if not evidence_observed: return refuse( "EVIDENCE_CLOCK_MISSING", - "Dependabot observed_at is missing or invalid.", + "Canonical security admission observed_at is missing or invalid.", ) if not receipt_produced: return refuse( @@ -355,21 +354,16 @@ def refuse(code: str, detail: str) -> tuple[None, dict[str, Any]]: if evaluated > valid_until_dt: return refuse( "SECURITY_EVIDENCE_EXPIRED", - "The underlying Dependabot observation expired; projection regeneration cannot rejuvenate it.", + "The oldest admitted security observation expired; projection regeneration cannot rejuvenate it.", ) - try: - critical = int(security.get("dependabot_critical")) - high = int(security.get("dependabot_high")) - except (TypeError, ValueError): - return refuse( - "SECURITY_COUNTS_INVALID", - "Dependabot high and critical counts are not valid integers.", - ) - if critical < 0 or high < 0 or critical + high <= 0: + critical = admission.total_open_critical + high = admission.total_open_high + secrets = admission.total_open_secrets + if critical + high + secrets <= 0: return refuse( "SECURITY_RISK_COUNT_MISMATCH", - "Security risk is set without a positive high or critical Dependabot count.", + "Security risk is set without an admitted blocking GitHub security finding.", ) valid_until = _iso(valid_until_dt) @@ -381,7 +375,7 @@ def refuse(code: str, detail: str) -> tuple[None, dict[str, Any]]: question = ( f"Should {owner} approve one repository-scoped security follow-up for " f"{basics['project']} against {critical} critical and {high} high " - f"Dependabot alerts before {valid_until}?" + f"alerts plus {secrets} open secret-scanning findings before {valid_until}?" ) evidence_reference = { "source_id": _text(github_source.get("source_id")), @@ -390,10 +384,16 @@ def refuse(code: str, detail: str) -> tuple[None, dict[str, Any]]: "content_sha256": _text(github_source.get("content_sha256")), "path": _text(github_source.get("path")), "project_identity": basics["project_identity"], - "provider": "dependabot", + "security_admission_schema": admission.schema_version, + "security_admission_status": admission.status, + "provider": "github_security_combined", "provider_observed_at": evidence_observed_at, - "dependabot_critical": critical, - "dependabot_high": high, + "provider_observed_at_by_name": admission.provider_observed_at, + "dependabot_critical": admission.dependabot_critical, + "dependabot_high": admission.dependabot_high, + "code_scanning_critical": admission.code_scanning_critical, + "code_scanning_high": admission.code_scanning_high, + "secret_scanning_open": admission.secret_scanning_open, } fingerprint_payload = { "schema_version": FINGERPRINT_SCHEMA_VERSION, @@ -419,9 +419,11 @@ def refuse(code: str, detail: str) -> tuple[None, dict[str, Any]]: "owner": owner, "allowed_outcomes": allowed_outcomes, "approval_boundary": approval_boundary, - "why_now": "Current authoritative GitHub security evidence reports open high or critical Dependabot alerts.", + "why_now": "Current authoritative GitHub security evidence reports admitted blocking findings.", "evidence": [ - f"security_risk=true; dependabot critical={critical}, high={high}" + "security_risk=true; " + f"critical={critical}, high={high}, open_secrets={secrets}; " + f"admission={admission.schema_version}" ], "authoritative_source": "github-security-coverage-receipt", "evidence_reference": evidence_reference, @@ -459,7 +461,8 @@ def _build_candidates( if basics["attention_state"] in {"archived", "evidence-history"}: continue risk = _mapping(project.get("risk")) - if bool(risk.get("security_risk")): + admission = derive_security_admission(project.get("security")) + if bool(risk.get("security_risk")) or admission.has_findings: item, refused = _security_decision( project, portfolio_truth=portfolio_truth, diff --git a/src/portfolio_risk.py b/src/portfolio_risk.py index 4feb6d45..ec5c8dde 100644 --- a/src/portfolio_risk.py +++ b/src/portfolio_risk.py @@ -27,7 +27,7 @@ "missing-doctor-standard": "doctor standard not declared", "no-run-instructions": "run instructions missing", "undocumented-risks": "known risks not documented", - "active-high-severity-alerts": "open high/critical security alerts", + "active-high-severity-alerts": "blocking GitHub security findings", } _DEFERRED_ARCHIVED = { @@ -96,22 +96,18 @@ def build_risk_entry( factors.append("undocumented-risks") # A currently active repo, or a stale repo intentionally kept on the maintain - # path, carrying open high- or critical-severity Dependabot alerts. + # path, carrying admitted high/critical alerts or an open secret finding. # High alerts contribute one normal factor toward the 3+ elevation threshold; # an open critical alert force-elevates on its own (see is_elevated below) — a # lone unpatched critical CVE cannot hide in an otherwise-clean repo. security_relevant = ( activity_status in ACTIVE_STATUSES or operating_path == "maintain" ) - if security_relevant and ( - security_high_alerts > 0 or security_critical_alerts > 0 - ): + if security_relevant and (security_high_alerts > 0 or security_critical_alerts > 0): factors.append("active-high-severity-alerts") # Derive tier - security_forces_elevated = ( - security_relevant and security_critical_alerts > 0 - ) + security_forces_elevated = security_relevant and security_critical_alerts > 0 is_elevated = ( len(factors) >= 3 or ("weak-context-active" in factors and "investigate-override" in factors) diff --git a/src/portfolio_security_gate.py b/src/portfolio_security_gate.py index 2cc70fdf..f1c2e59b 100644 --- a/src/portfolio_security_gate.py +++ b/src/portfolio_security_gate.py @@ -12,6 +12,8 @@ from datetime import datetime, timezone from typing import Any +from src.security_admission import derive_security_admission + @dataclass(frozen=True) class SecurityGateItem: @@ -20,6 +22,8 @@ class SecurityGateItem: high: int risk_tier: str secret_scanning_open: int = 0 + evidence_complete: bool = True + reason_codes: tuple[str, ...] = () @property def total(self) -> int: @@ -32,6 +36,8 @@ def to_dict(self) -> dict[str, Any]: "high": self.high, "secret_scanning_open": self.secret_scanning_open, "risk_tier": self.risk_tier, + "evidence_complete": self.evidence_complete, + "reason_codes": list(self.reason_codes), } @@ -51,6 +57,7 @@ class SecurityGateReport: max_age_hours: int | None = None source_age_hours: float | None = None freshness_error: str | None = None + unadmitted_repos: tuple[SecurityGateItem, ...] = () @property def repos_with_open_high_critical(self) -> int: @@ -113,6 +120,7 @@ def to_dict(self) -> dict[str, Any]: "source_age_hours": self.source_age_hours, "freshness_error": self.freshness_error, "flagged_repos": [item.to_dict() for item in self.flagged_repos], + "unadmitted_repos": [item.to_dict() for item in self.unadmitted_repos], } @@ -131,7 +139,9 @@ def _int(value: Any) -> int: return 0 -def _source_age_hours(generated_at: str, now: datetime) -> tuple[float | None, str | None]: +def _source_age_hours( + generated_at: str, now: datetime +) -> tuple[float | None, str | None]: if not generated_at or generated_at == "unknown": return None, "missing generated_at" try: @@ -140,7 +150,9 @@ def _source_age_hours(generated_at: str, now: datetime) -> tuple[float | None, s return None, f"invalid generated_at: {generated_at}" if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) - age_hours = (now.astimezone(timezone.utc) - parsed.astimezone(timezone.utc)).total_seconds() + age_hours = ( + now.astimezone(timezone.utc) - parsed.astimezone(timezone.utc) + ).total_seconds() return round(age_hours / 3600, 3), None @@ -161,6 +173,7 @@ def build_security_gate_report( total_high = 0 total_secrets = 0 flagged: list[SecurityGateItem] = [] + unadmitted: list[SecurityGateItem] = [] generated_at = _text(portfolio_truth.get("generated_at")) or "unknown" source_age_hours = freshness_error = None if max_age_hours is not None: @@ -176,8 +189,9 @@ def build_security_gate_report( if not security.get("cohort_member"): continue required_cohort_count += 1 - coverage_state = _text(security.get("coverage_state")) or "unknown" - if coverage_state == "complete" and security.get("alerts_available"): + admission = derive_security_admission(security) + coverage_state = admission.coverage_state + if admission.evidence_complete: complete_count += 1 scanned_count += 1 elif coverage_state == "partial": @@ -186,57 +200,35 @@ def build_security_gate_report( stale_count += 1 else: unknown_count += 1 - providers = _mapping(security.get("providers")) - dependabot = _mapping(providers.get("dependabot")) - code_scanning = _mapping(providers.get("code_scanning")) - secret_scanning = _mapping(providers.get("secret_scanning")) - critical = ( - _int(security.get("dependabot_critical")) - if dependabot.get("state") == "observed" - else 0 - ) + ( - _int(security.get("code_scanning_critical")) - if code_scanning.get("state") == "observed" - else 0 - ) - high = ( - _int(security.get("dependabot_high")) - if dependabot.get("state") == "observed" - else 0 - ) + ( - _int(security.get("code_scanning_high")) - if code_scanning.get("state") == "observed" - else 0 - ) - secrets = ( - _int(security.get("secret_scanning_open")) - if secret_scanning.get("state") == "observed" - else 0 - ) + critical = admission.total_open_critical + high = admission.total_open_high + secrets = admission.total_open_secrets total_critical += critical total_high += high total_secrets += secrets - if critical <= 0 and high <= 0 and secrets <= 0: - continue - identity = _mapping(project.get("identity")) risk = _mapping(project.get("risk")) - flagged.append( - SecurityGateItem( - repo=( - _text(identity.get("display_name")) - or _text(identity.get("repo_full_name")) - or _text(identity.get("path")) - or "Repo" - ), - critical=critical, - high=high, - risk_tier=_text(risk.get("risk_tier")) or "baseline", - secret_scanning_open=secrets, - ) + item = SecurityGateItem( + repo=( + _text(identity.get("display_name")) + or _text(identity.get("repo_full_name")) + or _text(identity.get("path")) + or "Repo" + ), + critical=critical, + high=high, + risk_tier=_text(risk.get("risk_tier")) or "baseline", + secret_scanning_open=secrets, + evidence_complete=admission.evidence_complete, + reason_codes=admission.reason_codes, ) + if admission.has_findings: + flagged.append(item) + if not admission.evidence_complete: + unadmitted.append(item) flagged.sort(key=lambda item: (-item.critical, -item.high, item.repo.lower())) + unadmitted.sort(key=lambda item: item.repo.lower()) return SecurityGateReport( generated_at=generated_at, scanned_count=scanned_count, @@ -252,6 +244,7 @@ def build_security_gate_report( max_age_hours=max_age_hours, source_age_hours=source_age_hours, freshness_error=freshness_error, + unadmitted_repos=tuple(unadmitted), ) @@ -277,9 +270,13 @@ def render_security_gate_markdown(report: SecurityGateReport) -> str: f"stale {report.stale_count}, unknown {report.unknown_count}). " "Do not treat the cohort as clear." ) + for item in report.unadmitted_repos: + lines.append(f"- {item.repo}: {', '.join(item.reason_codes)}") elif report.status == "stale": if report.freshness_error: - lines.append(f"Portfolio truth freshness could not be verified: {report.freshness_error}.") + lines.append( + f"Portfolio truth freshness could not be verified: {report.freshness_error}." + ) else: lines.append( f"Portfolio truth is {report.source_age_hours:.1f}h old, beyond the " diff --git a/src/portfolio_truth_reconcile.py b/src/portfolio_truth_reconcile.py index f7824193..5ca7db6b 100644 --- a/src/portfolio_truth_reconcile.py +++ b/src/portfolio_truth_reconcile.py @@ -50,6 +50,7 @@ ) from src.project_registry import DEFAULT_SUPPLEMENTARY from src.registry_parser import _normalize +from src.security_admission import derive_security_admission logger = logging.getLogger(__name__) @@ -399,9 +400,7 @@ def build_portfolio_truth_snapshot( catalog_data = load_portfolio_catalog(catalog_path) legacy_rows = load_legacy_registry_rows(legacy_registry_path) notion_context = load_safe_notion_project_context() if include_notion else {} - notion_source_mode = str( - getattr(notion_context, "source_mode", "live") or "live" - ) + notion_source_mode = str(getattr(notion_context, "source_mode", "live") or "live") notion_observed_at = getattr(notion_context, "observed_at", None) notion_context_carried_forward = False if include_notion and not notion_context and notion_context_fallback: @@ -571,13 +570,14 @@ def _cataloged_supplementary_projects( "path": canonical_key, "top_level_dir": "supplementary", "group_entry": { - "group_key": str(supplementary.get("group_key") or "operator_infra"), + "group_key": str( + supplementary.get("group_key") or "operator_infra" + ), "group_label": str( supplementary.get("group_label") or "Operator Infrastructure" ), "section_marker": str( - supplementary.get("section_marker") - or "Supplementary Projects" + supplementary.get("section_marker") or "Supplementary Projects" ), "section_label": str( supplementary.get("section_label") or "Operator OS" @@ -614,8 +614,7 @@ def _merge_supplementary_discoveries( ) -> list[dict[str, Any]]: """Keep one canonical identity while retaining local checkout observations.""" supplementary_by_name = { - _normalize(str(project.get("name") or "")): project - for project in supplementary + _normalize(str(project.get("name") or "")): project for project in supplementary } used: set[str] = set() merged: list[dict[str, Any]] = [] @@ -928,10 +927,10 @@ def _build_truth_project( live_status_available = bool( status_entry and status_entry.get("source") == "github_api" ) - remote_status_available = ( - remote_repository.get("state") in {"observed", "partial"} - and isinstance(remote_repository.get("archived"), bool) - ) + remote_status_available = remote_repository.get("state") in { + "observed", + "partial", + } and isinstance(remote_repository.get("archived"), bool) if live_status_available: github_archived = status_entry.get("archived") is True provenance["github.archived"] = { @@ -992,10 +991,8 @@ def _build_truth_project( } security = _build_security_fields(security_entry) + security_admission = derive_security_admission(security.to_dict()) - # Only Dependabot high/critical counts drive the risk tier today. Code-scanning - # and secret-scanning counts are captured in SecurityFields for visibility but do - # not yet feed the active-high-severity-alerts factor (Dependabot-only scope). risk_entry, attention_state = build_project_decision( display_name=raw_project["name"], operating_path=path_entry.get("operating_path", ""), @@ -1009,9 +1006,12 @@ def _build_truth_project( doctor_standard=declared_values["doctor_standard"], known_risks_present=bool(raw_project["known_risks_present"]), run_instructions_present=bool(raw_project["run_instructions_present"]), - security_coverage_state=security.coverage_state, - security_high_alerts=security.dependabot_high or 0, - security_critical_alerts=security.dependabot_critical or 0, + security_coverage_state=security_admission.effective_coverage_state, + security_high_alerts=security_admission.total_open_high, + security_critical_alerts=( + security_admission.total_open_critical + + security_admission.total_open_secrets + ), ) if ( not security.receipt_schema_version diff --git a/src/portfolio_truth_render.py b/src/portfolio_truth_render.py index 733acd14..e65fac8f 100644 --- a/src/portfolio_truth_render.py +++ b/src/portfolio_truth_render.py @@ -9,6 +9,7 @@ PortfolioTruthSnapshot, display_activity_status, ) +from src.security_admission import derive_security_admission def _displayed_status(project: PortfolioTruthProject) -> str: @@ -29,46 +30,51 @@ def _displayed_status(project: PortfolioTruthProject) -> str: def _security_overview(projects: list[PortfolioTruthProject]) -> dict[str, int]: - """Aggregate the opt-in security overlay across scanned repos. ``scanned_count`` is - repos with alerts_available=True (the overlay ran for them); a scanned repo with zero - open alerts is genuinely clear, distinct from an unscanned one — so consumers don't - mislabel an unscanned repo as secure.""" - scanned = repos_with_open = total_critical = total_high = 0 + """Aggregate canonical admission across the governed security cohort.""" + scanned = repos_with_open = unadmitted = 0 + total_critical = total_high = total_secrets = 0 for project in projects: security = project.security - if not security.alerts_available: + if not (security.cohort_member or security.alerts_available): continue - scanned += 1 - total_critical += security.dependabot_critical - total_high += security.dependabot_high - if security.open_high_critical > 0: + admission = derive_security_admission(security.to_dict()) + scanned += int(admission.evidence_complete) + unadmitted += int(not admission.evidence_complete) + total_critical += admission.total_open_critical + total_high += admission.total_open_high + total_secrets += admission.total_open_secrets + if admission.has_findings: repos_with_open += 1 return { "scanned_count": scanned, + "unadmitted_count": unadmitted, "repos_with_open_high_critical": repos_with_open, + "repos_with_blocking_findings": repos_with_open, "total_open_critical": total_critical, "total_open_high": total_high, + "total_open_secrets": total_secrets, } def _security_attention_items( projects: list[PortfolioTruthProject], ) -> list[PortfolioTruthProject]: - """Scanned repos carrying open high/critical Dependabot alerts, critical-first then - high then name, capped — mirrors the weekly digest's security attention list.""" - flagged = [ - project - for project in projects - if project.security.alerts_available and project.security.open_high_critical > 0 - ] + """Repos carrying admitted blocking findings, critical/secrets first.""" + flagged = [] + for project in projects: + if not (project.security.cohort_member or project.security.alerts_available): + continue + admission = derive_security_admission(project.security.to_dict()) + if admission.has_findings: + flagged.append((project, admission)) flagged.sort( - key=lambda project: ( - -project.security.dependabot_critical, - -project.security.dependabot_high, - project.identity.display_name.lower(), + key=lambda item: ( + -(item[1].total_open_critical + item[1].total_open_secrets), + -item[1].total_open_high, + item[0].identity.display_name.lower(), ) ) - return flagged[:MAX_SECURITY_ATTENTION_ITEMS] + return [project for project, _ in flagged[:MAX_SECURITY_ATTENTION_ITEMS]] def render_registry_markdown(snapshot: PortfolioTruthSnapshot) -> str: @@ -205,7 +211,7 @@ def render_portfolio_report_markdown( f"- Operating path distribution: maintain `{operating_path_counts.get('maintain', 0)}`, finish `{operating_path_counts.get('finish', 0)}`, archive `{operating_path_counts.get('archive', 0)}`, experiment `{operating_path_counts.get('experiment', 0)}`, unspecified `{operating_path_counts.get('unspecified', 0)}`", f"- Investigate overrides currently surfaced: `{override_counts.get('investigate', 0)}`", f"- Risk posture: elevated `{risk_tier_counts.get('elevated', 0)}`, moderate `{risk_tier_counts.get('moderate', 0)}`, baseline `{risk_tier_counts.get('baseline', 0)}`, deferred `{risk_tier_counts.get('deferred', 0)}`", - f"- Security posture: scanned `{security_overview['scanned_count']}`, with open high/critical Dependabot alerts `{security_overview['repos_with_open_high_critical']}` (critical `{security_overview['total_open_critical']}`, high `{security_overview['total_open_high']}`)", + f"- Security posture: admitted `{security_overview['scanned_count']}`, with blocking GitHub security findings `{security_overview['repos_with_blocking_findings']}` (critical `{security_overview['total_open_critical']}`, high `{security_overview['total_open_high']}`, open secrets `{security_overview['total_open_secrets']}`, unadmitted `{security_overview['unadmitted_count']}`)", f"- Catalog warnings carried into the snapshot: `{len(snapshot.warnings)}`", "", "## Breakdown by Portfolio Signals", @@ -248,14 +254,22 @@ def render_portfolio_report_markdown( scanned_count = security_overview["scanned_count"] if attention: for project in attention: + admission = derive_security_admission(project.security.to_dict()) lines.append( f"- **{project.identity.display_name}** [{project.risk.risk_tier}]: " - f"{project.security.dependabot_critical} critical, " - f"{project.security.dependabot_high} high open Dependabot alerts" + f"{admission.total_open_critical} critical, " + f"{admission.total_open_high} high, " + f"{admission.total_open_secrets} open secrets " + f"(admission {admission.status})" ) - elif scanned_count > 0: + elif scanned_count > 0 and not security_overview["unadmitted_count"]: + lines.append( + f"- All {scanned_count} admitted repos are clear of blocking GitHub security findings." + ) + elif security_overview["unadmitted_count"]: lines.append( - f"- All {scanned_count} scanned repos are clear of open high/critical Dependabot alerts." + "- Security evidence remains UNKNOWN for " + f"{security_overview['unadmitted_count']} repo(s); do not treat them as clear." ) else: lines.append( @@ -465,15 +479,17 @@ def _default_section_note(marker: str, projects: list[PortfolioTruthProject]) -> def _security_note_flag(project: PortfolioTruthProject) -> str: - """Pipe-free per-repo security marker for the registry Notes column. Fires only for - scanned repos carrying open high/critical Dependabot alerts. Pipe-free by design so - the registry table still round-trips through parse_registry without shifting columns.""" - security = project.security - if not security.alerts_available or security.open_high_critical == 0: + """Pipe-free marker for one repo with admitted blocking findings.""" + admission = derive_security_admission(project.security.to_dict()) + if ( + not (project.security.cohort_member or project.security.alerts_available) + or not admission.has_findings + ): return "" return ( - f"[security: {security.dependabot_critical} critical / " - f"{security.dependabot_high} high open Dependabot alerts]" + f"[security: {admission.total_open_critical} critical / " + f"{admission.total_open_high} high / " + f"{admission.total_open_secrets} open secrets]" ) @@ -512,10 +528,12 @@ def _render_summary_section(projects: list[PortfolioTruthProject]) -> list[str]: f"| Projects with minimum-viable context | {context_counts.get('minimum-viable', 0)} |", f"| Projects with boilerplate only | {context_counts.get('boilerplate', 0)} |", f"| Projects with no context | {context_counts.get('none', 0)} |", - f"| Repos scanned for security alerts | {security['scanned_count']} |", - f"| Repos with open high/critical alerts | {security['repos_with_open_high_critical']} |", - f"| Open critical Dependabot alerts | {security['total_open_critical']} |", - f"| Open high Dependabot alerts | {security['total_open_high']} |", + f"| Repos admitted for security findings | {security['scanned_count']} |", + f"| Repos with blocking GitHub security findings | {security['repos_with_blocking_findings']} |", + f"| Repos with unadmitted security evidence | {security['unadmitted_count']} |", + f"| Open critical GitHub security findings | {security['total_open_critical']} |", + f"| Open high GitHub security findings | {security['total_open_high']} |", + f"| Open secret-scanning findings | {security['total_open_secrets']} |", ] diff --git a/src/portfolio_truth_types.py b/src/portfolio_truth_types.py index 544873cb..4a96e95b 100644 --- a/src/portfolio_truth_types.py +++ b/src/portfolio_truth_types.py @@ -6,6 +6,11 @@ from pathlib import Path from typing import Any +from src.security_admission import ( + SECURITY_ADMISSION_SCHEMA_VERSION, + derive_security_admission, +) + SCHEMA_VERSION = "0.11.0" CHECKOUT_COLLISION_SCHEMA_VERSION = "CheckoutCollisionV1" CHECKOUT_COLLISION_SUMMARY_SCHEMA_VERSION = "CheckoutCollisionSummaryV1" @@ -232,7 +237,7 @@ class SecurityFields: @property def open_high_critical(self) -> int: - """Dependabot high + critical — the security-risk-factor trigger surface.""" + """Legacy Dependabot-only compatibility field for pre-admission readers.""" return (self.dependabot_high or 0) + (self.dependabot_critical or 0) def provider_state(self, provider: str) -> str: @@ -296,6 +301,7 @@ def from_projects( repos_with_open_high_critical = 0 total_open_high = 0 total_open_critical = 0 + total_open_secrets = 0 unavailable_count = 0 complete_repo_count = 0 partial_repo_count = 0 @@ -324,6 +330,7 @@ def from_projects( risk_tier_counts[tier] += 1 if not project.identity.project_key.startswith("supp:"): security = project.security + security_admission = derive_security_admission(security.to_dict()) if security.cohort_member: cohort_repository_count += 1 provider_states = { @@ -333,10 +340,11 @@ def from_projects( dependabot_observed = provider_states["dependabot"] == "observed" if dependabot_observed: dependabot_observed_count += 1 - if security.open_high_critical > 0: - repos_with_open_high_critical += 1 - total_open_high += security.dependabot_high or 0 - total_open_critical += security.dependabot_critical or 0 + if security_admission.has_findings: + repos_with_open_high_critical += 1 + total_open_high += security_admission.total_open_high + total_open_critical += security_admission.total_open_critical + total_open_secrets += security_admission.total_open_secrets if provider_states["code_scanning"] == "observed": code_scanning_observed_count += 1 if provider_states["secret_scanning"] == "observed": @@ -346,9 +354,7 @@ def from_projects( is True ) code_scanning_zero_finding_count += int( - (security.providers.get("code_scanning") or {}).get( - "zero_findings" - ) + (security.providers.get("code_scanning") or {}).get("zero_findings") is True ) secret_scanning_zero_finding_count += int( @@ -435,6 +441,10 @@ def from_projects( "repos_with_open_high_critical": repos_with_open_high_critical, "total_open_high": total_open_high, "total_open_critical": total_open_critical, + "total_open_secrets": total_open_secrets, + "security_admission_schema_version": ( + SECURITY_ADMISSION_SCHEMA_VERSION + ), }, decision={ "decision_needed_count": decision_needed_count, diff --git a/src/portfolio_truth_validate.py b/src/portfolio_truth_validate.py index 1dd1e4c5..3e670dde 100644 --- a/src/portfolio_truth_validate.py +++ b/src/portfolio_truth_validate.py @@ -66,6 +66,7 @@ ) from src.producer_preflight import ProducerEvidence from src.registry_parser import _normalize, parse_registry +from src.security_admission import derive_security_admission def validate_truth_snapshot( @@ -253,6 +254,7 @@ def validate_truth_snapshot( snapshot.generated_at, security_max_age_hours, ) + security_admission = derive_security_admission(project.security.to_dict()) expected_risk, expected_attention = build_project_decision( display_name=project.identity.display_name, operating_path=project.declared.operating_path, @@ -266,9 +268,12 @@ def validate_truth_snapshot( doctor_standard=project.declared.doctor_standard, known_risks_present=project.derived.known_risks_present, run_instructions_present=project.derived.run_instructions_present, - security_coverage_state=project.security.coverage_state, - security_high_alerts=project.security.dependabot_high or 0, - security_critical_alerts=project.security.dependabot_critical or 0, + security_coverage_state=security_admission.effective_coverage_state, + security_high_alerts=security_admission.total_open_high, + security_critical_alerts=( + security_admission.total_open_critical + + security_admission.total_open_secrets + ), ) if project.risk.to_dict() != expected_risk: raise ValueError( @@ -466,7 +471,9 @@ def _runtime_value_matches(value: object, annotation: object) -> bool: return True origin = get_origin(annotation) if origin in {Union, UnionType}: - return any(_runtime_value_matches(value, option) for option in get_args(annotation)) + return any( + _runtime_value_matches(value, option) for option in get_args(annotation) + ) if origin is list: return type(value) is list if origin is dict: @@ -555,11 +562,15 @@ def _validate_snapshot_inputs( ) -> None: inputs = snapshot.inputs allowed_input_keys = {"catalog", "workspace", "notion", "github_security"} - if not isinstance(inputs, dict) or not { - "catalog", - "workspace", - "notion", - }.issubset(inputs) or not set(inputs).issubset(allowed_input_keys): + if ( + not isinstance(inputs, dict) + or not { + "catalog", + "workspace", + "notion", + }.issubset(inputs) + or not set(inputs).issubset(allowed_input_keys) + ): raise ValueError("PortfolioTruth input envelope fields are invalid.") generated_at = snapshot.generated_at.isoformat() catalog = inputs.get("catalog") @@ -579,11 +590,10 @@ def _validate_snapshot_inputs( ) ): raise ValueError("PortfolioTruth catalog input is invalid.") - if ( - not isinstance(workspace, dict) - or workspace - != {"source_id": "projects-root", "observed_at": generated_at} - ): + if not isinstance(workspace, dict) or workspace != { + "source_id": "projects-root", + "observed_at": generated_at, + }: raise ValueError("PortfolioTruth workspace input is invalid.") if not isinstance(notion, dict) or set(notion) != { "mode", @@ -609,9 +619,7 @@ def _validate_snapshot_inputs( and carried_from == observed_at ) unknown_origin = ( - mode == "unavailable" - and observed_at is None - and carried_from is None + mode == "unavailable" and observed_at is None and carried_from is None ) if not known_origin and not unknown_origin: raise ValueError("PortfolioTruth carried Notion input is inconsistent.") @@ -627,7 +635,10 @@ def _validate_snapshot_inputs( or carried_from is not None ): raise ValueError("PortfolioTruth observed Notion input is inconsistent.") - if _parse_datetime(observed_at, "inputs.notion.observed_at") > snapshot.generated_at: + if ( + _parse_datetime(observed_at, "inputs.notion.observed_at") + > snapshot.generated_at + ): raise ValueError("PortfolioTruth Notion input is future-dated.") github_security = inputs.get("github_security") has_receipt_rows = any( @@ -681,7 +692,9 @@ def _validate_github_security_input( "cohort_policy", "path", } - if any(field in value and not _nonempty_text(value[field]) for field in text_fields): + if any( + field in value and not _nonempty_text(value[field]) for field in text_fields + ): raise ValueError("PortfolioTruth GitHub security input text is invalid.") if value.get("source_id", "github-security-coverage-receipt") != ( "github-security-coverage-receipt" @@ -766,7 +779,9 @@ def _validate_github_security_input( if allow_synthetic_security_matrix else receipt_states != {value["state"]} ): - raise ValueError("PortfolioTruth GitHub security receipt state is inconsistent.") + raise ValueError( + "PortfolioTruth GitHub security receipt state is inconsistent." + ) def _validate_snapshot_exclusions(exclusions: Any) -> None: @@ -1131,8 +1146,7 @@ def _validate_repository_state_shape( if state == "not_a_repository": _require_repository_keys( repository_state, - {"state", "observed_at", "remote_default_branch"} - | checkout_authority_key, + {"state", "observed_at", "remote_default_branch"} | checkout_authority_key, project_key, "repository state", ) @@ -1665,9 +1679,7 @@ def validate_truth_snapshot_payload( == { "deterministic": True, "producer_evidence": "absent", - "security_evidence_semantics": ( - "synthetic-cross-receipt-state-matrix" - ), + "security_evidence_semantics": ("synthetic-cross-receipt-state-matrix"), } and payload.get("producer") == {} ) @@ -1752,9 +1764,7 @@ def _validate_portable_repository_paths( def _contains_private_identity(value: Any) -> bool: if isinstance(value, str): return ( - re.search( - r"(?:^|[/\\])(?:users|home|root)[/\\]", value, re.IGNORECASE - ) + re.search(r"(?:^|[/\\])(?:users|home|root)[/\\]", value, re.IGNORECASE) is not None or re.search( r"(?:^|[/\\])private[/\\]var[/\\]folders(?:[/\\]|$)", @@ -1777,9 +1787,7 @@ def _contains_private_identity(value: Any) -> bool: ) if isinstance(value, Mapping): return any( - _contains_private_identity(item) - for pair in value.items() - for item in pair + _contains_private_identity(item) for pair in value.items() for item in pair ) if isinstance(value, list): return any(_contains_private_identity(item) for item in value) @@ -1990,9 +1998,10 @@ def _validate_contract_envelope(payload: Mapping[str, Any]) -> None: "Canonical producer evidence must declare zero dirty paths." ) receipt_id = producer.get("receipt_id") - if not isinstance(receipt_id, str) or re.fullmatch( - r"sha256:[0-9a-f]{64}", receipt_id - ) is None: + if ( + not isinstance(receipt_id, str) + or re.fullmatch(r"sha256:[0-9a-f]{64}", receipt_id) is None + ): raise ValueError("Producer receipt_id must be a SHA-256 identity.") verified_at = _parse_datetime( producer.get("verified_at"), "producer.verified_at" diff --git a/src/security_admission.py b/src/security_admission.py new file mode 100644 index 00000000..f1a36509 --- /dev/null +++ b/src/security_admission.py @@ -0,0 +1,320 @@ +"""Canonical security evidence admission shared by PortfolioTruth consumers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Mapping + + +SECURITY_ADMISSION_SCHEMA_VERSION = "SecurityAdmissionV1" +SECURITY_PROVIDERS = ("dependabot", "code_scanning", "secret_scanning") + +_BLOCKING_COUNT_FIELDS: dict[str, dict[str, str]] = { + "dependabot": { + "critical": "dependabot_critical", + "high": "dependabot_high", + }, + "code_scanning": { + "critical": "code_scanning_critical", + "high": "code_scanning_high", + }, + "secret_scanning": {"open": "secret_scanning_open"}, +} + + +def _mapping(value: Any) -> Mapping[str, Any]: + return value if isinstance(value, Mapping) else {} + + +def _text(value: Any) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _parse_datetime(value: Any) -> datetime | None: + text = _text(value) + if not text: + return None + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + return None + return parsed.astimezone(timezone.utc) + + +def _reason_provider(provider: str, suffix: str) -> str: + return f"SECURITY_PROVIDER_{provider.upper()}_{suffix}" + + +def _append_once(reasons: list[str], reason: str) -> None: + if reason not in reasons: + reasons.append(reason) + + +@dataclass(frozen=True) +class SecurityAdmissionV1: + """One deterministic interpretation of normalized security evidence.""" + + schema_version: str + status: str + evidence_complete: bool + has_findings: bool + reason_codes: tuple[str, ...] + coverage_state: str + receipt_state: str + source_produced_at: str | None + evidence_observed_at: str | None + provider_states: dict[str, str] + provider_observed_at: dict[str, str | None] + dependabot_critical: int + dependabot_high: int + code_scanning_critical: int + code_scanning_high: int + secret_scanning_open: int + + @property + def total_open_critical(self) -> int: + return self.dependabot_critical + self.code_scanning_critical + + @property + def total_open_high(self) -> int: + return self.dependabot_high + self.code_scanning_high + + @property + def total_open_secrets(self) -> int: + return self.secret_scanning_open + + @property + def total_blocking_findings(self) -> int: + return self.total_open_critical + self.total_open_high + self.total_open_secrets + + @property + def effective_coverage_state(self) -> str: + if self.evidence_complete: + return "complete" + if self.coverage_state in {"partial", "stale", "unknown"}: + return self.coverage_state + return "unknown" + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "status": self.status, + "evidence_complete": self.evidence_complete, + "has_findings": self.has_findings, + "reason_codes": list(self.reason_codes), + "coverage_state": self.coverage_state, + "receipt_state": self.receipt_state, + "source_produced_at": self.source_produced_at, + "evidence_observed_at": self.evidence_observed_at, + "provider_states": dict(self.provider_states), + "provider_observed_at": dict(self.provider_observed_at), + "dependabot_critical": self.dependabot_critical, + "dependabot_high": self.dependabot_high, + "code_scanning_critical": self.code_scanning_critical, + "code_scanning_high": self.code_scanning_high, + "secret_scanning_open": self.secret_scanning_open, + "total_open_critical": self.total_open_critical, + "total_open_high": self.total_open_high, + "total_open_secrets": self.total_open_secrets, + "total_blocking_findings": self.total_blocking_findings, + "effective_coverage_state": self.effective_coverage_state, + } + + +def derive_security_admission(security_value: Any) -> SecurityAdmissionV1: + """Derive a fail-closed admission result from one project security envelope. + + Known blocking findings remain visible even when another provider is unknown, + but ``evidence_complete`` stays false and no consumer may describe the repo as + clear or create a fully admitted decision from incomplete evidence. + """ + + security = _mapping(security_value) + evidence_reasons: list[str] = [] + + coverage_state = _text(security.get("coverage_state")) or "unknown" + if coverage_state != "complete": + coverage_reason = { + "partial": "SECURITY_COVERAGE_PARTIAL", + "stale": "SECURITY_COVERAGE_STALE", + "unknown": "SECURITY_COVERAGE_UNKNOWN", + }.get(coverage_state, "SECURITY_COVERAGE_INVALID") + _append_once(evidence_reasons, coverage_reason) + + receipt_state = _text(security.get("receipt_state")) or "unknown" + if receipt_state != "fresh": + receipt_reason = { + "stale": "SECURITY_RECEIPT_STALE", + "unknown": "SECURITY_RECEIPT_UNKNOWN", + }.get(receipt_state, "SECURITY_RECEIPT_STATE_INVALID") + _append_once(evidence_reasons, receipt_reason) + + alerts_available = security.get("alerts_available") + if alerts_available is not None and ( + (coverage_state == "complete") is not (alerts_available is True) + ): + _append_once( + evidence_reasons, + "SECURITY_ALERTS_AVAILABILITY_CONFLICT", + ) + + source_produced_text = _text(security.get("source_produced_at")) + source_produced = _parse_datetime(source_produced_text) + if not source_produced_text: + _append_once(evidence_reasons, "SECURITY_RECEIPT_CLOCK_MISSING") + elif source_produced is None: + _append_once(evidence_reasons, "SECURITY_RECEIPT_CLOCK_INVALID") + + providers = _mapping(security.get("providers")) + if set(providers) != set(SECURITY_PROVIDERS): + _append_once(evidence_reasons, "SECURITY_PROVIDER_SET_INCOMPLETE") + + provider_states: dict[str, str] = {} + provider_observed_at: dict[str, str | None] = {} + valid_observed_times: list[datetime] = [] + admitted_counts = { + "dependabot_critical": 0, + "dependabot_high": 0, + "code_scanning_critical": 0, + "code_scanning_high": 0, + "secret_scanning_open": 0, + } + + for provider_name in SECURITY_PROVIDERS: + provider = _mapping(providers.get(provider_name)) + state = _text(provider.get("state")) or "missing" + provider_states[provider_name] = state + observed_at_text = _text(provider.get("observed_at")) + provider_observed_at[provider_name] = observed_at_text or None + + if state != "observed": + _append_once( + evidence_reasons, + _reason_provider(provider_name, "NOT_OBSERVED"), + ) + continue + if provider.get("reason_code") not in {None, "observed"}: + _append_once( + evidence_reasons, + _reason_provider(provider_name, "REASON_CONFLICT"), + ) + if provider.get("pagination_complete") is not True: + _append_once( + evidence_reasons, + _reason_provider(provider_name, "PAGINATION_INCOMPLETE"), + ) + completed = provider.get("completed") + if completed is not None and completed is not True: + _append_once( + evidence_reasons, + _reason_provider(provider_name, "OBSERVATION_INCOMPLETE"), + ) + + observed_at = _parse_datetime(observed_at_text) + if not observed_at_text: + _append_once( + evidence_reasons, + _reason_provider(provider_name, "CLOCK_MISSING"), + ) + elif observed_at is None: + _append_once( + evidence_reasons, + _reason_provider(provider_name, "CLOCK_INVALID"), + ) + else: + valid_observed_times.append(observed_at) + if source_produced is not None and observed_at > source_produced: + _append_once( + evidence_reasons, + _reason_provider(provider_name, "CLOCK_CONFLICT"), + ) + + raw_counts = provider.get("counts") + counts = _mapping(raw_counts) + if isinstance(raw_counts, Mapping): + for count_name, compatibility_name in _BLOCKING_COUNT_FIELDS[ + provider_name + ].items(): + count = counts.get(count_name) + if ( + isinstance(count, int) + and not isinstance(count, bool) + and count >= 0 + ): + # Preserve a provider-observed positive finding even when a + # compatibility field or another count is contradictory. + # Admission still fails closed via the reason code below. + admitted_counts[compatibility_name] = count + counts_valid = isinstance(raw_counts, Mapping) and all( + isinstance(value, int) and not isinstance(value, bool) and value >= 0 + for value in counts.values() + ) + required_count_names = set(_BLOCKING_COUNT_FIELDS[provider_name]) + if not counts_valid or not required_count_names.issubset(counts): + _append_once( + evidence_reasons, + _reason_provider(provider_name, "COUNTS_INVALID"), + ) + continue + + count_total = sum(counts.values()) + zero_findings = provider.get("zero_findings") + if zero_findings is not None and zero_findings is not (count_total == 0): + _append_once( + evidence_reasons, + _reason_provider(provider_name, "ZERO_FINDINGS_CONFLICT"), + ) + + for count_name, compatibility_name in _BLOCKING_COUNT_FIELDS[ + provider_name + ].items(): + count = counts[count_name] + compatibility_count = security.get(compatibility_name) + if ( + not isinstance(compatibility_count, int) + or isinstance(compatibility_count, bool) + or compatibility_count < 0 + or compatibility_count != count + ): + _append_once( + evidence_reasons, + _reason_provider(provider_name, "COUNT_CONFLICT"), + ) + continue + evidence_observed_at = ( + min(valid_observed_times).isoformat() if valid_observed_times else None + ) + total_findings = sum(admitted_counts.values()) + has_findings = total_findings > 0 + evidence_complete = not evidence_reasons + if has_findings: + status = "fail" + reason_codes = ("SECURITY_ADMISSION_FINDINGS", *evidence_reasons) + elif evidence_reasons: + status = "unknown" + reason_codes = ("SECURITY_ADMISSION_UNKNOWN", *evidence_reasons) + else: + status = "pass" + reason_codes = ("SECURITY_ADMISSION_CLEAR",) + + return SecurityAdmissionV1( + schema_version=SECURITY_ADMISSION_SCHEMA_VERSION, + status=status, + evidence_complete=evidence_complete, + has_findings=has_findings, + reason_codes=tuple(reason_codes), + coverage_state=coverage_state, + receipt_state=receipt_state, + source_produced_at=source_produced_text or None, + evidence_observed_at=evidence_observed_at, + provider_states=provider_states, + provider_observed_at=provider_observed_at, + dependabot_critical=admitted_counts["dependabot_critical"], + dependabot_high=admitted_counts["dependabot_high"], + code_scanning_critical=admitted_counts["code_scanning_critical"], + code_scanning_high=admitted_counts["code_scanning_high"], + secret_scanning_open=admitted_counts["secret_scanning_open"], + ) diff --git a/src/weekly_command_center.py b/src/weekly_command_center.py index 89367e3f..25337694 100644 --- a/src/weekly_command_center.py +++ b/src/weekly_command_center.py @@ -13,6 +13,7 @@ render_movement_summary, ) from src.report_enrichment import build_weekly_review_pack +from src.security_admission import derive_security_admission CONTRACT_VERSION = "weekly_command_center_digest_v1" AUTHORITY_CAP = "bounded-automation" @@ -321,7 +322,7 @@ def render_weekly_command_center_markdown(digest: dict[str, Any]) -> str: f"- Operating Paths: {_safe_text(digest.get('operating_paths_summary')) or 'No operating-path summary is recorded yet.'}", f"- Portfolio Truth: {portfolio_truth.get('project_count', 0)} projects, {portfolio_truth.get('active_project_count', 0)} active registry entries, {portfolio_truth.get('default_attention_count', 0)} default attention, {portfolio_truth.get('decision_queue_count', 0)} decision queue", f"- Risk Posture: {risk_posture.get('elevated_count', 0)} elevated, {tier_counts.get('moderate', 0)} moderate, {tier_counts.get('baseline', 0)} baseline", - f"- Security Posture: {security_posture.get('scanned_count', 0)} scanned, {security_posture.get('repos_with_open_high_critical', 0)} with open high/critical Dependabot alerts ({security_posture.get('total_open_critical', 0)} critical, {security_posture.get('total_open_high', 0)} high)", + f"- Security Posture: {security_posture.get('scanned_count', 0)} admitted, {security_posture.get('repos_with_blocking_findings', 0)} with blocking GitHub security findings ({security_posture.get('total_open_critical', 0)} critical, {security_posture.get('total_open_high', 0)} high, {security_posture.get('total_open_secrets', 0)} open secrets; {security_posture.get('unadmitted_count', 0)} unadmitted)", "", "## Decision Queue", ] @@ -380,12 +381,18 @@ def render_weekly_command_center_markdown(digest: dict[str, Any]) -> str: for item in security_items: lines.append( f"- **{item['repo']}** [{item['risk_tier']}]: " - f"{item['dependabot_critical']} critical, {item['dependabot_high']} high " - "open Dependabot alerts" + f"{item['total_open_critical']} critical, {item['total_open_high']} high, " + f"{item['secret_scanning_open']} open secrets " + f"(admission {item['security_admission_status']})" ) - elif scanned_count > 0: + elif scanned_count > 0 and not security_posture.get("unadmitted_count"): lines.append( - f"- All {scanned_count} scanned repos are clear of open high/critical Dependabot alerts." + f"- All {scanned_count} admitted repos are clear of blocking GitHub security findings." + ) + elif security_posture.get("unadmitted_count"): + lines.append( + "- Security evidence remains UNKNOWN for " + f"{security_posture['unadmitted_count']} repo(s); do not treat them as clear." ) else: lines.append( @@ -588,46 +595,52 @@ def _build_risk_attention_items( def _build_security_summary(portfolio_truth: dict[str, Any]) -> dict[str, Any]: - """Aggregate the opt-in security overlay across scanned repos. scanned_count is - repos with alerts_available=True (the security overlay ran for them); a scanned - repo with zero open alerts is genuinely clear, distinct from an unscanned one.""" + """Aggregate the canonical security admission across overlay-backed repos.""" projects = list(portfolio_truth.get("projects") or []) scanned = 0 repos_with_open = 0 + unadmitted = 0 total_critical = 0 total_high = 0 + total_secrets = 0 + status_counts: dict[str, int] = {} for project in projects: security = _mapping(project.get("security")) - if not security.get("alerts_available"): + if not security.get("cohort_member"): continue - scanned += 1 - critical = int(security.get("dependabot_critical") or 0) - high = int(security.get("dependabot_high") or 0) - total_critical += critical - total_high += high - if critical > 0 or high > 0: + admission = derive_security_admission(security) + status_counts[admission.status] = status_counts.get(admission.status, 0) + 1 + scanned += int(admission.evidence_complete) + unadmitted += int(not admission.evidence_complete) + total_critical += admission.total_open_critical + total_high += admission.total_open_high + total_secrets += admission.total_open_secrets + if admission.has_findings: repos_with_open += 1 return { "scanned_count": scanned, "repos_with_open_high_critical": repos_with_open, + "repos_with_blocking_findings": repos_with_open, + "unadmitted_count": unadmitted, + "admission_status_counts": status_counts, "total_open_critical": total_critical, "total_open_high": total_high, + "total_open_secrets": total_secrets, } def _build_security_attention_items( portfolio_truth: dict[str, Any], ) -> list[dict[str, Any]]: - """Top scanned repos carrying open high/critical Dependabot alerts, critical-first.""" + """Top repos carrying known blocking findings, using canonical admission.""" projects = list(portfolio_truth.get("projects") or []) items: list[dict[str, Any]] = [] for project in projects: security = _mapping(project.get("security")) - if not security.get("alerts_available"): + if not security.get("cohort_member"): continue - critical = int(security.get("dependabot_critical") or 0) - high = int(security.get("dependabot_high") or 0) - if critical <= 0 and high <= 0: + admission = derive_security_admission(security) + if not admission.has_findings: continue identity = _mapping(project.get("identity")) risk = _mapping(project.get("risk")) @@ -635,10 +648,22 @@ def _build_security_attention_items( items.append( { "repo": repo, - "dependabot_critical": critical, - "dependabot_high": high, + "dependabot_critical": admission.dependabot_critical, + "dependabot_high": admission.dependabot_high, + "code_scanning_critical": admission.code_scanning_critical, + "code_scanning_high": admission.code_scanning_high, + "secret_scanning_open": admission.secret_scanning_open, + "total_open_critical": admission.total_open_critical, + "total_open_high": admission.total_open_high, + "security_admission_status": admission.status, + "security_admission_evidence_complete": (admission.evidence_complete), + "security_admission_reason_codes": list(admission.reason_codes), "risk_tier": _safe_text(risk.get("risk_tier")) or "baseline", - "_sort_key": (-critical, -high, repo), + "_sort_key": ( + -(admission.total_open_critical + admission.total_open_secrets), + -admission.total_open_high, + repo, + ), } ) items.sort(key=lambda x: x["_sort_key"]) diff --git a/tests/fixtures/portfolio_decision_current_security_v2.json b/tests/fixtures/portfolio_decision_current_security_v2.json index bf03e1d3..00ed507a 100644 --- a/tests/fixtures/portfolio_decision_current_security_v2.json +++ b/tests/fixtures/portfolio_decision_current_security_v2.json @@ -25,12 +25,20 @@ "derived": { "attention_state": "decision-needed" }, "risk": { "security_risk": true }, "security": { + "alerts_available": true, "coverage_state": "complete", "receipt_state": "fresh", "source_produced_at": "2026-08-05T05:00:16.563112+00:00", "dependabot_critical": 0, "dependabot_high": 1, - "providers": { "dependabot": { "state": "observed", "observed_at": "2026-08-05T05:00:01.822262+00:00", "pagination_complete": true, "completed": true } } + "code_scanning_critical": 0, + "code_scanning_high": 0, + "secret_scanning_open": 0, + "providers": { + "dependabot": { "state": "observed", "reason_code": "observed", "observed_at": "2026-08-05T05:00:01.822262+00:00", "pagination_complete": true, "completed": true, "zero_findings": false, "counts": { "critical": 0, "high": 1, "medium": 0, "low": 0 } }, + "code_scanning": { "state": "observed", "reason_code": "observed", "observed_at": "2026-08-05T05:00:01.822262+00:00", "pagination_complete": true, "completed": true, "zero_findings": true, "counts": { "critical": 0, "high": 0, "warning": 0, "note": 0 } }, + "secret_scanning": { "state": "observed", "reason_code": "observed", "observed_at": "2026-08-05T05:00:01.822262+00:00", "pagination_complete": true, "completed": true, "zero_findings": true, "counts": { "open": 0 } } + } } }, { @@ -39,12 +47,20 @@ "derived": { "attention_state": "decision-needed" }, "risk": { "security_risk": true }, "security": { + "alerts_available": true, "coverage_state": "complete", "receipt_state": "fresh", "source_produced_at": "2026-08-05T05:00:16.563112+00:00", "dependabot_critical": 0, "dependabot_high": 4, - "providers": { "dependabot": { "state": "observed", "observed_at": "2026-08-05T05:00:01.822262+00:00", "pagination_complete": true, "completed": true } } + "code_scanning_critical": 0, + "code_scanning_high": 0, + "secret_scanning_open": 0, + "providers": { + "dependabot": { "state": "observed", "reason_code": "observed", "observed_at": "2026-08-05T05:00:01.822262+00:00", "pagination_complete": true, "completed": true, "zero_findings": false, "counts": { "critical": 0, "high": 4, "medium": 0, "low": 0 } }, + "code_scanning": { "state": "observed", "reason_code": "observed", "observed_at": "2026-08-05T05:00:01.822262+00:00", "pagination_complete": true, "completed": true, "zero_findings": true, "counts": { "critical": 0, "high": 0, "warning": 0, "note": 0 } }, + "secret_scanning": { "state": "observed", "reason_code": "observed", "observed_at": "2026-08-05T05:00:01.822262+00:00", "pagination_complete": true, "completed": true, "zero_findings": true, "counts": { "open": 0 } } + } } }, { @@ -53,12 +69,20 @@ "derived": { "attention_state": "decision-needed" }, "risk": { "security_risk": true }, "security": { + "alerts_available": true, "coverage_state": "complete", "receipt_state": "fresh", "source_produced_at": "2026-08-05T05:00:16.563112+00:00", "dependabot_critical": 0, "dependabot_high": 2, - "providers": { "dependabot": { "state": "observed", "observed_at": "2026-08-05T05:00:01.822262+00:00", "pagination_complete": true, "completed": true } } + "code_scanning_critical": 0, + "code_scanning_high": 0, + "secret_scanning_open": 0, + "providers": { + "dependabot": { "state": "observed", "reason_code": "observed", "observed_at": "2026-08-05T05:00:01.822262+00:00", "pagination_complete": true, "completed": true, "zero_findings": false, "counts": { "critical": 0, "high": 2, "medium": 0, "low": 0 } }, + "code_scanning": { "state": "observed", "reason_code": "observed", "observed_at": "2026-08-05T05:00:01.822262+00:00", "pagination_complete": true, "completed": true, "zero_findings": true, "counts": { "critical": 0, "high": 0, "warning": 0, "note": 0 } }, + "secret_scanning": { "state": "observed", "reason_code": "observed", "observed_at": "2026-08-05T05:00:01.822262+00:00", "pagination_complete": true, "completed": true, "zero_findings": true, "counts": { "open": 0 } } + } } }, { @@ -67,12 +91,20 @@ "derived": { "attention_state": "decision-needed" }, "risk": { "security_risk": true }, "security": { + "alerts_available": true, "coverage_state": "complete", "receipt_state": "fresh", "source_produced_at": "2026-08-05T05:00:16.563112+00:00", "dependabot_critical": 0, "dependabot_high": 1, - "providers": { "dependabot": { "state": "observed", "observed_at": "2026-08-05T05:00:01.822262+00:00", "pagination_complete": true, "completed": true } } + "code_scanning_critical": 0, + "code_scanning_high": 0, + "secret_scanning_open": 0, + "providers": { + "dependabot": { "state": "observed", "reason_code": "observed", "observed_at": "2026-08-05T05:00:01.822262+00:00", "pagination_complete": true, "completed": true, "zero_findings": false, "counts": { "critical": 0, "high": 1, "medium": 0, "low": 0 } }, + "code_scanning": { "state": "observed", "reason_code": "observed", "observed_at": "2026-08-05T05:00:01.822262+00:00", "pagination_complete": true, "completed": true, "zero_findings": true, "counts": { "critical": 0, "high": 0, "warning": 0, "note": 0 } }, + "secret_scanning": { "state": "observed", "reason_code": "observed", "observed_at": "2026-08-05T05:00:01.822262+00:00", "pagination_complete": true, "completed": true, "zero_findings": true, "counts": { "open": 0 } } + } } }, { @@ -92,19 +124,19 @@ "expected_security_decisions": { "payments-api": { "decision_key": "sha256:fddaff8be63ec131629eb8b3eb2f2232b999f3730748fcfc407f467a915a23cd", - "decision_fingerprint": "sha256:71bf4b0914e8d34be50369d084e45407ccf733a021b5cb97bad6097e04b305e0" + "decision_fingerprint": "sha256:569284e465a9b29993e8b7cf34fc562731054ec53b968f28b367335aa8387c8d" }, "audit-engine": { "decision_key": "sha256:595399eab7f31296ce96f7277673d955ad7fd0d540e37da54b5ce12f4cc5eb6e", - "decision_fingerprint": "sha256:e16817a5216a197802f71c11931391ec77e310435781b9c599da3c24d1293d99" + "decision_fingerprint": "sha256:df7441a6ba99aafb25eb9d8d877cdb634983194bda4b3ee2b6bcf37d55b1a810" }, "trust-store": { "decision_key": "sha256:98e4e3ebbadc0a4479704f33e0ca9d991fdb87defa6eb7ce8eb7654d821066f8", - "decision_fingerprint": "sha256:f7a589059d617bc5759b03ff418a1a4ffb61ac8e1bab92b858ed7a61101c026c" + "decision_fingerprint": "sha256:786c1dbd1709ebd6051ad5d359948b80055bc5798c2205955741183b688066cc" }, "security-console": { "decision_key": "sha256:2fec2aef256380d1957c3cbf6cf332bf04d04050e62bf5d8cf5413e3ae2fe89f", - "decision_fingerprint": "sha256:ff5107001f3e30f062e01816ea6b4240533de7a7eafd21ae5784a816f2c2057e" + "decision_fingerprint": "sha256:c646394805520758fb2616a00071b72524f0deaa4227ac111c481126d9fdeaed" } }, "expected_withheld": { diff --git a/tests/test_demo_portfolio.py b/tests/test_demo_portfolio.py index fa049586..4d4ea8a0 100644 --- a/tests/test_demo_portfolio.py +++ b/tests/test_demo_portfolio.py @@ -85,8 +85,7 @@ def _expected_inputs(snapshot: dict) -> dict: "producer_commit": "a" * 40, "cohort_policy": "portfolio-default-attention-v1", "cohort_repository_count": sum( - project["security"]["cohort_member"] - for project in snapshot["projects"] + project["security"]["cohort_member"] for project in snapshot["projects"] ), "path": "/demo-workspace/github-security-coverage.json", "receipt_id": "sha256:" + "b" * 64, @@ -224,9 +223,10 @@ def test_every_demo_row_carries_meaningful_production_shaped_provenance() -> Non provenance["derived.activity_status"]["detail"] == project["derived"]["activity_status"] ) - assert provenance["derived.archived"]["detail"] == str( - project["derived"]["archived"] - ).lower() + assert ( + provenance["derived.archived"]["detail"] + == str(project["derived"]["archived"]).lower() + ) assert ( provenance["derived.context_quality"]["detail"] == project["derived"]["context_quality"] @@ -324,29 +324,43 @@ def test_rollups_agree_with_the_project_records() -> None: ) -def test_risk_text_and_tiers_use_canonical_dependabot_alert_counts() -> None: +def test_risk_text_and_tiers_use_canonical_security_admission_counts() -> None: snapshot = _snapshot() repos_with_open_high_critical = 0 for project in snapshot["projects"]: security = project["security"] risk = project["risk"] - canonical_count = (security["dependabot_critical"] or 0) + ( + legacy_dependabot_count = (security["dependabot_critical"] or 0) + ( security["dependabot_high"] or 0 ) + blocking_critical = ( + (security["dependabot_critical"] or 0) + + (security["code_scanning_critical"] or 0) + + (security["secret_scanning_open"] or 0) + ) + blocking_high = (security["dependabot_high"] or 0) + ( + security["code_scanning_high"] or 0 + ) + canonical_count = blocking_critical + blocking_high factor = "active-high-severity-alerts" - assert security["open_high_critical"] == canonical_count + assert security["open_high_critical"] == legacy_dependabot_count assert risk["security_risk"] is (canonical_count > 0) assert (factor in risk["risk_factors"]) is (canonical_count > 0) if canonical_count > 0: repos_with_open_high_critical += 1 - if (security["dependabot_critical"] or 0) > 0: + if blocking_critical > 0: assert risk["risk_tier"] == "elevated" - assert snapshot["rollups"]["security"][ - "repos_with_open_high_critical" - ] == repos_with_open_high_critical + assert ( + snapshot["rollups"]["security"]["repos_with_open_high_critical"] + == repos_with_open_high_critical + ) + assert snapshot["rollups"]["security"]["total_open_secrets"] == sum( + (project["security"]["secret_scanning_open"] or 0) + for project in snapshot["projects"] + ) def test_attention_state_counts_match_the_project_records() -> None: @@ -400,19 +414,43 @@ def test_proposals_present_a_mixed_state_triage_queue() -> None: def test_weekly_digest_and_burndown_agree_with_the_snapshot() -> None: + from src.security_admission import derive_security_admission + snapshot = _snapshot() digest = build_weekly_digest(snapshot) burndown = build_security_burndown(snapshot) + admissions = [ + derive_security_admission(project["security"]) + for project in snapshot["projects"] + if project["security"]["cohort_member"] + ] assert digest["generated_at"] == snapshot["generated_at"] assert ( digest["risk_posture"]["risk_tier_counts"] == (snapshot["rollups"]["risk_tier_counts"]) ) - assert ( - digest["security_posture"]["total_open_high"] - == (snapshot["rollups"]["security"]["total_open_high"]) + posture = digest["security_posture"] + assert posture["scanned_count"] == sum( + admission.evidence_complete for admission in admissions + ) + assert posture["unadmitted_count"] == sum( + not admission.evidence_complete for admission in admissions + ) + assert posture["repos_with_blocking_findings"] == sum( + admission.has_findings for admission in admissions + ) + assert posture["total_open_high"] == sum( + admission.total_open_high for admission in admissions + ) + assert posture["total_open_critical"] == sum( + admission.total_open_critical for admission in admissions + ) + assert posture["total_open_secrets"] == sum( + admission.total_open_secrets for admission in admissions ) + assert digest["headline"].endswith("blocking GitHub security findings.") + assert all("security_admission_status" in item for item in posture["top_alerts"]) assert burndown["repos_touched"] == sum( 1 for p in snapshot["projects"] diff --git a/tests/test_portfolio_decision_queue.py b/tests/test_portfolio_decision_queue.py index 0970ebb4..0791dd0b 100644 --- a/tests/test_portfolio_decision_queue.py +++ b/tests/test_portfolio_decision_queue.py @@ -27,6 +27,18 @@ PRODUCER_COMMIT = "d" * 40 +def _provider(counts: dict[str, int], observed_at: str = OBSERVED_AT) -> dict: + return { + "state": "observed", + "reason_code": "observed", + "observed_at": observed_at, + "pagination_complete": True, + "completed": True, + "zero_findings": sum(counts.values()) == 0, + "counts": counts, + } + + def _project( name: str, *, @@ -34,6 +46,9 @@ def _project( security_risk: bool = False, dependabot_critical: int = 0, dependabot_high: int = 0, + code_scanning_critical: int = 0, + code_scanning_high: int = 0, + secret_scanning_open: int = 0, owner: str = "d", ) -> dict: return { @@ -54,22 +69,33 @@ def _project( "security_risk": security_risk, }, "security": { + "alerts_available": True, "coverage_state": "complete", "receipt_state": "fresh", "source_produced_at": PRODUCED_AT, "dependabot_critical": dependabot_critical, "dependabot_high": dependabot_high, + "code_scanning_critical": code_scanning_critical, + "code_scanning_high": code_scanning_high, + "secret_scanning_open": secret_scanning_open, "providers": { - "dependabot": { - "state": "observed", - "observed_at": OBSERVED_AT, - "pagination_complete": True, - "completed": True, - "counts": { + "dependabot": _provider( + { "critical": dependabot_critical, "high": dependabot_high, - }, - } + "medium": 0, + "low": 0, + } + ), + "code_scanning": _provider( + { + "critical": code_scanning_critical, + "high": code_scanning_high, + "warning": 0, + "note": 0, + } + ), + "secret_scanning": _provider({"open": secret_scanning_open}), }, }, } @@ -114,9 +140,8 @@ def _next_truth( source["receipt_id"] = "sha256:" + "f" * 64 source["content_sha256"] = "1" * 64 project["security"]["source_produced_at"] = produced_at - project["security"]["providers"]["dependabot"]["observed_at"] = ( - "2026-08-06T01:03:15+00:00" - ) + for provider in project["security"]["providers"].values(): + provider["observed_at"] = "2026-08-06T01:03:15+00:00" value["projects"] = [project] return value @@ -190,6 +215,33 @@ def test_security_decision_has_complete_stable_contract_and_dual_clocks() -> Non assert SECURITY_DECISION_VALIDITY_HOURS == 36 +@pytest.mark.parametrize( + ("security_changes", "reference_field"), + [ + ({"code_scanning_high": 1}, "code_scanning_high"), + ({"secret_scanning_open": 1}, "secret_scanning_open"), + ], +) +def test_non_dependabot_findings_enter_the_same_decision_contract( + security_changes: dict[str, int], reference_field: str +) -> None: + project = _project( + "ProviderFinding", + attention_state="active-infra", + security_risk=False, + **security_changes, + ) + + [decision] = build_decision_queue(_truth([project])) + + assert decision["evidence_reference"]["security_admission_schema"] == ( + "SecurityAdmissionV1" + ) + assert decision["evidence_reference"][reference_field] == 1 + assert decision["evidence_reference"]["provider"] == ("github_security_combined") + assert decision["readback_contract"]["success_condition"][reference_field] == 0 + + def test_current_security_fixture_freezes_ids_fingerprints_and_withheld_items() -> None: fixture = json.loads( ( diff --git a/tests/test_portfolio_risk.py b/tests/test_portfolio_risk.py index fa74567b..47fa3f85 100644 --- a/tests/test_portfolio_risk.py +++ b/tests/test_portfolio_risk.py @@ -130,7 +130,7 @@ def test_security_high_alert_adds_single_factor_moderate(): assert result["risk_tier"] == "moderate" assert result["risk_factors"] == ["active-high-severity-alerts"] assert result["security_risk"] is True - assert "open high/critical security alerts" in result["risk_summary"] + assert "blocking GitHub security findings" in result["risk_summary"] def test_security_critical_alert_force_elevates(): diff --git a/tests/test_portfolio_security_gate.py b/tests/test_portfolio_security_gate.py index b3985f40..fc3a896b 100644 --- a/tests/test_portfolio_security_gate.py +++ b/tests/test_portfolio_security_gate.py @@ -12,6 +12,21 @@ render_security_gate_markdown, ) +OBSERVED_AT = "2026-07-04T11:03:00+00:00" +PRODUCED_AT = "2026-07-04T11:04:00+00:00" + + +def _provider(counts: dict[str, int], *, observed: bool) -> dict: + return { + "state": "observed" if observed else "not_requested", + "reason_code": "observed" if observed else "not_requested", + "observed_at": OBSERVED_AT if observed else None, + "pagination_complete": observed, + "completed": observed, + "zero_findings": sum(counts.values()) == 0 if observed else None, + "counts": counts if observed else None, + } + def _project( name: str, @@ -31,11 +46,30 @@ def _project( "alerts_available": alerts_available, "cohort_member": True, "coverage_state": "complete" if alerts_available else "unknown", + "receipt_state": "fresh", + "source_produced_at": PRODUCED_AT, "providers": { - provider: { - "state": "observed" if alerts_available else "not_requested" - } - for provider in ("dependabot", "code_scanning", "secret_scanning") + "dependabot": _provider( + { + "critical": critical, + "high": high, + "medium": 0, + "low": 0, + }, + observed=alerts_available, + ), + "code_scanning": _provider( + { + "critical": code_critical, + "high": code_high, + "warning": 0, + "note": 0, + }, + observed=alerts_available, + ), + "secret_scanning": _provider( + {"open": secrets}, observed=alerts_available + ), }, "dependabot_critical": critical, "dependabot_high": high, @@ -173,11 +207,25 @@ def test_security_gate_treats_missing_overlay_as_unknown_not_pass() -> None: assert report.passed is False assert report.status == "unknown" assert report.scanned_count == 0 - assert "security coverage is missing or incomplete" in render_security_gate_markdown( - report + assert ( + "security coverage is missing or incomplete" + in render_security_gate_markdown(report) ) +def test_security_gate_surfaces_admission_reason_for_contradictory_counts() -> None: + project = _project("Contradictory") + project["security"]["code_scanning_high"] = 4 + + report = build_security_gate_report({"projects": [project]}) + + assert report.status == "unknown" + assert report.complete_count == 0 + [unadmitted] = report.unadmitted_repos + assert "SECURITY_PROVIDER_CODE_SCANNING_COUNT_CONFLICT" in (unadmitted.reason_codes) + assert "SECURITY_ADMISSION_UNKNOWN" in render_security_gate_markdown(report) + + @pytest.mark.parametrize("coverage_state", ["partial", "stale", "unknown"]) def test_security_gate_fails_closed_on_incomplete_required_cohort( coverage_state: str, @@ -220,7 +268,12 @@ def test_security_gate_cli_json_exits_zero_on_clear_snapshot(tmp_path, capsys) - def test_security_gate_cli_exits_nonzero_on_stale_snapshot(tmp_path) -> None: (tmp_path / "portfolio-truth-latest.json").write_text( - json.dumps({"generated_at": "2026-07-01T11:00:00+00:00", "projects": [_project("Clear")]}), + json.dumps( + { + "generated_at": "2026-07-01T11:00:00+00:00", + "projects": [_project("Clear")], + } + ), encoding="utf-8", ) diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index ee521a34..62e8c2f8 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -161,12 +161,78 @@ def _security_test_project( risk=RiskFields(risk_tier=tier), security=SecurityFields( alerts_available=available, + coverage_state="complete" if available else "unknown", + cohort_member=available, + receipt_state="fresh" if available else "unknown", + source_produced_at=("2026-08-04T12:00:00+00:00" if available else None), + providers={ + "dependabot": { + "state": "observed" if available else "not_requested", + "observed_at": ("2026-08-04T11:59:00+00:00" if available else None), + "pagination_complete": available, + "counts": ( + {"critical": critical, "high": high, "medium": 0, "low": 0} + if available + else None + ), + }, + "code_scanning": { + "state": "observed" if available else "not_requested", + "observed_at": ("2026-08-04T11:59:00+00:00" if available else None), + "pagination_complete": available, + "counts": ( + {"critical": 0, "high": 0, "warning": 0, "note": 0} + if available + else None + ), + }, + "secret_scanning": { + "state": "observed" if available else "not_requested", + "observed_at": ("2026-08-04T11:59:00+00:00" if available else None), + "pagination_complete": available, + "counts": {"open": 0} if available else None, + }, + }, dependabot_critical=critical, dependabot_high=high, + code_scanning_critical=0 if available else None, + code_scanning_high=0 if available else None, + secret_scanning_open=0 if available else None, ), ) +def _security_overlay_entry( + *, + dependabot_critical: int = 0, + dependabot_high: int = 0, + code_scanning_critical: int = 0, + code_scanning_high: int = 0, + secret_scanning_open: int = 0, +) -> dict: + # The display-name keyed test seam intentionally uses the supported legacy + # envelope. It can surface known findings but cannot prove evidence clear. + return { + "cohort_member": True, + "cohort_policy": "portfolio-default-attention-v1", + "dependabot": { + "critical": dependabot_critical, + "high": dependabot_high, + "medium": 0, + "low": 0, + "available": True, + }, + "code_scanning": { + "critical": code_scanning_critical, + "high": code_scanning_high, + "warning": 0, + "note": 0, + "available": True, + }, + "secret_scanning": {"open": secret_scanning_open, "available": True}, + } + + def test_extract_github_full_name_uses_exact_github_host() -> None: assert _extract_github_full_name("https://github.com/octo/repo.git") == "octo/repo" assert _extract_github_full_name("git@github.com:octo/repo.git") == "octo/repo" @@ -628,6 +694,8 @@ def test_truth_snapshot_respects_declared_and_derived_fields( "repos_with_open_high_critical", "total_open_high", "total_open_critical", + "total_open_secrets", + "security_admission_schema_version", } assert rollups["security"]["cohort_repository_count"] == 1 assert rollups["security"]["cohort_unknown_count"] == 1 @@ -754,8 +822,7 @@ def test_unresolved_declared_checkout_flows_through_truth_validation( missing_target = portfolio_workspace / "_codex-worktrees" / "repo-retired" / "src" _write( repo / "AGENTS.md", - "# Repo\n\n## Canonical Paths\n\n" - f"- Source: `{missing_target}`\n", + f"# Repo\n\n## Canonical Paths\n\n- Source: `{missing_target}`\n", ) subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True) subprocess.run( @@ -796,10 +863,7 @@ def test_unresolved_declared_checkout_flows_through_truth_validation( authority = project.repository_state["checkout_authority"] assert authority["checkout_count"] == 1 assert authority["selection"]["state"] == "unknown" - assert ( - authority["selection"]["reason_code"] - == "declared_checkout_path_unresolved" - ) + assert authority["selection"]["reason_code"] == "declared_checkout_path_unresolved" assert authority["unresolved_declared_paths"] == [ "_codex-worktrees/repo-retired/src" ] @@ -817,8 +881,7 @@ def test_failed_singleton_observation_with_declaration_is_valid_unknown( declared_target = repo / "src" _write( repo / "AGENTS.md", - "# ObservationRepo\n\n## Canonical Paths\n\n" - f"- Source: `{declared_target}`\n", + f"# ObservationRepo\n\n## Canonical Paths\n\n- Source: `{declared_target}`\n", ) subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True) subprocess.run( @@ -878,10 +941,13 @@ def _timeout_status(project_path: Path, *args: str) -> str: assert authority["selection"]["reason_code"] == "checkout_observation_failed" assert authority["selection"]["selected_path"] is None assert authority["declared_checkout_paths"] == ["ObservationRepo"] - assert checkout_authority_blocker( - project, - workspace_root=portfolio_workspace, - ) == "checkout-authority-unknown:checkout_observation_failed" + assert ( + checkout_authority_blocker( + project, + workspace_root=portfolio_workspace, + ) + == "checkout-authority-unknown:checkout_observation_failed" + ) validate_truth_snapshot(result.snapshot) @@ -928,8 +994,7 @@ def test_declared_bare_singleton_publishes_unknown_and_blocks_consumers( ) _write( coordinator / "AGENTS.md", - "# BareRepo\n\n## Canonical Paths\n\n" - f"- Source: `{coordinator}`\n", + f"# BareRepo\n\n## Canonical Paths\n\n- Source: `{coordinator}`\n", ) result = build_portfolio_truth_snapshot( @@ -950,10 +1015,13 @@ def test_declared_bare_singleton_publishes_unknown_and_blocks_consumers( assert authority["selection"]["state"] == "unknown" assert authority["selection"]["selected_path"] is None assert authority["selection"]["reason_code"] == "bare_representative_unusable" - assert checkout_authority_blocker( - project, - workspace_root=portfolio_workspace, - ) == "checkout-authority-unknown:bare_representative_unusable" + assert ( + checkout_authority_blocker( + project, + workspace_root=portfolio_workspace, + ) + == "checkout-authority-unknown:bare_representative_unusable" + ) validate_truth_snapshot(result.snapshot) plan = build_context_recovery_plan( @@ -1032,13 +1100,15 @@ def _timeout_worktree_enumeration(_project_path: Path) -> list[Path]: assert summary["state"] == "unknown" assert summary["group_count"] == 1 assert summary["ambiguous_group_count"] == 1 - assert checkout_authority_blocker( - project, - workspace_root=portfolio_workspace, - ) == "checkout-authority-unknown:worktree_enumeration_failed" + assert ( + checkout_authority_blocker( + project, + workspace_root=portfolio_workspace, + ) + == "checkout-authority-unknown:worktree_enumeration_failed" + ) assert any( - "same-origin checkout groups" in warning - for warning in result.snapshot.warnings + "same-origin checkout groups" in warning for warning in result.snapshot.warnings ) assert all( "same-origin full-clone groups" not in warning @@ -1066,8 +1136,7 @@ def test_external_declared_checkout_is_opaque_unknown( external_target = escape / "ExternalRepo" / "src" _write( repo / "AGENTS.md", - "# ExternalRepo\n\n## Canonical Paths\n\n" - f"- Source: `{external_target}`\n", + f"# ExternalRepo\n\n## Canonical Paths\n\n- Source: `{external_target}`\n", ) subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True) subprocess.run( @@ -1113,17 +1182,17 @@ def test_external_declared_checkout_is_opaque_unknown( ) authority = project.repository_state["checkout_authority"] assert authority["selection"]["state"] == "unknown" - assert ( - authority["selection"]["reason_code"] - == "declared_checkout_path_unresolved" - ) + assert authority["selection"]["reason_code"] == "declared_checkout_path_unresolved" assert authority["unresolved_declared_paths"] == ["external-checkout"] assert str(external_target) not in json.dumps(authority) assert str(external_target.resolve()) not in json.dumps(authority) - assert checkout_authority_blocker( - project, - workspace_root=portfolio_workspace, - ) == "checkout-authority-unknown:declared_checkout_path_unresolved" + assert ( + checkout_authority_blocker( + project, + workspace_root=portfolio_workspace, + ) + == "checkout-authority-unknown:declared_checkout_path_unresolved" + ) validate_truth_snapshot(result.snapshot) @@ -1164,8 +1233,7 @@ def test_external_linked_worktree_flows_through_truth_validation_and_report( check=True, ) external = ( - portfolio_workspace.parent - / "external-worktree-path-must-not-be-published" + portfolio_workspace.parent / "external-worktree-path-must-not-be-published" ) subprocess.run( ["git", "worktree", "add", "-q", "-b", "external", str(external), "HEAD"], @@ -1191,8 +1259,7 @@ def test_external_linked_worktree_flows_through_truth_validation_and_report( assert authority["full_clone_count"] == 1 assert authority["selection"]["state"] == "unknown" assert ( - authority["selection"]["reason_code"] - == "external_linked_worktree_unobserved" + authority["selection"]["reason_code"] == "external_linked_worktree_unobserved" ) assert authority["discarded_checkouts"] == [ { @@ -1208,10 +1275,13 @@ def test_external_linked_worktree_flows_through_truth_validation_and_report( ] assert str(external) not in json.dumps(authority) assert str(external) not in json.dumps(result.snapshot.to_dict()) - assert checkout_authority_blocker( - project, - workspace_root=portfolio_workspace, - ) == "checkout-authority-unknown:external_linked_worktree_unobserved" + assert ( + checkout_authority_blocker( + project, + workspace_root=portfolio_workspace, + ) + == "checkout-authority-unknown:external_linked_worktree_unobserved" + ) validate_truth_snapshot(result.snapshot) markdown = render_portfolio_report_markdown(result.snapshot, "output/x.json") @@ -1291,10 +1361,13 @@ def test_prunable_linked_worktree_is_unknown_not_publication_failure( if item["path"] == "_codex-worktrees/prunable-repo" ) assert missing["state"] == "unknown" - assert checkout_authority_blocker( - project, - workspace_root=portfolio_workspace, - ) == "checkout-authority-unknown:checkout_observation_failed" + assert ( + checkout_authority_blocker( + project, + workspace_root=portfolio_workspace, + ) + == "checkout-authority-unknown:checkout_observation_failed" + ) validate_truth_snapshot(result.snapshot) @@ -1409,10 +1482,13 @@ def test_discovered_bare_coordinator_sibling_preserves_identity_and_mutation_pat assert authority["selection"]["selected_path"] == "Repo-main" assert checkout_authority_path(project) == "Repo-main" assert project.repository_state["local"]["path"] == str(linked) - assert checkout_authority_blocker( - project, - workspace_root=portfolio_workspace, - ) is None + assert ( + checkout_authority_blocker( + project, + workspace_root=portfolio_workspace, + ) + is None + ) validate_truth_snapshot(result.snapshot) plan = build_context_recovery_plan( @@ -1528,9 +1604,7 @@ def test_bare_coordinator_preserves_nested_canonical_group_policy( assert project.declared.category == "infrastructure" assert project.declared.owner != "wrong-physical-owner" assert authority["canonical_project_path"] == "Infra/Repo" - assert authority["selection"]["selected_path"] == ( - "_codex-worktrees/repo-main" - ) + assert authority["selection"]["selected_path"] == ("_codex-worktrees/repo-main") assert checkout_authority_path(project) == "_codex-worktrees/repo-main" assert project.repository_state["local"]["path"] == str(linked) assert checkout_authority_blocker(project, workspace_root=workspace) is None @@ -1611,9 +1685,7 @@ def test_live_catalog_produces_exact_tier_zero_attention_semantics( by_display_name[project.identity.display_name] = project if project.derived.attention_state not in {"active-infra", "active-product"}: continue - logical_key = ( - project.identity.repo_full_name or project.identity.display_name - ) + logical_key = project.identity.repo_full_name or project.identity.display_name active[logical_key] = project.derived.attention_state assert active == { @@ -1635,14 +1707,10 @@ def test_live_catalog_produces_exact_tier_zero_attention_semantics( result.snapshot.to_dict(), overrides_config_path=None, ) - registry_by_key = { - entry["canonical_key"]: entry for entry in registry["entries"] - } + registry_by_key = {entry["canonical_key"]: entry for entry in registry["entries"]} assert registry_by_key["supp:personal-ops"]["lifecycle_state"] == "active" assert registry_by_key["supp:personal-ops"]["group_key"] == "operator_infra" - coverage_by_source = { - row["source"]: row for row in result.snapshot.coverage - } + coverage_by_source = {row["source"]: row for row in result.snapshot.coverage} assert coverage_by_source["workspace"]["project_count"] == 15 assert coverage_by_source["git"]["project_count"] == 15 assert coverage_by_source["supplementary_registry"]["project_count"] == 1 @@ -1651,9 +1719,7 @@ def test_live_catalog_produces_exact_tier_zero_attention_semantics( security_rollup = result.snapshot.to_dict()["rollups"]["security"] assert security_rollup["unknown_count"] == 15 assert security_rollup["unavailable_count"] == 15 - assert ( - result.catalog_data["repos"]["personal-ops"]["lifecycle_state"] == "active" - ) + assert result.catalog_data["repos"]["personal-ops"]["lifecycle_state"] == "active" personal_ops = by_display_name["personal-ops"] assert personal_ops.identity.project_key == "supp:personal-ops" assert personal_ops.identity.group_key == "operator_infra" @@ -1742,8 +1808,9 @@ def test_live_catalog_resolves_current_eleven_repo_cohort_and_egress_alias( for name in excluded_egress_repositories: assert by_display_name[name].derived.attention_state == "manual-only" assert ( - by_display_name["egress-guard-oss"] - .provenance["declared.lifecycle_state"]["detail"] + by_display_name["egress-guard-oss"].provenance["declared.lifecycle_state"][ + "detail" + ] == "cross-provider-egress-guard" ) for name in ("agent-permission-diff-bot", "proof-pr"): @@ -1776,8 +1843,10 @@ def test_discovered_personal_ops_replaces_supplementary_registry_identity( assert len(matches) == 1 assert matches[0].identity.project_key == "supp:personal-ops" assert matches[0].security.cohort_member is False - assert matches[0].provenance["derived.context_quality"]["source"].startswith( - "workspace+supplementary-registry" + assert ( + matches[0] + .provenance["derived.context_quality"]["source"] + .startswith("workspace+supplementary-registry") ) by_source = {row["source"]: row for row in result.snapshot.coverage} assert by_source["workspace"]["project_count"] == 0 @@ -2241,12 +2310,8 @@ def test_receipt_archived_state_is_fallback_when_live_status_is_unavailable( ), "reason": None, "observed_at": now.isoformat(), - "default_branch": ( - "main" if remote_state == "observed" else None - ), - "head_sha": ( - "b" * 40 if remote_state == "observed" else None - ), + "default_branch": ("main" if remote_state == "observed" else None), + "head_sha": ("b" * 40 if remote_state == "observed" else None), "archived": True, }, "providers": {}, @@ -2499,8 +2564,9 @@ def test_receipt_partial_provider_coverage_emits_explicit_denominators( 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"] + assert ( + sum(github_coverage["remote_default_branch_counts"].values()) + == (github_coverage["project_count"]) ) @@ -2551,6 +2617,56 @@ def test_security_overlay_populates_and_force_elevates( assert alpha_dict["security"]["dependabot_critical"] == 1 +@pytest.mark.parametrize( + "provider_counts", + [ + {"code_scanning": {"critical": 0, "high": 1, "available": True}}, + {"secret_scanning": {"open": 1, "available": True}}, + ], +) +def test_non_dependabot_findings_drive_risk_and_attention( + portfolio_workspace: Path, + portfolio_catalog: Path, + legacy_registry: Path, + provider_counts: dict[str, dict[str, int | bool]], +) -> None: + now = datetime.fromtimestamp(1_700_200_000, tz=timezone.utc) + security = { + "Alpha": { + "dependabot": { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + "available": True, + }, + "code_scanning": {"critical": 0, "high": 0, "available": True}, + "secret_scanning": {"open": 0, "available": True}, + **provider_counts, + } + } + + 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, + ) + alpha = next( + project + for project in result.snapshot.projects + if project.identity.display_name == "Alpha" + ) + + assert alpha.security.dependabot_critical == 0 + assert alpha.security.dependabot_high == 0 + assert alpha.risk.security_risk is True + assert "active-high-severity-alerts" in alpha.risk.risk_factors + assert alpha.derived.attention_state == "decision-needed" + + def test_bound_security_identity_and_high_findings_reach_decision_queue( portfolio_workspace: Path, portfolio_catalog: Path, @@ -2680,9 +2796,7 @@ def test_security_receipt_rejects_same_count_identity_rollover(tmp_path: Path) - readme = project / "README.md" _write(readme, f"# {name}\n\nCohort rollover fixture.\n") _set_mtime(readme, (now - timedelta(days=31)).timestamp()) - subprocess.run( - ["git", "init"], cwd=project, capture_output=True, check=True - ) + subprocess.run(["git", "init"], cwd=project, capture_output=True, check=True) subprocess.run( [ "git", @@ -2755,9 +2869,7 @@ def test_security_receipt_rejects_same_count_identity_rollover(tmp_path: Path) - category: vanity """ ) - replacement_security = { - "d/New": {**security["d/Old"], "repo_full_name": "d/New"} - } + replacement_security = {"d/New": {**security["d/Old"], "repo_full_name": "d/New"}} with pytest.raises( ValueError, match=( @@ -2774,9 +2886,7 @@ def test_security_receipt_rejects_same_count_identity_rollover(tmp_path: Path) - security_coverage_metadata=metadata, prior_security_alerts_by_name=security, prior_security_cohort_repositories=("d/Old",), - repo_status_by_name={ - "Old": {"source": "github_api", "archived": True} - }, + repo_status_by_name={"Old": {"source": "github_api", "archived": True}}, ) @@ -2974,9 +3084,7 @@ def test_security_cohort_identity_rejects_case_only_drift() -> None: with pytest.raises( ValueError, - match=( - "receipt_only=\\['D/Alpha'\\]; derived_only=\\['d/Alpha'\\]" - ), + match=("receipt_only=\\['D/Alpha'\\]; derived_only=\\['d/Alpha'\\]"), ): _validate_security_receipt_cohort_identity( projects=[project], @@ -3223,9 +3331,7 @@ def receipt_entry(*, high: int, state: str = "observed") -> dict: security_alerts_by_name={"d/Manual": contradictory_archive}, security_coverage_metadata=metadata, prior_security_alerts_by_name=prior_security, - repo_status_by_name={ - "Manual": {"source": "github_api", "archived": False} - }, + repo_status_by_name={"Manual": {"source": "github_api", "archived": False}}, ) @@ -3295,9 +3401,7 @@ def receipt_entry(*, archived: bool) -> dict: "cohort_repository_count": 1, "path": "/evidence/github-security-coverage-latest.json", } - live_archived_status = { - "Active": {"source": "github_api", "archived": True} - } + live_archived_status = {"Active": {"source": "github_api", "archived": True}} prior_active_security = {"d/Active": receipt_entry(archived=False)} with pytest.raises(ValueError, match="without fresh observed Dependabot"): @@ -3342,12 +3446,8 @@ def receipt_entry(*, archived: bool) -> dict: now=now, security_alerts_by_name={"d/Active": receipt_entry(archived=False)}, security_coverage_metadata=metadata, - prior_security_alerts_by_name={ - "d/Active": receipt_entry(archived=True) - }, - repo_status_by_name={ - "Active": {"source": "github_api", "archived": False} - }, + prior_security_alerts_by_name={"d/Active": receipt_entry(archived=True)}, + repo_status_by_name={"Active": {"source": "github_api", "archived": False}}, ) reactivated = unarchived.snapshot.projects[0] assert reactivated.derived.archived is False @@ -3362,12 +3462,8 @@ def receipt_entry(*, archived: bool) -> dict: now=now, security_alerts_by_name={}, security_coverage_metadata=empty_metadata, - prior_security_alerts_by_name={ - "d/Active": receipt_entry(archived=True) - }, - repo_status_by_name={ - "Active": {"source": "github_api", "archived": False} - }, + prior_security_alerts_by_name={"d/Active": receipt_entry(archived=True)}, + repo_status_by_name={"Active": {"source": "github_api", "archived": False}}, ) with pytest.raises(ValueError, match="post-receipt attention contains"): @@ -3378,9 +3474,7 @@ def receipt_entry(*, archived: bool) -> dict: now=now, security_alerts_by_name={}, security_coverage_metadata=empty_metadata, - prior_security_alerts_by_name={ - "d/Active": receipt_entry(archived=True) - }, + prior_security_alerts_by_name={"d/Active": receipt_entry(archived=True)}, repo_status_by_name={ "Active": {"source": "audit_report", "archived": False} }, @@ -3393,9 +3487,7 @@ def receipt_entry(*, archived: bool) -> dict: now=now, security_alerts_by_name={"d/Active": receipt_entry(archived=True)}, security_coverage_metadata=metadata, - prior_security_alerts_by_name={ - "d/Active": receipt_entry(archived=True) - }, + prior_security_alerts_by_name={"d/Active": receipt_entry(archived=True)}, # The prior canonical truth kept this identity in its final cohort because # live GitHub status contradicted the receipt's archive claim. prior_security_cohort_repositories=("d/Active",), @@ -3504,15 +3596,15 @@ def metadata(observed_at: datetime, marker: str) -> dict: now=first_at, security_alerts_by_name={"d/Active": security_entry(first_at)}, security_coverage_metadata=metadata(first_at, "a"), - repo_status_by_name={ - "Active": {"source": "github_api", "archived": False} - }, + repo_status_by_name={"Active": {"source": "github_api", "archived": False}}, ) first_payload = json.loads(first.latest_path.read_text()) first_project = first_payload["projects"][0] assert first_project["derived"]["archived"] is False assert first_project["derived"]["attention_state"] == "active-infra" - assert first_project["repository_state"]["remote_default_branch"]["archived"] is True + assert ( + first_project["repository_state"]["remote_default_branch"]["archived"] is True + ) second_at = first_at + timedelta(hours=1) second = publish_portfolio_truth( @@ -4113,9 +4205,7 @@ def test_catalog_backed_high_criticality_infra_readme_support_promotes_to_standa assert infra.security.cohort_member is True assert infra.security.coverage_state == "unknown" security_coverage = next( - row - for row in result.snapshot.coverage - if row["source"] == "github_security" + row for row in result.snapshot.coverage if row["source"] == "github_security" ) assert security_coverage["cohort_repository_count"] == 1 assert security_coverage["cohort_unknown_count"] == 1 @@ -4246,18 +4336,12 @@ def test_registry_render_surfaces_security_and_round_trips( ) -> None: now = datetime(2026, 8, 4, 12, tzinfo=timezone.utc) security = { - "Alpha": { - "dependabot": { - "critical": 2, - "high": 1, - "medium": 0, - "low": 0, - "receipt_id": 7, - "available": True, - }, - "code_scanning": {"available": True}, - "secret_scanning": {"open": 0, "available": True}, - } + "Alpha": _security_overlay_entry( + dependabot_critical=2, + dependabot_high=1, + code_scanning_high=2, + secret_scanning_open=1, + ) } result = build_portfolio_truth_snapshot( workspace_root=portfolio_workspace, @@ -4269,13 +4353,15 @@ def test_registry_render_surfaces_security_and_round_trips( ) markdown = render_registry_markdown(result.snapshot) - # Per-repo Notes flag fires for the scanned repo carrying open high/critical alerts. - assert "[security: 2 critical / 1 high open Dependabot alerts]" in markdown + # Per-repo Notes flag reflects the same combined admitted result as other surfaces. + assert "[security: 2 critical / 3 high / 1 open secrets]" in markdown # Aggregate rows land in the Portfolio Summary table. - assert "| Repos scanned for security alerts | 1 |" in markdown - assert "| Repos with open high/critical alerts | 1 |" in markdown - assert "| Open critical Dependabot alerts | 2 |" in markdown - assert "| Open high Dependabot alerts | 1 |" in markdown + assert "| Repos admitted for security findings | 0 |" in markdown + assert "| Repos with blocking GitHub security findings | 1 |" in markdown + assert "| Repos with unadmitted security evidence | 1 |" in markdown + assert "| Open critical GitHub security findings | 2 |" in markdown + assert "| Open high GitHub security findings | 3 |" in markdown + assert "| Open secret-scanning findings | 1 |" in markdown # The security flag is pipe-free + digit summary rows, so the parser round-trip is # unchanged: same project row count, no inflation from the new content. @@ -4334,8 +4420,8 @@ def test_registry_render_omits_security_flag_when_unscanned( markdown = render_registry_markdown(result.snapshot) assert "[security:" not in markdown # Summary rows stay present, all zero, documenting that the overlay was not run. - assert "| Repos scanned for security alerts | 0 |" in markdown - assert "| Repos with open high/critical alerts | 0 |" in markdown + assert "| Repos admitted for security findings | 0 |" in markdown + assert "| Repos with blocking GitHub security findings | 0 |" in markdown def test_portfolio_report_security_posture_lists_open_alerts( @@ -4344,17 +4430,13 @@ def test_portfolio_report_security_posture_lists_open_alerts( legacy_registry: Path, ) -> None: security = { - "Alpha": { - "dependabot": { - "critical": 1, - "high": 2, - "medium": 0, - "low": 0, - "available": True, - }, - "code_scanning": {"available": True}, - "secret_scanning": {"open": 0, "available": True}, - } + "Alpha": _security_overlay_entry( + dependabot_critical=1, + dependabot_high=2, + code_scanning_critical=1, + code_scanning_high=3, + secret_scanning_open=1, + ) } result = build_portfolio_truth_snapshot( workspace_root=portfolio_workspace, @@ -4368,10 +4450,11 @@ def test_portfolio_report_security_posture_lists_open_alerts( assert "## Security Posture" in markdown assert "[Security Posture](#security-posture)" in markdown assert ( - "- **Alpha** [elevated]: 1 critical, 2 high open Dependabot alerts" in markdown + "- **Alpha** [elevated]: 2 critical, 5 high, 1 open secrets (admission fail)" + in markdown ) assert ( - "- Security posture: scanned `1`, with open high/critical Dependabot alerts `1`" + "- Security posture: admitted `0`, with blocking GitHub security findings `1`" in markdown ) # The new section keeps the report validator green. @@ -4383,40 +4466,38 @@ def test_portfolio_report_security_posture_scanned_clear( portfolio_catalog: Path, legacy_registry: Path, ) -> None: - # Scanned with zero open high/critical reads as "all clear", distinct from "not run". - security = { - "Alpha": { - "dependabot": { - "critical": 0, - "high": 0, - "medium": 3, - "low": 0, - "available": True, - }, - "code_scanning": {"available": True}, - "secret_scanning": {"open": 0, "available": True}, - } - } + # A complete, fresh, zero-finding admission reads as clear. result = build_portfolio_truth_snapshot( workspace_root=portfolio_workspace, catalog_path=portfolio_catalog, legacy_registry_path=legacy_registry, include_notion=False, - security_alerts_by_name=security, ) - markdown = render_portfolio_report_markdown(result.snapshot, "output/x.json") + complete_clear = _security_test_project( + "Alpha", critical=0, high=0, available=True, tier="baseline" + ).security + snapshot = replace( + result.snapshot, + projects=[ + replace(project, security=complete_clear) + if project.identity.display_name == "Alpha" + else project + for project in result.snapshot.projects + ], + ) + markdown = render_portfolio_report_markdown(snapshot, "output/x.json") assert ( - "All 1 scanned repos are clear of open high/critical Dependabot alerts." + "All 1 admitted repos are clear of blocking GitHub security findings." in markdown ) validate_portfolio_report_markdown(markdown) # Same guard governs the registry: a scanned repo with only medium alerts gets no # per-repo flag, but it still counts as scanned in the summary table. - registry_md = render_registry_markdown(result.snapshot) + registry_md = render_registry_markdown(snapshot) assert "[security:" not in registry_md - assert "| Repos scanned for security alerts | 1 |" in registry_md - assert "| Repos with open high/critical alerts | 0 |" in registry_md + assert "| Repos admitted for security findings | 1 |" in registry_md + assert "| Repos with blocking GitHub security findings | 0 |" in registry_md def test_security_attention_items_caps_at_five_and_sorts_critical_first() -> None: @@ -4458,8 +4539,9 @@ def test_portfolio_report_security_posture_not_run( include_notion=False, ) markdown = render_portfolio_report_markdown(result.snapshot, "output/x.json") - assert "Security overlay not run for this snapshot" in markdown - assert "- Security posture: scanned `0`," in markdown + assert "Security evidence remains UNKNOWN for 1 repo(s)" in markdown + assert "- Security posture: admitted `0`," in markdown + assert "unadmitted `1`" in markdown validate_portfolio_report_markdown(markdown) @@ -4615,9 +4697,7 @@ def test_publish_uses_bound_security_max_age_for_remote_evidence( cwd=alpha, check=True, ) - subprocess.run( - ["git", "config", "user.name", "Tests"], cwd=alpha, check=True - ) + subprocess.run(["git", "config", "user.name", "Tests"], cwd=alpha, check=True) subprocess.run( ["git", "remote", "add", "origin", "https://github.com/d/Alpha.git"], cwd=alpha, @@ -4714,9 +4794,14 @@ def test_publish_uses_bound_security_max_age_for_remote_evidence( ) payload = json.loads(published.latest_path.read_text()) alpha_payload = next( - project for project in payload["projects"] if project["identity"]["path"] == "Alpha" + project + for project in payload["projects"] + if project["identity"]["path"] == "Alpha" + ) + assert ( + alpha_payload["repository_state"]["remote_default_branch"]["state"] + == "observed" ) - assert alpha_payload["repository_state"]["remote_default_branch"]["state"] == "observed" second_at = now + timedelta(hours=1) second_security = json.loads(json.dumps(security)) @@ -5277,24 +5362,25 @@ def test_publish_refuses_nested_evidence_that_expires_after_snapshot( now=now + timedelta(seconds=1), ) assert loaded.receipt_id == at_boundary.receipt_id == reloaded.receipt_id - assert loaded.content_sha256 == at_boundary.content_sha256 == reloaded.content_sha256 + assert ( + loaded.content_sha256 == at_boundary.content_sha256 == reloaded.content_sha256 + ) assert loaded.receipt_state == at_boundary.receipt_state == "fresh" assert reloaded.receipt_state == "fresh" - assert loaded.entries_by_full_name["d/Alpha"]["providers"]["dependabot"][ - "state" - ] == "observed" - assert reloaded.entries_by_full_name["d/Alpha"]["providers"]["dependabot"][ - "state" - ] == "stale" - assert at_boundary.entries_by_full_name["d/Alpha"]["providers"]["dependabot"][ - "state" - ] == "observed" - assert loaded.entries_by_full_name["d/Alpha"]["repository"]["state"] == ( - "observed" + assert ( + loaded.entries_by_full_name["d/Alpha"]["providers"]["dependabot"]["state"] + == "observed" ) - assert reloaded.entries_by_full_name["d/Alpha"]["repository"]["state"] == ( - "stale" + assert ( + reloaded.entries_by_full_name["d/Alpha"]["providers"]["dependabot"]["state"] + == "stale" + ) + assert ( + at_boundary.entries_by_full_name["d/Alpha"]["providers"]["dependabot"]["state"] + == "observed" ) + assert loaded.entries_by_full_name["d/Alpha"]["repository"]["state"] == ("observed") + assert reloaded.entries_by_full_name["d/Alpha"]["repository"]["state"] == ("stale") assert at_boundary.entries_by_full_name["d/Alpha"]["repository"]["state"] == ( "observed" ) @@ -5812,8 +5898,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: captured.setdefault("repo_status_cache", kwargs["cache"]) - or {}, + lambda **kwargs: ( + captured.setdefault("repo_status_cache", kwargs["cache"]) or {} + ), ) monkeypatch.setenv("GHRA_REQUIRE_PRODUCER_EVIDENCE", "0") args = SimpleNamespace( @@ -6344,9 +6431,7 @@ def test_checkout_authority_path_falls_back_for_malformed_envelope_variants() -> dirty_discarded = _checkout_authority_fixture( canonical_path="Repo", origin="owner/Repo" ) - dirty_discarded["checkouts"][1].update( - {"dirty": True, "dirty_path_count": 1} - ) + dirty_discarded["checkouts"][1].update({"dirty": True, "dirty_path_count": 1}) variants.append(dirty_discarded) for authority in variants: diff --git a/tests/test_security_admission.py b/tests/test_security_admission.py new file mode 100644 index 00000000..b0e029cb --- /dev/null +++ b/tests/test_security_admission.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import copy + +import pytest + +from src.security_admission import derive_security_admission + + +OBSERVED_AT = "2026-08-05T05:47:30+00:00" +PRODUCED_AT = "2026-08-05T05:48:00+00:00" + + +def _provider(counts: dict[str, int]) -> dict: + return { + "state": "observed", + "reason_code": "observed", + "observed_at": OBSERVED_AT, + "pagination_complete": True, + "completed": True, + "zero_findings": sum(counts.values()) == 0, + "counts": counts, + } + + +def _security( + *, + dependabot_critical: int = 0, + dependabot_high: int = 0, + code_scanning_critical: int = 0, + code_scanning_high: int = 0, + secret_scanning_open: int = 0, +) -> dict: + return { + "alerts_available": True, + "coverage_state": "complete", + "receipt_state": "fresh", + "source_produced_at": PRODUCED_AT, + "dependabot_critical": dependabot_critical, + "dependabot_high": dependabot_high, + "code_scanning_critical": code_scanning_critical, + "code_scanning_high": code_scanning_high, + "secret_scanning_open": secret_scanning_open, + "providers": { + "dependabot": _provider( + { + "critical": dependabot_critical, + "high": dependabot_high, + "medium": 0, + "low": 0, + } + ), + "code_scanning": _provider( + { + "critical": code_scanning_critical, + "high": code_scanning_high, + "warning": 0, + "note": 0, + } + ), + "secret_scanning": _provider({"open": secret_scanning_open}), + }, + } + + +def test_complete_fresh_alert_free_evidence_is_admitted_clear() -> None: + admission = derive_security_admission(_security()) + + assert admission.schema_version == "SecurityAdmissionV1" + assert admission.status == "pass" + assert admission.evidence_complete is True + assert admission.has_findings is False + assert admission.reason_codes == ("SECURITY_ADMISSION_CLEAR",) + assert admission.evidence_observed_at == OBSERVED_AT + assert admission.total_blocking_findings == 0 + + +@pytest.mark.parametrize( + ("changes", "critical", "high", "secrets"), + [ + ({"dependabot_critical": 1}, 1, 0, 0), + ({"dependabot_high": 2}, 0, 2, 0), + ({"code_scanning_critical": 1}, 1, 0, 0), + ({"code_scanning_high": 3}, 0, 3, 0), + ({"secret_scanning_open": 1}, 0, 0, 1), + ], +) +def test_each_provider_can_fail_admission_without_dependabot_findings( + changes: dict[str, int], critical: int, high: int, secrets: int +) -> None: + admission = derive_security_admission(_security(**changes)) + + assert admission.status == "fail" + assert admission.evidence_complete is True + assert admission.has_findings is True + assert admission.total_open_critical == critical + assert admission.total_open_high == high + assert admission.total_open_secrets == secrets + assert admission.reason_codes == ("SECURITY_ADMISSION_FINDINGS",) + + +@pytest.mark.parametrize( + ("coverage_state", "reason_code"), + [ + ("partial", "SECURITY_COVERAGE_PARTIAL"), + ("stale", "SECURITY_COVERAGE_STALE"), + ("unknown", "SECURITY_COVERAGE_UNKNOWN"), + ("malformed", "SECURITY_COVERAGE_INVALID"), + ], +) +def test_non_complete_coverage_is_unknown( + coverage_state: str, reason_code: str +) -> None: + security = _security() + security["coverage_state"] = coverage_state + + admission = derive_security_admission(security) + + assert admission.status == "unknown" + assert admission.evidence_complete is False + assert admission.reason_codes[0] == "SECURITY_ADMISSION_UNKNOWN" + assert reason_code in admission.reason_codes + + +def test_missing_provider_and_unavailable_provider_are_unknown() -> None: + missing = _security() + del missing["providers"]["secret_scanning"] + missing_result = derive_security_admission(missing) + assert missing_result.status == "unknown" + assert "SECURITY_PROVIDER_SET_INCOMPLETE" in missing_result.reason_codes + + unavailable = _security() + unavailable["coverage_state"] = "partial" + unavailable["alerts_available"] = False + unavailable["providers"]["code_scanning"] = { + "state": "forbidden", + "reason_code": "forbidden", + "observed_at": OBSERVED_AT, + "pagination_complete": False, + "completed": False, + "zero_findings": None, + "counts": None, + } + unavailable_result = derive_security_admission(unavailable) + assert unavailable_result.status == "unknown" + assert ( + "SECURITY_PROVIDER_CODE_SCANNING_NOT_OBSERVED" + in unavailable_result.reason_codes + ) + + +@pytest.mark.parametrize( + "mutation", + [ + "negative_count", + "boolean_count", + "compatibility_count_conflict", + "zero_findings_conflict", + "clock_conflict", + "receipt_conflict", + ], +) +def test_malformed_and_contradictory_evidence_is_unknown(mutation: str) -> None: + security = _security() + if mutation == "negative_count": + security["providers"]["dependabot"]["counts"]["high"] = -1 + elif mutation == "boolean_count": + security["providers"]["code_scanning"]["counts"]["critical"] = True + elif mutation == "compatibility_count_conflict": + security["code_scanning_high"] = 7 + elif mutation == "zero_findings_conflict": + security["providers"]["secret_scanning"]["zero_findings"] = False + elif mutation == "clock_conflict": + security["providers"]["dependabot"]["observed_at"] = "2026-08-05T05:49:00+00:00" + elif mutation == "receipt_conflict": + security["receipt_state"] = "stale" + + admission = derive_security_admission(security) + + assert admission.status == "unknown" + assert admission.evidence_complete is False + assert admission.reason_codes[0] == "SECURITY_ADMISSION_UNKNOWN" + + +def test_known_findings_remain_visible_when_other_coverage_is_unknown() -> None: + security = _security(dependabot_high=1) + security["coverage_state"] = "partial" + security["alerts_available"] = False + security["providers"]["secret_scanning"] = { + "state": "forbidden", + "reason_code": "forbidden", + "observed_at": OBSERVED_AT, + "pagination_complete": False, + "completed": False, + "zero_findings": None, + "counts": None, + } + + admission = derive_security_admission(security) + + assert admission.status == "fail" + assert admission.evidence_complete is False + assert admission.has_findings is True + assert admission.total_open_high == 1 + assert admission.reason_codes[0] == "SECURITY_ADMISSION_FINDINGS" + assert "SECURITY_COVERAGE_PARTIAL" in admission.reason_codes + + +def test_provider_finding_remains_visible_when_compatibility_count_conflicts() -> None: + security = _security(code_scanning_high=2) + security["code_scanning_high"] = 0 + + admission = derive_security_admission(security) + + assert admission.status == "fail" + assert admission.evidence_complete is False + assert admission.has_findings is True + assert admission.total_open_high == 2 + assert admission.reason_codes[0] == "SECURITY_ADMISSION_FINDINGS" + assert "SECURITY_PROVIDER_CODE_SCANNING_COUNT_CONFLICT" in (admission.reason_codes) + + +def test_derivation_is_deterministic_and_does_not_mutate_input() -> None: + security = _security(code_scanning_high=1) + before = copy.deepcopy(security) + + first = derive_security_admission(security) + second = derive_security_admission(copy.deepcopy(security)) + + assert first == second + assert first.to_dict() == second.to_dict() + assert security == before + + +def test_additive_envelope_fields_remain_backward_compatible_when_absent() -> None: + security = _security() + security.pop("alerts_available") + for provider in security["providers"].values(): + provider.pop("reason_code") + provider.pop("completed") + provider.pop("zero_findings") + + admission = derive_security_admission(security) + + assert admission.status == "pass" + assert admission.evidence_complete is True diff --git a/tests/test_weekly_command_center.py b/tests/test_weekly_command_center.py index 2bd293e8..a3df6da5 100644 --- a/tests/test_weekly_command_center.py +++ b/tests/test_weekly_command_center.py @@ -326,28 +326,58 @@ def test_build_weekly_command_center_digest_blocks_stale_queue_when_truth_is_new assert "StaleRepo" not in rendered_md -def _sec(available: bool, critical: int = 0, high: int = 0) -> dict: +def _security_provider(counts: dict[str, int], *, available: bool) -> dict: return { + "state": "observed" if available else "not_requested", + "reason_code": "observed" if available else "not_requested", + "observed_at": "2026-04-14T11:57:00+00:00" if available else None, + "pagination_complete": available, + "completed": available, + "zero_findings": sum(counts.values()) == 0 if available else None, + "counts": counts if available else None, + } + + +def _sec( + available: bool, + critical: int = 0, + high: int = 0, + *, + code_critical: int = 0, + code_high: int = 0, + secrets: int = 0, +) -> dict: + return { + "cohort_member": True, "alerts_available": available, - "coverage_state": "complete" if available else "unavailable", + "coverage_state": "complete" if available else "unknown", "receipt_state": "fresh" if available else "unknown", "source_produced_at": "2026-04-14T11:58:00+00:00" if available else None, "providers": { - "dependabot": { - "state": "observed" if available else "unavailable", - "observed_at": "2026-04-14T11:57:00+00:00" if available else None, - "pagination_complete": available, - "completed": available, - "counts": {"critical": critical, "high": high}, - } + "dependabot": _security_provider( + {"critical": critical, "high": high, "medium": 0, "low": 0}, + available=available, + ), + "code_scanning": _security_provider( + { + "critical": code_critical, + "high": code_high, + "warning": 0, + "note": 0, + }, + available=available, + ), + "secret_scanning": _security_provider( + {"open": secrets}, available=available + ), }, "dependabot_critical": critical, "dependabot_high": high, "dependabot_medium": 0, "dependabot_low": 0, - "code_scanning_critical": 0, - "code_scanning_high": 0, - "secret_scanning_open": 0, + "code_scanning_critical": code_critical, + "code_scanning_high": code_high, + "secret_scanning_open": secrets, } @@ -377,7 +407,11 @@ def _security_project( "context_risk": False, "path_risk": False, "security_risk": bool( - security.get("dependabot_high") or security.get("dependabot_critical") + security.get("dependabot_high") + or security.get("dependabot_critical") + or security.get("code_scanning_high") + or security.get("code_scanning_critical") + or security.get("secret_scanning_open") ), }, "security": security, @@ -446,7 +480,7 @@ def test_security_posture_surfaces_open_alerts_critical_first() -> None: digest = _digest_for(portfolio_truth) posture = digest["security_posture"] - # Only repos with alerts_available are scanned; UnscannedRepo is excluded. + # Only complete canonical admissions count as scanned. assert posture["scanned_count"] == 3 assert posture["repos_with_open_high_critical"] == 2 assert posture["total_open_critical"] == 2 @@ -464,7 +498,7 @@ def test_security_posture_surfaces_open_alerts_critical_first() -> None: rendered = render_weekly_command_center_markdown(digest) assert "## Security Posture" in rendered assert "CriticalRepo" in rendered - assert "2 critical, 1 high" in rendered + assert "2 critical, 1 high, 0 open secrets" in rendered def test_security_posture_reports_clean_when_scanned_and_no_open_alerts() -> None: @@ -479,7 +513,59 @@ def test_security_posture_reports_clean_when_scanned_and_no_open_alerts() -> Non assert digest["security_posture"]["top_alerts"] == [] rendered = render_weekly_command_center_markdown(digest) - assert "All 2 scanned repos are clear" in rendered + assert "All 2 admitted repos are clear" in rendered + + +def test_security_posture_ignores_projects_outside_security_cohort() -> None: + out_of_cohort = _sec(False) + out_of_cohort["cohort_member"] = False + portfolio_truth = { + "projects": [ + _security_project("CleanCohort", "baseline", _sec(True)), + _security_project("OutsideCohort", "baseline", out_of_cohort), + ] + } + + digest = _digest_for(portfolio_truth) + posture = digest["security_posture"] + + assert posture["scanned_count"] == 1 + assert posture["unadmitted_count"] == 0 + assert posture["admission_status_counts"] == {"pass": 1} + assert posture["top_alerts"] == [] + + +def test_security_posture_uses_code_scanning_and_secret_findings() -> None: + portfolio_truth = { + "projects": [ + _security_project( + "CodeFinding", + "moderate", + _sec(True, code_high=2), + ["active-high-severity-alerts"], + ), + _security_project( + "SecretFinding", + "elevated", + _sec(True, secrets=1), + ["active-high-severity-alerts"], + ), + ] + } + + digest = _digest_for(portfolio_truth) + posture = digest["security_posture"] + + assert posture["repos_with_blocking_findings"] == 2 + assert posture["total_open_high"] == 2 + assert posture["total_open_secrets"] == 1 + assert {item["repo"] for item in posture["top_alerts"]} == { + "CodeFinding", + "SecretFinding", + } + rendered = render_weekly_command_center_markdown(digest) + assert "2 high" in rendered + assert "1 open secrets" in rendered def test_security_posture_reports_not_run_when_no_overlay() -> None: From 0671717335b49940398bd4bd20543fe9ef8720e9 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Wed, 5 Aug 2026 00:14:06 -0700 Subject: [PATCH 2/5] test(contract): refresh portable consumer fixtures --- fixtures/contracts/operator-control-plane-v1/manifest.json | 2 +- fixtures/contracts/portable-consumers-v1/portfolio-truth.json | 4 +++- fixtures/contracts/public-site-projection-v1/manifest.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/fixtures/contracts/operator-control-plane-v1/manifest.json b/fixtures/contracts/operator-control-plane-v1/manifest.json index e19014b7..dd8b66db 100644 --- a/fixtures/contracts/operator-control-plane-v1/manifest.json +++ b/fixtures/contracts/operator-control-plane-v1/manifest.json @@ -62,7 +62,7 @@ "portfolio_truth_schema_version": "0.11.0", "producer": { "artifact_path": "fixtures/contracts/portable-consumers-v1/portfolio-truth.json", - "artifact_sha256": "8af46309e1b7a891edf7489dcbfa5971cc3453fe983b9a824098d87dc42105d3", + "artifact_sha256": "e79ca6b203ff58b8b6ffbb01e778305887dfba4e4caf66334defea6ccc1713df", "generator": "src.portfolio_truth_contract_fixture:build_portable_contract_fixture", "manifest_path": "fixtures/contracts/operator-control-plane-v1/manifest.json", "repository": "saagpatel/GithubRepoAuditor" diff --git a/fixtures/contracts/portable-consumers-v1/portfolio-truth.json b/fixtures/contracts/portable-consumers-v1/portfolio-truth.json index 34e30f18..f07734d7 100644 --- a/fixtures/contracts/portable-consumers-v1/portfolio-truth.json +++ b/fixtures/contracts/portable-consumers-v1/portfolio-truth.json @@ -1492,7 +1492,7 @@ "risk_factors": [ "active-high-severity-alerts" ], - "risk_summary": "1 risk factor(s): open high/critical security alerts.", + "risk_summary": "1 risk factor(s): blocking GitHub security findings.", "risk_tier": "moderate", "security_risk": true }, @@ -1609,9 +1609,11 @@ "scanned_count": 1, "secret_scanning_observed_count": 1, "secret_scanning_zero_finding_count": 1, + "security_admission_schema_version": "SecurityAdmissionV1", "stale_count": 1, "total_open_critical": 0, "total_open_high": 3, + "total_open_secrets": 0, "unavailable_count": 3, "unknown_count": 1 } diff --git a/fixtures/contracts/public-site-projection-v1/manifest.json b/fixtures/contracts/public-site-projection-v1/manifest.json index d9a1522e..fe80b130 100644 --- a/fixtures/contracts/public-site-projection-v1/manifest.json +++ b/fixtures/contracts/public-site-projection-v1/manifest.json @@ -82,7 +82,7 @@ "portfolio_truth_schema_version": "0.11.0", "producer": { "artifact_path": "fixtures/contracts/portable-consumers-v1/portfolio-truth.json", - "artifact_sha256": "8af46309e1b7a891edf7489dcbfa5971cc3453fe983b9a824098d87dc42105d3", + "artifact_sha256": "e79ca6b203ff58b8b6ffbb01e778305887dfba4e4caf66334defea6ccc1713df", "generator": "src.portfolio_truth_contract_fixture:build_portable_contract_fixture", "manifest_path": "fixtures/contracts/public-site-projection-v1/manifest.json", "repository": "saagpatel/GithubRepoAuditor" From 2d4138d155853a11e6d7c0759d049a4ba5d56677 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Wed, 5 Aug 2026 00:25:29 -0700 Subject: [PATCH 3/5] test(security): prove count-only digest output --- src/portfolio_decision_queue.py | 2 ++ tests/test_portfolio_decision_queue.py | 25 +++++++++++++------------ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/portfolio_decision_queue.py b/src/portfolio_decision_queue.py index 68a5d745..211072b6 100644 --- a/src/portfolio_decision_queue.py +++ b/src/portfolio_decision_queue.py @@ -740,6 +740,8 @@ def main(argv: list[str] | None = None) -> int: ) digest = build_decision_digest(truth, previous_digest=previous) if args.format == "json": + # lgtm[py/clear-text-logging-sensitive-data] The digest contains only + # aggregate finding counts and receipt metadata, never secret values. print(json.dumps(digest, indent=2, sort_keys=True)) else: print(render_decision_digest_markdown(digest), end="") diff --git a/tests/test_portfolio_decision_queue.py b/tests/test_portfolio_decision_queue.py index 0791dd0b..20d62d3f 100644 --- a/tests/test_portfolio_decision_queue.py +++ b/tests/test_portfolio_decision_queue.py @@ -428,19 +428,18 @@ def test_cli_json_and_markdown_are_deterministic( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: truth_path = tmp_path / "portfolio-truth.json" + project = _project( + "MCPAudit", + attention_state="decision-needed", + security_risk=True, + secret_scanning_open=1, + ) + inert_secret_marker = "INERT_SECRET_VALUE_MUST_NOT_REACH_DIGEST" + project["security"]["providers"]["secret_scanning"]["alerts"] = [ + {"secret": inert_secret_marker} + ] truth_path.write_text( - json.dumps( - _truth( - [ - _project( - "MCPAudit", - attention_state="decision-needed", - security_risk=True, - dependabot_high=1, - ) - ] - ) - ), + json.dumps(_truth([project])), encoding="utf-8", ) @@ -449,6 +448,8 @@ def test_cli_json_and_markdown_are_deterministic( assert main(["--truth", str(truth_path), "--format", "json"]) == 0 second = capsys.readouterr().out assert first == second + assert inert_secret_marker not in first + assert '"secret_scanning_open": 1' in first previous = tmp_path / "previous.json" previous.write_text(first, encoding="utf-8") From fbd653d420164297c2a2e29eb4435d91eda28349 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Wed, 5 Aug 2026 00:31:16 -0700 Subject: [PATCH 4/5] fix(security): bind CodeQL suppression to digest sink --- src/portfolio_decision_queue.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/portfolio_decision_queue.py b/src/portfolio_decision_queue.py index 211072b6..345beaf1 100644 --- a/src/portfolio_decision_queue.py +++ b/src/portfolio_decision_queue.py @@ -740,8 +740,9 @@ def main(argv: list[str] | None = None) -> int: ) digest = build_decision_digest(truth, previous_digest=previous) if args.format == "json": - # lgtm[py/clear-text-logging-sensitive-data] The digest contains only - # aggregate finding counts and receipt metadata, never secret values. + # The digest contains aggregate finding counts and receipt metadata, + # never secret values; the regression test exercises that boundary. + # codeql[py/clear-text-logging-sensitive-data] print(json.dumps(digest, indent=2, sort_keys=True)) else: print(render_decision_digest_markdown(digest), end="") From 68ea25733920b16baaa5539c89e78021d4af7703 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Wed, 5 Aug 2026 00:36:03 -0700 Subject: [PATCH 5/5] chore(security): remove ineffective source suppression --- src/portfolio_decision_queue.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/portfolio_decision_queue.py b/src/portfolio_decision_queue.py index 345beaf1..554a9ac3 100644 --- a/src/portfolio_decision_queue.py +++ b/src/portfolio_decision_queue.py @@ -742,7 +742,6 @@ def main(argv: list[str] | None = None) -> int: if args.format == "json": # The digest contains aggregate finding counts and receipt metadata, # never secret values; the regression test exercises that boundary. - # codeql[py/clear-text-logging-sensitive-data] print(json.dumps(digest, indent=2, sort_keys=True)) else: print(render_decision_digest_markdown(digest), end="")