Skip to content

Commit

Permalink
Add PySafetyBear
Browse files Browse the repository at this point in the history
  • Loading branch information
underyx committed Nov 21, 2016
1 parent 76bb255 commit b4d2a55
Show file tree
Hide file tree
Showing 4 changed files with 160 additions and 0 deletions.
96 changes: 96 additions & 0 deletions bears/python/requirements/PySafetyBear.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
from collections import namedtuple
import pkg_resources
import re

from safety import safety

from coalib.bears.LocalBear import LocalBear
from coalib.bears.requirements.PipRequirement import PipRequirement
from coalib.results.Result import Result
from coalib.results.SourceRange import SourceRange
from coalib.settings.Setting import typed_list


# the safety module expects an object that looks like this
# (not importing it from there because it's in a private-ish location)
Package = namedtuple('Package', ('key', 'version'))


class PySafetyBear(LocalBear):
"""
Checks if any of your Python dependencies have known security issues.
Data is taken from pyup.io's vulnerability database hosted at
https://github.com/pyupio/safety.
"""

LANGUAGES = {
'Python Requirements',
'Python 2 Requirements',
'Python 3 Requirements',
}
AUTHORS = {'Bence Nagy'}
REQUIREMENTS = {PipRequirement('safety', '0.3.*')}
AUTHORS_EMAILS = {'[email protected]'}
LICENSE = 'AGPL'
CAN_DETECT = {'Security'}

def run(self, filename, file):
"""
Checks for vulnerable package versions in requirements files.
"""
packages = list(
Package(key=req.key, version=req.specs[0][1])
for req in self.try_parse_requirements(file)
if len(req.specs) == 1 and req.specs[0][0] == '=='
)

if not packages:
return

for vulnerability in safety.check(packages=packages):
if vulnerability.is_cve:
message_template = (
'{vuln.name}{vuln.spec} is vulnerable to {vuln.cve_id} '
'and your project is using {vuln.version}.'
)
else:
message_template = (
'{vuln.name}{vuln.spec} is vulnerable and your project is '
'using {vuln.version}.'
)

# StopIteration should not ever happen so skipping its branch
line_number, line = next( # pragma: no branch
(index, line) for index, line in enumerate(file, start=1)
if vulnerability.name in line
)
version_spec_match = re.search(r'[=<>]+(\S+?)(?:$|\s|#)', line)
source_range = SourceRange.from_values(
filename,
line_number,
version_spec_match.start(1) + 1,
line_number,
version_spec_match.end(1) + 1,
)

yield Result(
self,
message_template.format(vuln=vulnerability),
additional_info=vulnerability.description,
affected_code=(source_range, ),
)

@staticmethod
def try_parse_requirements(lines: typed_list(str)):
"""
Yields all package requirements parseable from the given lines.
:param lines: An iterable of lines from a requirements file.
"""
for line in lines:
try:
yield from pkg_resources.parse_requirements(line)
except pkg_resources.RequirementParseError:
# unsupported requirement specification
pass
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ pyflakes==1.2.* # Although we don't need this directly, solves a dep conflict
scspell3k==2.*
mypy-lang==0.4.*
rstcheck~=2.2
safety==0.3.*
63 changes: 63 additions & 0 deletions tests/python/requirements/PySafetyBearTest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import pytest
from queue import Queue
from unittest import mock

from safety.safety import Vulnerability

from bears.python.requirements.PySafetyBear import PySafetyBear, Package
from coalib.settings.Section import Section
from tests.LocalBearTestHelper import LocalBearTestHelper


class PySafetyBearTest(LocalBearTestHelper):

def setUp(self):
self.uut = PySafetyBear(Section('name'), Queue())

def test_without_vulnerability(self):
with mock.patch(
'bears.python.requirements.PySafetyBear.safety.check',
return_value=[],
) as check:
self.check_validity(self.uut, ['# whee', 'foo==1.0', '# whee'])
check.assert_called_once_with(packages=[Package('foo', '1.0')])

def test_with_vulnerability(self):
vuln_data = {
'description': 'foo',
'changelog': 'bar',
}
with mock.patch(
'bears.python.requirements.PySafetyBear.safety.check',
return_value=[Vulnerability('bar', '<0.2', '0.1', vuln_data)],
) as check:
self.check_validity(self.uut, ['foo<2', 'bar==0.1'], valid=False)
check.assert_called_once_with(packages=[Package('bar', '0.1')])

def test_with_cve_vulnerability(self):
vuln_data = {
'description': 'foo',
'cve': 'CVE-2016-9999',
}
with mock.patch(
'bears.python.requirements.PySafetyBear.safety.check',
return_value=[Vulnerability('baz', '<2.0', '1.10', vuln_data)],
) as check:
self.check_validity(self.uut, ['baz==1.10', '-e .'], valid=False)
check.assert_called_once_with(packages=[Package('baz', '1.10')])

def test_with_no_requirements(self):
with mock.patch(
'bears.python.requirements.PySafetyBear.safety.check',
return_value=[],
) as check:
self.check_validity(self.uut, [])
assert not check.called

def test_with_no_pinned_requirements(self):
with mock.patch(
'bears.python.requirements.PySafetyBear.safety.check',
return_value=[],
) as check:
self.check_validity(self.uut, ['foo', 'bar>2'])
assert not check.called
Empty file.

0 comments on commit b4d2a55

Please sign in to comment.