Skip to content

Commit 2c50219

Browse files
committed
Add ruby importer
Fix style test Fix test Rewrite affected_packages Ruby initial config Reference: #796 Signed-off-by: ziadhany <[email protected]>
1 parent 67558ec commit 2c50219

17 files changed

+425
-253
lines changed

vulnerabilities/importers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from vulnerabilities.importers import pysec
2929
from vulnerabilities.importers import redhat
3030
from vulnerabilities.importers import retiredotnet
31+
from vulnerabilities.importers import ruby
3132
from vulnerabilities.importers import suse_scores
3233
from vulnerabilities.importers import ubuntu
3334

@@ -55,6 +56,7 @@
5556
project_kb_msr2019.ProjectKBMSRImporter,
5657
suse_scores.SUSESeverityScoreImporter,
5758
elixir_security.ElixirSecurityImporter,
59+
ruby.RubyImporter,
5860
]
5961

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

vulnerabilities/importers/ruby.py

Lines changed: 164 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -7,132 +7,189 @@
77
# See https://aboutcode.org for more information about nexB OSS projects.
88
#
99

10-
import asyncio
11-
from typing import List
12-
from typing import Set
10+
import logging
11+
from pathlib import Path
12+
from typing import Iterable
1313

1414
from dateutil.parser import parse
15+
from django.db.models import QuerySet
1516
from packageurl import PackageURL
1617
from pytz import UTC
17-
from univers.version_range import VersionRange
18-
from univers.versions import SemverVersion
18+
from univers.version_range import GemVersionRange
19+
from univers.versions import RubygemsVersion
1920

2021
from vulnerabilities.importer import AdvisoryData
22+
from vulnerabilities.importer import AffectedPackage
2123
from vulnerabilities.importer import GitImporter
2224
from vulnerabilities.importer import Reference
25+
from vulnerabilities.importer import VulnerabilitySeverity
26+
from vulnerabilities.improver import Improver
27+
from vulnerabilities.improver import Inference
28+
from vulnerabilities.models import Advisory
2329
from vulnerabilities.package_managers import RubyVersionAPI
30+
from vulnerabilities.severity_systems import SCORING_SYSTEMS
31+
from vulnerabilities.utils import build_description
32+
from vulnerabilities.utils import evolve_purl
2433
from vulnerabilities.utils import load_yaml
25-
from vulnerabilities.utils import nearest_patched_package
2634

35+
logger = logging.getLogger(__name__)
2736

28-
class RubyImporter(GitImporter):
29-
def __enter__(self):
30-
super(RubyImporter, self).__enter__()
31-
32-
if not getattr(self, "_added_files", None):
33-
self._added_files, self._updated_files = self.file_changes(
34-
recursive=True, file_ext="yml", subdir="./gems"
35-
)
3637

37-
self.pkg_manager_api = RubyVersionAPI()
38-
self.set_api(self.collect_packages())
39-
40-
def set_api(self, packages):
41-
asyncio.run(self.pkg_manager_api.load_api(packages))
42-
43-
def updated_advisories(self) -> Set[AdvisoryData]:
44-
files = self._updated_files.union(self._added_files)
45-
advisories = []
46-
for f in files:
47-
processed_data = self.process_file(f)
48-
if processed_data:
49-
advisories.append(processed_data)
50-
return self.batch_advisories(advisories)
51-
52-
def collect_packages(self):
53-
packages = set()
54-
files = self._updated_files.union(self._added_files)
55-
for f in files:
56-
data = load_yaml(f)
57-
if data.get("gem"):
58-
packages.add(data["gem"])
59-
60-
return packages
61-
62-
def process_file(self, path) -> List[AdvisoryData]:
63-
record = load_yaml(path)
38+
class RubyImporter(GitImporter):
39+
license_url = "https://github.com/rubysec/ruby-advisory-db/blob/master/LICENSE.txt"
40+
spdx_license_expression = "unknown"
41+
42+
def __init__(self):
43+
super().__init__(repo_url="git+https://github.com/rubysec/ruby-advisory-db")
44+
45+
def advisory_data(self) -> Iterable[AdvisoryData]:
46+
self.clone()
47+
base_path = Path(self.vcs_response.dest_dir)
48+
supported_subdir = ["rubies", "gems"]
49+
for subdir in supported_subdir:
50+
for file_path in base_path.glob(f"{subdir}/**/*.yml"):
51+
raw_data = load_yaml(file_path)
52+
yield parse_ruby_advisory(raw_data, subdir)
53+
54+
55+
def parse_ruby_advisory(record, schema_type):
56+
"""
57+
Parse a ruby advisory file and return an AdvisoryData or None.
58+
Each advisory file contains the advisory information in YAML format.
59+
Schema: https://github.com/rubysec/ruby-advisory-db/tree/master/spec/schemas
60+
"""
61+
if schema_type == "gems":
6462
package_name = record.get("gem")
65-
if not package_name:
66-
return
67-
68-
if "cve" in record:
69-
cve_id = "CVE-{}".format(record["cve"])
70-
else:
71-
return
72-
73-
publish_time = parse(record["date"]).replace(tzinfo=UTC)
74-
safe_version_ranges = record.get("patched_versions", [])
75-
# this case happens when the advisory contain only 'patched_versions' field
76-
# and it has value None(i.e it is empty :( ).
77-
if not safe_version_ranges:
78-
safe_version_ranges = []
79-
safe_version_ranges += record.get("unaffected_versions", [])
80-
safe_version_ranges = [i for i in safe_version_ranges if i]
81-
82-
if not getattr(self, "pkg_manager_api", None):
83-
self.pkg_manager_api = RubyVersionAPI()
84-
all_vers = self.pkg_manager_api.get(package_name, until=publish_time).valid_versions
85-
safe_versions, affected_versions = self.categorize_versions(all_vers, safe_version_ranges)
86-
87-
impacted_purls = [
88-
PackageURL(
89-
name=package_name,
90-
type="gem",
91-
version=version,
92-
)
93-
for version in affected_versions
94-
]
95-
96-
resolved_purls = [
97-
PackageURL(
98-
name=package_name,
99-
type="gem",
100-
version=version,
101-
)
102-
for version in safe_versions
103-
]
63+
library = record.get("library")
64+
framework = record.get("framework")
65+
platform = record.get("platform")
66+
purl = PackageURL(type="gem", name=package_name)
10467

105-
references = []
106-
if record.get("url"):
107-
references.append(Reference(url=record.get("url")))
68+
return AdvisoryData(
69+
aliases=get_aliases(record),
70+
summary=get_summary(record),
71+
affected_packages=get_affected_packages(record, purl),
72+
references=get_references(record),
73+
date_published=get_publish_time(record),
74+
)
10875

76+
elif schema_type == "rubies":
77+
engine = record.get("engine") # engine enum: [jruby, rbx, ruby]
78+
purl = PackageURL(type="ruby", name=engine)
10979
return AdvisoryData(
110-
summary=record.get("description", ""),
111-
affected_packages=nearest_patched_package(impacted_purls, resolved_purls),
112-
references=references,
113-
vulnerability_id=cve_id,
80+
aliases=get_aliases(record),
81+
summary=get_summary(record),
82+
affected_packages=get_affected_packages(record, purl),
83+
references=get_references(record),
84+
date_published=get_publish_time(record),
11485
)
11586

116-
@staticmethod
117-
def categorize_versions(all_versions, unaffected_version_ranges):
11887

119-
for id, elem in enumerate(unaffected_version_ranges):
120-
unaffected_version_ranges[id] = VersionRange.from_scheme_version_spec_string(
121-
"semver", elem
88+
def get_affected_packages(record, purl):
89+
safe_version_ranges = record.get("patched_versions", [])
90+
# this case happens when the advisory contain only 'patched_versions' field
91+
# and it has value None(i.e it is empty :( ).
92+
if not safe_version_ranges:
93+
safe_version_ranges = []
94+
safe_version_ranges += record.get("unaffected_versions", [])
95+
safe_version_ranges = [i for i in safe_version_ranges if i]
96+
97+
affected_packages = []
98+
affected_version_ranges = [
99+
GemVersionRange.from_native(elem).invert() for elem in safe_version_ranges
100+
]
101+
102+
for affected_version_range in affected_version_ranges:
103+
affected_packages.append(
104+
AffectedPackage(
105+
package=purl,
106+
affected_version_range=affected_version_range,
122107
)
108+
)
109+
return affected_packages
110+
111+
112+
def get_aliases(record) -> [str]:
113+
aliases = []
114+
if record.get("cve"):
115+
aliases.append("CVE-{}".format(record.get("cve")))
116+
if record.get("osvdb"):
117+
aliases.append("OSV-{}".format(record.get("osvdb")))
118+
if record.get("ghsa"):
119+
aliases.append("GHSA-{}".format(record.get("ghsa")))
120+
return aliases
121+
123122

124-
safe_versions = []
125-
vulnerable_versions = []
126-
for i in all_versions:
127-
vobj = SemverVersion(i)
128-
is_vulnerable = False
129-
for ver_rng in unaffected_version_ranges:
130-
if vobj in ver_rng:
131-
safe_versions.append(i)
132-
is_vulnerable = True
133-
break
134-
135-
if not is_vulnerable:
136-
vulnerable_versions.append(i)
137-
138-
return safe_versions, vulnerable_versions
123+
def get_references(record) -> [Reference]:
124+
references = []
125+
cvss_v2 = record.get("cvss_v2")
126+
cvss_v3 = record.get("cvss_v3")
127+
128+
if record.get("url"):
129+
if not (cvss_v2 or cvss_v3):
130+
references.append(Reference(url=record.get("url")))
131+
if cvss_v2:
132+
references.append(
133+
Reference(
134+
url=record.get("url"),
135+
severities=[
136+
VulnerabilitySeverity(system=SCORING_SYSTEMS["cvssv2"], value=cvss_v2)
137+
],
138+
)
139+
)
140+
if cvss_v3:
141+
references.append(
142+
Reference(
143+
url=record.get("url"),
144+
severities=[
145+
VulnerabilitySeverity(system=SCORING_SYSTEMS["cvssv3"], value=cvss_v3)
146+
],
147+
)
148+
)
149+
return references
150+
151+
152+
def get_publish_time(record):
153+
return parse(record["date"]).replace(tzinfo=UTC)
154+
155+
156+
def get_summary(record):
157+
title = record.get("title")
158+
description = record.get("description", "")
159+
return build_description(summary=title, description=description)
160+
161+
162+
class RubyImprover(Improver):
163+
pkg_manager_api = RubyVersionAPI()
164+
165+
@property
166+
def interesting_advisories(self) -> QuerySet:
167+
return Advisory.objects.filter(created_by=RubyImporter.qualified_name)
168+
169+
def get_inferences(self, advisory_data) -> Iterable[Inference]:
170+
for affected_package in advisory_data.affected_packages:
171+
purl = affected_package.package
172+
pkg_name = purl.name
173+
all_vers_pkgs = self.pkg_manager_api.fetch(pkg_name)
174+
175+
safe_versions = []
176+
affected_purls = []
177+
for pkg_version in all_vers_pkgs:
178+
vobj = RubygemsVersion(pkg_version.value)
179+
try:
180+
if vobj in affected_package.affected_version_range:
181+
new_purl = evolve_purl(purl=purl, version=str(pkg_version.value))
182+
affected_purls.append(new_purl)
183+
else:
184+
safe_versions.append(pkg_version.value)
185+
except Exception as e:
186+
logger.error(f"{e}")
187+
188+
for fixed_version in safe_versions:
189+
fixed_purl = evolve_purl(purl=purl, version=str(fixed_version))
190+
yield Inference.from_advisory_data(
191+
advisory_data,
192+
confidence=90,
193+
affected_purls=affected_purls,
194+
fixed_purl=fixed_purl,
195+
)

vulnerabilities/improvers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
importers.gitlab.GitLabBasicImprover,
2020
oval.DebianOvalBasicImprover,
2121
oval.UbuntuOvalBasicImprover,
22+
importers.ruby.RubyImprover,
2223
]
2324

2425
IMPROVERS_REGISTRY = {x.qualified_name: x for x in IMPROVERS_REGISTRY}

vulnerabilities/tests/conftest.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ def no_rmtree(monkeypatch):
3030
"test_api.py",
3131
"test_models.py",
3232
"test_package_managers.py",
33-
"test_ruby.py",
3433
"test_rust.py",
3534
"test_safety_db.py",
3635
"test_suse_backports.py",
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"aliases": [
3+
"CVE-2007-5770"
4+
],
5+
"summary": "Ruby Net::HTTPS library does not validate server certificate CN\nThe (1) Net::ftptls, (2) Net::telnets, (3) Net::imap, (4) Net::pop, and (5)\nNet::smtp libraries in Ruby 1.8.5 and 1.8.6 do not verify that the\ncommonName (CN) field in a server certificate matches the domain name in a\nrequest sent over SSL, which makes it easier for remote attackers to\nintercept SSL transmissions via a man-in-the-middle attack or spoofed web\nsite, different components than CVE-2007-5162.",
6+
"affected_packages": [
7+
{
8+
"package": {
9+
"type": "ruby",
10+
"namespace": null,
11+
"name": "ruby",
12+
"version": null,
13+
"qualifiers": null,
14+
"subpath": null
15+
},
16+
"affected_version_range": "vers:gem/<1.8.6.230|>=1.8.7",
17+
"fixed_version": null
18+
},
19+
{
20+
"package": {
21+
"type": "ruby",
22+
"namespace": null,
23+
"name": "ruby",
24+
"version": null,
25+
"qualifiers": null,
26+
"subpath": null
27+
},
28+
"affected_version_range": "vers:gem/<1.8.7",
29+
"fixed_version": null
30+
}
31+
],
32+
"references": [
33+
{
34+
"reference_id": "",
35+
"url": "http://www.cvedetails.com/cve/CVE-2007-5770/",
36+
"severities": [
37+
{
38+
"system": "cvssv2",
39+
"value": "4.3",
40+
"scoring_elements": ""
41+
}
42+
]
43+
}
44+
],
45+
"date_published": "2007-10-08T00:00:00+00:00"
46+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
engine: ruby
3+
cve: 2007-5770
4+
url: http://www.cvedetails.com/cve/CVE-2007-5770/
5+
title: Ruby Net::HTTPS library does not validate server certificate CN
6+
date: 2007-10-08
7+
description: |
8+
The (1) Net::ftptls, (2) Net::telnets, (3) Net::imap, (4) Net::pop, and (5)
9+
Net::smtp libraries in Ruby 1.8.5 and 1.8.6 do not verify that the
10+
commonName (CN) field in a server certificate matches the domain name in a
11+
request sent over SSL, which makes it easier for remote attackers to
12+
intercept SSL transmissions via a man-in-the-middle attack or spoofed web
13+
site, different components than CVE-2007-5162.
14+
cvss_v2: 4.3
15+
patched_versions:
16+
- ~> 1.8.6.230
17+
- '>= 1.8.7'

0 commit comments

Comments
 (0)