2828 PortfolioTruthProject ,
2929 PortfolioTruthSnapshot ,
3030 RiskFields ,
31+ SecurityFields ,
3132)
3233from src .registry_parser import _normalize
3334
@@ -180,6 +181,7 @@ def build_portfolio_truth_snapshot(
180181 include_notion : bool = True ,
181182 now : datetime | None = None ,
182183 release_count_by_name : dict [str , int ] | None = None ,
184+ security_alerts_by_name : dict [str , dict ] | None = None ,
183185) -> PortfolioTruthBuildResult :
184186 now = now or datetime .now (timezone .utc )
185187 catalog_data = load_portfolio_catalog (catalog_path )
@@ -199,6 +201,7 @@ def build_portfolio_truth_snapshot(
199201 notion_context = notion_context ,
200202 now = now ,
201203 release_count_by_name = release_count_by_name ,
204+ security_alerts_by_name = security_alerts_by_name ,
202205 )
203206 for raw_project in workspace_projects
204207 ]
@@ -259,21 +262,44 @@ def _unresolved_duplicate_display_names(projects: list[PortfolioTruthProject]) -
259262 return sorted (
260263 name
261264 for name , members in grouped .items ()
262- if len (members ) > 1
263- and any (not _has_path_catalog_contract (project ) for project in members )
265+ if len (members ) > 1 and any (not _has_path_catalog_contract (project ) for project in members )
264266 )
265267
266268
267269def _has_path_catalog_contract (project : PortfolioTruthProject ) -> bool :
268270 for source in project .provenance .values ():
269- if (
270- source .get ("source" ) == "catalog_repo"
271- and source .get ("detail" ) == project .identity .path
272- ):
271+ if source .get ("source" ) == "catalog_repo" and source .get ("detail" ) == project .identity .path :
273272 return True
274273 return False
275274
276275
276+ def _build_security_fields (ghas_entry : dict [str , Any ] | None ) -> SecurityFields :
277+ """Map a per-repo GHAS alert entry (from output/ghas-alerts-<username>-*.json)
278+ into SecurityFields. A missing/None entry yields all-zero counts with
279+ alerts_available=False (the repo was not scanned) — distinct from a clean scan,
280+ and keeps the security overlay strictly opt-in (no entry → no security signal)."""
281+ if not ghas_entry :
282+ return SecurityFields ()
283+ dependabot = ghas_entry .get ("dependabot" ) or {}
284+ code_scanning = ghas_entry .get ("code_scanning" ) or {}
285+ secret_scanning = ghas_entry .get ("secret_scanning" ) or {}
286+
287+ def _count (source : dict [str , Any ], key : str ) -> int :
288+ value = source .get (key )
289+ return value if isinstance (value , int ) and value >= 0 else 0
290+
291+ return SecurityFields (
292+ alerts_available = bool (dependabot .get ("available" , False )),
293+ dependabot_critical = _count (dependabot , "critical" ),
294+ dependabot_high = _count (dependabot , "high" ),
295+ dependabot_medium = _count (dependabot , "medium" ),
296+ dependabot_low = _count (dependabot , "low" ),
297+ code_scanning_critical = _count (code_scanning , "critical" ),
298+ code_scanning_high = _count (code_scanning , "high" ),
299+ secret_scanning_open = _count (secret_scanning , "open" ),
300+ )
301+
302+
277303def _build_truth_project (
278304 raw_project : dict [str , Any ],
279305 * ,
@@ -282,6 +308,7 @@ def _build_truth_project(
282308 notion_context : dict [str , dict [str , str ]],
283309 now : datetime ,
284310 release_count_by_name : dict [str , int ] | None = None ,
311+ security_alerts_by_name : dict [str , dict ] | None = None ,
285312) -> PortfolioTruthProject :
286313 relative_path = raw_project ["path" ]
287314 group_entry = group_entry_for_path (relative_path , catalog_data )
@@ -393,6 +420,12 @@ def _build_truth_project(
393420 "detail" : "derived" ,
394421 }
395422
423+ security_entry = (security_alerts_by_name or {}).get (raw_project ["name" ])
424+ security = _build_security_fields (security_entry )
425+
426+ # Only Dependabot high/critical counts drive the risk tier today. Code-scanning
427+ # and secret-scanning counts are captured in SecurityFields for visibility but do
428+ # not yet feed the active-high-severity-alerts factor (Dependabot-only scope).
396429 risk_entry = build_risk_entry (
397430 display_name = raw_project ["name" ],
398431 operating_path = path_entry .get ("operating_path" , "" ),
@@ -405,6 +438,8 @@ def _build_truth_project(
405438 doctor_standard = declared_values ["doctor_standard" ],
406439 known_risks_present = bool (raw_project ["known_risks_present" ]),
407440 run_instructions_present = bool (raw_project ["run_instructions_present" ]),
441+ security_high_alerts = security .dependabot_high ,
442+ security_critical_alerts = security .dependabot_critical ,
408443 )
409444
410445 declared = DeclaredFields (
@@ -516,6 +551,7 @@ def _build_truth_project(
516551 doctor_gap = risk_entry ["doctor_gap" ],
517552 context_risk = risk_entry ["context_risk" ],
518553 path_risk = risk_entry ["path_risk" ],
554+ security_risk = risk_entry ["security_risk" ],
519555 )
520556 provenance ["risk.risk_tier" ] = {"source" : "derived" , "detail" : risk_entry ["risk_tier" ]}
521557 provenance ["risk.doctor_gap" ] = {
@@ -527,6 +563,7 @@ def _build_truth_project(
527563 declared = declared ,
528564 derived = derived ,
529565 risk = risk ,
566+ security = security ,
530567 advisory = advisory ,
531568 provenance = provenance ,
532569 warnings = warnings ,
0 commit comments