From fedd9f020e90dd01a2dec7ffcda7abbc5115027e Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 31 May 2026 07:36:25 -0700 Subject: [PATCH 1/2] feat(security): surface Dependabot posture in portfolio render surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The radar's truth-layer security dimension (RiskFields.security_risk, SecurityFields Dependabot counts, the active-high-severity-alerts factor) was wired into the risk model and weekly digest in #27, but the two human-facing render surfaces — PORTFOLIO-AUDIT-REPORT.md and project-registry.md — did not surface it. This adds that, mirroring the digest's Security Posture treatment: - Portfolio report: a Coverage Summary line + a dedicated '## Security Posture' section (TOC entry included) with the same three states as the digest — per-repo open high/critical (critical-first, capped at 5), 'all N scanned clear', or 'overlay not run'. - Registry: a pipe-free per-repo security flag in the Notes column (fires only for scanned repos with open high/critical) plus four aggregate rows in the Portfolio Summary table. Shared _security_overview / _security_attention_items helpers mirror the digest's aggregation on the in-memory snapshot. The Notes flag is pipe-free and the summary rows are digit-valued, so the registry still round-trips through parse_registry unchanged; both markdown validators stay green. 5 new tests cover all three report states, the registry flag + round-trip, and the unscanned case. --- src/portfolio_truth_render.py | 100 ++++++++++++++++++++++++- tests/test_portfolio_truth.py | 135 +++++++++++++++++++++++++++++++++- 2 files changed, 231 insertions(+), 4 deletions(-) diff --git a/src/portfolio_truth_render.py b/src/portfolio_truth_render.py index b0a6f76..043db19 100644 --- a/src/portfolio_truth_render.py +++ b/src/portfolio_truth_render.py @@ -5,6 +5,54 @@ from src.portfolio_truth_types import PortfolioTruthProject, PortfolioTruthSnapshot +# Mirrors the weekly digest's MAX_SECURITY_ATTENTION_ITEMS — the portfolio report and +# the digest cap the per-repo security callout list at the same depth so the two +# human-facing surfaces stay consistent. +MAX_SECURITY_ATTENTION_ITEMS = 5 + + +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 + for project in projects: + security = project.security + if not security.alerts_available: + continue + scanned += 1 + total_critical += security.dependabot_critical + total_high += security.dependabot_high + if security.open_high_critical > 0: + repos_with_open += 1 + return { + "scanned_count": scanned, + "repos_with_open_high_critical": repos_with_open, + "total_open_critical": total_critical, + "total_open_high": total_high, + } + + +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 + ] + flagged.sort( + key=lambda project: ( + -project.security.dependabot_critical, + -project.security.dependabot_high, + project.identity.display_name.lower(), + ) + ) + return flagged[:MAX_SECURITY_ATTENTION_ITEMS] + def render_registry_markdown(snapshot: PortfolioTruthSnapshot) -> str: generated_date = snapshot.generated_at.astimezone(timezone.utc).strftime("%Y-%m-%d") @@ -57,6 +105,7 @@ def render_portfolio_report_markdown( if project.derived.path_override ) risk_tier_counts = Counter(project.risk.risk_tier for project in snapshot.projects) + security_overview = _security_overview(snapshot.projects) lines = [ "# Portfolio Audit Report", "", @@ -72,8 +121,9 @@ def render_portfolio_report_markdown( "3. [Canonical Portfolio Truth Table](#canonical-portfolio-truth-table)", "4. [Coverage Summary](#coverage-summary)", "5. [Breakdown by Portfolio Signals](#breakdown-by-portfolio-signals)", - "6. [Accuracy Findings](#accuracy-findings)", - "7. [Recommended Next Sync Steps](#recommended-next-sync-steps)", + "6. [Security Posture](#security-posture)", + "7. [Accuracy Findings](#accuracy-findings)", + "8. [Recommended Next Sync Steps](#recommended-next-sync-steps)", "", "---", "", @@ -119,6 +169,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"- Catalog warnings carried into the snapshot: `{len(snapshot.warnings)}`", "", "## Breakdown by Portfolio Signals", @@ -150,6 +201,27 @@ def render_portfolio_report_markdown( ) lines.append("") + lines.extend(["## Security Posture", ""]) + attention = _security_attention_items(snapshot.projects) + scanned_count = security_overview["scanned_count"] + if attention: + for project in attention: + 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" + ) + elif scanned_count > 0: + lines.append( + f"- All {scanned_count} scanned repos are clear of open high/critical Dependabot alerts." + ) + else: + lines.append( + "- Security overlay not run for this snapshot " + "(re-run with `--portfolio-truth-include-security`)." + ) + lines.append("") + lines.extend( [ "## Accuracy Findings", @@ -284,6 +356,19 @@ def _default_section_note(marker: str, projects: list[PortfolioTruthProject]) -> return "" +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: + return "" + return ( + f"[security: {security.dependabot_critical} critical / " + f"{security.dependabot_high} high open Dependabot alerts]" + ) + + def _note_text(project: PortfolioTruthProject) -> str: note_parts = [] if project.declared.purpose: @@ -292,13 +377,18 @@ def _note_text(project: PortfolioTruthProject) -> str: note_parts.append(project.declared.notes) if not note_parts and project.warnings: note_parts.append(project.warnings[0]) - return " ".join(note_parts) or "—" + base = " ".join(note_parts) + flag = _security_note_flag(project) + if flag: + return f"{flag} {base}".rstrip() if base else flag + return base or "—" def _render_summary_section(projects: list[PortfolioTruthProject]) -> list[str]: total = len(projects) status_counts = Counter(project.derived.registry_status for project in projects) context_counts = Counter(project.derived.context_quality for project in projects) + security = _security_overview(projects) return [ "## Portfolio Summary", "", @@ -314,6 +404,10 @@ 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']} |", ] diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index da82578..d3732de 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -16,8 +16,12 @@ ) from src.portfolio_truth_publish import publish_portfolio_truth from src.portfolio_truth_reconcile import build_portfolio_truth_snapshot -from src.portfolio_truth_render import render_registry_markdown +from src.portfolio_truth_render import ( + render_portfolio_report_markdown, + render_registry_markdown, +) from src.portfolio_truth_sources import _classify_context_quality, _extract_github_full_name +from src.portfolio_truth_validate import validate_portfolio_report_markdown from src.registry_parser import parse_registry @@ -469,6 +473,135 @@ def test_rendered_registry_round_trips_through_parser( assert "## Cowork Task Notes" in markdown +def test_registry_render_surfaces_security_and_round_trips( + portfolio_workspace: Path, + portfolio_catalog: Path, + legacy_registry: Path, + tmp_path: Path, +) -> None: + security = { + "Alpha": { + "dependabot": {"critical": 2, "high": 1, "medium": 0, "low": 0, "available": True}, + "code_scanning": {"available": True}, + "secret_scanning": {"open": 0, "available": True}, + } + } + 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_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 + # 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 + + # 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. + registry_path = tmp_path / "generated-registry.md" + registry_path.write_text(markdown) + parsed = parse_registry(registry_path) + assert len(parsed) == len(result.snapshot.projects) + + +def test_registry_render_omits_security_flag_when_unscanned( + portfolio_workspace: Path, + portfolio_catalog: Path, + legacy_registry: Path, +) -> None: + result = build_portfolio_truth_snapshot( + workspace_root=portfolio_workspace, + catalog_path=portfolio_catalog, + legacy_registry_path=legacy_registry, + include_notion=False, + ) + 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 + + +def test_portfolio_report_security_posture_lists_open_alerts( + portfolio_workspace: Path, + portfolio_catalog: Path, + 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}, + } + } + 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") + + assert "## Security Posture" in markdown + assert "[Security Posture](#security-posture)" in markdown + assert "- **Alpha** [elevated]: 1 critical, 2 high open Dependabot alerts" in markdown + assert ( + "- Security posture: scanned `1`, with open high/critical Dependabot alerts `1`" in markdown + ) + # The new section keeps the report validator green. + validate_portfolio_report_markdown(markdown) + + +def test_portfolio_report_security_posture_scanned_clear( + portfolio_workspace: Path, + 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}, + } + } + 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") + assert "All 1 scanned repos are clear of open high/critical Dependabot alerts." in markdown + validate_portfolio_report_markdown(markdown) + + +def test_portfolio_report_security_posture_not_run( + portfolio_workspace: Path, + portfolio_catalog: Path, + legacy_registry: Path, +) -> None: + result = build_portfolio_truth_snapshot( + workspace_root=portfolio_workspace, + catalog_path=portfolio_catalog, + legacy_registry_path=legacy_registry, + 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 + validate_portfolio_report_markdown(markdown) + + def test_duplicate_display_names_are_disambiguated_in_registry(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() From 47e2e254bf0d126d962b377c62b30b4eb527f89d Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 31 May 2026 07:41:14 -0700 Subject: [PATCH 2/2] test(security): guard Security Posture section + cover cap/sort and registry clean path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code-review findings on the render surfaces: - validate_portfolio_report_markdown now requires the '## Security Posture' header, so the section can't silently vanish in a future refactor (every other section header is already guarded). - New unit test pins _security_attention_items' cap-at-5 and critical-desc / high-desc / name-asc sort — the one behavior unique to the attention list. - Extends the scanned-clear test to assert the registry's per-repo flag is absent for a medium-only repo while it still counts as scanned. --- src/portfolio_truth_validate.py | 1 + tests/test_portfolio_truth.py | 73 +++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/portfolio_truth_validate.py b/src/portfolio_truth_validate.py index 767359a..fbd1943 100644 --- a/src/portfolio_truth_validate.py +++ b/src/portfolio_truth_validate.py @@ -145,6 +145,7 @@ def validate_portfolio_report_markdown(markdown: str) -> None: "## Audit Methodology", "## Canonical Portfolio Truth Table", "## Coverage Summary", + "## Security Posture", "## Accuracy Findings", "## Recommended Next Sync Steps", ) diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index d3732de..06e2b0e 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -38,6 +38,47 @@ def _set_mtime(path: Path, timestamp: float) -> None: os.utime(path, (timestamp, timestamp)) +def _security_test_project( + name: str, + *, + critical: int, + high: int, + available: bool = True, + tier: str = "elevated", +): + """Minimal PortfolioTruthProject for exercising security render helpers directly.""" + from src.portfolio_truth_types import ( + DeclaredFields, + DerivedFields, + IdentityFields, + PortfolioTruthProject, + RiskFields, + SecurityFields, + ) + + return PortfolioTruthProject( + identity=IdentityFields( + project_key=name, + display_name=name, + path=name, + top_level_dir=name, + group_key="g", + group_label="G", + section_marker="Standalone Projects", + section_label="Standalone", + has_git=True, + ), + declared=DeclaredFields(), + derived=DerivedFields(), + risk=RiskFields(risk_tier=tier), + security=SecurityFields( + alerts_available=available, + dependabot_critical=critical, + dependabot_high=high, + ), + ) + + 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" @@ -584,6 +625,38 @@ def test_portfolio_report_security_posture_scanned_clear( assert "All 1 scanned repos are clear of open high/critical Dependabot alerts." 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) + 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 + + +def test_security_attention_items_caps_at_five_and_sorts_critical_first() -> None: + from src.portfolio_truth_render import ( + MAX_SECURITY_ATTENTION_ITEMS, + _security_attention_items, + ) + + projects = [ + _security_test_project("low-high", critical=0, high=1), + _security_test_project("mid-crit", critical=2, high=0), + _security_test_project("top-crit", critical=5, high=0), + _security_test_project("clean", critical=0, high=0), # excluded: nothing open + _security_test_project("unscanned", critical=9, high=9, available=False), # excluded + _security_test_project("a-high", critical=0, high=3), + _security_test_project("b-high", critical=0, high=3), + _security_test_project("c-crit", critical=1, high=0), + ] + items = _security_attention_items(projects) + + # clean + unscanned drop out; six remain but the list is capped. + assert len(items) == MAX_SECURITY_ATTENTION_ITEMS + names = [project.identity.display_name for project in items] + # critical desc, then high desc, then name asc — and the capped tail (low-high) falls off. + assert names == ["top-crit", "mid-crit", "c-crit", "a-high", "b-high"] + def test_portfolio_report_security_posture_not_run( portfolio_workspace: Path,