Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Using dparse to read requirements and fixes for custom integrations #406

Merged
merged 2 commits into from Sep 15, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 3 additions & 3 deletions safety/cli.py
Expand Up @@ -17,7 +17,7 @@
from safety.safety import get_packages, read_vulnerabilities, fetch_policy, post_results
from safety.util import get_proxy_dict, get_packages_licenses, output_exception, \
MutuallyExclusiveOption, DependentOption, transform_ignore, SafetyPolicyFile, active_color_if_needed, \
get_processed_options, get_safety_version, json_alias, bare_alias, SafetyContext
get_processed_options, get_safety_version, json_alias, bare_alias, SafetyContext, is_a_remote_mirror

LOG = logging.getLogger(__name__)

Expand Down Expand Up @@ -51,7 +51,7 @@ def cli(ctx, debug, telemetry, disable_optional_telemetry_data):
help="API Key for pyup.io's vulnerability database. Can be set as SAFETY_API_KEY "
"environment variable. Default: empty")
@click.option("--db", default="",
help="Path to a local vulnerability database. Default: empty")
help="Path to a local or remote vulnerability database. Default: empty")
@click.option("--full-report/--short-report", default=False, cls=MutuallyExclusiveOption,
mutually_exclusive=["output", "json", "bare"],
with_values={"output": ['json', 'bare'], "json": [True, False], "bare": [True, False]},
Expand Down Expand Up @@ -105,7 +105,7 @@ def check(ctx, key, db, full_report, stdin, files, cache, ignore, output, json,
proxy_dictionary = get_proxy_dict(proxy_protocol, proxy_host, proxy_port)

announcements = []
if not db:
if not db or is_a_remote_mirror(db):
LOG.info('Not local DB used, Getting announcements')
announcements = safety.get_announcements(key=key, proxy=proxy_dictionary, telemetry=ctx.parent.telemetry)

Expand Down
17 changes: 14 additions & 3 deletions safety/output_utils.py
@@ -1,12 +1,14 @@
import json
import logging
import os
import textwrap
from datetime import datetime

import click

from safety.constants import RED, YELLOW
from safety.util import get_safety_version, Package, get_terminal_size, SafetyContext, build_telemetry_data, build_git_data
from safety.util import get_safety_version, Package, get_terminal_size, \
SafetyContext, build_telemetry_data, build_git_data, is_a_remote_mirror

LOG = logging.getLogger(__name__)

Expand Down Expand Up @@ -485,16 +487,24 @@ def build_report_for_review_vuln_report(as_dict=False):

def build_using_sentence(key, db):
key_sentence = []
custom_integration = os.environ.get('SAFETY_CUSTOM_INTEGRATION',
'false').lower() == 'true'

if key:
key_sentence = [{'style': True, 'value': 'an API KEY'},
{'style': False, 'value': ' and the '}]
db_name = 'PyUp Commercial'
elif db and custom_integration and is_a_remote_mirror(db):
return []
else:
db_name = 'non-commercial'

if db:
db_name = "local file {0}".format(db)
db_type = 'local file'
if is_a_remote_mirror(db):
db_type = 'remote URL'

db_name = f"{db_type} {db}"

database_sentence = [{'style': True, 'value': db_name + ' database'}]

Expand Down Expand Up @@ -629,6 +639,7 @@ def get_report_brief_info(as_dict=False, report_type=1, **kwargs):
brief_data['json_version'] = 1

using_sentence = build_using_sentence(key, db)
using_sentence_section = [nl] if not using_sentence else [nl] + [build_using_sentence(key, db)]
scanned_count_sentence = build_scanned_count_sentence(packages)

timestamp = [{'style': False, 'value': 'Timestamp '}, {'style': True, 'value': current_time}]
Expand All @@ -638,7 +649,7 @@ def get_report_brief_info(as_dict=False, report_type=1, **kwargs):
{'style': False, 'value': ' is scanning for '},
{'style': True, 'value': scanning_types.get(context.command, {}).get('name', '')},
{'style': True, 'value': '...'}] + safety_policy_used + audit_and_monitor, action_executed
] + [nl] + scanned_items + [nl] + [using_sentence] + [scanned_count_sentence] + [timestamp]
] + [nl] + scanned_items + using_sentence_section + [scanned_count_sentence] + [timestamp]

brief_info.extend(additional_data)

Expand Down
30 changes: 23 additions & 7 deletions safety/safety.py
Expand Up @@ -19,7 +19,7 @@
RequestTimeoutError, ServerError, MalformedDatabase)
from .models import Vulnerability, CVE, Severity
from .util import RequirementFile, read_requirements, Package, build_telemetry_data, sync_safety_context, SafetyContext, \
validate_expiration_date
validate_expiration_date, is_a_remote_mirror

session = requests.session()

Expand Down Expand Up @@ -216,7 +216,7 @@ def fetch_database(full=False, key=False, db=False, cached=0, proxy=None, teleme
db_name = "insecure_full.json" if full else "insecure.json"
for mirror in mirrors:
# mirror can either be a local path or a URL
if mirror.startswith("http://") or mirror.startswith("https://"):
if is_a_remote_mirror(mirror):
data = fetch_database_url(mirror, db_name=db_name, key=key, cached=cached, proxy=proxy, telemetry=telemetry)
else:
data = fetch_database_file(mirror, db_name=db_name)
Expand Down Expand Up @@ -509,7 +509,7 @@ def get_licenses(key=False, db_mirror=False, cached=0, proxy=None, telemetry=Tru

for mirror in mirrors:
# mirror can either be a local path or a URL
if mirror.startswith("http://") or mirror.startswith("https://"):
if is_a_remote_mirror(mirror):
licenses = fetch_database_url(mirror, db_name=db_name, key=key, cached=cached, proxy=proxy,
telemetry=telemetry)
else:
Expand All @@ -522,20 +522,36 @@ def get_licenses(key=False, db_mirror=False, cached=0, proxy=None, telemetry=Tru
def get_announcements(key, proxy, telemetry=True):
LOG.info('Getting announcements')

body = build_telemetry_data(telemetry=telemetry)

announcements = []
headers = {}

if key:
headers["X-Api-Key"] = key

url = f"{API_BASE_URL}announcements/"
method = 'post'
data = build_telemetry_data(telemetry=telemetry)
request_kwargs = {'headers': headers, 'proxies': proxy, 'timeout': 3}
data_keyword = 'json'

source = os.environ.get('SAFETY_ANNOUNCEMENTS_URL', None)

if source:
LOG.debug(f'Getting the announcement from a different source: {source}')
url = source
method = 'get'
data = {
'telemetry': json.dumps(build_telemetry_data(telemetry=telemetry))}
data_keyword = 'params'

request_kwargs[data_keyword] = data
request_kwargs['url'] = url

LOG.debug(f'Telemetry body sent: {body}')
LOG.debug(f'Telemetry data sent: {data}')

try:
r = session.post(url=url, json=body, headers=headers, timeout=2, proxies=proxy)
request_func = getattr(session, method)
r = request_func(**request_kwargs)
LOG.debug(r.text)
except Exception as e:
LOG.info('Unexpected but HANDLED Exception happened getting the announcements: %s', e)
Expand Down
127 changes: 42 additions & 85 deletions safety/util.py
@@ -1,3 +1,4 @@
import json
import logging
import os
import platform
Expand All @@ -9,8 +10,10 @@
from typing import List

import click
import dparse.parser
from click import BadParameter
from dparse.parser import setuptools_parse_requirements_backport as _parse_requirements
from dparse import parse, filetypes
from packaging.utils import canonicalize_name
from packaging.version import parse as parse_version
from ruamel.yaml import YAML
Expand All @@ -21,102 +24,56 @@

LOG = logging.getLogger(__name__)

def iter_lines(fh, lineno=0):
for line in fh.readlines()[lineno:]:
yield line

def is_a_remote_mirror(mirror):
return mirror.startswith("http://") or mirror.startswith("https://")

def parse_line(line):
if line.startswith('-e') or line.startswith('http://') or line.startswith('https://'):
if "#egg=" in line:
line = line.split("#egg=")[-1]
if ' --hash' in line:
line = line.split(" --hash")[0]
return _parse_requirements(line)

def is_supported_by_parser(path):
supported_types = (".txt", ".in", ".yml", ".ini", "Pipfile",
"Pipfile.lock", "setup.cfg", "poetry.lock")
return path.endswith(supported_types)

def read_requirements(fh, resolve=False):

def read_requirements(fh, resolve=True):
"""
Reads requirements from a file like object and (optionally) from referenced files.
:param fh: file like object to read from
:param resolve: boolean. resolves referenced files.
:return: generator
"""
is_temp_file = not hasattr(fh, 'name')
for num, line in enumerate(iter_lines(fh)):
line = line.strip()
if not line:
# skip empty lines
continue
if line.startswith('#') or \
line.startswith('-i') or \
line.startswith('--index-url') or \
line.startswith('--extra-index-url') or \
line.startswith('-f') or line.startswith('--find-links') or \
line.startswith('--no-index') or line.startswith('--allow-external') or \
line.startswith('--allow-unverified') or line.startswith('-Z') or \
line.startswith('--always-unzip'):
# skip unsupported lines
continue
elif line.startswith('-r') or line.startswith('--requirement'):
# got a referenced file here, try to resolve the path
# if this is a tempfile, skip
if is_temp_file:
continue

# strip away the recursive flag
prefixes = ["-r", "--requirement"]
filename = line.strip()
for prefix in prefixes:
if filename.startswith(prefix):
filename = filename[len(prefix):].strip()

# if there is a comment, remove it
if " #" in filename:
filename = filename.split(" #")[0].strip()
req_file_path = os.path.join(os.path.dirname(fh.name), filename)
if resolve:
# recursively yield the resolved requirements
if os.path.exists(req_file_path):
with open(req_file_path) as _fh:
for req in read_requirements(_fh, resolve=True):
yield req
else:
yield RequirementFile(path=req_file_path)
else:
try:
parseable_line = line
# multiline requirements are not parseable
if "\\" in line:
parseable_line = line.replace("\\", "")
for next_line in iter_lines(fh, num + 1):
parseable_line += next_line.strip().replace("\\", "")
line += "\n" + next_line
if "\\" in next_line:
continue
break
req, = parse_line(parseable_line)
if len(req.specifier._specs) == 1 and \
next(iter(req.specifier._specs))._spec[0] == "==":
yield Package(name=req.name, version=next(iter(req.specifier._specs))._spec[1],
found='temp_file' if is_temp_file else fh.name, insecure_versions=[],
secure_versions=[], latest_version=None,
latest_version_without_known_vulnerabilities=None, more_info_url=None)
else:
try:
fname = fh.name
except AttributeError:
fname = line

click.secho(
"Warning: unpinned requirement '{req}' found in {fname}, "
"unable to check.".format(req=req.name,
fname=fname),
fg="yellow",
file=sys.stderr
)
except ValueError:
continue
path = None
found = 'temp_file'
file_type = filetypes.requirements_txt

if not is_temp_file and is_supported_by_parser(fh.name):
path = fh.name
found = path
file_type = None

dependency_file = parse(fh.read(), path=path, resolve=resolve,
file_type=file_type)
for dep in dependency_file.resolved_dependencies:
try:
spec = next(iter(dep.specs))._spec
except StopIteration:
click.secho(
f"Warning: unpinned requirement '{dep.name}' found in {path}, "
"unable to check.",
fg="yellow",
file=sys.stderr
)
return

version = spec[1]
if spec[0] == '==':
yield Package(name=dep.name, version=version,
found=found,
insecure_versions=[],
secure_versions=[], latest_version=None,
latest_version_without_known_vulnerabilities=None,
more_info_url=None)


def get_proxy_dict(proxy_protocol, proxy_host, proxy_port):
Expand Down