Skip to content

Commit 7ac5ef9

Browse files
authored
feat(security): surface Dependabot posture in portfolio render surfaces (#28)
* 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. * test(security): guard Security Posture section + cover cap/sort and registry clean path 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.
1 parent 1e9a8a7 commit 7ac5ef9

3 files changed

Lines changed: 305 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

src/portfolio_truth_validate.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ def validate_portfolio_report_markdown(markdown: str) -> None:
145145
"## Audit Methodology",
146146
"## Canonical Portfolio Truth Table",
147147
"## Coverage Summary",
148+
"## Security Posture",
148149
"## Accuracy Findings",
149150
"## Recommended Next Sync Steps",
150151
)

tests/test_portfolio_truth.py

Lines changed: 207 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

@@ -34,6 +38,47 @@ def _set_mtime(path: Path, timestamp: float) -> None:
3438
os.utime(path, (timestamp, timestamp))
3539

3640

41+
def _security_test_project(
42+
name: str,
43+
*,
44+
critical: int,
45+
high: int,
46+
available: bool = True,
47+
tier: str = "elevated",
48+
):
49+
"""Minimal PortfolioTruthProject for exercising security render helpers directly."""
50+
from src.portfolio_truth_types import (
51+
DeclaredFields,
52+
DerivedFields,
53+
IdentityFields,
54+
PortfolioTruthProject,
55+
RiskFields,
56+
SecurityFields,
57+
)
58+
59+
return PortfolioTruthProject(
60+
identity=IdentityFields(
61+
project_key=name,
62+
display_name=name,
63+
path=name,
64+
top_level_dir=name,
65+
group_key="g",
66+
group_label="G",
67+
section_marker="Standalone Projects",
68+
section_label="Standalone",
69+
has_git=True,
70+
),
71+
declared=DeclaredFields(),
72+
derived=DerivedFields(),
73+
risk=RiskFields(risk_tier=tier),
74+
security=SecurityFields(
75+
alerts_available=available,
76+
dependabot_critical=critical,
77+
dependabot_high=high,
78+
),
79+
)
80+
81+
3782
def test_extract_github_full_name_uses_exact_github_host() -> None:
3883
assert _extract_github_full_name("https://github.com/octo/repo.git") == "octo/repo"
3984
assert _extract_github_full_name("git@github.com:octo/repo.git") == "octo/repo"
@@ -469,6 +514,167 @@ def test_rendered_registry_round_trips_through_parser(
469514
assert "## Cowork Task Notes" in markdown
470515

471516

517+
def test_registry_render_surfaces_security_and_round_trips(
518+
portfolio_workspace: Path,
519+
portfolio_catalog: Path,
520+
legacy_registry: Path,
521+
tmp_path: Path,
522+
) -> None:
523+
security = {
524+
"Alpha": {
525+
"dependabot": {"critical": 2, "high": 1, "medium": 0, "low": 0, "available": True},
526+
"code_scanning": {"available": True},
527+
"secret_scanning": {"open": 0, "available": True},
528+
}
529+
}
530+
result = build_portfolio_truth_snapshot(
531+
workspace_root=portfolio_workspace,
532+
catalog_path=portfolio_catalog,
533+
legacy_registry_path=legacy_registry,
534+
include_notion=False,
535+
security_alerts_by_name=security,
536+
)
537+
markdown = render_registry_markdown(result.snapshot)
538+
539+
# Per-repo Notes flag fires for the scanned repo carrying open high/critical alerts.
540+
assert "[security: 2 critical / 1 high open Dependabot alerts]" in markdown
541+
# Aggregate rows land in the Portfolio Summary table.
542+
assert "| Repos scanned for security alerts | 1 |" in markdown
543+
assert "| Repos with open high/critical alerts | 1 |" in markdown
544+
assert "| Open critical Dependabot alerts | 2 |" in markdown
545+
assert "| Open high Dependabot alerts | 1 |" in markdown
546+
547+
# The security flag is pipe-free + digit summary rows, so the parser round-trip is
548+
# unchanged: same project row count, no inflation from the new content.
549+
registry_path = tmp_path / "generated-registry.md"
550+
registry_path.write_text(markdown)
551+
parsed = parse_registry(registry_path)
552+
assert len(parsed) == len(result.snapshot.projects)
553+
554+
555+
def test_registry_render_omits_security_flag_when_unscanned(
556+
portfolio_workspace: Path,
557+
portfolio_catalog: Path,
558+
legacy_registry: Path,
559+
) -> None:
560+
result = build_portfolio_truth_snapshot(
561+
workspace_root=portfolio_workspace,
562+
catalog_path=portfolio_catalog,
563+
legacy_registry_path=legacy_registry,
564+
include_notion=False,
565+
)
566+
markdown = render_registry_markdown(result.snapshot)
567+
assert "[security:" not in markdown
568+
# Summary rows stay present, all zero, documenting that the overlay was not run.
569+
assert "| Repos scanned for security alerts | 0 |" in markdown
570+
assert "| Repos with open high/critical alerts | 0 |" in markdown
571+
572+
573+
def test_portfolio_report_security_posture_lists_open_alerts(
574+
portfolio_workspace: Path,
575+
portfolio_catalog: Path,
576+
legacy_registry: Path,
577+
) -> None:
578+
security = {
579+
"Alpha": {
580+
"dependabot": {"critical": 1, "high": 2, "medium": 0, "low": 0, "available": True},
581+
"code_scanning": {"available": True},
582+
"secret_scanning": {"open": 0, "available": True},
583+
}
584+
}
585+
result = build_portfolio_truth_snapshot(
586+
workspace_root=portfolio_workspace,
587+
catalog_path=portfolio_catalog,
588+
legacy_registry_path=legacy_registry,
589+
include_notion=False,
590+
security_alerts_by_name=security,
591+
)
592+
markdown = render_portfolio_report_markdown(result.snapshot, "output/x.json")
593+
594+
assert "## Security Posture" in markdown
595+
assert "[Security Posture](#security-posture)" in markdown
596+
assert "- **Alpha** [elevated]: 1 critical, 2 high open Dependabot alerts" in markdown
597+
assert (
598+
"- Security posture: scanned `1`, with open high/critical Dependabot alerts `1`" in markdown
599+
)
600+
# The new section keeps the report validator green.
601+
validate_portfolio_report_markdown(markdown)
602+
603+
604+
def test_portfolio_report_security_posture_scanned_clear(
605+
portfolio_workspace: Path,
606+
portfolio_catalog: Path,
607+
legacy_registry: Path,
608+
) -> None:
609+
# Scanned with zero open high/critical reads as "all clear", distinct from "not run".
610+
security = {
611+
"Alpha": {
612+
"dependabot": {"critical": 0, "high": 0, "medium": 3, "low": 0, "available": True},
613+
"code_scanning": {"available": True},
614+
"secret_scanning": {"open": 0, "available": True},
615+
}
616+
}
617+
result = build_portfolio_truth_snapshot(
618+
workspace_root=portfolio_workspace,
619+
catalog_path=portfolio_catalog,
620+
legacy_registry_path=legacy_registry,
621+
include_notion=False,
622+
security_alerts_by_name=security,
623+
)
624+
markdown = render_portfolio_report_markdown(result.snapshot, "output/x.json")
625+
assert "All 1 scanned repos are clear of open high/critical Dependabot alerts." in markdown
626+
validate_portfolio_report_markdown(markdown)
627+
628+
# Same guard governs the registry: a scanned repo with only medium alerts gets no
629+
# per-repo flag, but it still counts as scanned in the summary table.
630+
registry_md = render_registry_markdown(result.snapshot)
631+
assert "[security:" not in registry_md
632+
assert "| Repos scanned for security alerts | 1 |" in registry_md
633+
assert "| Repos with open high/critical alerts | 0 |" in registry_md
634+
635+
636+
def test_security_attention_items_caps_at_five_and_sorts_critical_first() -> None:
637+
from src.portfolio_truth_render import (
638+
MAX_SECURITY_ATTENTION_ITEMS,
639+
_security_attention_items,
640+
)
641+
642+
projects = [
643+
_security_test_project("low-high", critical=0, high=1),
644+
_security_test_project("mid-crit", critical=2, high=0),
645+
_security_test_project("top-crit", critical=5, high=0),
646+
_security_test_project("clean", critical=0, high=0), # excluded: nothing open
647+
_security_test_project("unscanned", critical=9, high=9, available=False), # excluded
648+
_security_test_project("a-high", critical=0, high=3),
649+
_security_test_project("b-high", critical=0, high=3),
650+
_security_test_project("c-crit", critical=1, high=0),
651+
]
652+
items = _security_attention_items(projects)
653+
654+
# clean + unscanned drop out; six remain but the list is capped.
655+
assert len(items) == MAX_SECURITY_ATTENTION_ITEMS
656+
names = [project.identity.display_name for project in items]
657+
# critical desc, then high desc, then name asc — and the capped tail (low-high) falls off.
658+
assert names == ["top-crit", "mid-crit", "c-crit", "a-high", "b-high"]
659+
660+
661+
def test_portfolio_report_security_posture_not_run(
662+
portfolio_workspace: Path,
663+
portfolio_catalog: Path,
664+
legacy_registry: Path,
665+
) -> None:
666+
result = build_portfolio_truth_snapshot(
667+
workspace_root=portfolio_workspace,
668+
catalog_path=portfolio_catalog,
669+
legacy_registry_path=legacy_registry,
670+
include_notion=False,
671+
)
672+
markdown = render_portfolio_report_markdown(result.snapshot, "output/x.json")
673+
assert "Security overlay not run for this snapshot" in markdown
674+
assert "- Security posture: scanned `0`," in markdown
675+
validate_portfolio_report_markdown(markdown)
676+
677+
472678
def test_duplicate_display_names_are_disambiguated_in_registry(tmp_path: Path) -> None:
473679
workspace = tmp_path / "workspace"
474680
workspace.mkdir()

0 commit comments

Comments
 (0)