11import os
22import re
33from enum import Enum
4+ from urllib .parse import quote
45
56from termcolor import colored
67from guarddog .reporters import BaseReporter
@@ -201,7 +202,10 @@ def _rail(lines: List[str], color: str | None) -> List[str]:
201202
202203 @staticmethod
203204 def _format_one_risk (
204- risk : dict , path_prefix : Optional [str ], ceiling : str | None
205+ risk : dict ,
206+ path_prefix : Optional [str ],
207+ ceiling : str | None ,
208+ deep_base : Optional [str ] = None ,
205209 ) -> List [str ]:
206210 """Render a single risk as a railed block: rule, desc, location, code.
207211
@@ -227,7 +231,16 @@ def _format_one_risk(
227231 ]
228232 if desc :
229233 block .append (" " + colored (desc , sev_color ))
230- block .append (" " + colored (f"{ loc_kw } { _sanitize (loc )} " , "dark_grey" ))
234+
235+ inspector_url = (
236+ HumanReadableReporter ._pypi_finding_inspector_url (deep_base , loc_raw )
237+ if deep_base and loc_raw
238+ else None
239+ )
240+ loc_line = colored (f"{ loc_kw } { _sanitize (loc )} " , "dark_grey" )
241+ if inspector_url :
242+ loc_line = HumanReadableReporter ._hyperlink (inspector_url , loc_line )
243+ block .append (" " + loc_line )
231244
232245 code = risk .get ("threat_code" , "" )
233246 if code :
@@ -244,7 +257,10 @@ def _format_one_risk(
244257
245258 @staticmethod
246259 def _format_findings (
247- risks : list , path_prefix : Optional [str ], ceiling : str | None
260+ risks : list ,
261+ path_prefix : Optional [str ],
262+ ceiling : str | None ,
263+ deep_base : Optional [str ] = None ,
248264 ) -> List [str ]:
249265 """Per-tactic findings list, colored by rule severity clamped to the band."""
250266 lines : List [str ] = []
@@ -278,7 +294,7 @@ def _format_findings(
278294
279295 for risk in tactic_risks :
280296 lines += HumanReadableReporter ._format_one_risk (
281- risk , path_prefix , ceiling
297+ risk , path_prefix , ceiling , deep_base
282298 )
283299
284300 lines .append ("" )
@@ -305,6 +321,62 @@ def _format_summary(risk_score: dict, num_risks: int) -> List[str]:
305321 colored (stats , "dark_grey" ),
306322 ]
307323
324+ @staticmethod
325+ def _hyperlink (url : str , label : str ) -> str :
326+ """Wrap `label` in an OSC 8 terminal hyperlink pointing to `url`.
327+
328+ Terminals that support OSC 8 render `label` as a clickable link; the rest
329+ ignore the escape wrapper and show `label` unchanged. `url` must be free of
330+ terminal control bytes, otherwise a crafted value could break out of the
331+ escape sequence, so we fall back to the bare label when it isn't.
332+ """
333+ if _TERMINAL_CONTROL_RE .search (url ):
334+ return label
335+ return f"\x1b ]8;;{ url } \x1b \\ { label } \x1b ]8;;\x1b \\ "
336+
337+ @staticmethod
338+ def _pypi_deep_link_base (results : dict ) -> Optional [str ]:
339+ """Base for per-finding PyPI Inspector links, or None when unavailable.
340+
341+ Combines the project URL (already carries the escaped package/version) with
342+ the scanned distribution's `/packages/...` path, giving a prefix that only
343+ needs the in-archive file path and `#line.N` appended.
344+ """
345+ inspector_url = results .get ("pypi_inspector_url" )
346+ dist_path = results .get ("pypi_dist_path" )
347+ if not inspector_url or not dist_path or results .get ("issues" , 0 ) < 1 :
348+ return None
349+ return inspector_url .rstrip ("/" ) + dist_path
350+
351+ @staticmethod
352+ def _pypi_finding_inspector_url (
353+ deep_base : str , threat_location : str
354+ ) -> Optional [str ]:
355+ """Deep link to a finding's file and line on PyPI Inspector.
356+
357+ `threat_location` is `<relpath-in-archive>:<line>`, relative to the extract
358+ root, which is exactly the path Inspector expects after the distribution
359+ filename.
360+ """
361+ file_part , _ , line = threat_location .rpartition (":" )
362+ if not file_part or not line .isdigit ():
363+ return None
364+ return f"{ deep_base } /{ quote (file_part , safe = '/' )} #line.{ line } "
365+
366+ @staticmethod
367+ def _format_pypi_inspector_url (results : dict ) -> List [str ]:
368+ inspector_url = results .get ("pypi_inspector_url" )
369+ if not inspector_url or results .get ("issues" , 0 ) < 1 :
370+ return []
371+
372+ link = HumanReadableReporter ._hyperlink (
373+ inspector_url , colored ("view on PyPI Inspector" , "dark_grey" )
374+ )
375+ return [
376+ "" ,
377+ colored ("Package files:" , "dark_grey" ) + f" { link } " ,
378+ ]
379+
308380 @staticmethod
309381 def _format_header (identifier : str , num_risks : int ) -> List [str ]:
310382 bold = colored (_sanitize (identifier ), None , attrs = ["bold" ])
@@ -333,10 +405,15 @@ def print_scan_results(identifier: str, results: dict) -> str:
333405 )
334406 ceiling = HumanReadableReporter ._BAND_CEILING .get (label )
335407
408+ deep_base = HumanReadableReporter ._pypi_deep_link_base (results )
409+
336410 lines : List [str ] = []
337411 lines += HumanReadableReporter ._format_header (identifier , len (risks ))
338412 if risks :
339- lines += HumanReadableReporter ._format_findings (risks , path_prefix , ceiling )
413+ lines += HumanReadableReporter ._format_findings (
414+ risks , path_prefix , ceiling , deep_base
415+ )
416+ lines += HumanReadableReporter ._format_pypi_inspector_url (results )
340417 if risk_score :
341418 lines += HumanReadableReporter ._format_summary (risk_score , len (risks ))
342419
0 commit comments