Skip to content

Commit 2e5b46b

Browse files
authored
Merge pull request #315 from pyupio/nicholas/packages-licenses
Package license information on Safety.
2 parents 47f22f9 + 41714d8 commit 2e5b46b

File tree

6 files changed

+259
-16
lines changed

6 files changed

+259
-16
lines changed

safety/cli.py

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
import click
55
from safety import __version__
66
from safety import safety
7-
from safety.formatter import report
7+
from safety.formatter import report, license_report
88
import itertools
9-
from safety.util import read_requirements, read_vulnerabilities
9+
from safety.util import read_requirements, read_vulnerabilities, get_proxy_dict, get_packages_licenses
1010
from safety.errors import DatabaseFetchError, DatabaseFileNotFoundError, InvalidKeyError
1111

1212
try:
@@ -66,20 +66,14 @@ def check(key, db, json, full_report, bare, stdin, files, cache, ignore, output,
6666
d for d in pkg_resources.working_set
6767
if d.key not in {"python", "wsgiref", "argparse"}
6868
]
69-
proxy_dictionary = {}
70-
if proxyhost is not None:
71-
if proxyprotocol in ["http", "https"]:
72-
proxy_dictionary = {proxyprotocol: "{0}://{1}:{2}".format(proxyprotocol, proxyhost, str(proxyport))}
73-
else:
74-
click.secho("Proxy Protocol should be http or https only.", fg="red")
75-
sys.exit(-1)
69+
proxy_dictionary = get_proxy_dict(proxyprotocol, proxyhost, proxyport)
7670
try:
7771
vulns = safety.check(packages=packages, key=key, db_mirror=db, cached=cache, ignore_ids=ignore, proxy=proxy_dictionary)
7872
output_report = report(vulns=vulns,
7973
full=full_report,
8074
json_report=json,
8175
bare_report=bare,
82-
checked_packages=len(packages),
76+
checked_packages=len(packages),
8377
db=db,
8478
key=key)
8579

@@ -128,5 +122,40 @@ def review(full_report, bare, file):
128122
click.secho(output_report, nl=False if bare and not vulns else True)
129123

130124

125+
@cli.command()
126+
@click.option("--key", default="", envvar="SAFETY_API_KEY",
127+
help="API Key for pyup.io's vulnerability database. Can be set as SAFETY_API_KEY "
128+
"environment variable. Default: empty")
129+
@click.option("--db", default="",
130+
help="Path to a local license database. Default: empty")
131+
@click.option("--cache/--no-cache", default=True,
132+
help='Whether license database file should be cached.'
133+
'Default: --cache')
134+
@click.option("files", "--file", "-r", multiple=True, type=click.File(),
135+
help="Read input from one (or multiple) requirement files. Default: empty")
136+
@click.option("proxyhost", "--proxy-host", "-ph", multiple=False, type=str, default=None,
137+
help="Proxy host IP or DNS --proxy-host")
138+
@click.option("proxyport", "--proxy-port", "-pp", multiple=False, type=int, default=80,
139+
help="Proxy port number --proxy-port")
140+
@click.option("proxyprotocol", "--proxy-protocol", "-pr", multiple=False, type=str, default='http',
141+
help="Proxy protocol (https or http) --proxy-protocol")
142+
def license(key, db, cache, files, proxyprotocol, proxyhost, proxyport):
143+
144+
if files:
145+
packages = list(itertools.chain.from_iterable(read_requirements(f, resolve=True) for f in files))
146+
else:
147+
import pkg_resources
148+
packages = [
149+
d for d in pkg_resources.working_set
150+
if d.key not in {"python", "wsgiref", "argparse"}
151+
]
152+
153+
proxy_dictionary = get_proxy_dict(proxyprotocol, proxyhost, proxyport)
154+
licenses_db = safety.get_licenses(key, db, cache, proxy_dictionary)
155+
filtered_packages_licenses = get_packages_licenses(packages, licenses_db)
156+
output_report = license_report(packages=packages, licenses=filtered_packages_licenses)
157+
click.secho(output_report, nl=True)
158+
159+
131160
if __name__ == "__main__":
132161
cli()

safety/formatter.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import os
66
import textwrap
77

8+
from .util import get_packages_licenses
9+
810
# python 2.7 compat
911
try:
1012
FileNotFoundError
@@ -69,6 +71,12 @@ class SheetReport(object):
6971
+============================+===========+==========================+==========+
7072
""".strip()
7173

74+
TABLE_HEADING_LICENSES = r"""
75+
+=============================================+===========+====================+
76+
| package | version | license |
77+
+=============================================+===========+====================+
78+
""".strip()
79+
7280
REPORT_HEADING = r"""
7381
| REPORT |
7482
""".strip()
@@ -125,6 +133,52 @@ def render(vulns, full, checked_packages, used_db):
125133
content, SheetReport.REPORT_FOOTER]
126134
)
127135

136+
@staticmethod
137+
def render_licenses(packages, packages_licenses):
138+
heading = SheetReport.REPORT_HEADING.replace(" ", "", 12).replace(
139+
"REPORT", " Packages licenses"
140+
)
141+
if not packages_licenses:
142+
content = "| {:76} |".format("No packages licenses found.")
143+
return "\n".join(
144+
[SheetReport.REPORT_BANNER, heading, SheetReport.REPORT_SECTION,
145+
content, SheetReport.REPORT_FOOTER]
146+
)
147+
148+
table = []
149+
iteration = 1
150+
for pkg_license in packages_licenses:
151+
max_char = last_char = 43 # defines a limit for package name.
152+
current_line = 1
153+
package = pkg_license['package']
154+
license = pkg_license['license']
155+
version = pkg_license['version']
156+
license_line = int(int(len(package) / max_char) / 2) + 1 # Calc to get which line to add the license info.
157+
158+
table.append("| {:43} | {:9} | {:18} |".format(
159+
package[:max_char],
160+
version[:9] if current_line == license_line else "",
161+
license[:18] if current_line == license_line else "",
162+
))
163+
164+
long_name = True if len(package[max_char:]) > 0 else False
165+
while long_name: # If the package has a long name, break it into multiple lines.
166+
current_line += 1
167+
table.append("| {:43} | {:9} | {:18} |".format(
168+
package[last_char:last_char+max_char],
169+
version[:9] if current_line == license_line else "",
170+
license[:18] if current_line == license_line else "",
171+
))
172+
last_char = last_char+max_char
173+
long_name = True if len(package[last_char:]) > 0 else False
174+
175+
if iteration != len(packages_licenses): # Do not add dashes "----" for last package.
176+
table.append("|" + ("-" * 78) + "|")
177+
iteration += 1
178+
return "\n".join(
179+
[SheetReport.REPORT_BANNER, heading, SheetReport.TABLE_HEADING_LICENSES,
180+
"\n".join(table), SheetReport.REPORT_FOOTER]
181+
)
128182

129183
class BasicReport(object):
130184
"""Basic report, intented to be used for terminals with < 80 columns"""
@@ -157,6 +211,24 @@ def render(vulns, full, checked_packages, used_db):
157211
table
158212
)
159213

214+
@staticmethod
215+
def render_licenses(packages, packages_licenses):
216+
table = [
217+
"safety",
218+
"packages licenses",
219+
"---"
220+
]
221+
if not packages_licenses:
222+
table.append("No packages licenses found.")
223+
return "\n".join(table)
224+
225+
for pkg_license in packages_licenses:
226+
text = pkg_license['package'] + \
227+
", version " + pkg_license['version'] + \
228+
", license " + pkg_license['license'] + "\n"
229+
table.append(text)
230+
231+
return "\n".join(table)
160232

161233
class JsonReport(object):
162234
"""Json report, for when the output is input for something else"""
@@ -192,3 +264,11 @@ def report(vulns, full=False, json_report=False, bare_report=False, checked_pack
192264
if size.columns >= 80:
193265
return SheetReport.render(vulns, full=full, checked_packages=checked_packages, used_db=used_db)
194266
return BasicReport.render(vulns, full=full, checked_packages=checked_packages, used_db=used_db)
267+
268+
269+
def license_report(packages, licenses):
270+
size = get_terminal_size()
271+
272+
if size.columns >= 80:
273+
return SheetReport.render_licenses(packages, licenses)
274+
return BasicReport.render_licenses(packages, licenses)

safety/safety.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,26 @@ def review(vulnerabilities):
176176
Vulnerability(**current_vuln)
177177
)
178178
return vulnerable
179+
180+
181+
def get_licenses(key, db_mirror, cached, proxy):
182+
key = key if key else os.environ.get("SAFETY_API_KEY", False)
183+
184+
if not key:
185+
raise DatabaseFetchError("API-KEY not provided.")
186+
if db_mirror:
187+
mirrors = [db_mirror]
188+
else:
189+
mirrors = API_MIRRORS
190+
191+
db_name = "licenses.json"
192+
193+
for mirror in mirrors:
194+
# mirror can either be a local path or a URL
195+
if mirror.startswith("http://") or mirror.startswith("https://"):
196+
licenses = fetch_database_url(mirror, db_name=db_name, key=key, cached=cached, proxy=proxy)
197+
else:
198+
licenses = fetch_database_file(mirror, db_name=db_name)
199+
if licenses:
200+
return licenses
201+
raise DatabaseFetchError()

safety/util.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from dparse.parser import setuptools_parse_requirements_backport as _parse_requirements
22
from collections import namedtuple
3+
from packaging.version import parse as parse_version
34
import click
45
import sys
56
import json
@@ -105,3 +106,66 @@ def read_requirements(fh, resolve=False):
105106
)
106107
except ValueError:
107108
continue
109+
110+
111+
def get_proxy_dict(proxyprotocol, proxyhost, proxyport):
112+
proxy_dictionary = {}
113+
if proxyhost is not None:
114+
if proxyprotocol in ["http", "https"]:
115+
proxy_dictionary = {proxyprotocol: "{0}://{1}:{2}".format(proxyprotocol, proxyhost, str(proxyport))}
116+
else:
117+
click.secho("Proxy Protocol should be http or https only.", fg="red")
118+
sys.exit(-1)
119+
return proxy_dictionary
120+
121+
122+
def get_license_name_by_id(license_id, db):
123+
licenses = db.get('licenses', [])
124+
for name, id in licenses.items():
125+
if id == license_id:
126+
return name
127+
return None
128+
129+
def get_packages_licenses(packages, licenses_db):
130+
"""Get the licenses for the specified packages based on their version.
131+
132+
:param packages: packages list
133+
:param licenses_db: the licenses db in the raw form.
134+
:return: list of objects with the packages and their respectives licenses.
135+
"""
136+
packages_licenses_db = licenses_db.get('packages', {})
137+
filtered_packages_licenses = []
138+
139+
for pkg in packages:
140+
# Ignore recursive files not resolved
141+
if isinstance(pkg, RequirementFile):
142+
continue
143+
# normalize the package name
144+
pkg_name = pkg.key.replace("_", "-").lower()
145+
# packages may have different licenses depending their version.
146+
pkg_licenses = packages_licenses_db.get(pkg_name, [])
147+
version_requested = parse_version(pkg.version)
148+
license_id = None
149+
license_name = None
150+
for pkg_version in pkg_licenses:
151+
license_start_version = parse_version(pkg_version['start_version'])
152+
# Stops and return the previous stored license when a new
153+
# license starts on a version above the requested one.
154+
if version_requested >= license_start_version:
155+
license_id = pkg_version['license_id']
156+
else:
157+
# We found the license for the version requested
158+
break
159+
160+
if license_id:
161+
license_name = get_license_name_by_id(license_id, licenses_db)
162+
if not license_id or not license_name:
163+
license_name = "N/A"
164+
165+
filtered_packages_licenses.append({
166+
"package": pkg_name,
167+
"version": pkg.version,
168+
"license": license_name
169+
})
170+
171+
return filtered_packages_licenses

tests/test_db/licenses.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"licenses": {
3+
"BSD-3-Clause": 1
4+
},
5+
"packages": {
6+
"django": [
7+
{
8+
"start_version": "0.0",
9+
"license_id": 1
10+
}
11+
]
12+
}
13+
}

tests/test_safety.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ def test_check_from_file(self):
142142
cached=False,
143143
key=False,
144144
ignore_ids=[],
145-
proxy={}
145+
proxy={},
146146
)
147147
self.assertEqual(len(vulns), 2)
148148

@@ -160,7 +160,7 @@ def test_check_from_file_with_hash_pins(self):
160160
cached=False,
161161
key=False,
162162
ignore_ids=[],
163-
proxy={}
163+
proxy={},
164164
)
165165
self.assertEqual(len(vulns), 2)
166166

@@ -177,7 +177,7 @@ def test_multiple_versions(self):
177177
cached=False,
178178
key=False,
179179
ignore_ids=[],
180-
proxy={}
180+
proxy={},
181181
)
182182
self.assertEqual(len(vulns), 4)
183183

@@ -191,7 +191,7 @@ def test_check_live(self):
191191
cached=False,
192192
key=False,
193193
ignore_ids=[],
194-
proxy={}
194+
proxy={},
195195
)
196196
self.assertEqual(len(vulns), 1)
197197

@@ -205,7 +205,7 @@ def test_check_live_cached(self):
205205
cached=True,
206206
key=False,
207207
ignore_ids=[],
208-
proxy={}
208+
proxy={},
209209
)
210210
self.assertEqual(len(vulns), 1)
211211

@@ -218,10 +218,44 @@ def test_check_live_cached(self):
218218
cached=True,
219219
key=False,
220220
ignore_ids=[],
221-
proxy={}
221+
proxy={},
222222
)
223223
self.assertEqual(len(vulns), 1)
224224

225+
def test_get_packages_licenses(self):
226+
reqs = StringIO("Django==1.8.1\n\rinvalid==1.0.0")
227+
packages = util.read_requirements(reqs)
228+
licenses_db = safety.get_licenses(
229+
db_mirror=os.path.join(
230+
os.path.dirname(os.path.realpath(__file__)),
231+
"test_db"
232+
),
233+
cached=False,
234+
key="foobarqux",
235+
proxy={},
236+
)
237+
self.assertIn("licenses", licenses_db)
238+
self.assertIn("packages", licenses_db)
239+
self.assertIn("BSD-3-Clause", licenses_db['licenses'])
240+
self.assertIn("django", licenses_db['packages'])
241+
242+
pkg_licenses = util.get_packages_licenses(packages, licenses_db)
243+
244+
self.assertIsInstance(pkg_licenses, list)
245+
for pkg_license in pkg_licenses:
246+
license = pkg_license['license']
247+
version = pkg_license['version']
248+
if pkg_license['package'] == 'django':
249+
self.assertEqual(license, 'BSD-3-Clause')
250+
self.assertEqual(version, '1.8.1')
251+
elif pkg_license['package'] == 'invalid':
252+
self.assertEqual(license, 'N/A')
253+
self.assertEqual(version, '1.0.0')
254+
else:
255+
raise AssertionError(
256+
"unexpected package '" + pkg_license['package'] + "' was found"
257+
)
258+
225259

226260
class ReadRequirementsTestCase(unittest.TestCase):
227261

0 commit comments

Comments
 (0)