From 8b21e93a324bef315f6605e83c57e3ddafe9ddf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADcholas=20Kegler?= Date: Thu, 24 Dec 2020 11:28:47 -0300 Subject: [PATCH 1/3] Adding bare and json outputs to license command. --- HISTORY.rst | 1 + safety/cli.py | 15 +++++++++++++-- safety/formatter.py | 20 ++++++++++++++++++-- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index be9dcdbe..14afd45c 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -6,6 +6,7 @@ History ------------------- * Current unstable version +* Added bare and json outputs to license command 1.10.0 (2020-12-20) ------------------- diff --git a/safety/cli.py b/safety/cli.py index 3510c2af..337c6508 100644 --- a/safety/cli.py +++ b/safety/cli.py @@ -128,6 +128,12 @@ def review(full_report, bare, file): "environment variable. Default: empty") @click.option("--db", default="", help="Path to a local license database. Default: empty") +@click.option("--json/--no-json", default=False, + help="Output packages licenses in JSON format. Default: --no-json") +@click.option("--bare/--not-bare", default=False, + help='Output packages licenses names only. ' + 'Useful in combination with other tools. ' + 'Default: --not-bare') @click.option("--cache/--no-cache", default=True, help='Whether license database file should be cached.' 'Default: --cache') @@ -139,7 +145,7 @@ def review(full_report, bare, file): help="Proxy port number --proxy-port") @click.option("proxyprotocol", "--proxy-protocol", "-pr", multiple=False, type=str, default='http', help="Proxy protocol (https or http) --proxy-protocol") -def license(key, db, cache, files, proxyprotocol, proxyhost, proxyport): +def license(key, db, json, bare, cache, files, proxyprotocol, proxyhost, proxyport): if files: packages = list(itertools.chain.from_iterable(read_requirements(f, resolve=True) for f in files)) @@ -172,7 +178,12 @@ def license(key, db, cache, files, proxyprotocol, proxyhost, proxyport): click.secho("Unable to load licenses database", fg="red", file=sys.stderr) sys.exit(-1) filtered_packages_licenses = get_packages_licenses(packages, licenses_db) - output_report = license_report(packages=packages, licenses=filtered_packages_licenses) + output_report = license_report( + packages=packages, + licenses=filtered_packages_licenses, + json_report=json, + bare_report=bare + ) click.secho(output_report, nl=True) diff --git a/safety/formatter.py b/safety/formatter.py index af1e0903..8e756c97 100644 --- a/safety/formatter.py +++ b/safety/formatter.py @@ -236,6 +236,10 @@ class JsonReport(object): @staticmethod def render(vulns, full): return json.dumps(vulns, indent=4, sort_keys=True) + + @staticmethod + def render_licenses(packages_licenses): + return json.dumps(packages_licenses, indent=4, sort_keys=True) class BareReport(object): @@ -244,6 +248,14 @@ class BareReport(object): def render(vulns, full): return " ".join(set([v.name for v in vulns])) + @staticmethod + def render_licenses(packages_licenses): + licenses = set([pkg_li.get('license') for pkg_li in packages_licenses]) + if "N/A" in licenses: + licenses.remove("N/A") + sorted_licenses = sorted(licenses) + return " ".join(sorted_licenses) + def get_used_db(key, db): key = key if key else os.environ.get("SAFETY_API_KEY", False) @@ -266,9 +278,13 @@ def report(vulns, full=False, json_report=False, bare_report=False, checked_pack return BasicReport.render(vulns, full=full, checked_packages=checked_packages, used_db=used_db) -def license_report(packages, licenses): - size = get_terminal_size() +def license_report(packages, licenses, json_report=False, bare_report=False): + if json_report: + return JsonReport.render_licenses(packages_licenses=licenses) + elif bare_report: + return BareReport.render_licenses(packages_licenses=licenses) + size = get_terminal_size() if size.columns >= 80: return SheetReport.render_licenses(packages, licenses) return BasicReport.render_licenses(packages, licenses) From b6116af153795b1077532db646203d4081d0330a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADcholas=20Kegler?= Date: Fri, 25 Dec 2020 11:40:01 -0300 Subject: [PATCH 2/3] API key is not required when using local db. Using exception msg when available. --- safety/cli.py | 15 +++++++++------ safety/safety.py | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/safety/cli.py b/safety/cli.py index 337c6508..09737831 100644 --- a/safety/cli.py +++ b/safety/cli.py @@ -123,7 +123,7 @@ def review(full_report, bare, file): @cli.command() -@click.option("--key", required=True, envvar="SAFETY_API_KEY", +@click.option("--key", envvar="SAFETY_API_KEY", help="API Key for pyup.io's vulnerability database. Can be set as SAFETY_API_KEY " "environment variable. Default: empty") @click.option("--db", default="", @@ -159,11 +159,14 @@ def license(key, db, json, bare, cache, files, proxyprotocol, proxyhost, proxypo proxy_dictionary = get_proxy_dict(proxyprotocol, proxyhost, proxyport) try: licenses_db = safety.get_licenses(key, db, cache, proxy_dictionary) - except InvalidKeyError: - click.secho("Your API Key '{key}' is invalid. See {link}".format( - key=key, link='https://goo.gl/O7Y1rS'), - fg="red", - file=sys.stderr) + except InvalidKeyError as invalid_key_error: + if str(invalid_key_error): + message = str(invalid_key_error) + else: + message = "Your API Key '{key}' is invalid. See {link}".format( + key=key, link='https://goo.gl/O7Y1rS' + ) + click.secho(message, fg="red", file=sys.stderr) sys.exit(-1) except DatabaseFileNotFoundError: click.secho("Unable to load licenses database from {db}".format(db=db), fg="red", file=sys.stderr) diff --git a/safety/safety.py b/safety/safety.py index 14976ef1..2dd9c958 100644 --- a/safety/safety.py +++ b/safety/safety.py @@ -190,7 +190,7 @@ def get_licenses(key, db_mirror, cached, proxy): key = key if key else os.environ.get("SAFETY_API_KEY", False) if not key and not db_mirror: - raise InvalidKeyError("API-KEY not provided.") + raise InvalidKeyError("The API-KEY was not provided.") if db_mirror: mirrors = [db_mirror] else: From b27a273f2374ed3f73476bd00c16f227fd5877f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADcholas=20Kegler?= Date: Fri, 25 Dec 2020 11:40:19 -0300 Subject: [PATCH 3/3] Testing bare and json commands. --- tests/reqs_4.txt | 1 + tests/test_safety.py | 105 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 tests/reqs_4.txt diff --git a/tests/reqs_4.txt b/tests/reqs_4.txt new file mode 100644 index 00000000..0337f878 --- /dev/null +++ b/tests/reqs_4.txt @@ -0,0 +1 @@ +django==1.11 \ No newline at end of file diff --git a/tests/test_safety.py b/tests/test_safety.py index c36aa3b5..5f2c3e58 100644 --- a/tests/test_safety.py +++ b/tests/test_safety.py @@ -54,6 +54,42 @@ def test_review_fail(self): result = runner.invoke(cli.cli, ['review', '--bare', '--file', path_to_report]) assert result.exit_code == -1 + @patch("safety.safety.get_licenses") + def test_license_bare(self, get_licenses): + runner = CliRunner() + + dirname = os.path.dirname(__file__) + with open(os.path.join(dirname, "test_db", "licenses.json")) as f: + licenses_db = json.loads(f.read()) + get_licenses.return_value = licenses_db + reqs_path = os.path.join(dirname, "reqs_4.txt") + + result = runner.invoke(cli.cli, ['license', '--file', reqs_path, '--bare', '--db', 'licenses.json']) + self.assertEqual(result.exit_code, 0) + self.assertEqual(result.output, 'BSD-3-Clause\n') + + @patch("safety.safety.get_licenses") + def test_license_json(self, get_licenses): + runner = CliRunner() + + dirname = os.path.dirname(__file__) + with open(os.path.join(dirname, "test_db", "licenses.json")) as f: + licenses_db = json.loads(f.read()) + get_licenses.return_value = licenses_db + reqs_path = os.path.join(dirname, "reqs_4.txt") + + result = runner.invoke(cli.cli, ['license', '--file', reqs_path, '--json', '--db', 'licenses.json']) + expected_result = json.dumps( + [{ + "license": "BSD-3-Clause", + "package": "django", + "version": "1.11" + }], + indent=4, sort_keys=True + ) + self.assertEqual(result.exit_code, 0) + self.assertMultiLineEqual(result.output.rstrip(), expected_result) + class TestFormatter(unittest.TestCase): @@ -269,7 +305,7 @@ def test_get_packages_licenses_without_api_key(self): key=None ) db_generic_exception = error.exception - self.assertEqual(str(db_generic_exception), 'API-KEY not provided.') + self.assertEqual(str(db_generic_exception), 'The API-KEY was not provided.') @patch("safety.safety.requests") def test_get_packages_licenses_with_invalid_api_key(self, requests): @@ -387,6 +423,73 @@ def test_get_cached_packages_licenses(self, requests): self.assertNotEqual(resp, licenses_db) self.assertEqual(resp, original_db) + def test_report_licenses_bare(self): + from safety.formatter import license_report + + reqs = StringIO("Django==1.8.1\n\rinexistent==1.0.0") + packages = util.read_requirements(reqs) + + # Using DB: test.test_db.licenses.json + licenses_db = safety.get_licenses( + db_mirror=os.path.join( + os.path.dirname(os.path.realpath(__file__)), + "test_db" + ), + cached=False, + key=None, + proxy={}, + ) + + pkgs_licenses = util.get_packages_licenses(packages, licenses_db) + output_report = license_report( + packages=packages, + licenses=pkgs_licenses, + json_report=False, + bare_report=True + ) + self.assertEqual(output_report, "BSD-3-Clause") + + def test_report_licenses_json(self): + from safety.formatter import license_report + + reqs = StringIO("Django==1.8.1\n\rinexistent==1.0.0") + packages = util.read_requirements(reqs) + + # Using DB: test.test_db.licenses.json + licenses_db = safety.get_licenses( + db_mirror=os.path.join( + os.path.dirname(os.path.realpath(__file__)), + "test_db" + ), + cached=False, + key=None, + proxy={}, + ) + + pkgs_licenses = util.get_packages_licenses(packages, licenses_db) + output_report = license_report( + packages=packages, + licenses=pkgs_licenses, + json_report=True, + bare_report=False + ) + + expected_result = json.dumps( + [{ + "license": "BSD-3-Clause", + "package": "django", + "version": "1.8.1" + }, + { + "license": "N/A", + "package": "inexistent", + "version": "1.0.0" + }], + indent=4, sort_keys=True + ) + # Packages without license are reported as "N/A" + self.assertEqual(output_report.rstrip(), expected_result) + class ReadRequirementsTestCase(unittest.TestCase):