Skip to content

Commit eed065c

Browse files
authored
Merge pull request #691 from nexB/643-nginx-tests
Add nginx tests and other related improvements
2 parents 249c562 + d553201 commit eed065c

108 files changed

Lines changed: 24159 additions & 2592 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ build-backend = "setuptools.build_meta"
66
[tool.pytest.ini_options]
77
DJANGO_SETTINGS_MODULE = "vulnerablecode.settings"
88

9+
# Fail tests that render templates which make use of invalid template variables.
10+
FAIL_INVALID_TEMPLATE_VARS = true
11+
912
markers = [
1013
"webtest",
1114
]
@@ -85,3 +88,4 @@ profile = "black"
8588
line_length = 100
8689
force_single_line = true
8790
skip_gitignore = true
91+
skip_glob = "*/migrations/*"

setup.cfg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ dev =
9999
# misc
100100
docker-compose
101101
ipython==8.0.1
102+
# used for testing
103+
commoncode
102104

103105
[options.entry_points]
104106
console_scripts =

vulnerabilities/api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ def bulk_search(self, request):
147147
try:
148148
purl_string = purl
149149
purl = PackageURL.from_string(purl).to_dict()
150-
except ValueError as ve:
150+
except ValueError:
151151
return Response(status=400, data={"Error": f"Invalid Package URL: {purl}"})
152152
purl_data = Package.objects.filter(
153153
**{key: value for key, value in purl.items() if value}

vulnerabilities/helpers.py

Lines changed: 84 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import dataclasses
2525
import json
2626
import logging
27+
import os
2728
import re
2829
from functools import total_ordering
2930
from typing import List
@@ -38,7 +39,7 @@
3839
from packageurl import PackageURL
3940
from univers.version_range import RANGE_CLASS_BY_SCHEMES
4041

41-
LOGGER = logging.getLogger(__name__)
42+
logger = logging.getLogger(__name__)
4243

4344
cve_regex = re.compile(r"CVE-\d{4}-\d{4,7}", re.IGNORECASE)
4445
is_cve = cve_regex.match
@@ -75,39 +76,23 @@ def fetch_yaml(url):
7576
create_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

11398
def 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+
160169
def 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
216199
class 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()

vulnerabilities/import_runner.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,11 @@
2121
# VulnerableCode is a free software tool from nexB Inc. and others.
2222
# Visit https://github.com/nexB/vulnerablecode/ for support and download.
2323

24-
import dataclasses
2524
import datetime
26-
import json
2725
import logging
2826
from typing import Iterable
2927
from typing import List
3028

31-
from vulnerabilities import models
3229
from vulnerabilities.importer import AdvisoryData
3330
from vulnerabilities.importer import Importer
3431
from vulnerabilities.models import Advisory

vulnerabilities/importer.py

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from univers.versions import Version
4545

4646
from vulnerabilities.helpers import classproperty
47+
from vulnerabilities.helpers import evolve_purl
4748
from vulnerabilities.helpers import nearest_patched_package
4849
from vulnerabilities.oval_parser import OvalParser
4950
from vulnerabilities.severity_systems import SCORING_SYSTEMS
@@ -58,9 +59,6 @@ class VulnerabilitySeverity:
5859
value: str
5960

6061
def to_dict(self):
61-
"""
62-
Return a serializable dict that can be converted back using self.from_dict
63-
"""
6462
return {
6563
"system": self.system.identifier,
6664
"value": self.value,
@@ -69,7 +67,8 @@ def to_dict(self):
6967
@classmethod
7068
def from_dict(cls, severity: dict):
7169
"""
72-
Return a VulnerabilitySeverity object from dict generated by self.to_dict
70+
Return a VulnerabilitySeverity object from a ``severity`` mapping of
71+
VulnerabilitySeverity data.
7372
"""
7473
return cls(system=SCORING_SYSTEMS[severity["system"]], value=severity["value"])
7574

@@ -90,9 +89,6 @@ def normalized(self):
9089
return Reference(reference_id=self.reference_id, url=self.url, severities=severities)
9190

9291
def to_dict(self):
93-
"""
94-
Return a serializable dict that can be converted back using self.from_dict
95-
"""
9692
return {
9793
"reference_id": self.reference_id,
9894
"url": self.url,
@@ -101,9 +97,6 @@ def to_dict(self):
10197

10298
@classmethod
10399
def from_dict(cls, ref: dict):
104-
"""
105-
Return a Reference object from dict generated by self.to_dict
106-
"""
107100
return cls(
108101
reference_id=ref["reference_id"],
109102
url=ref["url"],
@@ -128,9 +121,9 @@ class NoAffectedPackages(Exception):
128121
@dataclasses.dataclass(order=True, frozen=True)
129122
class AffectedPackage:
130123
"""
131-
Contains a range of affected versions and a fixed version of a given package
132-
The PackageURL supplied must *not* have a version
133-
It must contain either `affected_version_range` or `fixed_version`
124+
Relate a Package URL with a range of affected versions and a fixed version.
125+
The Package URL must *not* have a version.
126+
AffectedPackage must contain either ``affected_version_range`` or ``fixed_version``.
134127
"""
135128

136129
package: PackageURL
@@ -139,19 +132,21 @@ class AffectedPackage:
139132

140133
def __post_init__(self):
141134
if self.package.version:
142-
raise ValueError("The PackageURL supplied must not have a version")
135+
raise ValueError(f"Affected Package URL {self.package!r} cannot have a version.")
136+
143137
if not (self.affected_version_range or self.fixed_version):
144138
raise ValueError(
145-
"Affected Package should at least have either a fixed version or affected version range"
139+
f"Affected Package {self.package!r} should have either a fixed version or an "
140+
"affected version range."
146141
)
147142

148143
def get_fixed_purl(self):
149144
"""
150-
Return PackageURL corresponding to object's fixed_version
145+
Return a Package URL corresponding to object's fixed_version
151146
"""
152147
if not self.fixed_version:
153-
raise ValueError("Affected package should have a fixed version")
154-
fixed_purl = self.package._replace(version=str(self.fixed_version))
148+
raise ValueError(f"Affected Package {self.package!r} does not have a fixed version")
149+
fixed_purl = evolve_purl(purl=self.package, version=str(self.fixed_version))
155150
return fixed_purl
156151

157152
@classmethod
@@ -256,6 +251,20 @@ def to_dict(self):
256251
"date_published": self.date_published.isoformat() if self.date_published else None,
257252
}
258253

254+
@classmethod
255+
def from_dict(cls, advisory_data):
256+
date_published = advisory_data["date_published"]
257+
transformed = {
258+
"aliases": advisory_data["aliases"],
259+
"summary": advisory_data["summary"],
260+
"affected_packages": [
261+
AffectedPackage.from_dict(pkg) for pkg in advisory_data["affected_packages"]
262+
],
263+
"references": [Reference.from_dict(ref) for ref in advisory_data["references"]],
264+
"date_published": date_published.isoformat() if date_published else None,
265+
}
266+
return cls(**transformed)
267+
259268

260269
class NoLicenseError(Exception):
261270
pass
@@ -592,7 +601,7 @@ def get_data_from_xml_doc(self, xml_doc: ET.ElementTree, pkg_metadata={}) -> Lis
592601
version_class = version_class_by_package_type[pkg_metadata["type"]]
593602
version_scheme = version_class.scheme
594603

595-
affected_version_range = VersionSpecifier.from_scheme_version_spec_string(
604+
affected_version_range = VersionRange.from_scheme_version_spec_string(
596605
version_scheme, affected_version_range
597606
)
598607
all_versions = self.pkg_manager_api.get(package_name).valid_versions
@@ -623,7 +632,7 @@ def get_data_from_xml_doc(self, xml_doc: ET.ElementTree, pkg_metadata={}) -> Lis
623632
)
624633

625634
all_adv.append(
626-
Advisory(
635+
AdvisoryData(
627636
summary=description,
628637
affected_packages=affected_packages,
629638
vulnerability_id=vuln_id,

0 commit comments

Comments
 (0)