Skip to content
This repository has been archived by the owner on Apr 11, 2022. It is now read-only.

Commit

Permalink
Merge pull request #33 from rafaelcaricio/show-cves
Browse files Browse the repository at this point in the history
Display Clair security information
  • Loading branch information
hjacobs committed May 27, 2016
2 parents 903f8e2 + a4d7edd commit 991c05e
Show file tree
Hide file tree
Showing 7 changed files with 368 additions and 30 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ htmlcov/
virtualenv
*.sw*
.cache/
.tox/
163 changes: 153 additions & 10 deletions pierone/cli.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import datetime
import os
import re

import click

import requests
import tarfile
import tempfile
import time
import zign.api
from clickclick import error, AliasedGroup, print_table, OutputFormat, UrlType

from .api import docker_login, request, get_latest_tag, DockerImage
import click
import pierone
import requests
import stups_cli.config
import zign.api
from clickclick import AliasedGroup, OutputFormat, UrlType, error, print_table

from .api import DockerImage, docker_login, get_latest_tag, request

KEYRING_KEY = 'pierone'

Expand All @@ -24,6 +22,48 @@
help='Use alternative output format')

url_option = click.option('--url', help='Pier One URL', metavar='URI')
clair_url_option = click.option('--clair-url', help='Clair URL', metavar='CLAIR_URI')

CVE_STYLES = {
'TOO_OLD': {
'bold': True,
'fg': 'red'
},
'NOT_PROCESSED_YET': {
'bold': True,
'fg': 'red'
},
'COULDNT_FIGURE_OUT': {
'bold': True,
'fg': 'red'
},
'CRITICAL': {
'bold': True,
'fg': 'red'
},
'HIGH': {
'bold': True,
'fg': 'red'
},
'MEDIUM': {
'fg': 'yellow'
},
'LOW': {
'fg': 'yellow'
},
'NEGLIGIBLE': {
'fg': 'yellow'
},
'UNKNOWN': {
'fg': 'yellow'
},
'PENDING': {
'fg': 'yellow'
},
'NO_CVES_FOUND': {
'fg': 'green'
}
}

TEAM_PATTERN_STR = r'[a-z][a-z0-9-]+'
TEAM_PATTERN = re.compile(r'^{}$'.format(TEAM_PATTERN_STR))
Expand Down Expand Up @@ -54,6 +94,19 @@ def parse_time(s: str) -> float:
return None


def parse_severity(value, clair_id_exists):
'''Parse severity values to displayable values'''
if value is None and clair_id_exists:
return 'NOT_PROCESSED_YET'
elif value is None:
return 'TOO_OLD'

value = re.sub('^clair:', '', value)
value = re.sub('(?P<upper_letter>(?<=[a-z])[A-Z])', '_\g<upper_letter>', value)

return value.upper()


def print_version(ctx, param, value):
if not value or ctx.resilient_parsing:
return
Expand Down Expand Up @@ -82,6 +135,28 @@ def set_pierone_url(config: dict, url: str) -> None:
return url


def set_clair_url(config: dict, url: str) -> None:
'''Read Clair URL from cli, from config file or from stdin.'''
url = url or config.get('clair_url')

while not url:
url = click.prompt('Please enter the Clair URL', type=UrlType())

try:
requests.get(url, timeout=5)
except:
error('Could not reach {}'.format(url))
url = None

if '://' not in url:
# issue 63: gracefully handle URLs without scheme
url = 'https://{}'.format(url)

config['clair_url'] = url
stups_cli.config.store_config(config, 'pierone')
return url


@click.group(cls=AliasedGroup, context_settings=CONTEXT_SETTINGS)
@click.option('-V', '--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True,
help='Print the current version number and exit.')
Expand Down Expand Up @@ -147,6 +222,19 @@ def get_tags(url, team, art, access_token):
return r.json()


def get_clair_features(url, layer_id, access_token):
if layer_id is None:
return []

r = request(url, '/v1/layers/{}?vulnerabilities&features'.format(layer_id), access_token)
if r.status_code == 404:
# empty list of tags (layer does not exist)
return []
else:
r.raise_for_status()
return r.json()['Layer']['Features']


@cli.command()
@click.argument('team', callback=validate_team)
@url_option
Expand Down Expand Up @@ -184,14 +272,69 @@ def tags(config, team: str, artifact, url, output):
'artifact': art,
'tag': row['name'],
'created_by': row['created_by'],
'created_time': parse_time(row['created'])}
'created_time': parse_time(row['created']),
'severity_fix_available': parse_severity(
row.get('severity_fix_available'), row.get('clair_id', False)),
'severity_no_fix_available': parse_severity(
row.get('severity_no_fix_available'), row.get('clair_id', False))}
for row in r])

# sorts are guaranteed to be stable, i.e. tags will be sorted by time (as returned from REST service)
rows.sort(key=lambda row: (row['team'], row['artifact']))
with OutputFormat(output):
print_table(['team', 'artifact', 'tag', 'created_time', 'created_by'], rows,
titles={'created_time': 'Created', 'created_by': 'By'})
titles = {
'created_time': 'Created',
'created_by': 'By',
'severity_fix_available': 'Fixable CVE Severity',
'severity_no_fix_available': 'Unfixable CVE Severity'
}
print_table(['team', 'artifact', 'tag', 'created_time', 'created_by',
'severity_fix_available', 'severity_no_fix_available'],
rows, titles=titles, styles=CVE_STYLES)


@cli.command()
@click.argument('team', callback=validate_team)
@click.argument('artifact')
@click.argument('tag')
@url_option
@clair_url_option
@output_option
@click.pass_obj
def cves(config, team, artifact, tag, url, clair_url, output):
'''List all CVE's found by Clair service for a specific artifact tag'''
set_pierone_url(config, url)
set_clair_url(config, clair_url)

rows = []
token = get_token()
for artifact_tag in get_tags(config.get('url'), team, artifact, token):
if artifact_tag['name'] == tag:
installed_software = get_clair_features(config.get('clair_url'), artifact_tag.get('clair_id'), token)
for software_pkg in installed_software:
for cve in software_pkg.get('Vulnerabilities', []):
rows.append({
'cve': cve['Name'],
'severity': cve['Severity'].upper(),
'affected_feature': '{}:{}'.format(software_pkg['Name'],
software_pkg['Version']),
'fixing_feature': cve.get(
'FixedBy') and '{}:{}'.format(software_pkg['Name'],
cve['FixedBy']),
'link': cve['Link'],
})
severity_rating = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'NEGLIGIBLE', 'UNKNOWN', 'PENDING']
rows.sort(key=lambda row: severity_rating.index(row['severity']))
with OutputFormat(output):
titles = {
'cve': 'CVE',
'severity': 'Severity',
'affected_feature': 'Affected Feature',
'fixing_feature': 'Fixing Feature',
'link': 'Link'
}
print_table(['cve', 'severity', 'affected_feature', 'fixing_feature', 'link'],
rows, titles=titles, styles=CVE_STYLES)


@cli.command()
Expand Down
Empty file modified setup.py
100644 → 100755
Empty file.
70 changes: 70 additions & 0 deletions tests/fixtures/clair_response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{
"Layer": {
"Name": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"NamespaceName": "ubuntu:16.04",
"ParentName": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"IndexedByVersion": 2,
"Features": [
{
"Name": "python3.5",
"NamespaceName": "ubuntu:16.04",
"Version": "3.5.1-10",
"AddedBy": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
},
{
"Name": "python-pip",
"NamespaceName": "ubuntu:16.04",
"Version": "8.1.1-2",
"Vulnerabilities": [
{
"Name": "CVE-2013-5123",
"NamespaceName": "ubuntu:16.04",
"Description": "The mirroring support (-M, --use-mirrors) was implemented without any sort of authenticity checks and is downloaded over plaintext HTTP. Further more by default it will dynamically discover the list of available mirrors by querying a DNS entry and extrapolating from that data. It does not attempt to use any sort of method of securing this querying of the DNS like DNSSEC. Software packages are downloaded over these insecure links, unpacked, and then typically the setup.py python file inside of them is executed.",
"Link": "http://people.ubuntu.com/~ubuntu-security/cve/CVE-2013-5123",
"Severity": "Medium"
},
{
"Name": "CVE-2014-8991",
"NamespaceName": "ubuntu:16.04",
"Description": "pip 1.3 through 1.5.6 allows local users to cause a denial of service (prevention of package installation) by creating a /tmp/pip-build-* file for another user.",
"Link": "http://people.ubuntu.com/~ubuntu-security/cve/CVE-2014-8991",
"Severity": "Low",
"Metadata": {
"NVD": {
"CVSSv2": {
"Score": 2.1,
"Vectors": "AV:L/AC:L/Au:N/C:N/I:N"
}
}
}
}
],
"AddedBy": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
},
{
"Name": "openssl",
"NamespaceName": "ubuntu:16.04",
"Version": "1.0.2g-1ubuntu4",
"Vulnerabilities": [
{
"Name": "CVE-2016-2108",
"NamespaceName": "ubuntu:16.04",
"Description": "The ASN.1 implementation in OpenSSL before 1.0.1o and 1.0.2 before 1.0.2c allows remote attackers to execute arbitrary code or cause a denial of service (buffer underflow and memory corruption) via an ANY field in crafted serialized data, aka the \"negative zero\" issue.",
"Link": "http://people.ubuntu.com/~ubuntu-security/cve/CVE-2016-2108",
"Severity": "High",
"Metadata": {
"NVD": {
"CVSSv2": {
"Score": 10,
"Vectors": "AV:N/AC:L/Au:N/C:C/I:C"
}
}
},
"FixedBy": "1.0.2g-1ubuntu4.1"
}
],
"AddedBy": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
}
]
}
}
24 changes: 13 additions & 11 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import json
import os
from unittest.mock import MagicMock
import yaml
from pierone.api import docker_login, DockerImage, get_latest_tag, Unauthorized, image_exists

import pytest
import yaml
from pierone.api import (DockerImage, Unauthorized, docker_login,
get_latest_tag, image_exists)


def test_docker_login(monkeypatch, tmpdir):
Expand All @@ -12,22 +14,22 @@ def test_docker_login(monkeypatch, tmpdir):
response.status_code = 200
response.json.return_value = {'access_token': '12377'}
monkeypatch.setattr('requests.get', MagicMock(return_value=response))
token = docker_login('https://pierone.example.org', 'services', 'mytok',
'myuser', 'mypass', 'https://token.example.org', use_keyring=False)
docker_login('https://pierone.example.org', 'services', 'mytok',
'myuser', 'mypass', 'https://token.example.org', use_keyring=False)
path = os.path.expanduser('~/.docker/config.json')
with open(path) as fd:
data = yaml.safe_load(fd)
assert {'auth': 'b2F1dGgyOjEyMzc3', 'email': 'no-mail-required@example.org'} == data.get('auths').get('https://pierone.example.org')
assert {'auth': 'b2F1dGgyOjEyMzc3', 'email': 'no-mail-required@example.org'} == data.get('auths').get('https://pierone.example.org')


def test_docker_login_service_token(monkeypatch, tmpdir):
monkeypatch.setattr('os.path.expanduser', lambda x: x.replace('~', str(tmpdir)))
monkeypatch.setattr('tokens.get', lambda x: '12377')
token = docker_login('https://pierone.example.org', None, 'mytok', 'myuser', 'mypass', 'https://token.example.org')
docker_login('https://pierone.example.org', None, 'mytok', 'myuser', 'mypass', 'https://token.example.org')
path = os.path.expanduser('~/.docker/config.json')
with open(path) as fd:
data = yaml.safe_load(fd)
assert {'auth': 'b2F1dGgyOjEyMzc3', 'email': 'no-mail-required@example.org'} == data.get('auths').get('https://pierone.example.org')
assert {'auth': 'b2F1dGgyOjEyMzc3', 'email': 'no-mail-required@example.org'} == data.get('auths').get('https://pierone.example.org')


def test_keep_dockercfg_entries(monkeypatch, tmpdir):
Expand All @@ -49,12 +51,12 @@ def test_keep_dockercfg_entries(monkeypatch, tmpdir):
with open(path, 'w') as fd:
json.dump(existing_data, fd)

token = docker_login('https://pierone.example.org', 'services', 'mytok',
'myuser', 'mypass', 'https://token.example.org', use_keyring=False)
docker_login('https://pierone.example.org', 'services', 'mytok',
'myuser', 'mypass', 'https://token.example.org', use_keyring=False)
with open(path) as fd:
data = yaml.safe_load(fd)
assert {'auth': 'b2F1dGgyOjEyMzc3', 'email': 'no-mail-required@example.org'} == data.get('auths', {}).get('https://pierone.example.org')
assert existing_data.get(key) == data.get(key)
assert {'auth': 'b2F1dGgyOjEyMzc3', 'email': 'no-mail-required@example.org'} == data.get('auths', {}).get('https://pierone.example.org')
assert existing_data.get(key) == data.get(key)


def test_get_latest_tag(monkeypatch):
Expand Down
Loading

0 comments on commit 991c05e

Please sign in to comment.