Skip to content

Commit 0143133

Browse files
0xTaoZJensen Zanechristophetd
authored
Show PyPI Inspector link for remote PyPI findings (#797)
* feat: show PyPI Inspector link for remote findings * feat: clickable per-finding PyPI Inspector deep links * fix: populate inspector links on verify/project scan path --------- Co-authored-by: Jensen Zane <jensenzane@Jensens-MacBook-Air.local> Co-authored-by: Christophe Tafani-Dereeper <christophe.tafanidereeper@datadoghq.com>
1 parent ca8b5b4 commit 0143133

6 files changed

Lines changed: 357 additions & 19 deletions

File tree

guarddog/cli.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,14 +443,16 @@ def _scan_remote_sandboxed(scanner, name, version, rules):
443443
}
444444
for risk in risk_objects
445445
]
446-
return {
446+
results = {
447447
"issues": metadata_results["issues"] + sourcecode_results["issues"],
448448
"errors": metadata_results["errors"] | sourcecode_results["errors"],
449449
"results": metadata_results["results"] | sourcecode_results["results"],
450450
"path": file_path,
451451
"risk_score": risk_score,
452452
"risks": formatted_risks,
453453
}
454+
scanner._annotate_remote_results(results, package_info, name, version)
455+
return results
454456
finally:
455457
shutil.rmtree(tmpdir, ignore_errors=True)
456458

guarddog/reporters/human_readable.py

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
import re
33
from enum import Enum
4+
from urllib.parse import quote
45

56
from termcolor import colored
67
from 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

guarddog/scanners/pypi_package_scanner.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import typing
3+
from urllib.parse import quote, urlparse
34

45
from guarddog.analyzer.analyzer import Analyzer
56
from guarddog.ecosystems import ECOSYSTEM
@@ -18,6 +19,33 @@ def download_and_get_package_info(
1819
extract_dir = self.download_package(package_name, directory, version)
1920
return get_package_info(package_name), extract_dir
2021

22+
def get_package_version(self, package_info: dict, requested_version=None):
23+
if requested_version is not None:
24+
return requested_version
25+
return package_info.get("info", {}).get("version")
26+
27+
@staticmethod
28+
def _select_distribution(package_info: dict, version) -> typing.Optional[dict]:
29+
"""Return the release file GuardDog scans for a version (first supported archive)."""
30+
releases = package_info.get("releases", {})
31+
if version is None:
32+
version = package_info.get("info", {}).get("version")
33+
for file in releases.get(version, []):
34+
if is_supported_archive(file["filename"]):
35+
return file
36+
return None
37+
38+
def get_package_dist_path(self, package_info: dict, requested_version=None):
39+
distribution = self._select_distribution(package_info, requested_version)
40+
if distribution is None:
41+
return None
42+
return urlparse(distribution["url"]).path
43+
44+
def get_package_inspector_url(self, name, version):
45+
package = quote(name, safe="")
46+
version = quote(str(version), safe="")
47+
return f"https://inspector.pypi.io/project/{package}/{version}/"
48+
2149
def download_package(self, package_name, directory, version=None) -> str:
2250
"""Downloads the PyPI distribution for a given package and version
2351
@@ -46,20 +74,15 @@ def download_package(self, package_name, directory, version=None) -> str:
4674
f"Version {version} for package {package_name} doesn't exist."
4775
)
4876

49-
files = releases[version]
50-
url, file_extension = None, None
51-
52-
for file in files:
53-
if is_supported_archive(file["filename"]):
54-
url = file["url"]
55-
_, file_extension = os.path.splitext(file["filename"])
56-
break
57-
58-
if not (url and file_extension):
77+
distribution = self._select_distribution(data, version)
78+
if distribution is None:
5979
raise Exception(
6080
f"Compressed file for {package_name} does not exist on PyPI."
6181
)
6282

83+
url = distribution["url"]
84+
_, file_extension = os.path.splitext(distribution["filename"])
85+
6386
# Path to compressed package
6487
zippath = os.path.join(directory, package_name + file_extension)
6588
unzippedpath = os.path.join(directory, package_name)

guarddog/scanners/scanner.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,38 @@ def download_and_get_package_info(
217217
) -> typing.Tuple[dict, str]:
218218
raise NotImplementedError("download_and_get_package_info is not implemented")
219219

220+
def get_package_version(self, package_info: dict, requested_version=None):
221+
return requested_version
222+
223+
def get_package_dist_path(self, package_info: dict, requested_version=None):
224+
"""Return the PyPI-style `/packages/...` path of the scanned distribution.
225+
226+
Used to build PyPI Inspector deep links. Only PyPI packages have one.
227+
"""
228+
return None
229+
230+
def get_package_inspector_url(self, name, version) -> typing.Optional[str]:
231+
"""URL to the package's files on PyPI Inspector, or None if unsupported."""
232+
return None
233+
234+
def _annotate_remote_results(self, results, package_info, name, version):
235+
"""Attach PyPI Inspector metadata to a remote scan's results.
236+
237+
Shared by the plain and sandboxed remote scan paths so direct scans and
238+
`verify` project scans surface the same version, distribution path, and
239+
Inspector links.
240+
"""
241+
scanned_version = self.get_package_version(package_info, version)
242+
if scanned_version is not None:
243+
results["package_version"] = scanned_version
244+
if results.get("issues", 0) >= 1:
245+
inspector_url = self.get_package_inspector_url(name, scanned_version)
246+
if inspector_url is not None:
247+
results["pypi_inspector_url"] = inspector_url
248+
dist_path = self.get_package_dist_path(package_info, version)
249+
if dist_path is not None:
250+
results["pypi_dist_path"] = dist_path
251+
220252
def _scan_remote(
221253
self, name, base_dir, version=None, rules=None, write_package_info=False
222254
):
@@ -233,6 +265,7 @@ def _scan_remote(
233265
return {"issues": 0, "errors": {"download-package": str(e)}}
234266

235267
results = self.analyzer.analyze(file_path, package_info, rules, name, version)
268+
self._annotate_remote_results(results, package_info, name, version)
236269
if write_package_info:
237270
package_name = name.replace("/", "-")
238271
suffix = (
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from guarddog.scanners import PypiPackageScanner
2+
3+
4+
def _package_info():
5+
return {
6+
"info": {"version": "1.0"},
7+
"releases": {
8+
"1.0": [
9+
{
10+
"filename": "aws-dd-forwarder-1.0-py3-none-any.whl",
11+
"url": (
12+
"https://files.pythonhosted.org/packages/ab/cd/"
13+
"ef01/aws-dd-forwarder-1.0-py3-none-any.whl"
14+
),
15+
},
16+
{
17+
"filename": "aws-dd-forwarder-1.0.tar.gz",
18+
"url": (
19+
"https://files.pythonhosted.org/packages/f8/57/"
20+
"87be/aws-dd-forwarder-1.0.tar.gz"
21+
),
22+
},
23+
]
24+
},
25+
}
26+
27+
28+
def test_get_package_dist_path_returns_first_supported_archive_path():
29+
scanner = PypiPackageScanner()
30+
path = scanner.get_package_dist_path(_package_info(), "1.0")
31+
assert path == "/packages/ab/cd/ef01/aws-dd-forwarder-1.0-py3-none-any.whl"
32+
33+
34+
def test_get_package_dist_path_defaults_to_latest_version():
35+
scanner = PypiPackageScanner()
36+
path = scanner.get_package_dist_path(_package_info())
37+
assert path == "/packages/ab/cd/ef01/aws-dd-forwarder-1.0-py3-none-any.whl"
38+
39+
40+
def test_get_package_dist_path_none_when_no_supported_archive():
41+
scanner = PypiPackageScanner()
42+
package_info = {
43+
"info": {"version": "1.0"},
44+
"releases": {
45+
"1.0": [{"filename": "aws-dd-forwarder-1.0.metadata", "url": "https://x/y"}]
46+
},
47+
}
48+
assert scanner.get_package_dist_path(package_info, "1.0") is None
49+
50+
51+
def test_get_package_dist_path_none_for_unknown_version():
52+
scanner = PypiPackageScanner()
53+
assert scanner.get_package_dist_path(_package_info(), "9.9") is None
54+
55+
56+
def test_get_package_inspector_url_builds_project_url():
57+
scanner = PypiPackageScanner()
58+
url = scanner.get_package_inspector_url("requests", "2.28.1")
59+
assert url == "https://inspector.pypi.io/project/requests/2.28.1/"
60+
61+
62+
def test_get_package_inspector_url_escapes_name_and_version():
63+
scanner = PypiPackageScanner()
64+
url = scanner.get_package_inspector_url("my package", "1.0.0+local")
65+
assert url == "https://inspector.pypi.io/project/my%20package/1.0.0%2Blocal/"
66+
67+
68+
def test_annotate_remote_results_sets_links_when_findings_exist():
69+
scanner = PypiPackageScanner()
70+
results = {"issues": 1}
71+
scanner._annotate_remote_results(
72+
results, _package_info(), "aws-dd-forwarder", "1.0"
73+
)
74+
assert results["package_version"] == "1.0"
75+
assert results["pypi_inspector_url"] == (
76+
"https://inspector.pypi.io/project/aws-dd-forwarder/1.0/"
77+
)
78+
assert results["pypi_dist_path"] == (
79+
"/packages/ab/cd/ef01/aws-dd-forwarder-1.0-py3-none-any.whl"
80+
)
81+
82+
83+
def test_annotate_remote_results_omits_inspector_url_without_findings():
84+
scanner = PypiPackageScanner()
85+
results = {"issues": 0}
86+
scanner._annotate_remote_results(
87+
results, _package_info(), "aws-dd-forwarder", "1.0"
88+
)
89+
assert "pypi_inspector_url" not in results
90+
# Version and distribution path are still recorded.
91+
assert results["package_version"] == "1.0"
92+
assert "pypi_dist_path" in results

0 commit comments

Comments
 (0)