Skip to content

Commit fedd9f0

Browse files
committed
feat(security): surface Dependabot posture in portfolio render surfaces
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.
1 parent 1e9a8a7 commit fedd9f0

2 files changed

Lines changed: 231 additions & 4 deletions

File tree

src/portfolio_truth_render.py

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,54 @@
55

66
from src.portfolio_truth_types import PortfolioTruthProject, PortfolioTruthSnapshot
77

8+
# Mirrors the weekly digest's MAX_SECURITY_ATTENTION_ITEMS — the portfolio report and
9+
# the digest cap the per-repo security callout list at the same depth so the two
10+
# human-facing surfaces stay consistent.
11+
MAX_SECURITY_ATTENTION_ITEMS = 5
12+
13+
14+
def _security_overview(projects: list[PortfolioTruthProject]) -> dict[str, int]:
15+
"""Aggregate the opt-in security overlay across scanned repos. ``scanned_count`` is
16+
repos with alerts_available=True (the overlay ran for them); a scanned repo with zero
17+
open alerts is genuinely clear, distinct from an unscanned one — so consumers don't
18+
mislabel an unscanned repo as secure."""
19+
scanned = repos_with_open = total_critical = total_high = 0
20+
for project in projects:
21+
security = project.security
22+
if not security.alerts_available:
23+
continue
24+
scanned += 1
25+
total_critical += security.dependabot_critical
26+
total_high += security.dependabot_high
27+
if security.open_high_critical > 0:
28+
repos_with_open += 1
29+
return {
30+
"scanned_count": scanned,
31+
"repos_with_open_high_critical": repos_with_open,
32+
"total_open_critical": total_critical,
33+
"total_open_high": total_high,
34+
}
35+
36+
37+
def _security_attention_items(
38+
projects: list[PortfolioTruthProject],
39+
) -> list[PortfolioTruthProject]:
40+
"""Scanned repos carrying open high/critical Dependabot alerts, critical-first then
41+
high then name, capped — mirrors the weekly digest's security attention list."""
42+
flagged = [
43+
project
44+
for project in projects
45+
if project.security.alerts_available and project.security.open_high_critical > 0
46+
]
47+
flagged.sort(
48+
key=lambda project: (
49+
-project.security.dependabot_critical,
50+
-project.security.dependabot_high,
51+
project.identity.display_name.lower(),
52+
)
53+
)
54+
return flagged[:MAX_SECURITY_ATTENTION_ITEMS]
55+
856

957
def render_registry_markdown(snapshot: PortfolioTruthSnapshot) -> str:
1058
generated_date = snapshot.generated_at.astimezone(timezone.utc).strftime("%Y-%m-%d")
@@ -57,6 +105,7 @@ def render_portfolio_report_markdown(
57105
if project.derived.path_override
58106
)
59107
risk_tier_counts = Counter(project.risk.risk_tier for project in snapshot.projects)
108+
security_overview = _security_overview(snapshot.projects)
60109
lines = [
61110
"# Portfolio Audit Report",
62111
"",
@@ -72,8 +121,9 @@ def render_portfolio_report_markdown(
72121
"3. [Canonical Portfolio Truth Table](#canonical-portfolio-truth-table)",
73122
"4. [Coverage Summary](#coverage-summary)",
74123
"5. [Breakdown by Portfolio Signals](#breakdown-by-portfolio-signals)",
75-
"6. [Accuracy Findings](#accuracy-findings)",
76-
"7. [Recommended Next Sync Steps](#recommended-next-sync-steps)",
124+
"6. [Security Posture](#security-posture)",
125+
"7. [Accuracy Findings](#accuracy-findings)",
126+
"8. [Recommended Next Sync Steps](#recommended-next-sync-steps)",
77127
"",
78128
"---",
79129
"",
@@ -119,6 +169,7 @@ def render_portfolio_report_markdown(
119169
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)}`",
120170
f"- Investigate overrides currently surfaced: `{override_counts.get('investigate', 0)}`",
121171
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)}`",
172+
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']}`)",
122173
f"- Catalog warnings carried into the snapshot: `{len(snapshot.warnings)}`",
123174
"",
124175
"## Breakdown by Portfolio Signals",
@@ -150,6 +201,27 @@ def render_portfolio_report_markdown(
150201
)
151202
lines.append("")
152203

204+
lines.extend(["## Security Posture", ""])
205+
attention = _security_attention_items(snapshot.projects)
206+
scanned_count = security_overview["scanned_count"]
207+
if attention:
208+
for project in attention:
209+
lines.append(
210+
f"- **{project.identity.display_name}** [{project.risk.risk_tier}]: "
211+
f"{project.security.dependabot_critical} critical, "
212+
f"{project.security.dependabot_high} high open Dependabot alerts"
213+
)
214+
elif scanned_count > 0:
215+
lines.append(
216+
f"- All {scanned_count} scanned repos are clear of open high/critical Dependabot alerts."
217+
)
218+
else:
219+
lines.append(
220+
"- Security overlay not run for this snapshot "
221+
"(re-run with `--portfolio-truth-include-security`)."
222+
)
223+
lines.append("")
224+
153225
lines.extend(
154226
[
155227
"## Accuracy Findings",
@@ -284,6 +356,19 @@ def _default_section_note(marker: str, projects: list[PortfolioTruthProject]) ->
284356
return ""
285357

286358

359+
def _security_note_flag(project: PortfolioTruthProject) -> str:
360+
"""Pipe-free per-repo security marker for the registry Notes column. Fires only for
361+
scanned repos carrying open high/critical Dependabot alerts. Pipe-free by design so
362+
the registry table still round-trips through parse_registry without shifting columns."""
363+
security = project.security
364+
if not security.alerts_available or security.open_high_critical == 0:
365+
return ""
366+
return (
367+
f"[security: {security.dependabot_critical} critical / "
368+
f"{security.dependabot_high} high open Dependabot alerts]"
369+
)
370+
371+
287372
def _note_text(project: PortfolioTruthProject) -> str:
288373
note_parts = []
289374
if project.declared.purpose:
@@ -292,13 +377,18 @@ def _note_text(project: PortfolioTruthProject) -> str:
292377
note_parts.append(project.declared.notes)
293378
if not note_parts and project.warnings:
294379
note_parts.append(project.warnings[0])
295-
return " ".join(note_parts) or "—"
380+
base = " ".join(note_parts)
381+
flag = _security_note_flag(project)
382+
if flag:
383+
return f"{flag} {base}".rstrip() if base else flag
384+
return base or "—"
296385

297386

298387
def _render_summary_section(projects: list[PortfolioTruthProject]) -> list[str]:
299388
total = len(projects)
300389
status_counts = Counter(project.derived.registry_status for project in projects)
301390
context_counts = Counter(project.derived.context_quality for project in projects)
391+
security = _security_overview(projects)
302392
return [
303393
"## Portfolio Summary",
304394
"",
@@ -314,6 +404,10 @@ def _render_summary_section(projects: list[PortfolioTruthProject]) -> list[str]:
314404
f"| Projects with minimum-viable context | {context_counts.get('minimum-viable', 0)} |",
315405
f"| Projects with boilerplate only | {context_counts.get('boilerplate', 0)} |",
316406
f"| Projects with no context | {context_counts.get('none', 0)} |",
407+
f"| Repos scanned for security alerts | {security['scanned_count']} |",
408+
f"| Repos with open high/critical alerts | {security['repos_with_open_high_critical']} |",
409+
f"| Open critical Dependabot alerts | {security['total_open_critical']} |",
410+
f"| Open high Dependabot alerts | {security['total_open_high']} |",
317411
]
318412

319413

tests/test_portfolio_truth.py

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@
1616
)
1717
from src.portfolio_truth_publish import publish_portfolio_truth
1818
from src.portfolio_truth_reconcile import build_portfolio_truth_snapshot
19-
from src.portfolio_truth_render import render_registry_markdown
19+
from src.portfolio_truth_render import (
20+
render_portfolio_report_markdown,
21+
render_registry_markdown,
22+
)
2023
from src.portfolio_truth_sources import _classify_context_quality, _extract_github_full_name
24+
from src.portfolio_truth_validate import validate_portfolio_report_markdown
2125
from src.registry_parser import parse_registry
2226

2327

@@ -469,6 +473,135 @@ def test_rendered_registry_round_trips_through_parser(
469473
assert "## Cowork Task Notes" in markdown
470474

471475

476+
def test_registry_render_surfaces_security_and_round_trips(
477+
portfolio_workspace: Path,
478+
portfolio_catalog: Path,
479+
legacy_registry: Path,
480+
tmp_path: Path,
481+
) -> None:
482+
security = {
483+
"Alpha": {
484+
"dependabot": {"critical": 2, "high": 1, "medium": 0, "low": 0, "available": True},
485+
"code_scanning": {"available": True},
486+
"secret_scanning": {"open": 0, "available": True},
487+
}
488+
}
489+
result = build_portfolio_truth_snapshot(
490+
workspace_root=portfolio_workspace,
491+
catalog_path=portfolio_catalog,
492+
legacy_registry_path=legacy_registry,
493+
include_notion=False,
494+
security_alerts_by_name=security,
495+
)
496+
markdown = render_registry_markdown(result.snapshot)
497+
498+
# Per-repo Notes flag fires for the scanned repo carrying open high/critical alerts.
499+
assert "[security: 2 critical / 1 high open Dependabot alerts]" in markdown
500+
# Aggregate rows land in the Portfolio Summary table.
501+
assert "| Repos scanned for security alerts | 1 |" in markdown
502+
assert "| Repos with open high/critical alerts | 1 |" in markdown
503+
assert "| Open critical Dependabot alerts | 2 |" in markdown
504+
assert "| Open high Dependabot alerts | 1 |" in markdown
505+
506+
# The security flag is pipe-free + digit summary rows, so the parser round-trip is
507+
# unchanged: same project row count, no inflation from the new content.
508+
registry_path = tmp_path / "generated-registry.md"
509+
registry_path.write_text(markdown)
510+
parsed = parse_registry(registry_path)
511+
assert len(parsed) == len(result.snapshot.projects)
512+
513+
514+
def test_registry_render_omits_security_flag_when_unscanned(
515+
portfolio_workspace: Path,
516+
portfolio_catalog: Path,
517+
legacy_registry: Path,
518+
) -> None:
519+
result = build_portfolio_truth_snapshot(
520+
workspace_root=portfolio_workspace,
521+
catalog_path=portfolio_catalog,
522+
legacy_registry_path=legacy_registry,
523+
include_notion=False,
524+
)
525+
markdown = render_registry_markdown(result.snapshot)
526+
assert "[security:" not in markdown
527+
# Summary rows stay present, all zero, documenting that the overlay was not run.
528+
assert "| Repos scanned for security alerts | 0 |" in markdown
529+
assert "| Repos with open high/critical alerts | 0 |" in markdown
530+
531+
532+
def test_portfolio_report_security_posture_lists_open_alerts(
533+
portfolio_workspace: Path,
534+
portfolio_catalog: Path,
535+
legacy_registry: Path,
536+
) -> None:
537+
security = {
538+
"Alpha": {
539+
"dependabot": {"critical": 1, "high": 2, "medium": 0, "low": 0, "available": True},
540+
"code_scanning": {"available": True},
541+
"secret_scanning": {"open": 0, "available": True},
542+
}
543+
}
544+
result = build_portfolio_truth_snapshot(
545+
workspace_root=portfolio_workspace,
546+
catalog_path=portfolio_catalog,
547+
legacy_registry_path=legacy_registry,
548+
include_notion=False,
549+
security_alerts_by_name=security,
550+
)
551+
markdown = render_portfolio_report_markdown(result.snapshot, "output/x.json")
552+
553+
assert "## Security Posture" in markdown
554+
assert "[Security Posture](#security-posture)" in markdown
555+
assert "- **Alpha** [elevated]: 1 critical, 2 high open Dependabot alerts" in markdown
556+
assert (
557+
"- Security posture: scanned `1`, with open high/critical Dependabot alerts `1`" in markdown
558+
)
559+
# The new section keeps the report validator green.
560+
validate_portfolio_report_markdown(markdown)
561+
562+
563+
def test_portfolio_report_security_posture_scanned_clear(
564+
portfolio_workspace: Path,
565+
portfolio_catalog: Path,
566+
legacy_registry: Path,
567+
) -> None:
568+
# Scanned with zero open high/critical reads as "all clear", distinct from "not run".
569+
security = {
570+
"Alpha": {
571+
"dependabot": {"critical": 0, "high": 0, "medium": 3, "low": 0, "available": True},
572+
"code_scanning": {"available": True},
573+
"secret_scanning": {"open": 0, "available": True},
574+
}
575+
}
576+
result = build_portfolio_truth_snapshot(
577+
workspace_root=portfolio_workspace,
578+
catalog_path=portfolio_catalog,
579+
legacy_registry_path=legacy_registry,
580+
include_notion=False,
581+
security_alerts_by_name=security,
582+
)
583+
markdown = render_portfolio_report_markdown(result.snapshot, "output/x.json")
584+
assert "All 1 scanned repos are clear of open high/critical Dependabot alerts." in markdown
585+
validate_portfolio_report_markdown(markdown)
586+
587+
588+
def test_portfolio_report_security_posture_not_run(
589+
portfolio_workspace: Path,
590+
portfolio_catalog: Path,
591+
legacy_registry: Path,
592+
) -> None:
593+
result = build_portfolio_truth_snapshot(
594+
workspace_root=portfolio_workspace,
595+
catalog_path=portfolio_catalog,
596+
legacy_registry_path=legacy_registry,
597+
include_notion=False,
598+
)
599+
markdown = render_portfolio_report_markdown(result.snapshot, "output/x.json")
600+
assert "Security overlay not run for this snapshot" in markdown
601+
assert "- Security posture: scanned `0`," in markdown
602+
validate_portfolio_report_markdown(markdown)
603+
604+
472605
def test_duplicate_display_names_are_disambiguated_in_registry(tmp_path: Path) -> None:
473606
workspace = tmp_path / "workspace"
474607
workspace.mkdir()

0 commit comments

Comments
 (0)