Repository: pyupio/safety

Issue:
Load and pretty-print previously saved results
* safety version: 1.8.1 (current at the moment)
* Python version: 3.6.3
* Operating System: Debian 8 (Docker container)

### Description

This is a feature suggestion.
It'd be nice to be able to recall the saved results in the tidy way _safety_ displays.

Firstly, a user may run the check and save its result:
```shell
safety check --json > vulns_2018-06-26.json
```

To review the pretty-printed result later, do:
```shell
safety review --stdin < vulns_2018-06-26.json
# Alternatively, 
safety review --file=vulns_2018-06-26.json
```

_safety_ reads the input JSON and display its readings:

```
╒══════════════════════════════════════════════════════════════════════════════╕
│                                                                              │
│                               /$$$$$$            /$$                         │
│                              /$$__  $$          | $$                         │
│           /$$$$$$$  /$$$$$$ | $$  \__//$$$$$$  /$$$$$$   /$$   /$$           │
│          /$$_____/ |____  $$| $$$$   /$$__  $$|_  $$_/  | $$  | $$           │
│         |  $$$$$$   /$$$$$$$| $$_/  | $$$$$$$$  | $$    | $$  | $$           │
│          \____  $$ /$$__  $$| $$    | $$_____/  | $$ /$$| $$  | $$           │
│          /$$$$$$$/|  $$$$$$$| $$    |  $$$$$$$  |  $$$$/|  $$$$$$$           │
│         |_______/  \_______/|__/     \_______/   \___/   \____  $$           │
│                                                          /$$  | $$           │
│                                                         |  $$$$$$/           │
│  by pyup.io                                              \______/            │
│                                                                              │
╞══════════════════════════════════════════════════════════════════════════════╡
│ REPORT                                                                       │
│ checked 144 packages, using default DB                                       │
╞════════════════════════════╤═══════════╤══════════════════════════╤══════════╡
│ package                    │ installed │ affected                 │ ID       │
╞════════════════════════════╧═══════════╧══════════════════════════╧══════════╡
│ pyjwt                      │ 1.5.0     │ <1.5.1                   │ 35014    │
│ newrelic                   │ 2.78.0.57 │ >=1.1.0.192,<=2.106.0.87 │ 35805    │
╘══════════════════════════════════════════════════════════════════════════════╛
```

Adding `--bare` and `--full-report` supports would be great, too.

### What I Did

I thought of this idea.

Comments:

Patch:
diff --git a/.gitignore b/.gitignore
index 4ea7593..177ba7f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,6 +61,8 @@ target/
 # pyenv python configuration file
 .python-version
 sandbox.py
+*.idea/*
 
 # Virtual Env
 venv/
+.venv/
diff --git a/README.md b/README.md
index 4cecdb6..d8aa35c 100644
--- a/README.md
+++ b/README.md
@@ -307,4 +307,88 @@ safety check --ignore=1234
 ```bash
 safety check -i 1234 -i 4567 -i 89101
 ```
+
+### `--output`, `-o`
+
+*Save the report to a file*
+
+**Example**
+```bash
+safety check -o insecure_report.txt
+```
+```bash
+safety check --output --json insecure_report.json
+```
+___
+
+# Review
+
+If you save the report in JSON format you can review in the report format again.
+
+## Options
+
+### `--file`, `-f` (REQUIRED)
+
+*Read an insecure report.*
+
+**Example**
+```bash
+safety check -f insecure.json
+```
+```bash
+safety check --file=insecure.json
+```
+___
+
+### `--full-report`
+
+*Full reports include a security advisory (if available).*
+
+**Example**
+```bash
+safety review -r insecure.json --full-report
+```
+
+```
+╒══════════════════════════════════════════════════════════════════════════════╕
+│                                                                              │
+│                               /$$$$$$            /$$                         │
+│                              /$$__  $$          | $$                         │
+│           /$$$$$$$  /$$$$$$ | $$  \__//$$$$$$  /$$$$$$   /$$   /$$           │
+│          /$$_____/ |____  $$| $$$$   /$$__  $$|_  $$_/  | $$  | $$           │
+│         |  $$$$$$   /$$$$$$$| $$_/  | $$$$$$$$  | $$    | $$  | $$           │
+│          \____  $$ /$$__  $$| $$    | $$_____/  | $$ /$$| $$  | $$           │
+│          /$$$$$$$/|  $$$$$$$| $$    |  $$$$$$$  |  $$$$/|  $$$$$$$           │
+│         |_______/  \_______/|__/     \_______/   \___/   \____  $$           │
+│                                                          /$$  | $$           │
+│                                                         |  $$$$$$/           │
+│  by pyup.io                                              \______/            │
+│                                                                              │
+╞══════════════════════════════════════════════════════════════════════════════╡
+│ REPORT                                                                       │
+╞════════════════════════════╤═══════════╤══════════════════════════╤══════════╡
+│ package                    │ installed │ affected                 │ ID       │
+╞════════════════════════════╧═══════════╧══════════════════════════╧══════════╡
+│ django                     │ 1.2       │ <1.2.2                   │ 25701    │
+╞══════════════════════════════════════════════════════════════════════════════╡
+│ Cross-site scripting (XSS) vulnerability in Django 1.2.x before 1.2.2 allows │
+│  remote attackers to inject arbitrary web script or HTML via a csrfmiddlewar │
+│ etoken (aka csrf_token) cookie.                                              │
+╘══════════════════════════════════════════════════════════════════════════════╛
+```
 ___
+
+### `--bare`
+
+*Output vulnerable packages only. *
+
+**Example**
+```bash
+safety review --file report.json --bare
+```
+
+```
+django
+```
+___
+
diff --git a/safety/cli.py b/safety/cli.py
index fa7ccfe..351bdd8 100644
--- a/safety/cli.py
+++ b/safety/cli.py
@@ -6,9 +6,13 @@ from safety import __version__
 from safety import safety
 from safety.formatter import report
 import itertools
-from safety.util import read_requirements
+from safety.util import read_requirements, read_vulnerabilities
 from safety.errors import DatabaseFetchError, DatabaseFileNotFoundError, InvalidKeyError
 
+try:
+    from json.decoder import JSONDecodeError
+except ImportError:
+    JSONDecodeError = ValueError
 
 @click.group()
 @click.version_option(version=__version__)
@@ -38,14 +42,15 @@ def cli():
               help="Read input from one (or multiple) requirement files. Default: empty")
 @click.option("ignore", "--ignore", "-i", multiple=True, type=str, default=[],
               help="Ignore one (or multiple) vulnerabilities by ID. Default: empty")
+@click.option("--output", "-o", default="",
+              help="Path to where output file will be placed. Default: empty")
 @click.option("proxyhost", "--proxy-host", "-ph", multiple=False, type=str, default=None,
               help="Proxy host IP or DNS --proxy-host")
 @click.option("proxyport", "--proxy-port", "-pp", multiple=False, type=int, default=80,
               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 check(key, db, json, full_report, bare, stdin, files, cache, ignore, proxyprotocol, proxyhost, proxyport):
-
+def check(key, db, json, full_report, bare, stdin, files, cache, ignore, output, proxyprotocol, proxyhost, proxyport):
     if files and stdin:
         click.secho("Can't read from --stdin and --file at the same time, exiting", fg="red")
         sys.exit(-1)
@@ -69,17 +74,19 @@ def check(key, db, json, full_report, bare, stdin, files, cache, ignore, proxypr
             sys.exit(-1)
     try:
         vulns = safety.check(packages=packages, key=key, db_mirror=db, cached=cache, ignore_ids=ignore, proxy=proxy_dictionary)
-        click.secho(report(
-            vulns=vulns,
-            full=full_report,
-            json_report=json,
-            bare_report=bare,
-            checked_packages=len(packages),
-            db=db,
-            key=key
-            ),
-          nl=False if bare and not vulns else True
-          )
+        output_report = report(vulns=vulns, 
+                               full=full_report, 
+                               json_report=json, 
+                               bare_report=bare,
+                               checked_packages=len(packages), 
+                               db=db, 
+                               key=key)
+
+        if output:
+            with open(output, 'w+') as output_file:
+                output_file.write(output_report)
+        else:
+            click.secho(output_report, nl=False if bare and not vulns else True)
         sys.exit(-1 if vulns else 0)
     except InvalidKeyError:
         click.secho("Your API Key '{key}' is invalid. See {link}".format(
@@ -94,5 +101,30 @@ def check(key, db, json, full_report, bare, stdin, files, cache, ignore, proxypr
         sys.exit(-1)
 
 
+@cli.command()
+@click.option("--full-report/--short-report", default=False,
+              help='Full reports include a security advisory (if available). Default: '
+                   '--short-report')
+@click.option("--bare/--not-bare", default=False,
+              help='Output vulnerable packages only. Useful in combination with other tools.'
+                   'Default: --not-bare')
+@click.option("file", "--file", "-f", type=click.File(), required=True,
+              help="Read input from an insecure report file. Default: empty")
+def review(full_report, bare, file):
+    if full_report and bare:
+        click.secho("Can't choose both --bare and --full-report/--short-report", fg="red")
+        sys.exit(-1)
+
+    try:
+        input_vulns = read_vulnerabilities(file)
+    except JSONDecodeError:
+        click.secho("Not a valid JSON file", fg="red")
+        sys.exit(-1)
+
+    vulns = safety.review(input_vulns)
+    output_report = report(vulns=vulns, full=full_report, bare_report=bare)
+    click.secho(output_report, nl=False if bare and not vulns else True)
+
+
 if __name__ == "__main__":
     cli()
diff --git a/safety/safety.py b/safety/safety.py
index ca9fb04..871bd77 100644
--- a/safety/safety.py
+++ b/safety/safety.py
@@ -9,6 +9,7 @@ import json
 import time
 import errno
 
+
 class Vulnerability(namedtuple("Vulnerability",
                                ["name", "spec", "version", "advisory", "vuln_id"])):
     pass
@@ -150,3 +151,19 @@ def check(packages, key, db_mirror, cached, ignore_ids, proxy):
                                 )
                             )
     return vulnerable
+
+
+def review(vulnerabilities):
+    vulnerable = []
+    for vuln in vulnerabilities:
+        current_vuln = {
+            "name": vuln[0],
+            "spec": vuln[1],
+            "version": vuln[2],
+            "advisory": vuln[3],
+            "vuln_id": vuln[4],
+        }
+        vulnerable.append(
+            Vulnerability(**current_vuln)
+        )
+    return vulnerable
diff --git a/safety/util.py b/safety/util.py
index 2e6efae..00a40d9 100644
--- a/safety/util.py
+++ b/safety/util.py
@@ -1,11 +1,16 @@
 from dparse.parser import setuptools_parse_requirements_backport as _parse_requirements
 from collections import namedtuple
 import click
+import json
 import os
 Package = namedtuple("Package", ["key", "version"])
 RequirementFile = namedtuple("RequirementFile", ["path"])
 
 
+def read_vulnerabilities(fh):
+    return json.load(fh)
+
+
 def iter_lines(fh, lineno=0):
     for line in fh.readlines()[lineno:]:
         yield line


Test patch:
diff --git a/tests/test_db/example_report.json b/tests/test_db/example_report.json
new file mode 100644
index 0000000..a68da92
--- /dev/null
+++ b/tests/test_db/example_report.json
@@ -0,0 +1,23 @@
+[
+    [
+        "django",
+        "<1.2.7",
+        "1.2.2",
+        "django.contrib.sessions in Django before 1.2.7 and 1.3.x before 1.3.1, when session data is stored in the cache, uses the root namespace for both session identifiers and application-data keys, which allows remote attackers to modify a session by triggering use of a key that is equal to that session's identifier.",
+        "33063"
+    ],
+    [
+        "django",
+        "<1.2.7",
+        "1.2.2",
+        "The verify_exists functionality in the URLField implementation in Django before 1.2.7 and 1.3.x before 1.3.1 relies on Python libraries that attempt access to an arbitrary URL with no timeout, which allows remote attackers to cause a denial of service (resource consumption) via a URL associated with (1) a slow response, (2) a completed TCP connection with no application data sent, or (3) a large amount of application data, a related issue to CVE-2011-1521.",
+        "33064"
+    ],
+    [
+        "django",
+        "<1.2.7",
+        "1.2.2",
+        "The verify_exists functionality in the URLField implementation in Django before 1.2.7 and 1.3.x before 1.3.1 originally tests a URL's validity through a HEAD request, but then uses a GET request for the new target URL in the case of a redirect, which might allow remote attackers to trigger arbitrary GET requests with an unintended source IP address via a crafted Location header.",
+        "33065"
+    ]
+]
diff --git a/tests/test_db/invalid_example_report.json b/tests/test_db/invalid_example_report.json
new file mode 100644
index 0000000..5cd3752
--- /dev/null
+++ b/tests/test_db/invalid_example_report.json
@@ -0,0 +1,21 @@
+[
+    [
+        "django",
+        "<1.2.7",
+        "1.2.2",
+        "django.contrib.sessions in Django before 1.2.7 and 1.3.x before 1.3.1, when session data is stored in the cache, uses the root namespace for both session identifiers and application-data keys, which allows remote attackers to modify a session by triggering use of a key that is equal to that session's identifier.",
+        "33063"
+    ],
+    [
+        "django",
+        "<1.2.7",
+        "1.2.2",
+        "The verify_exists functionality in the URLField implementation in Django before 1.2.7 and 1.3.x before 1.3.1 relies on Python libraries that attempt access to an arbitrary URL with no timeout, which allows remote attackers to cause a denial of service (resource consumption) via a URL associated with (1) a slow response, (2) a completed TCP connection with no application data sent, or (3) a large amount of application data, a related issue to CVE-2011-1521.",
+        "33064"
+    ],
+    [
+        "django",
+        "<1.2.7",
+        "1.2.2",
+        "The verify_exists functionality in the URLField implementation in Django before 1.2.7 and 1.3.x before 1.3.1 originally tests a URL's validity through a HEAD request, but then uses a GET request for the new target URL in the case of a redirect, which might allow remote attackers to trigger arbitrary GET requests with an unintended source IP address via a crafted Location header.",
+        "33065"
diff --git a/tests/test_safety.py b/tests/test_safety.py
index 2bde30f..fdb63df 100644
--- a/tests/test_safety.py
+++ b/tests/test_safety.py
@@ -24,6 +24,8 @@ try:
 except ImportError:
     from io import StringIO
 from safety.util import read_requirements
+from safety.util import read_vulnerabilities
+
 
 class TestSafetyCLI(unittest.TestCase):
 
@@ -36,6 +38,21 @@ class TestSafetyCLI(unittest.TestCase):
         assert help_result.exit_code == 0
         assert '--help' in help_result.output
 
+    def test_review_pass(self):
+        runner = CliRunner()
+        dirname = os.path.dirname(__file__)
+        path_to_report = os.path.join(dirname, "test_db", "example_report.json")
+        result = runner.invoke(cli.cli, ['review', '--bare', '--file', path_to_report])
+        assert result.exit_code == 0
+        assert result.output == u'django\n'
+
+    def test_review_fail(self):
+        runner = CliRunner()
+        dirname = os.path.dirname(__file__)
+        path_to_report = os.path.join(dirname, "test_db", "invalid_example_report.json")
+        result = runner.invoke(cli.cli, ['review', '--bare', '--file', path_to_report])
+        assert result.exit_code == -1
+
 
 class TestFormatter(unittest.TestCase):
 
@@ -48,7 +65,7 @@ class TestFormatter(unittest.TestCase):
             self.fail(e)
 
     def test_report_json(self):
-        test_arr = [['libfoo'],['libbar']]
+        test_arr = [['libfoo'], ['libbar']]
         json_report = formatter.report(test_arr, full=False, json_report=True)
         assert json.loads(json_report) == test_arr
 
@@ -103,6 +120,14 @@ class TestFormatter(unittest.TestCase):
 
 
 class TestSafety(unittest.TestCase):
+    def test_review_from_file(self):
+        dirname = os.path.dirname(__file__)
+        path_to_report = os.path.join(dirname, "test_db", "example_report.json")
+        with open(path_to_report) as insecure:
+            input_vulns = read_vulnerabilities(insecure)
+
+        vulns = safety.review(input_vulns)
+        assert(len(vulns), 3)
 
     def test_check_from_file(self):
         reqs = StringIO("Django==1.8.1")
@@ -137,6 +162,7 @@ class TestSafety(unittest.TestCase):
             proxy={}
         )
         self.assertEqual(len(vulns), 4)
+
     def test_check_live(self):
         reqs = StringIO("insecure-package==0.1")
         packages = util.read_requirements(reqs)