2424import dataclasses
2525import json
2626import logging
27+ import os
2728import re
2829from functools import total_ordering
2930from typing import List
3839from packageurl import PackageURL
3940from univers .version_range import RANGE_CLASS_BY_SCHEMES
4041
41- LOGGER = logging .getLogger (__name__ )
42+ logger = logging .getLogger (__name__ )
4243
4344cve_regex = re .compile (r"CVE-\d{4}-\d{4,7}" , re .IGNORECASE )
4445is_cve = cve_regex .match
@@ -75,39 +76,23 @@ def fetch_yaml(url):
7576create_etag = MagicMock ()
7677
7778
78- def split_markdown_front_matter (lines : str ) -> Tuple [str , str ]:
79+ def split_markdown_front_matter (text : str ) -> Tuple [str , str ]:
7980 """
80- This function splits lines into markdown front matter and the markdown body
81- and returns list of lines for both
82-
83- for example :
84- lines =
85- ---
86- title: ISTIO-SECURITY-2019-001
87- description: Incorrect access control.
88- cves: [CVE-2019-12243]
89- ---
90- # Markdown starts here
91-
92- split_markdown_front_matter(lines) would return
93- ['title: ISTIO-SECURITY-2019-001','description: Incorrect access control.'
94- ,'cves: [CVE-2019-12243]'],
95- ["# Markdown starts here"]
81+ Return a tuple of (front matter, markdown body) strings split from a
82+ ``text`` string. Each can be an empty string. This is used when security
83+ advisories are provided in this format.
9684 """
85+ lines = text .splitlines ()
86+ if not lines :
87+ return "" , ""
9788
98- fmlines = []
99- mdlines = []
100- splitter = mdlines
101-
102- for index , line in enumerate (lines .split ("\n " )):
103- if index == 0 and line .strip ().startswith ("---" ):
104- splitter = fmlines
105- elif line .strip ().startswith ("---" ):
106- splitter = mdlines
107- else :
108- splitter .append (line )
89+ if lines [0 ] == "---" :
90+ lines = lines [1 :]
91+ text = "\n " .join (lines )
92+ frontmatter , _ , markdown = text .partition ("\n ---\n " )
93+ return frontmatter , markdown
10994
110- return "\n " . join ( fmlines ), " \n " . join ( mdlines )
95+ return "" , text
11196
11297
11398def contains_alpha (string ):
@@ -123,7 +108,7 @@ def requests_with_5xx_retry(max_retries=5, backoff_factor=0.5):
123108 Returns a requests sessions which retries on 5xx errors with
124109 a backoff_factor
125110 """
126- retries = urllib3 .util . Retry (
111+ retries = urllib3 .Retry (
127112 total = max_retries ,
128113 backoff_factor = backoff_factor ,
129114 raise_on_status = True ,
@@ -157,6 +142,30 @@ def __lt__(self, other):
157142 return self .version < other .version
158143
159144
145+ def evolve_purl (purl , ** kwargs ):
146+ """
147+ Return a new PackageURL derived from the ``purl`` PackageURL where any of
148+ the provided kwarg replaces the corresponding attribute of this PackageURL.
149+ Qaulifiers if provided must be a mapping
150+ For example::
151+ >>> purl = PackageURL.from_string("pkg:generic/this@1.2.3")
152+ >>> evolved = PackageURL.from_string("pkg:npm/@baz/that@2.2.3?foo=bar")
153+ >>> evolve_purl(purl,
154+ ... type="npm", namespace="@baz", name="that",
155+ ... version="2.2.3", qualifiers={"foo": "bar"}
156+ ... ) == evolved
157+ True
158+
159+ """
160+ if not kwargs :
161+ return PackageURL .from_string (str (purl ))
162+
163+ kwargs = {name : value for name , value in kwargs .items () if hasattr (purl , name )}
164+ merged = purl .to_dict ()
165+ merged .update (kwargs )
166+ return PackageURL (** merged )
167+
168+
160169def nearest_patched_package (
161170 vulnerable_packages : List [PackageURL ], resolved_packages : List [PackageURL ]
162171) -> List [AffectedPackage ]:
@@ -186,32 +195,6 @@ def nearest_patched_package(
186195 return affected_package_with_patched_package_objects
187196
188197
189- def split_markdown_front_matter (text : str ) -> Tuple [str , str ]:
190- r"""
191- Return a tuple of (front matter, markdown body) strings split from ``text``.
192- Each can be an empty string.
193-
194- >>> text='''---
195- ... title: DUMMY-SECURITY-2019-001
196- ... description: Incorrect access control.
197- ... cves: [CVE-2042-1337]
198- ... ---
199- ... # Markdown starts here
200- ... '''
201- >>> split_markdown_front_matter(text)
202- ('title: DUMMY-SECURITY-2019-001\ndescription: Incorrect access control.\ncves: [CVE-2042-1337]', '# Markdown starts here')
203- """
204- # The doctest contains \n and for the sake of clarity I chose raw strings than escaping those.
205- lines = text .splitlines ()
206- if lines [0 ] == "---" :
207- lines = lines [1 :]
208- text = "\n " .join (lines )
209- frontmatter , _ , markdown = text .partition ("\n ---\n " )
210- return frontmatter , markdown
211-
212- return "" , text
213-
214-
215198# TODO: Replace this with combination of @classmethod and @property after upgrading to python 3.9
216199class classproperty (object ):
217200 def __init__ (self , fget ):
@@ -233,12 +216,54 @@ def get_item(object: dict, *attributes):
233216 >>> assert(get_item({'a': {'b': {'c': 'd'}}}, 'a', 'b', 'e')) == None
234217 """
235218 if not object :
236- LOGGER .error (f"Object is empty: { object } " )
237219 return
238220 item = object
239221 for attribute in attributes :
240222 if attribute not in item :
241- LOGGER .error (f"Missing attribute { attribute } in { item } " )
223+ logger .error (f"Missing attribute { attribute } in { item } " )
242224 return None
243225 item = item [attribute ]
244226 return item
227+
228+
229+ class GitHubTokenError (Exception ):
230+ pass
231+
232+
233+ class GraphQLError (Exception ):
234+ pass
235+
236+
237+ def fetch_github_graphql_query (graphql_query : dict ):
238+ """
239+ Return results from calling the Github graphql API with the ``graphql_query`` mapping.
240+ Raise a GitHubTokenError if the "GH_TOKEN" environment variable is not set.
241+ Raise a GraphQLError on query errors.
242+ """
243+ gh_token = os .environ .get ("GH_TOKEN" , None )
244+ # graphql api cannot work without api token
245+ if not gh_token :
246+ msg = "Cannot call GitHub API without a token set in the GH_TOKEN environment variable."
247+ logger .error (msg )
248+ raise GitHubTokenError (msg )
249+
250+ response = _get_gh_response (gh_token = gh_token , graphql_query = graphql_query )
251+
252+ message = response .get ("message" )
253+ if message and message == "Bad credentials" :
254+ raise GitHubTokenError (f"Invalid GitHub token: { message } " )
255+
256+ errors = response .get ("errors" )
257+ if errors :
258+ raise GraphQLError (errors )
259+
260+ return response
261+
262+
263+ def _get_gh_response (gh_token , graphql_query ):
264+ """
265+ Convenience function to easy mocking in tests
266+ """
267+ endpoint = "https://api.github.com/graphql"
268+ headers = {"Authorization" : f"bearer { gh_token } " }
269+ return requests .post (endpoint , headers = headers , json = graphql_query ).json ()
0 commit comments