Skip to content

Commit 283ef0d

Browse files
committed
Merge branch 'sify21-main' into main
2 parents d4f38aa + 954a7fb commit 283ef0d

5 files changed

Lines changed: 201 additions & 18 deletions

File tree

vulnerabilities/importers/github.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from vulnerabilities.importer import Reference
4242
from vulnerabilities.importer import VulnerabilitySeverity
4343
from vulnerabilities.package_managers import ComposerVersionAPI
44+
from vulnerabilities.package_managers import GoproxyVersionAPI
4445
from vulnerabilities.package_managers import MavenVersionAPI
4546
from vulnerabilities.package_managers import NugetVersionAPI
4647
from vulnerabilities.package_managers import PypiVersionAPI
@@ -196,6 +197,7 @@ def set_version_api(self, ecosystem: str) -> None:
196197
"COMPOSER": ComposerVersionAPI,
197198
"PIP": PypiVersionAPI,
198199
"RUBYGEMS": RubyVersionAPI,
200+
"GO": GoproxyVersionAPI,
199201
}
200202
versioner = versioners.get(ecosystem)
201203
if versioner:
@@ -219,7 +221,7 @@ def process_name(ecosystem: str, pkg_name: str) -> Optional[Tuple[Optional[str],
219221
return None
220222
return vendor, name
221223

222-
if ecosystem == "NUGET" or ecosystem == "PIP" or ecosystem == "RUBYGEMS":
224+
if ecosystem in ("NUGET", "PIP", "RUBYGEMS", "GO"):
223225
return None, pkg_name
224226

225227
@staticmethod
@@ -255,6 +257,10 @@ def process_response(self) -> List[Advisory]:
255257
unaffected_purls = []
256258
if self.process_name(ecosystem, name):
257259
ns, pkg_name = self.process_name(ecosystem, name)
260+
if hasattr(self.version_api, "module_name_by_package_name"):
261+
pkg_name = self.version_api.module_name_by_package_name.get(
262+
name, pkg_name
263+
)
258264
aff_range = adv["node"]["vulnerableVersionRange"]
259265
aff_vers, unaff_vers = self.categorize_versions(
260266
self.version_api.package_type,

vulnerabilities/package_managers.py

Lines changed: 140 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,26 +22,30 @@
2222
import asyncio
2323
import dataclasses
2424
import os
25+
import traceback
2526
import xml.etree.ElementTree as ET
2627
from datetime import datetime
2728
from json import JSONDecodeError
2829
from subprocess import check_output
2930
from typing import List
30-
from typing import Mapping
31+
from typing import MutableMapping
32+
from typing import Optional
3133
from typing import Set
3234

3335
import aiohttp
3436
from aiohttp import ClientSession
3537
from aiohttp.client_exceptions import ClientResponseError
3638
from aiohttp.client_exceptions import ServerDisconnectedError
39+
from aiohttp.web_exceptions import HTTPGone
3740
from bs4 import BeautifulSoup
3841
from dateutil import parser as dateparser
42+
from django.utils.dateparse import parse_datetime
3943

4044

4145
@dataclasses.dataclass(frozen=True)
4246
class 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

5761
class 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

125133
class 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

257269
class 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
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
v0.0.1
2+
v0.0.5
3+
v0.0.3
4+
v0.0.4
5+
v0.0.2
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"Version":"v0.0.5","Time":"2022-01-04T13:54:01Z"}

vulnerabilities/tests/test_package_managers.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,12 @@
3232

3333
from aiohttp.client import ClientSession
3434
from dateutil.tz import tzlocal
35+
from dateutil.tz import tzutc
3536
from pytz import UTC
3637

3738
from vulnerabilities.package_managers import ComposerVersionAPI
3839
from vulnerabilities.package_managers import GitHubTagsAPI
40+
from vulnerabilities.package_managers import GoproxyVersionAPI
3941
from vulnerabilities.package_managers import MavenVersionAPI
4042
from vulnerabilities.package_managers import NugetVersionAPI
4143
from vulnerabilities.package_managers import Version
@@ -54,6 +56,7 @@ async def request(self, *args, **kwargs):
5456
mock_response = AsyncMock()
5557
mock_response.json = self.json
5658
mock_response.read = self.read
59+
mock_response.text = self.text
5760
return mock_response
5861

5962
def get(self, *args, **kwargs):
@@ -70,6 +73,9 @@ async def json(self):
7073
async def read(self):
7174
return self.return_val
7275

76+
async def text(self):
77+
return self.return_val
78+
7379

7480
class RecordedClientSession:
7581
def __init__(self, test_id, regen=False):
@@ -449,6 +455,48 @@ def test_fetch(self):
449455
assert self.version_api.get("org.apache:kafka") == VersionResponse(valid_versions=expected)
450456

451457

458+
class TestGoproxyVersionAPI(TestCase):
459+
def test_trim_url_path(self):
460+
url1 = "https://pkg.go.dev/github.com/containous/traefik/v2"
461+
url2 = "github.com/FerretDB/FerretDB/cmd/ferretdb"
462+
url3 = GoproxyVersionAPI.trim_url_path(url2)
463+
assert "github.com/containous/traefik" == GoproxyVersionAPI.trim_url_path(url1)
464+
assert "github.com/FerretDB/FerretDB/cmd" == url3
465+
assert "github.com/FerretDB/FerretDB" == GoproxyVersionAPI.trim_url_path(url3)
466+
467+
def test_escape_path(self):
468+
path = "github.com/FerretDB/FerretDB"
469+
assert "github.com/!ferret!d!b/!ferret!d!b" == GoproxyVersionAPI.escape_path(path)
470+
471+
def test_parse_version_info(self):
472+
with open(os.path.join(TEST_DATA, "goproxy_api", "version_info")) as f:
473+
vinfo = json.load(f)
474+
client_session = MockClientSession(vinfo)
475+
assert asyncio.run(
476+
GoproxyVersionAPI.parse_version_info(
477+
"v0.0.5", "github.com/!ferret!d!b/!ferret!d!b", client_session
478+
)
479+
) == Version(
480+
value="v0.0.5",
481+
release_date=datetime(2022, 1, 4, 13, 54, 1, tzinfo=tzutc()),
482+
)
483+
484+
def test_fetch(self):
485+
version_api = GoproxyVersionAPI()
486+
assert version_api.get("github.com/FerretDB/FerretDB") == VersionResponse()
487+
with open(os.path.join(TEST_DATA, "goproxy_api", "ferretdb_versions")) as f:
488+
vlist = f.read()
489+
client_session = MockClientSession(vlist)
490+
asyncio.run(version_api.fetch("github.com/FerretDB/FerretDB", client_session))
491+
assert version_api.cache["github.com/FerretDB/FerretDB"] == {
492+
Version(value="v0.0.1"),
493+
Version(value="v0.0.2"),
494+
Version(value="v0.0.3"),
495+
Version(value="v0.0.4"),
496+
Version(value="v0.0.5"),
497+
}
498+
499+
452500
class TestNugetVersionAPI(TestCase):
453501
@classmethod
454502
def setUpClass(cls):

0 commit comments

Comments
 (0)