Skip to content

Commit cb83cee

Browse files
committed
Migrate alpine importer to importer improver model
Current alpine importer models need to be refactored to give AdvisoryData instead of Advisory, also add some validation to parse license expression and make affected_version_range optional Add tests to test scraping of webpages and parsing of data Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com>
1 parent 4390b6a commit cb83cee

14 files changed

Lines changed: 782 additions & 185 deletions

File tree

AUTHORS.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ The following organizations or individuals have contributed to this repo:
1313
- Navonil Das @NavonilDas
1414
- Tushar Upadhyay @tushar912
1515
- Hritik Vijay @hritik14
16+
- Tushar Goel @TG1999

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ django-widget-tweaks>=1.4.8
88
packageurl-python>=0.9.4
99
binaryornot>=0.4.4
1010
GitPython>=3.1.17
11-
univers>=30.0.0
11+
univers>=30.1.0
1212
saneyaml>=0.5.2
1313
beautifulsoup4>=4.9.3
1414
python-dateutil>=2.8.1
@@ -17,5 +17,6 @@ lxml>=4.6.4
1717
gunicorn>=20.1.0
1818
django-environ==0.4.5
1919
defusedxml==0.7.1
20+
license-expression>=21.6.14
2021

2122
Markdown==3.3.4

vulnerabilities/importer.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from binaryornot.helpers import is_binary_string
3939
from git import DiffIndex
4040
from git import Repo
41+
from license_expression import Licensing
4142
from packageurl import PackageURL
4243
from univers.version_range import VersionRange
4344
from univers.versions import Version
@@ -117,22 +118,28 @@ class AffectedPackage:
117118
"""
118119
Contains a range of affected versions and a fixed version of a given package
119120
The PackageURL supplied must *not* have a version
121+
It must contain either `affected_version_range` or `fixed_version`
120122
"""
121123

122124
package: PackageURL
123-
affected_version_range: VersionRange
125+
affected_version_range: Optional[VersionRange] = None
124126
fixed_version: Optional[Version] = None
125127

126128
def __post_init__(self):
127129
if self.package.version:
128-
raise ValueError
130+
raise ValueError("The PackageURL supplied must not have a version")
131+
if not (self.affected_version_range or self.fixed_version):
132+
raise ValueError(
133+
"Affected Package should at least have either a fixed version or affected version range"
134+
)
129135

130136
def get_fixed_purl(self):
131137
"""
132138
Return PackageURL corresponding to object's fixed_version
133139
"""
134-
fixed_version = self.fixed_version
135-
fixed_purl = self.package._replace(version=str(fixed_version))
140+
if not self.fixed_version:
141+
raise ValueError("Affected package should have a fixed version")
142+
fixed_purl = self.package._replace(version=str(self.fixed_version))
136143
return fixed_purl
137144

138145
@classmethod
@@ -203,7 +210,7 @@ class AdvisoryData:
203210
"""
204211

205212
aliases: List[str] = dataclasses.field(default_factory=list)
206-
summary: str = None
213+
summary: Optional[str] = None
207214
affected_packages: List[AffectedPackage] = dataclasses.field(default_factory=list)
208215
references: List[Reference] = dataclasses.field(default_factory=list)
209216
date_published: Optional[datetime.datetime] = None
@@ -224,10 +231,18 @@ class Importer:
224231
"""
225232

226233
spdx_license_expression = ""
234+
license_url = ""
227235

228236
def __init__(self):
229237
if not self.spdx_license_expression:
230238
raise Exception(f"Cannot run importer {self!r} without a license")
239+
licensing = Licensing()
240+
try:
241+
licensing.parse(self.spdx_license_expression)
242+
except Exception as e:
243+
raise Exception(
244+
f"{self.spdx_license_expression!r} is not a valid SPDX license expression"
245+
) from e
231246

232247
@classproperty
233248
def qualified_name(cls):

vulnerabilities/importers/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@
1919
# for any legal advice.
2020
# VulnerableCode is a free software code scanning tool from nexB Inc. and others.
2121
# Visit https://github.com/nexB/vulnerablecode/ for support and download.
22+
from vulnerabilities.importers import alpine_linux
2223
from vulnerabilities.importers import nginx
2324

24-
IMPORTERS_REGISTRY = [nginx.NginxImporter]
25+
IMPORTERS_REGISTRY = [nginx.NginxImporter, alpine_linux.AlpineImporter]
2526

2627
IMPORTERS_REGISTRY = {x.qualified_name: x for x in IMPORTERS_REGISTRY}

vulnerabilities/importers/alpine_linux.py

Lines changed: 161 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -21,123 +21,190 @@
2121
# VulnerableCode is a free software code scanning tool from nexB Inc. and others.
2222
# Visit https://github.com/nexB/vulnerablecode/ for support and download.
2323
from typing import Any
24+
from typing import Iterable
2425
from typing import List
2526
from typing import Mapping
26-
from typing import Set
2727

2828
import requests
29-
import saneyaml
3029
from bs4 import BeautifulSoup
30+
from packageurl import PackageURL
31+
from univers.versions import AlpineLinuxVersion
3132

3233
from vulnerabilities.helpers import is_cve
33-
from vulnerabilities.importer import Advisory
34+
from vulnerabilities.importer import AdvisoryData
35+
from vulnerabilities.importer import AffectedPackage
3436
from vulnerabilities.importer import Importer
35-
from vulnerabilities.importer import Reference
37+
from vulnerabilities.references import WireSharkReference
38+
from vulnerabilities.references import XsaReference
39+
from vulnerabilities.references import ZbxReference
3640

3741
BASE_URL = "https://secdb.alpinelinux.org/"
3842

3943

4044
class AlpineImporter(Importer):
41-
@staticmethod
42-
def fetch_advisory_links():
43-
index_page = BeautifulSoup(requests.get(BASE_URL).content, features="lxml")
44-
45-
alpine_versions = [
46-
link.text for link in index_page.find_all("a") if link.text.startswith("v")
47-
]
48-
49-
advisory_directory_links = [f"{BASE_URL}{version}" for version in alpine_versions]
45+
spdx_license_expression = "CC-BY-SA-4.0"
46+
license_url = "https://secdb.alpinelinux.org/license.txt"
5047

48+
def advisory_data(self) -> Iterable[AdvisoryData]:
49+
advisories = []
50+
page_response_content = fetch_response(BASE_URL).content
51+
advisory_directory_links = fetch_advisory_directory_links(page_response_content)
5152
advisory_links = []
5253
for advisory_directory_link in advisory_directory_links:
53-
advisory_directory_page = requests.get(advisory_directory_link).content
54-
advisory_directory_page = BeautifulSoup(advisory_directory_page, features="lxml")
54+
advisory_directory_page = fetch_response(advisory_directory_link).content
5555
advisory_links.extend(
56-
[
57-
f"{advisory_directory_link}{anchore_tag.text}"
58-
for anchore_tag in advisory_directory_page.find_all("a")
59-
if anchore_tag.text.endswith("yaml")
60-
]
56+
fetch_advisory_links(advisory_directory_page, advisory_directory_link)
6157
)
62-
63-
return advisory_links
64-
65-
def updated_advisories(self) -> Set[Advisory]:
66-
advisories = []
67-
advisory_links = self.fetch_advisory_links()
6858
for link in advisory_links:
69-
advisories.extend(self._process_link(link))
70-
71-
return self.batch_advisories(advisories)
72-
73-
def _process_link(self, link) -> List[Advisory]:
74-
advisories = []
75-
yaml_response = requests.get(link).content
76-
record = saneyaml.load(yaml_response)
77-
78-
if record["packages"] is None:
79-
return advisories
80-
81-
for p in record["packages"]:
82-
advisories.extend(
83-
self._load_advisories(
84-
p["pkg"],
85-
)
86-
)
87-
59+
record = fetch_response(link).json()
60+
if not record["packages"]:
61+
raise Exception(f'"packages" not found in {link}')
62+
advisories.extend(process_record(record))
8863
return advisories
8964

90-
@staticmethod
91-
def _load_advisories(
92-
pkg_infos: Mapping[str, Any],
93-
) -> List[Advisory]:
9465

95-
advisories = []
96-
97-
for fixed_vulns in pkg_infos["secfixes"].values():
98-
99-
if fixed_vulns is None:
100-
continue
101-
102-
for vuln_ids in fixed_vulns:
103-
vuln_ids = vuln_ids.split()
104-
references = []
105-
for reference_id in vuln_ids[1:]:
106-
107-
if reference_id.startswith("XSA"):
108-
xsa_id = reference_id.split("-")[-1]
109-
references.append(
110-
Reference(
111-
reference_id=reference_id,
112-
url="https://xenbits.xen.org/xsa/advisory-{}.html".format(xsa_id),
113-
)
114-
)
115-
116-
elif reference_id.startswith("ZBX"):
117-
references.append(
118-
Reference(
119-
reference_id=reference_id,
120-
url="https://support.zabbix.com/browse/{}".format(reference_id),
121-
)
66+
def fetch_response(url):
67+
"""
68+
Fetch and return `response` from the `url`
69+
"""
70+
response = requests.get(url)
71+
if response.status_code == 200:
72+
return response
73+
raise Exception("Failed to fetch data from the URL")
74+
75+
76+
def fetch_advisory_directory_links(page_response_content):
77+
"""
78+
Return `advisory_directory_links` present in `index_page`
79+
"""
80+
index_page = BeautifulSoup(page_response_content, features="lxml")
81+
alpine_versions = [link.text for link in index_page.find_all("a") if link.text.startswith("v")]
82+
83+
assert alpine_versions, f"No versions found in {BASE_URL}"
84+
85+
advisory_directory_links = [f"{BASE_URL}{version}" for version in alpine_versions]
86+
87+
return advisory_directory_links
88+
89+
90+
def fetch_advisory_links(advisory_directory_page, advisory_directory_link):
91+
"""
92+
Yield json file urls present in `advisory_directory_page`
93+
"""
94+
advisory_directory_page = BeautifulSoup(advisory_directory_page, features="lxml")
95+
anchor_tags = advisory_directory_page.find_all("a")
96+
assert anchor_tags, f"No anchor tags found in {advisory_directory_link}"
97+
for anchor_tag in anchor_tags:
98+
if anchor_tag.text.endswith("json"):
99+
yield f"{advisory_directory_link}{anchor_tag.text}"
100+
101+
102+
def process_record(record: dict) -> List[AdvisoryData]:
103+
"""
104+
Return a list of AdvisoryData objects by processing data
105+
present in that `link`
106+
"""
107+
108+
advisories: List[AdvisoryData] = []
109+
110+
for package in record["packages"]:
111+
assert package["pkg"]
112+
attributes = ["distroversion", "reponame", "archs"]
113+
for attribute in attributes:
114+
assert attribute in record
115+
loaded_advisories = load_advisories(
116+
package["pkg"],
117+
record["distroversion"],
118+
record["reponame"],
119+
record["archs"],
120+
)
121+
advisories.extend(loaded_advisories)
122+
return advisories
123+
124+
125+
def load_advisories(
126+
pkg_infos: Mapping[str, Any],
127+
distroversion: str,
128+
reponame: str,
129+
archs: Iterable[str],
130+
) -> Iterable[AdvisoryData]:
131+
"""
132+
Yields AdvisoryData by mapping data from `pkg_infos`
133+
and form PURL for AffectedPackages by using
134+
`distroversion`, `reponame`, `archs`
135+
"""
136+
137+
if not pkg_infos.get("name"):
138+
raise Exception('"name" is not available in package')
139+
140+
for version, fixed_vulns in pkg_infos["secfixes"].items():
141+
142+
if fixed_vulns is None:
143+
raise Exception(f"No fixed vulnerabilities in {version}")
144+
145+
for vuln_ids in fixed_vulns:
146+
assert isinstance(vuln_ids, str)
147+
vuln_ids = vuln_ids.split()
148+
aliases = []
149+
vuln_id = vuln_ids[0]
150+
# check for valid vuln ID, if there is valid vuln ID then iterate over
151+
# the remaining elements of the list else iterate over the whole list
152+
# and also check if the initial element is a reference or not
153+
if is_cve(vuln_id):
154+
aliases = [vuln_id]
155+
vuln_ids = vuln_ids[1:]
156+
references = []
157+
for reference_id in vuln_ids:
158+
159+
if reference_id.startswith("XSA"):
160+
references.append(XsaReference.from_id(xsa_id=reference_id))
161+
162+
elif reference_id.startswith("ZBX"):
163+
references.append(ZbxReference.from_id(zbx_id=reference_id))
164+
165+
elif reference_id.startswith("wnpa-sec"):
166+
references.append(WireSharkReference.from_id(wnpa_sec_id=reference_id))
167+
168+
qualifiers = {
169+
"distroversion": distroversion,
170+
"reponame": reponame,
171+
}
172+
173+
affected_packages = []
174+
175+
try:
176+
fixed_version = AlpineLinuxVersion(version)
177+
except Exception as e:
178+
raise Exception(f"{version!r} Not a valid Alpine Version") from e
179+
assert isinstance(archs, List)
180+
if archs:
181+
for arch in archs:
182+
qualifiers["arch"] = arch
183+
affected_packages.append(
184+
AffectedPackage(
185+
package=PackageURL(
186+
type="alpine",
187+
name=pkg_infos["name"],
188+
qualifiers=qualifiers,
189+
),
190+
fixed_version=fixed_version,
122191
)
123-
124-
elif reference_id.startswith("wnpa-sec"):
125-
references.append(
126-
Reference(
127-
reference_id=reference_id,
128-
url="https://www.wireshark.org/security/{}.html".format(
129-
reference_id
130-
),
131-
)
132-
)
133-
134-
# TODO: Handle the CVE-????-????? case
135-
advisories.append(
136-
Advisory(
137-
summary="",
138-
references=references,
139-
vulnerability_id=vuln_ids[0] if is_cve(vuln_ids[0]) else "",
192+
)
193+
else:
194+
# there is no arch, this is not an arch-specific package
195+
affected_packages.append(
196+
AffectedPackage(
197+
package=PackageURL(
198+
type="alpine",
199+
name=pkg_infos["name"],
200+
qualifiers=qualifiers,
201+
),
202+
fixed_version=fixed_version,
140203
)
141204
)
142205

143-
return advisories
206+
yield AdvisoryData(
207+
references=references,
208+
affected_packages=affected_packages,
209+
aliases=aliases,
210+
)

vulnerabilities/importers/nginx.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
# for any legal advice.
2020
# VulnerableCode is a free software tool from nexB Inc. and others.
2121
# Visit https://github.com/nexB/vulnerablecode/ for support and download.
22-
2322
import asyncio
2423
import dataclasses
2524
import datetime

0 commit comments

Comments
 (0)