Skip to content

Commit aded87c

Browse files
authored
Merge pull request #188 from sbs2001/ubuntu_oval_rewrite
Ubuntu oval rewrite
2 parents 75a4867 + 4e19a12 commit aded87c

11 files changed

Lines changed: 485 additions & 121 deletions

File tree

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
aiohttp==3.6.2
12
asgiref==3.2.7
23
attrs==19.3.0
34
backcall==0.1.0

vulnerabilities/data_source.py

Lines changed: 137 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,13 @@
3535
from typing import Sequence
3636
from typing import Set
3737
from typing import Tuple
38+
import xml.etree.ElementTree as ET
3839

3940
import pygit2
4041
from packageurl import PackageURL
4142

43+
from vulnerabilities.oval_parser import OvalParser
44+
4245

4346
@dataclasses.dataclass
4447
class Advisory:
@@ -257,7 +260,8 @@ def file_changes(
257260

258261
return {str(p) for p in path.glob(glob) if p.is_file()}, set()
259262

260-
return self._collect_file_changes(subdir=subdir, recursive=recursive, file_ext=file_ext)
263+
return self._collect_file_changes(
264+
subdir=subdir, recursive=recursive, file_ext=file_ext)
261265

262266
def _collect_file_changes(
263267
self,
@@ -269,7 +273,8 @@ def _collect_file_changes(
269273
previous_commit = None
270274
added_files, updated_files = set(), set()
271275

272-
for commit in self._repo.walk(self._repo.head.target, pygit2.GIT_SORT_TIME):
276+
for commit in self._repo.walk(
277+
self._repo.head.target, pygit2.GIT_SORT_TIME):
273278
commit_time = commit.commit_time + commit.commit_time_offset # convert to UTC
274279

275280
if commit_time < self.cutoff_timestamp:
@@ -280,13 +285,16 @@ def _collect_file_changes(
280285
continue
281286

282287
for d in commit.tree.diff_to_tree(previous_commit.tree).deltas:
283-
if not _include_file(d.new_file.path, subdir, recursive, file_ext) or d.is_binary:
288+
if not _include_file(
289+
d.new_file.path, subdir, recursive, file_ext) or d.is_binary:
284290
continue
285291

286-
abspath = os.path.join(self.config.working_directory, d.new_file.path)
292+
abspath = os.path.join(
293+
self.config.working_directory, d.new_file.path)
287294
# TODO
288295
# Just filtering on the two status values for "added" and "modified" is too
289-
# simplistic. This does not cover file renames, copies & deletions.
296+
# simplistic. This does not cover file renames, copies &
297+
# deletions.
290298
if d.status == pygit2.GIT_DELTA_ADDED:
291299
added_files.add(abspath)
292300
elif d.status == pygit2.GIT_DELTA_MODIFIED:
@@ -381,3 +389,127 @@ def _include_file(
381389
match = match and path.endswith(f'.{file_ext}')
382390

383391
return match
392+
393+
394+
class OvalDataSource(DataSource):
395+
"""
396+
All data sources which collect data from OVAL files must inherit from this
397+
`OvalDataSource` class. Subclasses must implement the methods `_fetch` and `set_api`.
398+
"""
399+
@staticmethod
400+
def create_purl(pkg_name: str, pkg_version: str, pkg_data: Mapping) -> PackageURL:
401+
"""
402+
Helper method for creating different purls for subclasses without them reimplementing
403+
get_data_from_xml_doc method
404+
Note: pkg_data must include 'type' of package
405+
"""
406+
return PackageURL(name=pkg_name, version=pkg_version, **pkg_data)
407+
408+
@staticmethod
409+
def _collect_pkgs(parsed_oval_data: Mapping) -> Set:
410+
"""
411+
Helper method, used for loading the API. It expects data from
412+
OvalParser.get_data().
413+
"""
414+
all_pkgs = set()
415+
for definition_data in parsed_oval_data:
416+
for test_data in definition_data['test_data']:
417+
for package in test_data['package_list']:
418+
all_pkgs.add(package)
419+
420+
return all_pkgs
421+
422+
def _fetch() -> Tuple[Mapping, Iterable[ET.ElementTree]]:
423+
"""
424+
This method contains logic to fetch OVAL files and yield them into
425+
a tuple of file's metadata and it's ET.ElementTree.
426+
Subclasses must implement this method.
427+
428+
Note: Mapping MUST INCLUDE "type" key. Example values of Mapping
429+
{"type":"deb","qualifiers":{"distro":"buster"} }
430+
431+
"""
432+
raise NotImplementedError
433+
434+
def updated_advisories(self) -> List[Advisory]:
435+
"""
436+
Note: metadata MUST INCLUDE "type" key, implement _fetch accordingly.
437+
"""
438+
advisories = []
439+
for metadata, oval_file in self._fetch():
440+
advisories.extend(self.get_data_from_xml_doc(oval_file, metadata))
441+
return self.batch_advisories(advisories)
442+
443+
def set_api(self, all_pkgs: Iterable[str]):
444+
"""
445+
This method loads the self.pkg_manager_api with the specified packages. It fetches
446+
and caches all the versions of these packages and exposes them through
447+
self.pkg_manager_api.get(<package_name>). Example
448+
449+
>>> self.set_api(['electron'])
450+
Assume 'electron' has only versions 1.0.0 and 1.2.0
451+
>>> assert self.pkg_manager_api.get('electron') == {'1.0.0','1.2.0'}
452+
453+
"""
454+
raise NotImplementedError
455+
456+
def get_data_from_xml_doc(self, xml_doc: ET.ElementTree, pkg_metadata={}) -> List[Advisory]:
457+
"""
458+
The orchestration method of the OvalDataSource. This method breaks an OVAL xml
459+
ElementTree into a list of `Advisory`.
460+
461+
Note: pkg_metadata MUST INCLUDE "type" key. Example value of pkg_metadata,
462+
{"type":"deb","qualifiers":{"distro":"buster"} }
463+
"""
464+
all_adv = []
465+
oval_doc = OvalParser(self.translations, xml_doc)
466+
raw_data = oval_doc.get_data()
467+
all_pkgs = self._collect_pkgs(raw_data)
468+
self.set_api(all_pkgs)
469+
for definition_data in raw_data: # definition_data -> Advisory
470+
471+
# These fields are definition level, i.e common for all
472+
# elements connected/linked to an OvalDefinition
473+
vuln_id = definition_data['vuln_id']
474+
description = definition_data['description']
475+
affected_purls = set()
476+
safe_purls = set()
477+
urls = definition_data['reference_urls']
478+
479+
for test_data in definition_data['test_data']:
480+
for package in test_data['package_list']:
481+
pkg_name = package
482+
aff_ver_range = test_data['version_ranges']
483+
all_versions = self.pkg_manager_api.get(package)
484+
# This filter is for filtering out long versions.
485+
# 50 is limit because that's what db permits atm.
486+
all_versions = set(
487+
filter(
488+
lambda x: len(x) < 50,
489+
all_versions))
490+
if not all_versions:
491+
continue
492+
affected_versions = set(
493+
filter(
494+
lambda x: x in aff_ver_range,
495+
all_versions))
496+
safe_versions = all_versions - affected_versions
497+
498+
for version in affected_versions:
499+
pkg_url = self.create_purl(
500+
pkg_name=pkg_name, pkg_version=version, pkg_data=pkg_metadata)
501+
affected_purls.add(pkg_url)
502+
503+
for version in safe_versions:
504+
pkg_url = self.create_purl(
505+
pkg_name=pkg_name, pkg_version=version, pkg_data=pkg_metadata)
506+
safe_purls.add(pkg_url)
507+
508+
all_adv.append(
509+
Advisory(
510+
summary=description,
511+
impacted_package_urls=affected_purls,
512+
resolved_package_urls=safe_purls,
513+
cve_id=vuln_id,
514+
reference_urls=urls))
515+
return all_adv

vulnerabilities/importers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@
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.
2222

23+
2324
from vulnerabilities.importers.alpine_linux import AlpineDataSource
2425
from vulnerabilities.importers.archlinux import ArchlinuxDataSource
2526
from vulnerabilities.importers.debian import DebianDataSource
2627
from vulnerabilities.importers.npm import NpmDataSource
2728
from vulnerabilities.importers.rust import RustDataSource
2829
from vulnerabilities.importers.safety_db import SafetyDbDataSource
2930
from vulnerabilities.importers.ruby import RubyDataSource
31+
from vulnerabilities.importers.ubuntu import UbuntuDataSource
Lines changed: 99 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
#
21
# Copyright (c) 2017 nexB Inc. and others. All rights reserved.
32
# http://nexb.com and https://github.com/nexB/vulnerablecode/
43
# The VulnerableCode software is licensed under the Apache License version 2.0.
@@ -21,36 +20,112 @@
2120
# VulnerableCode is a free software code scanning tool from nexB Inc. and others.
2221
# Visit https://github.com/nexB/vulnerablecode/ for support and download.
2322

24-
from urllib.request import urlopen
2523

26-
import bs4
24+
import asyncio
25+
import bz2
26+
import dataclasses
27+
from typing import Iterable
28+
from typing import List
29+
from typing import Mapping
30+
from typing import Set
31+
import xml.etree.ElementTree as ET
32+
33+
34+
from aiohttp import ClientSession
35+
from aiohttp.client_exceptions import ClientResponseError
36+
import requests
37+
38+
39+
from vulnerabilities.data_source import OvalDataSource, DataSourceConfiguration
40+
41+
42+
@dataclasses.dataclass
43+
class UbuntuConfiguration(DataSourceConfiguration):
44+
releases: list
45+
etags: dict
46+
47+
48+
class UbuntuDataSource(OvalDataSource):
49+
50+
CONFIG_CLASS = UbuntuConfiguration
51+
52+
def __init__(self, *args, **kwargs):
53+
super().__init__(*args, **kwargs)
54+
# we could avoid setting translations, and have it
55+
# set by default in the OvalParser, but we don't yet know
56+
# whether all OVAL providers use the same format
57+
self.translations = {'less than': '<'}
58+
self.pkg_manager_api = VersionAPI()
2759

60+
def _fetch(self):
61+
base_url = 'https://people.canonical.com/~ubuntu-security/oval/'
62+
file_name = 'com.ubuntu.{}.cve.oval.xml.bz2'
63+
releases = self.config.releases
64+
for release in releases:
65+
file_url = base_url + file_name.format(release)
66+
if not self.create_etag(file_url):
67+
continue
68+
resp = requests.get(file_url)
69+
extracted = bz2.decompress(resp.content)
70+
yield (
71+
{'type': 'deb', 'namespace': 'ubuntu'},
72+
ET.ElementTree(ET.fromstring(extracted.decode('utf-8')))
73+
)
74+
# In case every file is latest, _fetch won't yield anything(due to checking for new etags),
75+
# this would return None to added_advisories
76+
# which will cause error, hence this
77+
# function return an empty list
78+
return []
2879

29-
UBUNTU_ROOT_URL = 'https://people.canonical.com/~ubuntu-security/cve/main.html'
80+
def set_api(self, packages):
81+
asyncio.run(self.pkg_manager_api.load_api(packages))
3082

83+
def create_etag(self, url):
3184

32-
def extract_cves(html):
33-
soup = bs4.BeautifulSoup(html, 'lxml')
85+
etag = requests.head(url).headers.get('ETag')
86+
if not etag:
87+
# Kind of inaccurate to return True since etag is
88+
# not created
89+
return True
90+
elif url in self.config.etags:
91+
if self.config.etags[url] == etag:
92+
return False
93+
self.config.etags[url] = etag
94+
return True
3495

35-
# Exclude the header row which has no class attribute
36-
rows = soup.find_all('tr', attrs={'class': True})
3796

38-
cves = []
39-
for row in rows:
40-
columns = row.text.split()
41-
cves.append({
42-
'cve_id': columns[0],
43-
'package_name': columns[1],
44-
'vulnerability_status': row.get('class')[0],
45-
})
97+
class VersionAPI:
98+
def __init__(self, cache: Mapping[str, Set[str]] = None):
99+
self.cache = cache or {}
46100

47-
return cves
101+
def get(self, package_name: str) -> Set[str]:
102+
return self.cache[package_name]
48103

104+
async def load_api(self, pkg_set):
105+
async with ClientSession(raise_for_status=True) as session:
106+
await asyncio.gather(*[self.set_api(pkg, session)
107+
for pkg in pkg_set if pkg not in self.cache])
49108

50-
def scrape_cves():
51-
"""
52-
Runs the full scraping process of Ubuntu CVEs.
53-
"""
54-
html = urlopen(UBUNTU_ROOT_URL).read()
55-
cves = extract_cves(html)
56-
return cves
109+
async def set_api(self, pkg, session):
110+
if pkg in self.cache:
111+
return
112+
url = ('https://api.launchpad.net/1.0/ubuntu/+archive/'
113+
'primary?ws.op=getPublishedSources&'
114+
'source_name={}&exact_match=true'.format(pkg))
115+
try:
116+
all_versions = set()
117+
while True:
118+
response = await session.request(method='GET', url=url)
119+
resp_json = await response.json()
120+
if resp_json['entries'] == []:
121+
self.cache[pkg] = {}
122+
break
123+
for release in resp_json['entries']:
124+
all_versions.add(release['source_package_version'])
125+
if resp_json.get('next_collection_link'):
126+
url = resp_json['next_collection_link']
127+
else:
128+
break
129+
self.cache[pkg] = all_versions
130+
except (ClientResponseError, asyncio.exceptions.TimeoutError):
131+
self.cache[pkg] = {}

0 commit comments

Comments
 (0)