2222import asyncio
2323import dataclasses
2424import os
25+ import traceback
2526import xml .etree .ElementTree as ET
2627from datetime import datetime
2728from json import JSONDecodeError
2829from subprocess import check_output
2930from typing import List
30- from typing import Mapping
31+ from typing import MutableMapping
32+ from typing import Optional
3133from typing import Set
3234
3335import aiohttp
3436from aiohttp import ClientSession
3537from aiohttp .client_exceptions import ClientResponseError
3638from aiohttp .client_exceptions import ServerDisconnectedError
39+ from aiohttp .web_exceptions import HTTPGone
3740from bs4 import BeautifulSoup
3841from dateutil import parser as dateparser
42+ from django .utils .dateparse import parse_datetime
3943
4044
4145@dataclasses .dataclass (frozen = True )
4246class Version :
4347 value : str
44- release_date : datetime = None
48+ release_date : Optional [ datetime ] = None
4549
4650
4751@dataclasses .dataclass
@@ -55,10 +59,10 @@ class GraphQLError(Exception):
5559
5660
5761class VersionAPI :
58- def __init__ (self , cache : Mapping [str , Set [Version ]] = None ):
62+ def __init__ (self , cache : MutableMapping [str , Set [Version ]] = None ):
5963 self .cache = cache or {}
6064
61- def get (self , package_name , until = None ) -> Set [ str ] :
65+ def get (self , package_name , until = None ) -> VersionResponse :
6266 new_versions = set ()
6367 valid_versions = set ()
6468 for version in self .cache .get (package_name , set ()):
@@ -104,7 +108,7 @@ async def fetch(self, pkg, session):
104108 response = await session .request (method = "GET" , url = url )
105109 resp_json = await response .json ()
106110 if resp_json ["entries" ] == []:
107- self .cache [pkg ] = {}
111+ self .cache [pkg ] = set ()
108112 break
109113 for release in resp_json ["entries" ]:
110114 all_versions .add (
@@ -118,8 +122,12 @@ async def fetch(self, pkg, session):
118122 else :
119123 break
120124 self .cache [pkg ] = all_versions
121- except (ClientResponseError , asyncio .exceptions .TimeoutError , ServerDisconnectedError ):
122- self .cache [pkg ] = {}
125+ except (
126+ ClientResponseError ,
127+ asyncio .exceptions .TimeoutError ,
128+ ServerDisconnectedError ,
129+ ):
130+ self .cache [pkg ] = set ()
123131
124132
125133class PypiVersionAPI (VersionAPI ):
@@ -242,16 +250,20 @@ async def fetch(self, pkg, session, retry_count=5):
242250 resp_json = await response .json ()
243251
244252 if resp_json .get ("error" ) or not resp_json .get ("versions" ):
245- self .cache [pkg ] = {}
253+ self .cache [pkg ] = set ()
246254 return
247255 for release in resp_json ["versions" ]:
248256 all_versions .add (Version (value = release ["version" ].replace ("0:" , "" )))
249257
250258 self .cache [pkg ] = all_versions
251259 # TODO : Handle ServerDisconnectedError by using some sort of
252260 # retry mechanism
253- except (ClientResponseError , asyncio .exceptions .TimeoutError , ServerDisconnectedError ):
254- self .cache [pkg ] = {}
261+ except (
262+ ClientResponseError ,
263+ asyncio .exceptions .TimeoutError ,
264+ ServerDisconnectedError ,
265+ ):
266+ self .cache [pkg ] = set ()
255267
256268
257269class MavenVersionAPI (VersionAPI ):
@@ -295,10 +307,10 @@ def artifact_url(artifact_comps: List[str]) -> str:
295307 return endpoint
296308
297309 @staticmethod
298- def extract_versions (xml_response : ET .ElementTree ) -> Set [str ]:
310+ def extract_versions (xml_response : ET .ElementTree ) -> Set [Version ]:
299311 all_versions = set ()
300312 for child in xml_response .getroot ().iter ():
301- if child .tag == "version" :
313+ if child .tag == "version" and child . text :
302314 all_versions .add (Version (child .text ))
303315
304316 return all_versions
@@ -321,7 +333,7 @@ def nuget_url(pkg_name: str) -> str:
321333 return base_url .format (pkg_name )
322334
323335 @staticmethod
324- def extract_versions (resp : dict ) -> Set [str ]:
336+ def extract_versions (resp : dict ) -> Set [Version ]:
325337 all_versions = set ()
326338 try :
327339 for entry_group in resp ["items" ]:
@@ -353,7 +365,7 @@ async def fetch(self, pkg, session) -> None:
353365 self .cache [pkg ] = self .extract_versions (resp , pkg )
354366
355367 @staticmethod
356- def composer_url (pkg_name : str ) -> str :
368+ def composer_url (pkg_name : str ) -> Optional [ str ] :
357369 try :
358370 vendor , name = pkg_name .split ("/" )
359371 except ValueError :
@@ -362,7 +374,7 @@ def composer_url(pkg_name: str) -> str:
362374 return f"https://repo.packagist.org/p/{ vendor } /{ name } .json"
363375
364376 @staticmethod
365- def extract_versions (resp : dict , pkg_name : str ) -> Set [str ]:
377+ def extract_versions (resp : dict , pkg_name : str ) -> Set [Version ]:
366378 all_versions = set ()
367379 for version in resp ["packages" ][pkg_name ]:
368380 if "dev" in version :
@@ -412,7 +424,7 @@ class GitHubTagsAPI(VersionAPI):
412424 }
413425 }"""
414426
415- def __init__ (self , cache : Mapping [str , Set [Version ]] = None ):
427+ def __init__ (self , cache : MutableMapping [str , Set [Version ]] = None ):
416428 self .gh_token = os .getenv ("GH_TOKEN" )
417429 super ().__init__ (cache = cache )
418430
@@ -427,7 +439,10 @@ async def fetch(self, owner_repo: str, session: aiohttp.ClientSession) -> None:
427439 session .headers ["Authorization" ] = "token " + self .gh_token
428440 endpoint = f"https://api.github.com/graphql"
429441 owner , name = owner_repo .split ("/" )
430- query = {"query" : self .GQL_QUERY , "variables" : {"name" : name , "owner" : owner }}
442+ query = {
443+ "query" : self .GQL_QUERY ,
444+ "variables" : {"name" : name , "owner" : owner },
445+ }
431446
432447 while True :
433448 response = await session .post (endpoint , json = query )
@@ -489,3 +504,111 @@ async def fetch(self, pkg, session):
489504 pass
490505
491506 self .cache [pkg ] = versions
507+
508+
509+ class GoproxyVersionAPI (VersionAPI ):
510+
511+ package_type = "golang"
512+ module_name_by_package_name = {}
513+
514+ @staticmethod
515+ def trim_url_path (url_path : str ) -> Optional [str ]:
516+ """
517+ Return a trimmed Go `url_path` removing trailing
518+ package references and keeping only the module
519+ references.
520+
521+ Github advisories for Go are using package names
522+ such as "https://github.com/nats-io/nats-server/v2/server"
523+ (e.g., https://github.com/advisories/GHSA-jp4j-47f9-2vc3 ),
524+ yet goproxy works with module names instead such as
525+ "https://github.com/nats-io/nats-server" (see for details
526+ https://golang.org/ref/mod#goproxy-protocol ).
527+ This functions trims the trailing part(s) of a package URL
528+ and returns the remaining the module name.
529+ For example:
530+ >>> module = "https://github.com/xx/a"
531+ >>> assert GoproxyVersionAPI.trim_url_path("https://github.com/xx/a/b") == module
532+ """
533+ # some advisories contains this prefix in package name, e.g. https://github.com/advisories/GHSA-7h6j-2268-fhcm
534+ if url_path .startswith ("https://pkg.go.dev/" ):
535+ url_path = url_path .removeprefix ("https://pkg.go.dev/" )
536+ parts = url_path .split ("/" )
537+ if len (parts ) >= 2 :
538+ return "/" .join (parts [:- 1 ])
539+ else :
540+ return None
541+
542+ @staticmethod
543+ def escape_path (path : str ) -> str :
544+ """
545+ Return an case-encoded module path or version name.
546+
547+ This is done by replacing every uppercase letter with an exclamation
548+ mark followed by the corresponding lower-case letter, in order to
549+ avoid ambiguity when serving from case-insensitive file systems.
550+ Refer to https://golang.org/ref/mod#goproxy-protocol.
551+ """
552+ escaped_path = ""
553+ for c in path :
554+ if c >= "A" and c <= "Z" :
555+ # replace uppercase with !lowercase
556+ escaped_path += "!" + chr (ord (c ) + ord ("a" ) - ord ("A" ))
557+ else :
558+ escaped_path += c
559+ return escaped_path
560+
561+ @staticmethod
562+ async def parse_version_info (
563+ version_info : str , escaped_pkg : str , session : ClientSession
564+ ) -> Optional [Version ]:
565+ v = version_info .split ()
566+ if not v :
567+ return None
568+ value = v [0 ]
569+ if len (v ) > 1 :
570+ # get release date from the second part. see https://github.com/golang/go/blob/master/src/cmd/go/internal/modfetch/proxy.go#latest()
571+ release_date = parse_datetime (v [1 ])
572+ else :
573+ escaped_ver = GoproxyVersionAPI .escape_path (value )
574+ try :
575+ response = await session .request (
576+ method = "GET" ,
577+ url = f"https://proxy.golang.org/{ escaped_pkg } /@v/{ escaped_ver } .info" ,
578+ )
579+ resp_json = await response .json ()
580+ release_date = parse_datetime (resp_json .get ("Time" , "" ))
581+ except :
582+ traceback .print_exc ()
583+ print (
584+ f"error while fetching version info for { escaped_pkg } /{ escaped_ver } from goproxy"
585+ )
586+ release_date = None
587+ return Version (value = value , release_date = release_date )
588+
589+ async def fetch (self , pkg : str , session : ClientSession ):
590+ # escape uppercase in module path
591+ escaped_pkg = GoproxyVersionAPI .escape_path (pkg )
592+ trimmed_pkg = pkg
593+ resp_text = None
594+ # resolve module name from package name, see https://go.dev/ref/mod#resolve-pkg-mod
595+ while escaped_pkg is not None :
596+ url = f"https://proxy.golang.org/{ escaped_pkg } /@v/list"
597+ try :
598+ response = await session .request (method = "GET" , url = url )
599+ resp_text = await response .text ()
600+ except HTTPGone :
601+ escaped_pkg = GoproxyVersionAPI .trim_url_path (escaped_pkg )
602+ trimmed_pkg = GoproxyVersionAPI .trim_url_path (trimmed_pkg ) or ""
603+ continue
604+ break
605+ if resp_text is None or escaped_pkg is None or trimmed_pkg is None :
606+ print (f"error while fetching versions for { pkg } from goproxy" )
607+ return
608+ self .module_name_by_package_name [pkg ] = trimmed_pkg
609+ versions = set ()
610+ for version_info in resp_text .split ("\n " ):
611+ version = await GoproxyVersionAPI .parse_version_info (version_info , escaped_pkg , session )
612+ if version is not None :
613+ versions .add (version )
614+ self .cache [pkg ] = versions
0 commit comments